From b411ffde83af47de4a1e948eb02ae2623ca81ed3 Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 17 Jun 2026 18:24:42 +0200 Subject: [PATCH 01/34] Move links queries from entity to the individual link tables --- .../hash-frontend/src/pages/shared/entity.tsx | 73 +++++++++-- .../src/pages/shared/entity/entity-editor.tsx | 7 + .../entity-editor/entity-editor-context.tsx | 3 + .../entity/entity-editor/links-section.tsx | 60 --------- .../links-section/incoming-links-section.tsx | 95 ++++++++++++-- .../incoming-links-table.tsx | 25 +++- .../links-section/outgoing-links-section.tsx | 84 ++++++++++-- .../readonly-outgoing-links-table.tsx | 25 +++- .../links-section/use-entity-links.ts | 120 ++++++++++++++++++ 9 files changed, 384 insertions(+), 108 deletions(-) delete mode 100644 apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 98660ced0a1..f39db73c944 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -56,6 +56,7 @@ import type { EntityEditorProps } from "./entity/entity-editor"; import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; import type { EntityId, PropertyObject } from "@blockprotocol/type-system"; import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import type { EntityTraversalPath } from "@rust/hash-graph-store/types"; interface EntityProps { entityId: EntityId; @@ -250,6 +251,23 @@ export const Entity = ({ const [isDirty, setIsDirty] = useState(!!draftLocalEntity); + /** + * Whether the main entity query should include the entity's incoming and + * outgoing link data. + * + * We only fetch it here when the entity is editable, so that the edit flow has + * the links available in the editor subgraph. When the entity is readonly, the + * link tables fetch this data themselves (see `selfFetchLinks` below), keeping + * the main query (and the editor shell) independent of the entity's link + * volume. + * + * This starts `false` (we don't know the user's permissions until the first + * response) and is set from the entity permissions in `onCompleted`. If the + * user can edit, the query variables change and the query refetches with the + * link data included. + */ + const [includeLinkDataInQuery, setIncludeLinkDataInQuery] = useState(false); + const { data: queryEntitySubgraphData, refetch } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables @@ -302,6 +320,10 @@ export const Entity = ({ setDraftLinksToCreate([]); setDraftLinksToArchive([]); + setIncludeLinkDataInQuery( + !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update, + ); + setLoading(false); }, variables: { @@ -325,18 +347,31 @@ export const Entity = ({ }, temporalAxes: currentTimeInstantTemporalAxes, traversalPaths: [ - { - edges: [ - { kind: "has-left-entity", direction: "incoming" }, - { kind: "has-right-entity", direction: "outgoing" }, - ], - }, - { - edges: [ - { kind: "has-right-entity", direction: "incoming" }, - { kind: "has-left-entity", direction: "outgoing" }, - ], - }, + /** + * Incoming and outgoing links (and their source/target entities) are + * only fetched here when the entity is editable. When readonly, the + * link tables fetch this data themselves (see `selfFetchLinks`). + */ + ...(includeLinkDataInQuery + ? ([ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, + ] satisfies EntityTraversalPath[]) + : []), + /** + * These paths resolve the entity's own source/target when the entity + * is itself a link, and are always required (e.g. by `LinkSection`). + */ { edges: [{ kind: "has-left-entity", direction: "outgoing" }], }, @@ -389,6 +424,18 @@ export const Entity = ({ [dataFromDb], ); + /** + * Whether the link tables should fetch their own incoming/outgoing link data, + * rather than reading it from the editor subgraph. + * + * This is the case for readonly entities that are persisted in the database + * (i.e. not local drafts or Flow proposals, whose link data is contained in + * the provided subgraph). It mirrors `includeLinkDataInQuery`: the link tables + * self-fetch exactly when the main query omits the link data. + */ + const selfFetchLinks = + !draftLocalEntity && !proposedEntitySubgraph && !includeLinkDataInQuery; + const resetDraftState = () => { setIsDirty(false); setDraftLinksToCreate([]); @@ -556,6 +603,7 @@ export const Entity = ({ {isQueryEntity && shouldShowQueryEditor ? ( { - const { draftLinksToArchive, entity, entitySubgraph } = useEntityEditor(); - - const outgoingLinks = getOutgoingLinksForEntity( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ).filter( - (incomingLink) => !draftLinksToArchive.includes(incomingLink.entityId), - ); - - const incomingLinksAndSources = getIncomingLinkAndSourceEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ).filter((incomingLinkAndSource) => { - return ( - incomingLinkAndSource.linkEntity[0] && - !draftLinksToArchive.includes( - incomingLinkAndSource.linkEntity[0].entityId, - ) && - incomingLinkAndSource.leftEntity[0] && - !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( - systemEntityTypes.claim.entityTypeId, - ) - ); - }); - - return ( - - - - - - ); -}; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 51c9c0f3afd..eefe407fd56 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -1,22 +1,88 @@ -import { Stack } from "@mui/material"; +import { Box, CircularProgress, Stack } from "@mui/material"; +import { getIncomingLinkAndSourceEntities } from "@blockprotocol/graph/stdlib"; import { Chip } from "@hashintel/design-system"; +import { noisySystemTypeIds } from "@local/hash-isomorphic-utils/graph-queries"; +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { SectionWrapper } from "../../../section-wrapper"; import { LinksSectionEmptyState } from "../../shared/links-section-empty-state"; +import { useEntityEditor } from "../entity-editor-context"; import { IncomingLinksTable } from "./incoming-links-section/incoming-links-table"; +import { useEntityLinks } from "./use-entity-links"; -import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; - -interface IncomingLinksSectionProps { - incomingLinksAndSources: LinkEntityAndLeftEntity[]; - isLinkEntity: boolean; -} +import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; export const IncomingLinksSection = ({ - incomingLinksAndSources, isLinkEntity, -}: IncomingLinksSectionProps) => { +}: { + isLinkEntity: boolean; +}) => { + const { + closedMultiEntityTypesDefinitions: editorDefinitions, + draftLinksToArchive, + entity, + entitySubgraph: editorSubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, + selfFetchLinks, + } = useEntityEditor(); + + /** + * When the entity is readonly we fetch the link data here, so that it does not + * need to be part of the main entity query. When editable, the link data is + * part of the editor subgraph. + */ + const { + loading, + linksSubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, + closedMultiEntityTypesDefinitions: fetchedDefinitions, + } = useEntityLinks({ + direction: "incoming", + entityId: entity.metadata.recordId.entityId, + skip: !selfFetchLinks, + }); + + if (selfFetchLinks && (loading || !linksSubgraph || !fetchedDefinitions)) { + return ( + + + + + + ); + } + + const entitySubgraph = selfFetchLinks ? linksSubgraph! : editorSubgraph; + const closedMultiEntityTypesMap = selfFetchLinks + ? (fetchedTypesMap ?? null) + : editorTypesMap; + const closedMultiEntityTypesDefinitions = selfFetchLinks + ? fetchedDefinitions! + : editorDefinitions; + + const incomingLinksAndSources = getIncomingLinkAndSourceEntities( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ).filter((incomingLinkAndSource) => { + return ( + incomingLinkAndSource.linkEntity[0] && + !draftLinksToArchive.includes( + incomingLinkAndSource.linkEntity[0].entityId, + ) && + !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( + (typeId) => noisySystemTypeIds.includes(typeId as NoisySystemTypeId), + ) && + incomingLinkAndSource.leftEntity[0] && + !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( + systemEntityTypes.claim.entityTypeId, + ) + ); + }); + if (incomingLinksAndSources.length === 0 && isLinkEntity) { /** * We don't show the links tables for link entities unless they have some links already set, @@ -34,13 +100,20 @@ export const IncomingLinksSection = ({ } > {incomingLinksAndSources.length ? ( - + ) : ( )} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index 5df01a2e36b..c583a66a69b 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -48,13 +48,22 @@ import type { } from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; import type { CustomEntityLinksColumn } from "../../shared/types"; -import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; +import type { + EntityRootType, + LinkEntityAndLeftEntity, + Subgraph, +} from "@blockprotocol/graph"; import type { Entity, EntityId, PartialEntityType, VersionedUrl, } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; type FieldId = "linkedFrom" | "linkTypes" | "linkedFromTypes" | "link"; @@ -251,23 +260,27 @@ const createRowContent: CreateVirtualizedRowContentFn< > = (_index, row) => ; type IncomingLinksTableProps = { + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; + closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + entitySubgraph: Subgraph>; incomingLinksAndSources: LinkEntityAndLeftEntity[]; }; export const IncomingLinksTable = memo( - ({ incomingLinksAndSources }: IncomingLinksTableProps) => { + ({ + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, + entitySubgraph, + incomingLinksAndSources, + }: IncomingLinksTableProps) => { const [sort, setSort] = useState>({ fieldId: "linkedFrom", direction: "asc", }); const { - linkAndDestinationEntitiesClosedMultiEntityTypesMap: - closedMultiEntityTypesMap, - closedMultiEntityTypesDefinitions, customEntityLinksColumns: customColumns, draftLinksToArchive, - entitySubgraph, onEntityClick, onTypeClick, } = useEntityEditor(); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index c46dbee8ac4..b53f7eac119 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -1,8 +1,11 @@ import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; -import { Paper, Stack } from "@mui/material"; +import { Box, CircularProgress, Paper, Stack } from "@mui/material"; import { useCallback, useState } from "react"; -import { getOutgoingLinkAndTargetEntities } from "@blockprotocol/graph/stdlib"; +import { + getOutgoingLinkAndTargetEntities, + getOutgoingLinksForEntity, +} from "@blockprotocol/graph/stdlib"; import { Chip, FontAwesomeIcon, IconButton } from "@hashintel/design-system"; import { Grid } from "../../../../../components/grid/grid"; @@ -17,6 +20,7 @@ import { linkGridColumns } from "./outgoing-links-section/constants"; import { OutgoingLinksTable } from "./outgoing-links-section/readonly-outgoing-links-table"; import { useCreateGetCellContent } from "./outgoing-links-section/use-create-get-cell-content"; import { useRows } from "./outgoing-links-section/use-rows"; +import { useEntityLinks } from "./use-entity-links"; import type { SortGridRows } from "../../../../../components/grid/grid"; import type { @@ -24,20 +28,39 @@ import type { LinkColumnKey, LinkRow, } from "./outgoing-links-section/types"; -import type { Entity } from "@blockprotocol/type-system"; - -interface OutgoingLinksSectionPropsProps { - isLinkEntity: boolean; - outgoingLinks: Entity[]; -} export const OutgoingLinksSection = ({ isLinkEntity, - outgoingLinks, -}: OutgoingLinksSectionPropsProps) => { +}: { + isLinkEntity: boolean; +}) => { const [showSearch, setShowSearch] = useState(false); - const { entitySubgraph, entity, readonly } = useEntityEditor(); + const { + closedMultiEntityTypesDefinitions: editorDefinitions, + draftLinksToArchive, + entity, + entitySubgraph: editorSubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, + readonly, + selfFetchLinks, + } = useEntityEditor(); + + /** + * When the entity is readonly we fetch the link data here, so that it does not + * need to be part of the main entity query. When editable, the link data is + * part of the editor subgraph (so adding/removing/saving links is unchanged). + */ + const { + loading, + linksSubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, + closedMultiEntityTypesDefinitions: fetchedDefinitions, + } = useEntityLinks({ + direction: "outgoing", + entityId: entity.metadata.recordId.entityId, + skip: !selfFetchLinks, + }); const rows = useRows(); const createGetCellContent = useCreateGetCellContent(); @@ -68,6 +91,34 @@ export const OutgoingLinksSection = ({ }); }, []); + if (selfFetchLinks && (loading || !linksSubgraph || !fetchedDefinitions)) { + return ( + + + + + + ); + } + + const entitySubgraph = selfFetchLinks ? linksSubgraph! : editorSubgraph; + const closedMultiEntityTypesMap = selfFetchLinks + ? (fetchedTypesMap ?? null) + : editorTypesMap; + const closedMultiEntityTypesDefinitions = selfFetchLinks + ? fetchedDefinitions! + : editorDefinitions; + + const outgoingLinks = getOutgoingLinksForEntity( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ).filter( + (outgoingLink) => !draftLinksToArchive.includes(outgoingLink.entityId), + ); + if (outgoingLinks.length === 0 && isLinkEntity) { /** * We don't show the links tables for link entities unless they have some links already set, @@ -95,7 +146,9 @@ export const OutgoingLinksSection = ({ {!!rows.length && ( @@ -132,7 +185,12 @@ export const OutgoingLinksSection = ({ /> ) : outgoingLinksAndTargets?.length ? ( - + ) : ( )} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index ea1e6e0d73b..a31fbf7a238 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -48,13 +48,22 @@ import type { } from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; import type { CustomEntityLinksColumn } from "../../shared/types"; -import type { LinkEntityAndRightEntity } from "@blockprotocol/graph"; +import type { + EntityRootType, + LinkEntityAndRightEntity, + Subgraph, +} from "@blockprotocol/graph"; import type { Entity, EntityId, PartialEntityType, VersionedUrl, } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; import type { ReactElement } from "react"; type OutgoingLinksFieldId = "linkTypes" | "linkedTo" | "linkedToTypes" | "link"; @@ -239,11 +248,19 @@ const createRowContent: CreateVirtualizedRowContentFn< > = (_index, row) => ; type OutgoingLinksTableProps = { + closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; + closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + entitySubgraph: Subgraph>; outgoingLinksAndTargets: LinkEntityAndRightEntity[]; }; export const OutgoingLinksTable = memo( - ({ outgoingLinksAndTargets }: OutgoingLinksTableProps) => { + ({ + closedMultiEntityTypesDefinitions, + closedMultiEntityTypesMap, + entitySubgraph, + outgoingLinksAndTargets, + }: OutgoingLinksTableProps) => { const [sort, setSort] = useState< VirtualizedTableSort >({ @@ -252,10 +269,6 @@ export const OutgoingLinksTable = memo( }); const { - closedMultiEntityTypesDefinitions, - linkAndDestinationEntitiesClosedMultiEntityTypesMap: - closedMultiEntityTypesMap, - entitySubgraph, customEntityLinksColumns: customColumns, defaultOutgoingLinkFilters, onEntityClick, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts new file mode 100644 index 00000000000..9402a9a2bd0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -0,0 +1,120 @@ +import { useQuery } from "@apollo/client"; +import { useMemo } from "react"; + +import { type EntityId, splitEntityId } from "@blockprotocol/type-system"; +import { deserializeQueryEntitySubgraphResponse } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; +import { queryEntitySubgraphQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../../../graphql/api-types.gen"; +import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { + ClosedMultiEntityTypesDefinitions, + ClosedMultiEntityTypesRootMap, +} from "@local/hash-graph-sdk/ontology"; +import type { EntityTraversalPath } from "@rust/hash-graph-store/types"; + +const traversalPathByDirection = { + /** + * Outgoing links (where the entity is the link's left/source) and their + * right/target entities. + */ + outgoing: { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, + /** + * Incoming links (where the entity is the link's right/target) and their + * left/source entities. + */ + incoming: { + edges: [ + { kind: "has-right-entity", direction: "incoming" }, + { kind: "has-left-entity", direction: "outgoing" }, + ], + }, +} satisfies Record<"outgoing" | "incoming", EntityTraversalPath>; + +/** + * Fetches the incoming or outgoing links (and their source/target entities and + * resolved types) for an entity, for display in the readonly link tables. + * + * This data used to be fetched as part of the main entity query in + * `entity.tsx`, but is now fetched by the link tables themselves when they are + * readonly, so that the main entity query (and the editor shell) does not have + * to wait on – or grow with – the entity's link data. + */ +export const useEntityLinks = ({ + direction, + entityId, + skip = false, +}: { + direction: "outgoing" | "incoming"; + entityId: EntityId; + skip?: boolean; +}): { + loading: boolean; + linksSubgraph?: Subgraph>; + linkAndDestinationEntitiesClosedMultiEntityTypesMap?: ClosedMultiEntityTypesRootMap; + closedMultiEntityTypesDefinitions?: ClosedMultiEntityTypesDefinitions; +} => { + const [webId, entityUuid, draftId] = splitEntityId(entityId); + + const { data, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + skip, + variables: { + request: { + filter: { + all: [ + { + equal: [{ path: ["uuid"] }, { parameter: entityUuid }], + }, + { + equal: [{ path: ["webId"] }, { parameter: webId }], + }, + ...(draftId + ? [ + { + equal: [{ path: ["draftId"] }, { parameter: draftId }], + }, + ] + : []), + ], + }, + temporalAxes: currentTimeInstantTemporalAxes, + traversalPaths: [traversalPathByDirection[direction]], + includeDrafts: !!draftId, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }, + }); + + return useMemo(() => { + if (!data) { + return { loading }; + } + + const { definitions, closedMultiEntityTypes } = data.queryEntitySubgraph; + + return { + loading, + linksSubgraph: deserializeQueryEntitySubgraphResponse( + data.queryEntitySubgraph, + ).subgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: + closedMultiEntityTypes ?? undefined, + closedMultiEntityTypesDefinitions: definitions ?? undefined, + }; + }, [data, loading]); +}; From ac79203fcfb2b2ff9295cb480ee766e1ac9704da Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 17 Jun 2026 19:17:44 +0200 Subject: [PATCH 02/34] Add infinite scroll to link tables --- .../hash-frontend/src/pages/shared/entity.tsx | 27 +- .../src/pages/shared/entity/entity-editor.tsx | 71 ++++- .../links-section/incoming-links-section.tsx | 145 ++++++--- .../incoming-links-table.tsx | 141 ++++++++- .../links-section/outgoing-links-section.tsx | 168 +++++++--- .../linked-entity-list-editor.tsx | 18 +- .../linked-entity-list-row.tsx | 7 +- .../linked-entity-selector.tsx | 7 +- .../linked-with-cell-editor.tsx | 13 +- .../readonly-outgoing-links-table.tsx | 31 +- .../outgoing-links-section/types.ts | 11 + .../use-create-get-cell-content.ts | 9 +- .../outgoing-links-section/use-rows.ts | 56 +++- .../shared/properties-tooltip.tsx | 8 +- .../links-section/use-entity-links.ts | 295 +++++++++++++++--- .../shared/use-mark-link-entity-to-archive.ts | 35 ++- .../src/pages/shared/virtualized-table.tsx | 68 +++- 17 files changed, 867 insertions(+), 243 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index f39db73c944..6e7c9a784e6 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -257,9 +257,8 @@ export const Entity = ({ * * We only fetch it here when the entity is editable, so that the edit flow has * the links available in the editor subgraph. When the entity is readonly, the - * link tables fetch this data themselves (see `selfFetchLinks` below), keeping - * the main query (and the editor shell) independent of the entity's link - * volume. + * link tables fetch this data themselves (see `isReadOnly` below), keeping the + * main query (and the editor shell) independent of the entity's link volume. * * This starts `false` (we don't know the user's permissions until the first * response) and is set from the entity permissions in `onCompleted`. If the @@ -320,10 +319,6 @@ export const Entity = ({ setDraftLinksToCreate([]); setDraftLinksToArchive([]); - setIncludeLinkDataInQuery( - !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update, - ); - setLoading(false); }, variables: { @@ -350,7 +345,7 @@ export const Entity = ({ /** * Incoming and outgoing links (and their source/target entities) are * only fetched here when the entity is editable. When readonly, the - * link tables fetch this data themselves (see `selfFetchLinks`). + * link tables fetch this data themselves (see `isReadOnly`). */ ...(includeLinkDataInQuery ? ([ @@ -424,18 +419,6 @@ export const Entity = ({ [dataFromDb], ); - /** - * Whether the link tables should fetch their own incoming/outgoing link data, - * rather than reading it from the editor subgraph. - * - * This is the case for readonly entities that are persisted in the database - * (i.e. not local drafts or Flow proposals, whose link data is contained in - * the provided subgraph). It mirrors `includeLinkDataInQuery`: the link tables - * self-fetch exactly when the main query omits the link data. - */ - const selfFetchLinks = - !draftLocalEntity && !proposedEntitySubgraph && !includeLinkDataInQuery; - const resetDraftState = () => { setIsDirty(false); setDraftLinksToCreate([]); @@ -603,7 +586,7 @@ export const Entity = ({ {isQueryEntity && shouldShowQueryEditor ? ( { - const { entitySubgraph } = props; + const { + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + customEntityLinksColumns, + defaultOutgoingLinkFilters, + draftLinksToArchive, + draftLinksToCreate, + entityLabel, + entitySubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap, + onEntityClick, + onTypeClick, + readonly, + selfFetchLinks, + setDraftLinksToArchive, + setDraftLinksToCreate, + slideContainerRef, + } = props; const entity = useMemo(() => { const roots = getRoots(entitySubgraph); @@ -166,7 +184,52 @@ export const EntityEditor = (props: EntityEditorProps) => { - + + + + + diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index eefe407fd56..3ae333c38f7 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -1,40 +1,69 @@ import { Box, CircularProgress, Stack } from "@mui/material"; -import { getIncomingLinkAndSourceEntities } from "@blockprotocol/graph/stdlib"; +import { + getIncomingLinkAndSourceEntities, + getLeftEntityForLinkEntity, +} from "@blockprotocol/graph/stdlib"; import { Chip } from "@hashintel/design-system"; import { noisySystemTypeIds } from "@local/hash-isomorphic-utils/graph-queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { SectionWrapper } from "../../../section-wrapper"; import { LinksSectionEmptyState } from "../../shared/links-section-empty-state"; -import { useEntityEditor } from "../entity-editor-context"; import { IncomingLinksTable } from "./incoming-links-section/incoming-links-table"; import { useEntityLinks } from "./use-entity-links"; +import type { EntityEditorProps } from "../../entity-editor"; +import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; -export const IncomingLinksSection = ({ - isLinkEntity, -}: { +type IncomingLinksSectionProps = Pick< + EntityEditorProps, + | "closedMultiEntityTypesDefinitions" + | "customEntityLinksColumns" + | "draftLinksToArchive" + | "entityLabel" + | "entitySubgraph" + | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" + | "onEntityClick" + | "onTypeClick" + | "selfFetchLinks" + | "slideContainerRef" +> & { + entity: HashEntity; isLinkEntity: boolean; -}) => { - const { - closedMultiEntityTypesDefinitions: editorDefinitions, - draftLinksToArchive, - entity, - entitySubgraph: editorSubgraph, - linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, - selfFetchLinks, - } = useEntityEditor(); +}; +export const IncomingLinksSection = ({ + closedMultiEntityTypesDefinitions: editorDefinitions, + customEntityLinksColumns, + draftLinksToArchive, + entity, + entityLabel, + entitySubgraph: editorSubgraph, + isLinkEntity, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, + onEntityClick, + onTypeClick, + selfFetchLinks, + slideContainerRef, +}: IncomingLinksSectionProps) => { /** - * When the entity is readonly we fetch the link data here, so that it does not - * need to be part of the main entity query. When editable, the link data is - * part of the editor subgraph. + * When the entity is readonly we fetch the link data here (paginated), so it + * does not need to be part of the main entity query. When editable, the link + * data is part of the editor subgraph and is not paginated. The noisy-type / + * claim exclusions below are applied server-side in the paginated case (so the + * count is accurate), and client-side for the editor subgraph. */ const { - loading, - linksSubgraph, + initialLoading, + loadingMore, + loadMore, + hasMore, + count: fetchedCount, + linkEntities, + subgraph: fetchedSubgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, closedMultiEntityTypesDefinitions: fetchedDefinitions, } = useEntityLinks({ @@ -43,7 +72,10 @@ export const IncomingLinksSection = ({ skip: !selfFetchLinks, }); - if (selfFetchLinks && (loading || !linksSubgraph || !fetchedDefinitions)) { + if ( + selfFetchLinks && + (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) + ) { return ( @@ -53,7 +85,7 @@ export const IncomingLinksSection = ({ ); } - const entitySubgraph = selfFetchLinks ? linksSubgraph! : editorSubgraph; + const entitySubgraph = selfFetchLinks ? fetchedSubgraph! : editorSubgraph; const closedMultiEntityTypesMap = selfFetchLinks ? (fetchedTypesMap ?? null) : editorTypesMap; @@ -61,29 +93,43 @@ export const IncomingLinksSection = ({ ? fetchedDefinitions! : editorDefinitions; - const incomingLinksAndSources = getIncomingLinkAndSourceEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ).filter((incomingLinkAndSource) => { - return ( - incomingLinkAndSource.linkEntity[0] && - !draftLinksToArchive.includes( - incomingLinkAndSource.linkEntity[0].entityId, - ) && - !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( - (typeId) => noisySystemTypeIds.includes(typeId as NoisySystemTypeId), - ) && - incomingLinkAndSource.leftEntity[0] && - !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( - systemEntityTypes.claim.entityTypeId, - ) - ); - }); + const incomingLinksAndSources: LinkEntityAndLeftEntity[] = selfFetchLinks + ? linkEntities!.map((linkEntity) => ({ + linkEntity: [linkEntity], + leftEntity: + getLeftEntityForLinkEntity( + entitySubgraph, + linkEntity.metadata.recordId.entityId, + ) ?? [], + })) + : getIncomingLinkAndSourceEntities( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ).filter((incomingLinkAndSource) => { + return ( + incomingLinkAndSource.linkEntity[0] && + !draftLinksToArchive.includes( + incomingLinkAndSource.linkEntity[0].entityId, + ) && + !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( + (typeId) => + noisySystemTypeIds.includes(typeId as NoisySystemTypeId), + ) && + incomingLinkAndSource.leftEntity[0] && + !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( + systemEntityTypes.claim.entityTypeId, + ) + ); + }); + + const linkCount = selfFetchLinks + ? (fetchedCount ?? incomingLinksAndSources.length) + : incomingLinksAndSources.length; - if (incomingLinksAndSources.length === 0 && isLinkEntity) { + if (linkCount === 0 && isLinkEntity) { /** * We don't show the links tables for link entities unless they have some links already set, * because we don't yet fully support linking to/from links in the UI. @@ -100,9 +146,7 @@ export const IncomingLinksSection = ({ } @@ -111,8 +155,17 @@ export const IncomingLinksSection = ({ ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index c583a66a69b..8a18a133091 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -2,6 +2,7 @@ import { Box, Stack, TableCell, Typography } from "@mui/material"; import { memo, type ReactElement, + type RefObject, useLayoutEffect, useMemo, useRef, @@ -26,7 +27,6 @@ import { VirtualizedTable } from "../../../../virtualized-table"; import { virtualizedTableHeaderHeight } from "../../../../virtualized-table/header"; import { isValueIncludedInFilter } from "../../../../virtualized-table/header/filter"; import { useVirtualizedTableFilterState } from "../../../../virtualized-table/use-filter-state"; -import { useEntityEditor } from "../../entity-editor-context"; import { PropertiesTooltip } from "../shared/properties-tooltip"; import { linksTableCellSx, @@ -34,6 +34,7 @@ import { linksTableRowHeight, maxLinksTableHeight, } from "../shared/table-styling"; +import { linksTablePageSize } from "../use-entity-links"; import type { CreateVirtualizedRowContentFn, @@ -47,6 +48,7 @@ import type { VirtualizedTableFilterValuesByFieldId, } from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; +import type { DraftLinksToArchive } from "../../../shared/use-draft-link-state"; import type { CustomEntityLinksColumn } from "../../shared/types"; import type { EntityRootType, @@ -127,10 +129,12 @@ type IncomingLinkRow = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; customFields: { [fieldId: string]: string | number }; + entityLabel: string; + slideContainerRef?: RefObject; }; const TableRow = memo(({ row }: { row: IncomingLinkRow }) => { - const { entityLabel } = useEntityEditor(); + const { entityLabel } = row; const customCells: ReactElement[] = []; for (const [fieldId, value] of typedEntries(row.customFields)) { @@ -147,6 +151,7 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => { @@ -232,6 +237,7 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => { row.onEntityClick(row.linkEntity.entityId)} @@ -254,37 +260,72 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => { ); }); +/** + * A placeholder row standing in for a link that is part of the total count but + * has not yet been fetched. These pad the scroll content out to the total link + * count so the scrollbar reflects the full set, not just the loaded rows. + */ +type PlaceholderRow = { placeholder: true }; + +type IncomingLinkRowOrPlaceholder = IncomingLinkRow | PlaceholderRow; + const createRowContent: CreateVirtualizedRowContentFn< - IncomingLinkRow, + IncomingLinkRowOrPlaceholder, FieldId -> = (_index, row) => ; +> = (_index, row, { columns }) => + "placeholder" in row.data ? ( + <> + {columns.map((column) => ( + + ))} + + ) : ( + + ); type IncomingLinksTableProps = { closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + customEntityLinksColumns?: CustomEntityLinksColumn[]; + draftLinksToArchive: DraftLinksToArchive; + entityLabel: string; entitySubgraph: Subgraph>; incomingLinksAndSources: LinkEntityAndLeftEntity[]; + loadingMore?: boolean; + onEndReached?: () => void; + onEntityClick: (entityId: EntityId) => void; + onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; + slideContainerRef?: RefObject; + /** + * The total number of links matching the query, including those not yet + * loaded. When greater than the number of loaded links, the scroll content is + * padded with up to one page of placeholder rows so the scrollbar extends + * slightly past the loaded rows to indicate there is more to load. + */ + totalLinkCount?: number; }; export const IncomingLinksTable = memo( ({ closedMultiEntityTypesDefinitions, closedMultiEntityTypesMap, + customEntityLinksColumns: customColumns, + draftLinksToArchive, + entityLabel, entitySubgraph, incomingLinksAndSources, + loadingMore, + onEndReached, + onEntityClick, + onTypeClick, + slideContainerRef, + totalLinkCount, }: IncomingLinksTableProps) => { const [sort, setSort] = useState>({ fieldId: "linkedFrom", direction: "asc", }); - const { - customEntityLinksColumns: customColumns, - draftLinksToArchive, - onEntityClick, - onTypeClick, - } = useEntityEditor(); - const outputContainerRef = useRef(null); const [outputContainerHeight, setOutputContainerHeight] = useState(400); useLayoutEffect(() => { @@ -485,11 +526,13 @@ export const IncomingLinksTable = memo( inverse: type.inverse, }; }), + entityLabel, linkEntity, linkEntityLabel, linkEntityProperties, onEntityClick, onTypeClick, + slideContainerRef, sourceEntity: leftEntity, sourceEntityLabel: leftEntityLabel, sourceEntityProperties, @@ -525,10 +568,12 @@ export const IncomingLinksTable = memo( closedMultiEntityTypesMap, customColumns, draftLinksToArchive, + entityLabel, entitySubgraph, incomingLinksAndSources, onEntityClick, onTypeClick, + slideContainerRef, ]); const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ @@ -651,9 +696,46 @@ export const IncomingLinksTable = memo( [filterValues, sort, unsortedRows], ); + /** + * Pad the scroll content with placeholder rows for not-yet-loaded links, so + * the scrollbar extends a little beyond the loaded rows to indicate there is + * more to load. These fill in as further pages load while scrolling (see + * `onRangeChange` below). + * + * The padding is capped at a single page rather than the full remaining + * total: because the query only supports sequential (cursor) pagination, we + * can't load an arbitrary middle window, so we don't allow scrolling far + * past the loaded rows. + */ + const placeholderCount = + totalLinkCount === undefined + ? 0 + : Math.min( + linksTablePageSize, + Math.max(0, totalLinkCount - incomingLinksAndSources.length), + ); + + const paddedRows = useMemo< + VirtualizedTableRow[] + >( + () => + placeholderCount === 0 + ? rows + : [ + ...rows, + ...Array.from({ length: placeholderCount }, (_, index) => ({ + id: `placeholder-${index}`, + data: { placeholder: true } as const, + })), + ], + [placeholderCount, rows], + ); + const height = Math.min( maxLinksTableHeight, - rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, + paddedRows.length * linksTableRowHeight + + virtualizedTableHeaderHeight + + 2, ); const columns = useMemo(() => { @@ -666,6 +748,15 @@ export const IncomingLinksTable = memo( return createColumns(applicableCustomColumns ?? []); }, [filterValues, customColumns]); + /** + * Whether scrolling to the bottom may trigger a load of the next page. It is + * disarmed as soon as a load is triggered and only re-armed when the user + * starts scrolling again – so a single scroll to the bottom loads at most + * one page, and the user must scroll again to load more (rather than the + * table looping while the scroll position stays at the bottom). + */ + const canLoadMoreRef = useRef(true); + return ( { + if (isScrolling) { + canLoadMoreRef.current = true; + } + }} + onRangeChange={ + onEndReached + ? ({ endIndex }) => { + // Load the next page once the loaded rows scroll into view, + // before the placeholder rows are reached. + if ( + canLoadMoreRef.current && + !loadingMore && + endIndex >= rows.length - 1 + ) { + canLoadMoreRef.current = false; + onEndReached(); + } + } + : undefined + } setFilterValues={setFilterValues} - rows={rows} + rows={paddedRows} sort={sort} setSort={setSort} /> diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index b53f7eac119..fa8c08f1100 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -5,6 +5,7 @@ import { useCallback, useState } from "react"; import { getOutgoingLinkAndTargetEntities, getOutgoingLinksForEntity, + getRightEntityForLinkEntity, } from "@blockprotocol/graph/stdlib"; import { Chip, FontAwesomeIcon, IconButton } from "@hashintel/design-system"; @@ -12,7 +13,6 @@ import { Grid } from "../../../../../components/grid/grid"; import { createRenderChipCell } from "../../../chip-cell"; import { SectionWrapper } from "../../../section-wrapper"; import { LinksSectionEmptyState } from "../../shared/links-section-empty-state"; -import { useEntityEditor } from "../entity-editor-context"; import { renderSummaryChipCell } from "../shared/summary-chip-cell"; import { renderLinkCell } from "./outgoing-links-section/cells/link-cell"; import { renderLinkedWithCell } from "./outgoing-links-section/cells/linked-with-cell"; @@ -23,37 +23,72 @@ import { useRows } from "./outgoing-links-section/use-rows"; import { useEntityLinks } from "./use-entity-links"; import type { SortGridRows } from "../../../../../components/grid/grid"; +import type { EntityEditorProps } from "../../entity-editor"; import type { LinkColumn, LinkColumnKey, LinkRow, } from "./outgoing-links-section/types"; +import type { LinkEntityAndRightEntity } from "@blockprotocol/graph"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +type OutgoingLinksSectionProps = Pick< + EntityEditorProps, + | "closedMultiEntityType" + | "closedMultiEntityTypesDefinitions" + | "customEntityLinksColumns" + | "defaultOutgoingLinkFilters" + | "draftLinksToArchive" + | "draftLinksToCreate" + | "entitySubgraph" + | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" + | "onEntityClick" + | "onTypeClick" + | "readonly" + | "selfFetchLinks" + | "setDraftLinksToArchive" + | "setDraftLinksToCreate" + | "slideContainerRef" +> & { + entity: HashEntity; + isLinkEntity: boolean; +}; export const OutgoingLinksSection = ({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions: editorDefinitions, + customEntityLinksColumns, + defaultOutgoingLinkFilters, + draftLinksToArchive, + draftLinksToCreate, + entity, + entitySubgraph: editorSubgraph, isLinkEntity, -}: { - isLinkEntity: boolean; -}) => { + linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, + onEntityClick, + onTypeClick, + readonly, + selfFetchLinks, + setDraftLinksToArchive, + setDraftLinksToCreate, + slideContainerRef, +}: OutgoingLinksSectionProps) => { const [showSearch, setShowSearch] = useState(false); - const { - closedMultiEntityTypesDefinitions: editorDefinitions, - draftLinksToArchive, - entity, - entitySubgraph: editorSubgraph, - linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, - readonly, - selfFetchLinks, - } = useEntityEditor(); - /** - * When the entity is readonly we fetch the link data here, so that it does not - * need to be part of the main entity query. When editable, the link data is - * part of the editor subgraph (so adding/removing/saving links is unchanged). + * When the entity is readonly we fetch the link data here (paginated), so it + * does not need to be part of the main entity query. When editable, the link + * data is part of the editor subgraph (so adding/removing/saving is + * unchanged) and is not paginated. */ const { - loading, - linksSubgraph, + initialLoading, + loadingMore, + loadMore, + hasMore, + count: fetchedCount, + linkEntities, + subgraph: fetchedSubgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, closedMultiEntityTypesDefinitions: fetchedDefinitions, } = useEntityLinks({ @@ -62,8 +97,23 @@ export const OutgoingLinksSection = ({ skip: !selfFetchLinks, }); - const rows = useRows(); - const createGetCellContent = useCreateGetCellContent(); + const rows = useRows({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions: editorDefinitions, + draftLinksToArchive, + draftLinksToCreate, + entity, + entitySubgraph: editorSubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, + onEntityClick, + readonly, + setDraftLinksToArchive, + setDraftLinksToCreate, + }); + const createGetCellContent = useCreateGetCellContent({ + readonly, + onTypeClick, + }); const sortRows = useCallback< SortGridRows @@ -91,7 +141,10 @@ export const OutgoingLinksSection = ({ }); }, []); - if (selfFetchLinks && (loading || !linksSubgraph || !fetchedDefinitions)) { + if ( + selfFetchLinks && + (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) + ) { return ( @@ -101,7 +154,7 @@ export const OutgoingLinksSection = ({ ); } - const entitySubgraph = selfFetchLinks ? linksSubgraph! : editorSubgraph; + const entitySubgraph = selfFetchLinks ? fetchedSubgraph! : editorSubgraph; const closedMultiEntityTypesMap = selfFetchLinks ? (fetchedTypesMap ?? null) : editorTypesMap; @@ -109,17 +162,27 @@ export const OutgoingLinksSection = ({ ? fetchedDefinitions! : editorDefinitions; - const outgoingLinks = getOutgoingLinksForEntity( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ).filter( - (outgoingLink) => !draftLinksToArchive.includes(outgoingLink.entityId), - ); + /** + * When paginated, the link count comes from the query; otherwise it is the + * number of outgoing links in the editor subgraph (minus any draft removals). + */ + const outgoingLinks = selfFetchLinks + ? null + : getOutgoingLinksForEntity( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ).filter( + (outgoingLink) => !draftLinksToArchive.includes(outgoingLink.entityId), + ); + + const linkCount = selfFetchLinks + ? (fetchedCount ?? linkEntities!.length) + : outgoingLinks!.length; - if (outgoingLinks.length === 0 && isLinkEntity) { + if (linkCount === 0 && isLinkEntity) { /** * We don't show the links tables for link entities unless they have some links already set, * because we don't yet fully support linking to/from links in the UI. @@ -128,15 +191,25 @@ export const OutgoingLinksSection = ({ return null; } - const outgoingLinksAndTargets = readonly - ? getOutgoingLinkAndTargetEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ) - : null; + let outgoingLinksAndTargets: LinkEntityAndRightEntity[] | null = null; + if (readonly) { + outgoingLinksAndTargets = selfFetchLinks + ? linkEntities!.map((linkEntity) => ({ + linkEntity: [linkEntity], + rightEntity: + getRightEntityForLinkEntity( + entitySubgraph, + linkEntity.metadata.recordId.entityId, + ) ?? [], + })) + : getOutgoingLinkAndTargetEntities( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ); + } return ( {!!rows.length && ( @@ -188,8 +259,15 @@ export const OutgoingLinksSection = ({ ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx index 4443fee89ee..3cb4ccbac69 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-entity-list-editor.tsx @@ -9,8 +9,6 @@ import { import { HashEntity } from "@local/hash-graph-sdk/entity"; import { getImageUrlFromEntityProperties } from "../../../../../../get-file-properties"; -import { useMarkLinkEntityToArchive } from "../../../../../shared/use-mark-link-entity-to-archive"; -import { useEntityEditor } from "../../../../entity-editor-context"; import { AddAnotherButton } from "../../../../properties-section/property-table/cells/value-cell/array-editor/add-another-button"; import { GridEditorWrapper } from "../../../../shared/grid-editor-wrapper"; import { sortLinkAndTargetEntities } from "../sort-link-and-target-entities"; @@ -84,17 +82,19 @@ export const createDraftLinkEntity = ({ export const LinkedEntityListEditor: ProvideEditorComponent = ( props, ) => { - const { entity, draftLinksToCreate, setDraftLinksToCreate, readonly } = - useEntityEditor(); - const markLinkEntityToArchive = useMarkLinkEntityToArchive(); - const { value: cell, onFinishedEditing, onChange } = props; const { + draftLinksToCreate, + entity, expectedEntityTypes, linkAndTargetEntities, linkEntityTypeId, linkTitle, + markLinkAsArchived, maxItems, + onEntityClick, + readonly, + setDraftLinksToCreate, } = cell.data.linkRow; const [addingLink, setAddingLink] = useState(!linkAndTargetEntities.length); @@ -174,6 +174,8 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( = ( onChange(newCell); - markLinkEntityToArchive(linkEntityId); + markLinkAsArchived(linkEntityId); }} /> ); @@ -212,6 +214,7 @@ export const LinkedEntityListEditor: ProvideEditorComponent = ( !readonly && (addingLink ? ( = ( expectedEntityTypes={expectedEntityTypes} entityIdsToFilterOut={linkedEntityIds} linkEntityTypeId={linkEntityTypeId} + readonly={readonly} /> ) : ( void; entityId: EntityId; + onEntityClick: (entityId: EntityId) => void; + readonly: boolean; title: string; imageSrc?: string; onDelete: () => void; }) => { - const { readonly, onEntityClick } = useEntityEditor(); - return ( void; onFinishedEditing: () => void; expectedEntityTypes: Pick[]; entityIdsToFilterOut?: EntityId[]; linkEntityTypeId: VersionedUrl; + readonly: boolean; } const FileCreationContext = createContext< @@ -70,15 +71,15 @@ const FileCreationPane = (props: PaperProps) => { }; export const LinkedEntitySelector = ({ + entity, includeDrafts, onSelect, onFinishedEditing, expectedEntityTypes, entityIdsToFilterOut, linkEntityTypeId, + readonly, }: LinkedEntitySelectorProps) => { - const { entity, readonly } = useEntityEditor(); - const entityId = entity.metadata.recordId.entityId; const [showUploadFileMenu, setShowUploadFileMenu] = useState(false); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx index 2b5f59af5fd..f57a7744d16 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/cells/linked-with-cell/linked-with-cell-editor.tsx @@ -1,7 +1,5 @@ import { extractDraftIdFromEntityId } from "@blockprotocol/type-system"; -import { useMarkLinkEntityToArchive } from "../../../../../shared/use-mark-link-entity-to-archive"; -import { useEntityEditor } from "../../../../entity-editor-context"; import { createDraftLinkEntity, LinkedEntityListEditor, @@ -15,16 +13,17 @@ import type { HashEntity } from "@local/hash-graph-sdk/entity"; export const LinkedWithCellEditor: ProvideEditorComponent = ( props, ) => { - const { entity, setDraftLinksToCreate } = useEntityEditor(); - const markLinkEntityToArchive = useMarkLinkEntityToArchive(); - const { value: cell, onFinishedEditing } = props; const { + entity, expectedEntityTypes, linkAndTargetEntities, linkEntityTypeId, linkTitle, + markLinkAsArchived, maxItems, + readonly, + setDraftLinksToCreate, } = cell.data.linkRow; const onSelectForSingleLink = ( @@ -45,7 +44,7 @@ export const LinkedWithCellEditor: ProvideEditorComponent = ( // if there is an existing link, archive it if (currentLink) { - markLinkEntityToArchive(currentLink.metadata.recordId.entityId); + markLinkAsArchived(currentLink.metadata.recordId.entityId); } // create new link @@ -78,6 +77,7 @@ export const LinkedWithCellEditor: ProvideEditorComponent = ( return ( = ( expectedEntityTypes={expectedEntityTypes} entityIdsToFilterOut={linkedEntityId && [linkedEntityId]} linkEntityTypeId={linkEntityTypeId} + readonly={readonly} /> ); } diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index a31fbf7a238..07760cfdda4 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -26,7 +26,6 @@ import { VirtualizedTable } from "../../../../virtualized-table"; import { virtualizedTableHeaderHeight } from "../../../../virtualized-table/header"; import { isValueIncludedInFilter } from "../../../../virtualized-table/header/filter"; import { useVirtualizedTableFilterState } from "../../../../virtualized-table/use-filter-state"; -import { useEntityEditor } from "../../entity-editor-context"; import { PropertiesTooltip } from "../shared/properties-tooltip"; import { linksTableCellSx, @@ -64,7 +63,7 @@ import type { ClosedMultiEntityTypesDefinitions, ClosedMultiEntityTypesRootMap, } from "@local/hash-graph-sdk/ontology"; -import type { ReactElement } from "react"; +import type { ReactElement, RefObject } from "react"; type OutgoingLinksFieldId = "linkTypes" | "linkedTo" | "linkedToTypes" | "link"; @@ -131,6 +130,7 @@ type OutgoingLinkRow = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; customFields: { [fieldId: string]: string | number }; + slideContainerRef?: RefObject; }; const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => { @@ -171,6 +171,7 @@ const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => { @@ -218,6 +219,7 @@ const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => { @@ -250,16 +252,30 @@ const createRowContent: CreateVirtualizedRowContentFn< type OutgoingLinksTableProps = { closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; + customEntityLinksColumns?: CustomEntityLinksColumn[]; + defaultOutgoingLinkFilters?: Partial; entitySubgraph: Subgraph>; + loadingMore?: boolean; + onEndReached?: () => void; + onEntityClick: (entityId: EntityId) => void; + onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; outgoingLinksAndTargets: LinkEntityAndRightEntity[]; + slideContainerRef?: RefObject; }; export const OutgoingLinksTable = memo( ({ closedMultiEntityTypesDefinitions, closedMultiEntityTypesMap, + customEntityLinksColumns: customColumns, + defaultOutgoingLinkFilters, entitySubgraph, + loadingMore, + onEndReached, + onEntityClick, + onTypeClick, outgoingLinksAndTargets, + slideContainerRef, }: OutgoingLinksTableProps) => { const [sort, setSort] = useState< VirtualizedTableSort @@ -268,13 +284,6 @@ export const OutgoingLinksTable = memo( direction: "asc", }); - const { - customEntityLinksColumns: customColumns, - defaultOutgoingLinkFilters, - onEntityClick, - onTypeClick, - } = useEntityEditor(); - const outputContainerRef = useRef(null); const [outputContainerHeight, setOutputContainerHeight] = useState(400); useLayoutEffect(() => { @@ -475,6 +484,7 @@ export const OutgoingLinksTable = memo( linkEntityProperties, onEntityClick, onTypeClick, + slideContainerRef, targetEntity: rightEntity, targetEntityLabel: rightEntityLabel, targetEntityProperties, @@ -513,6 +523,7 @@ export const OutgoingLinksTable = memo( outgoingLinksAndTargets, onEntityClick, onTypeClick, + slideContainerRef, ]); const [highlightOutgoingLinks, setHighlightOutgoingLinks] = useState( @@ -675,6 +686,8 @@ export const OutgoingLinksTable = memo( createRowContent={createRowContent} filterDefinitions={filterDefinitions} filterValues={filterValues} + loadingMore={loadingMore} + onEndReached={onEndReached} setFilterValues={setFilterValues} rows={rows} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/types.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/types.ts index 953671a089d..b2db8a76316 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/types.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/types.ts @@ -1,3 +1,4 @@ +import type { DraftLinksToCreate } from "../../../shared/use-draft-link-state"; import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; import type { Entity, @@ -6,6 +7,8 @@ import type { VersionedUrl, } from "@blockprotocol/type-system"; import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; +import type { Dispatch, SetStateAction } from "react"; export type LinkAndTargetEntity = { rightEntity: Entity; @@ -27,6 +30,14 @@ export type LinkRow = { rightEntityLabel: string; })[]; entitySubgraph: Subgraph; + /** + * The entity being edited – the source/left entity of these outgoing links. + * Carried on the row so the editable cell editors don't need `useEntityEditor`. + */ + entity: HashEntity; + readonly: boolean; + draftLinksToCreate: DraftLinksToCreate; + setDraftLinksToCreate: Dispatch>; markLinkAsArchived: (linkEntityId: EntityId) => void; onEntityClick: (entityId: EntityId) => void; retryErroredUpload?: () => void; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts index 4285a437190..95efde2cb2c 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-create-get-cell-content.ts @@ -2,19 +2,20 @@ import { GridCellKind } from "@glideapps/glide-data-grid"; import { useTheme } from "@mui/material"; import { useCallback } from "react"; -import { useEntityEditor } from "../../entity-editor-context"; import { linkGridIndexes } from "./constants"; import type { ChipCell } from "../../../../chip-cell"; +import type { EntityEditorProps } from "../../../entity-editor"; import type { SummaryChipCell } from "../../shared/summary-chip-cell"; import type { LinkCell } from "./cells/link-cell"; import type { LinkedWithCell } from "./cells/linked-with-cell"; import type { LinkRow } from "./types"; import type { Item } from "@glideapps/glide-data-grid"; -export const useCreateGetCellContent = () => { - const { readonly, onTypeClick } = useEntityEditor(); - +export const useCreateGetCellContent = ({ + readonly, + onTypeClick, +}: Pick) => { const theme = useTheme(); const createGetCellContent = useCallback( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts index c580034651c..4cd9b3eb8ab 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts @@ -10,28 +10,50 @@ import { generateEntityLabel } from "@local/hash-isomorphic-utils/generate-entit import { useEntityTypesContextRequired } from "../../../../../../shared/entity-types-context/hooks/use-entity-types-context-required"; import { useFileUploads } from "../../../../../../shared/file-upload-context"; -import { useMarkLinkEntityToArchive } from "../../../shared/use-mark-link-entity-to-archive"; -import { useEntityEditor } from "../../entity-editor-context"; +import { createMarkLinkEntityToArchive } from "../../../shared/use-mark-link-entity-to-archive"; +import type { EntityEditorProps } from "../../../entity-editor"; import type { LinkRow } from "./types"; import type { PartialEntityType, VersionedUrl, } from "@blockprotocol/type-system"; +import type { HashEntity } from "@local/hash-graph-sdk/entity"; + +export type UseRowsParams = Pick< + EntityEditorProps, + | "closedMultiEntityType" + | "closedMultiEntityTypesDefinitions" + | "draftLinksToArchive" + | "draftLinksToCreate" + | "entitySubgraph" + | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" + | "onEntityClick" + | "readonly" + | "setDraftLinksToArchive" + | "setDraftLinksToCreate" +> & { + entity: HashEntity; +}; -export const useRows = () => { - const { - closedMultiEntityType, - closedMultiEntityTypesDefinitions, - linkAndDestinationEntitiesClosedMultiEntityTypesMap, - entity, - entitySubgraph, - draftLinksToArchive, +export const useRows = ({ + closedMultiEntityType, + closedMultiEntityTypesDefinitions, + draftLinksToArchive, + draftLinksToCreate, + entity, + entitySubgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap, + onEntityClick, + readonly, + setDraftLinksToArchive, + setDraftLinksToCreate, +}: UseRowsParams) => { + const markLinkEntityToArchive = createMarkLinkEntityToArchive({ draftLinksToCreate, - onEntityClick, - } = useEntityEditor(); - - const markLinkEntityToArchive = useMarkLinkEntityToArchive(); + setDraftLinksToCreate, + setDraftLinksToArchive, + }); const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); @@ -206,6 +228,10 @@ export const useRows = () => { isList: linkSchema.maxItems === undefined || linkSchema.maxItems > 1, expectedEntityTypes, entitySubgraph, + entity, + readonly, + draftLinksToCreate, + setDraftLinksToCreate, markLinkAsArchived: markLinkEntityToArchive, onEntityClick, retryErroredUpload, @@ -217,8 +243,10 @@ export const useRows = () => { closedMultiEntityTypesDefinitions, entitySubgraph, entity, + readonly, draftLinksToArchive, draftLinksToCreate, + setDraftLinksToCreate, isSpecialEntityTypeLookup, linkAndDestinationEntitiesClosedMultiEntityTypesMap, markLinkEntityToArchive, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/shared/properties-tooltip.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/shared/properties-tooltip.tsx index 98e6e981575..512bbf20df7 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/shared/properties-tooltip.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/shared/properties-tooltip.tsx @@ -2,21 +2,19 @@ import { Box, Stack, Tooltip, Typography } from "@mui/material"; import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; -import { useEntityEditor } from "../../entity-editor-context"; - -import type { ReactElement } from "react"; +import type { ReactElement, RefObject } from "react"; export const PropertiesTooltip = ({ children, entityType, properties, + slideContainerRef, }: { children: ReactElement; properties: { [propertyTitle: string]: string }; entityType: "link entity" | "source entity" | "target entity"; + slideContainerRef?: RefObject; }) => { - const { slideContainerRef } = useEntityEditor(); - return ( >; + +type LinkPage = { /** - * Incoming links (where the entity is the link's right/target) and their - * left/source entities. + * A key identifying which cursor produced this page, so that a page is + * replaced (rather than duplicated) if its query completes more than once + * (e.g. a cache hit followed by a network response). */ - incoming: { - edges: [ - { kind: "has-right-entity", direction: "incoming" }, - { kind: "has-left-entity", direction: "outgoing" }, - ], - }, -} satisfies Record<"outgoing" | "incoming", EntityTraversalPath>; + cursorKey: string; + count?: number; + linkEntities: HashLinkEntity[]; + nextCursor: EntityQueryCursor | null; + subgraph: LinksSubgraph; + typesMap: ClosedMultiEntityTypesRootMap; + definitions?: ClosedMultiEntityTypesDefinitions; +}; + +const initialCursorKey = "__initial__"; + +const cursorKeyFor = (cursor: EntityQueryCursor | undefined) => + cursor ? JSON.stringify(cursor) : initialCursorKey; /** - * Fetches the incoming or outgoing links (and their source/target entities and - * resolved types) for an entity, for display in the readonly link tables. + * Shallow-merge two `{ [baseId]: { [revisionId]: value } }` records (the shape + * of a subgraph's `vertices` and `edges`), so that revisions from later pages + * are added without dropping those from earlier pages. + */ +const mergeRecordOfRecords = ( + a: Record>, + b: Record>, +): Record> => { + const result: Record> = { ...a }; + for (const [baseId, revisions] of Object.entries(b)) { + result[baseId] = { ...(result[baseId] ?? {}), ...revisions }; + } + return result; +}; + +const mergeSubgraphs = (pages: LinkPage[]): LinksSubgraph | undefined => { + const [first, ...rest] = pages; + if (!first) { + return undefined; + } + + return rest.reduce((merged, page) => { + const vertices = mergeRecordOfRecords( + merged.vertices as Record>, + page.subgraph.vertices as Record>, + ) as LinksSubgraph["vertices"]; + + const edges = mergeRecordOfRecords( + merged.edges as Record>, + page.subgraph.edges as Record>, + ) as LinksSubgraph["edges"]; + + return { ...merged, vertices, edges }; + }, first.subgraph); +}; + +/** + * Fetches an entity's incoming or outgoing links a page at a time, for display + * in the readonly link tables. + * + * The query is rooted on the *link entities* (filtered by the endpoint that is + * the entity being viewed) rather than on the entity itself, so that `limit` / + * `cursor` / `includeCount` paginate the links. Each page is accumulated, and + * `loadMore` fetches the next page. * - * This data used to be fetched as part of the main entity query in - * `entity.tsx`, but is now fetched by the link tables themselves when they are - * readonly, so that the main entity query (and the editor shell) does not have - * to wait on – or grow with – the entity's link data. + * This is only used when the link data is readonly; when the entity is editable + * the links are part of the editor subgraph and are not paginated. */ export const useEntityLinks = ({ direction, @@ -59,14 +111,47 @@ export const useEntityLinks = ({ entityId: EntityId; skip?: boolean; }): { - loading: boolean; - linksSubgraph?: Subgraph>; + /** Whether the first page is still loading. */ + initialLoading: boolean; + /** Whether a subsequent page is being fetched. */ + loadingMore: boolean; + /** Fetch the next page (no-op if there are no more pages). */ + loadMore: () => void; + /** Whether there are more pages to fetch. */ + hasMore: boolean; + /** The total number of links matching the query. */ + count?: number; + /** The accumulated link entities loaded so far. */ + linkEntities?: HashLinkEntity[]; + /** A subgraph containing the loaded links and their source/target entities. */ + subgraph?: LinksSubgraph; linkAndDestinationEntitiesClosedMultiEntityTypesMap?: ClosedMultiEntityTypesRootMap; closedMultiEntityTypesDefinitions?: ClosedMultiEntityTypesDefinitions; } => { const [webId, entityUuid, draftId] = splitEntityId(entityId); - const { data, loading } = useQuery< + const [cursor, setCursor] = useState( + undefined, + ); + const [pages, setPages] = useState([]); + + /** + * Reset accumulated pages when the entity or direction changes (the query + * identity, and therefore the cursors, are no longer valid). + */ + useEffect(() => { + setCursor(undefined); + setPages([]); + }, [entityId, direction]); + + /** + * The endpoint of the link that is the entity being viewed: its left/source + * entity for outgoing links, its right/target entity for incoming links. + */ + const filterEndpoint = + direction === "outgoing" ? "leftEntity" : "rightEntity"; + + const { loading } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables >(queryEntitySubgraphQuery, { @@ -77,44 +162,154 @@ export const useEntityLinks = ({ filter: { all: [ { - equal: [{ path: ["uuid"] }, { parameter: entityUuid }], + equal: [ + { path: [filterEndpoint, "uuid"] }, + { parameter: entityUuid }, + ], }, { - equal: [{ path: ["webId"] }, { parameter: webId }], + equal: [ + { path: [filterEndpoint, "webId"] }, + { parameter: webId }, + ], }, - ...(draftId + ...(direction === "incoming" ? [ + ignoreNoisySystemTypesFilter, { - equal: [{ path: ["draftId"] }, { parameter: draftId }], + notEqual: [ + { path: ["leftEntity", "type", "versionedUrl"] }, + { + parameter: systemEntityTypes.claim.entityTypeId, + }, + ], }, ] : []), ], }, + cursor, + limit: linksTablePageSize, + includeCount: true, + sortingPaths: [ + { path: ["uuid"], ordering: "ascending", nulls: "last" }, + ], temporalAxes: currentTimeInstantTemporalAxes, - traversalPaths: [traversalPathByDirection[direction]], + traversalPaths: [ + { edges: [{ kind: "has-right-entity", direction: "outgoing" }] }, + { edges: [{ kind: "has-left-entity", direction: "outgoing" }] }, + ], includeDrafts: !!draftId, includeEntityTypes: "resolvedWithDataTypeChildren", includePermissions: false, }, }, + onCompleted: (data) => { + const response = deserializeQueryEntitySubgraphResponse( + data.queryEntitySubgraph, + ); + + const page: LinkPage = { + cursorKey: cursorKeyFor(cursor), + count: data.queryEntitySubgraph.count ?? undefined, + linkEntities: getRoots(response.subgraph).map( + (rootEntity) => new HashLinkEntity(rootEntity), + ), + nextCursor: data.queryEntitySubgraph.cursor ?? null, + subgraph: response.subgraph, + typesMap: data.queryEntitySubgraph.closedMultiEntityTypes ?? {}, + definitions: data.queryEntitySubgraph.definitions ?? undefined, + }; + + setPages((previousPages) => { + if (!cursor) { + // First page – replace any accumulated pages. + return [page]; + } + + const existingIndex = previousPages.findIndex( + (previousPage) => previousPage.cursorKey === page.cursorKey, + ); + + if (existingIndex !== -1) { + const next = [...previousPages]; + next[existingIndex] = page; + return next; + } + + return [...previousPages, page]; + }); + }, }); - return useMemo(() => { - if (!data) { - return { loading }; + const accumulated = useMemo(() => { + if (pages.length === 0) { + return undefined; + } + + const seenLinkIds = new Set(); + const linkEntities: HashLinkEntity[] = []; + for (const page of pages) { + for (const linkEntity of page.linkEntities) { + const linkEntityId = linkEntity.metadata.recordId.entityId; + if (!seenLinkIds.has(linkEntityId)) { + seenLinkIds.add(linkEntityId); + linkEntities.push(linkEntity); + } + } + } + + const typesMap: ClosedMultiEntityTypesRootMap = {}; + let definitions: ClosedMultiEntityTypesDefinitions | undefined; + for (const page of pages) { + Object.assign(typesMap, page.typesMap); + if (page.definitions) { + definitions = definitions + ? { + dataTypes: { + ...definitions.dataTypes, + ...page.definitions.dataTypes, + }, + entityTypes: { + ...definitions.entityTypes, + ...page.definitions.entityTypes, + }, + propertyTypes: { + ...definitions.propertyTypes, + ...page.definitions.propertyTypes, + }, + } + : page.definitions; + } } - const { definitions, closedMultiEntityTypes } = data.queryEntitySubgraph; + const lastPage = pages[pages.length - 1]!; return { - loading, - linksSubgraph: deserializeQueryEntitySubgraphResponse( - data.queryEntitySubgraph, - ).subgraph, - linkAndDestinationEntitiesClosedMultiEntityTypesMap: - closedMultiEntityTypes ?? undefined, - closedMultiEntityTypesDefinitions: definitions ?? undefined, + linkEntities, + subgraph: mergeSubgraphs(pages), + typesMap, + definitions, + count: lastPage.count, + nextCursor: lastPage.nextCursor, }; - }, [data, loading]); + }, [pages]); + + const loadMore = useCallback(() => { + if (accumulated?.nextCursor) { + setCursor(accumulated.nextCursor); + } + }, [accumulated?.nextCursor]); + + return { + initialLoading: loading && pages.length === 0, + loadingMore: loading && cursor !== undefined, + loadMore, + hasMore: !!accumulated?.nextCursor, + count: accumulated?.count, + linkEntities: accumulated?.linkEntities, + subgraph: accumulated?.subgraph, + linkAndDestinationEntitiesClosedMultiEntityTypesMap: accumulated?.typesMap, + closedMultiEntityTypesDefinitions: accumulated?.definitions, + }; }; diff --git a/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts b/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts index bf803d22dcf..a78927770f1 100644 --- a/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts +++ b/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts @@ -1,12 +1,28 @@ -import { useEntityEditor } from "../entity-editor/entity-editor-context"; - +import type { + DraftLinksToCreate, + DraftLinkState, +} from "./use-draft-link-state"; import type { EntityId } from "@blockprotocol/type-system"; -export const useMarkLinkEntityToArchive = () => { - const { draftLinksToCreate, setDraftLinksToCreate, setDraftLinksToArchive } = - useEntityEditor(); - - const markLinkEntityToArchive = (linkEntityId: EntityId) => { +/** + * Create a function that marks a link entity for archival: if the link is an + * as-yet-uncreated draft it is removed from the draft-create list, otherwise it + * is added to the draft-archive list. + * + * This was previously a hook that read the draft link state from + * `useEntityEditor`; it is now a plain factory so that the draft state can be + * passed in as props/row data instead of via context. + */ +export const createMarkLinkEntityToArchive = + ({ + draftLinksToCreate, + setDraftLinksToCreate, + setDraftLinksToArchive, + }: Pick< + DraftLinkState, + "draftLinksToCreate" | "setDraftLinksToCreate" | "setDraftLinksToArchive" + >) => + (linkEntityId: EntityId) => { const foundIndex = draftLinksToCreate.findIndex( (item) => item.linkEntity.metadata.recordId.entityId === linkEntityId, ); @@ -22,5 +38,6 @@ export const useMarkLinkEntityToArchive = () => { } }; - return markLinkEntityToArchive; -}; +export type MarkLinkEntityToArchive = (linkEntityId: EntityId) => void; + +export type { DraftLinksToCreate }; diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index e46d1b94da8..564f594ecd5 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -1,4 +1,4 @@ -import { Box } from "@mui/material"; +import { Box, CircularProgress } from "@mui/material"; /* eslint-disable no-restricted-imports */ import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -22,7 +22,7 @@ import type { } from "./virtualized-table/header/sort"; import type { SxProps, Theme } from "@mui/material"; import type { ComponentPropsWithoutRef, ReactElement } from "react"; -import type { TableComponents } from "react-virtuoso"; +import type { FollowOutput, ListRange, TableComponents } from "react-virtuoso"; export const defaultCellSx = { padding: "5px 14px", @@ -130,6 +130,40 @@ type VirtualizedTableProps< columns?: VirtualizedTableColumn[]; fixedColumns?: number; EmptyPlaceholder?: () => ReactElement; + /** + * Called when the user scrolls to the end of the loaded rows, for fetching + * the next page of data. + */ + onEndReached?: () => void; + /** + * Called when the visible row range changes. Useful for triggering paged + * loading before the very end of the data is reached (e.g. when the scroll + * content is padded with placeholder rows up to a known total count). + */ + onRangeChange?: (range: ListRange) => void; + /** + * Called when the user starts (`true`) or stops (`false`) scrolling. Useful + * for gating paged loading to deliberate user scrolls. + */ + onIsScrolling?: (isScrolling: boolean) => void; + /** + * Auto-scroll behaviour when new rows are appended to the end. Defaults to + * `"smooth"`. Set to `false` for paged tables, where appending a page should + * not pull the viewport down to the new bottom. + */ + followOutput?: FollowOutput; + /** + * Whether a further page is currently being fetched – shows a loading + * indicator at the foot of the table. + */ + loadingMore?: boolean; + /** + * When all rows are the same known height, set this so virtuoso uses it + * directly instead of measuring each row. This avoids the scroll position + * recalculating (and jumping) when placeholder rows are swapped for loaded + * data of a slightly different measured height. + */ + fixedItemHeight?: number; rows: VirtualizedTableRow[]; } & TableSortProps & Partial>; @@ -147,6 +181,12 @@ export const VirtualizedTable = < columns, fixedColumns, EmptyPlaceholder, + onEndReached, + onRangeChange, + onIsScrolling, + followOutput = "smooth", + loadingMore, + fixedItemHeight, rows, filterDefinitions, filterValues, @@ -188,14 +228,36 @@ export const VirtualizedTable = < const context = useMemo(() => ({ columns: columns ?? [] }), [columns]); + const fixedFooterContent = useMemo(() => { + if (!loadingMore) { + return undefined; + } + + return () => ( + + + + + + ); + }, [columns?.length, loadingMore]); + return ( Date: Fri, 19 Jun 2026 15:29:48 +0200 Subject: [PATCH 03/34] Use readonly outgoing links --- .../links-section/outgoing-links-section.tsx | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index fa8c08f1100..b84a4d04cf0 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -192,24 +192,22 @@ export const OutgoingLinksSection = ({ } let outgoingLinksAndTargets: LinkEntityAndRightEntity[] | null = null; - if (readonly) { - outgoingLinksAndTargets = selfFetchLinks - ? linkEntities!.map((linkEntity) => ({ - linkEntity: [linkEntity], - rightEntity: - getRightEntityForLinkEntity( - entitySubgraph, - linkEntity.metadata.recordId.entityId, - ) ?? [], - })) - : getOutgoingLinkAndTargetEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ); - } + outgoingLinksAndTargets = selfFetchLinks + ? linkEntities!.map((linkEntity) => ({ + linkEntity: [linkEntity], + rightEntity: + getRightEntityForLinkEntity( + entitySubgraph, + linkEntity.metadata.recordId.entityId, + ) ?? [], + })) + : getOutgoingLinkAndTargetEntities( + entitySubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + entitySubgraph.temporalAxes.resolved.variable.axis + ], + ); return ( } > - {rows.length && !readonly ? ( + {rows.length && !readonly && !selfFetchLinks ? ( Date: Fri, 19 Jun 2026 15:48:49 +0200 Subject: [PATCH 04/34] Remove client side sorting in link sections --- .../links-section/incoming-links-section.tsx | 40 +++ .../incoming-links-table.tsx | 283 +++++++++++------- .../links-section/outgoing-links-section.tsx | 44 ++- .../readonly-outgoing-links-table.tsx | 271 ++++++++++------- .../links-section/use-entity-links.ts | 54 +++- 5 files changed, 459 insertions(+), 233 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 3ae333c38f7..4157f0d414d 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -1,4 +1,5 @@ import { Box, CircularProgress, Stack } from "@mui/material"; +import { useMemo, useState } from "react"; import { getIncomingLinkAndSourceEntities, @@ -13,8 +14,11 @@ import { LinksSectionEmptyState } from "../../shared/links-section-empty-state"; import { IncomingLinksTable } from "./incoming-links-section/incoming-links-table"; import { useEntityLinks } from "./use-entity-links"; +import type { VirtualizedTableSort } from "../../../virtualized-table/header/sort"; import type { EntityEditorProps } from "../../entity-editor"; +import type { IncomingLinksFieldId } from "./incoming-links-section/incoming-links-table"; import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; +import type { EntityQuerySortingRecord } from "@local/hash-graph-client"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; @@ -49,6 +53,38 @@ export const IncomingLinksSection = ({ selfFetchLinks, slideContainerRef, }: IncomingLinksSectionProps) => { + /** + * When links are fetched here (paginated), sorting is applied server-side, so + * the sort state lives here in order to drive the query. The graph API can + * only sort the link entities by their own label (the API cannot sort by the + * source entity, and the link type column shows the inverse title which the + * API cannot sort by), so we default to – and only support – the link + * entity's label. + */ + const [sort, setSort] = useState>({ + fieldId: "link", + direction: "asc", + }); + + /** + * Translate the table sort into graph-query sorting paths, which apply to the + * query's root (the link entities). Only `link` (the link entity label) is + * sortable server-side. + */ + const sortingPaths = useMemo(() => { + if (!selfFetchLinks) { + return undefined; + } + + return [ + { + path: ["label"], + ordering: sort.direction === "asc" ? "ascending" : "descending", + nulls: "last", + }, + ]; + }, [selfFetchLinks, sort.direction]); + /** * When the entity is readonly we fetch the link data here (paginated), so it * does not need to be part of the main entity query. When editable, the link @@ -70,6 +106,7 @@ export const IncomingLinksSection = ({ direction: "incoming", entityId: entity.metadata.recordId.entityId, skip: !selfFetchLinks, + sortingPaths, }); if ( @@ -164,7 +201,10 @@ export const IncomingLinksSection = ({ onEndReached={selfFetchLinks && hasMore ? loadMore : undefined} onEntityClick={onEntityClick} onTypeClick={onTypeClick} + serverSideSorting={selfFetchLinks} + setSort={selfFetchLinks ? setSort : undefined} slideContainerRef={slideContainerRef} + sort={selfFetchLinks ? sort : undefined} totalLinkCount={selfFetchLinks ? fetchedCount : undefined} /> ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index 8a18a133091..d918c4ff270 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -67,7 +67,26 @@ import type { ClosedMultiEntityTypesRootMap, } from "@local/hash-graph-sdk/ontology"; -type FieldId = "linkedFrom" | "linkTypes" | "linkedFromTypes" | "link"; +export type IncomingLinksFieldId = + | "linkedFrom" + | "linkTypes" + | "linkedFromTypes" + | "link"; + +type FieldId = IncomingLinksFieldId; + +/** + * The columns that can be sorted server-side. Sorting paths are applied to the + * query's root (the link entities), and the graph API only exposes `label` and + * `typeTitle` as sortable tokens (it cannot traverse to the source entity). + * + * Only the link entity's label (`link`) is included: the API cannot sort by the + * source entity (`linkedFrom`/`linkedFromTypes`), and although it can sort by + * the link's `typeTitle`, the "Link type" column displays the *inverse* title, + * so a `typeTitle` sort would not match what is shown. When sorting is + * server-side the other columns are therefore not sortable. + */ +const serverSortableFieldIds: FieldId[] = ["link"]; const staticColumns: VirtualizedTableColumn[] = [ { @@ -295,6 +314,18 @@ type IncomingLinksTableProps = { onEndReached?: () => void; onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; + /** + * When `true`, the table is backed by a paginated server-side query: + * - Sorting is applied by the query (the rows are already ordered), so the + * table does not re-sort locally and only exposes the columns the graph API + * can sort by. `sort`/`setSort` must be provided (controlled) so the parent + * can re-query when the sort changes. + * - Filtering is disabled entirely, because client-side filters would only + * ever see the currently loaded pages. + */ + serverSideSorting?: boolean; + sort?: VirtualizedTableSort; + setSort?: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; /** * The total number of links matching the query, including those not yet @@ -318,14 +349,26 @@ export const IncomingLinksTable = memo( onEndReached, onEntityClick, onTypeClick, + serverSideSorting = false, + sort: controlledSort, + setSort: controlledSetSort, slideContainerRef, totalLinkCount, }: IncomingLinksTableProps) => { - const [sort, setSort] = useState>({ + const [internalSort, setInternalSort] = useState< + VirtualizedTableSort + >({ fieldId: "linkedFrom", direction: "asc", }); + /** + * When sorting server-side the sort is controlled by the parent (so it can + * re-query); otherwise it is local state. + */ + const sort = controlledSort ?? internalSort; + const setSort = controlledSetSort ?? setInternalSort; + const outputContainerRef = useRef(null); const [outputContainerHeight, setOutputContainerHeight] = useState(400); useLayoutEffect(() => { @@ -581,120 +624,127 @@ export const IncomingLinksTable = memo( filterDefinitions, }); - const rows = useMemo( - () => - unsortedRows - .filter((row) => { - for (const [fieldId, currentValue] of typedEntries(filterValues)) { - switch (fieldId) { - case "linkTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.linkEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "linkedFrom": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.sourceEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - case "linkedFromTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.sourceEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "link": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.linkEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - } - } - - return true; - }) - .sort((a, b) => { - const field = sort.fieldId; - const direction = sort.direction === "asc" ? 1 : -1; + const rows = useMemo(() => { + if (serverSideSorting) { + /** + * In self-fetch mode the rows are a server-ordered, paginated page: + * sorting is applied by the query and filtering is disabled entirely + * (client-side filtering would only ever see the loaded pages), so the + * rows are used as-is. + */ + return unsortedRows; + } - switch (field) { + return unsortedRows + .filter((row) => { + for (const [fieldId, currentValue] of typedEntries(filterValues)) { + switch (fieldId) { case "linkTypes": { - const aValue = - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ - a.data.linkEntityTypes[0]!.inverse?.title || - a.data.linkEntityTypes[0]!.title; - - const bValue = - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ - b.data.linkEntityTypes[0]!.inverse?.title || - b.data.linkEntityTypes[0]!.title; - - return aValue.localeCompare(bValue) * direction; - } - case "linkedFromTypes": { - return ( - a.data.sourceEntityTypes[0]!.title.localeCompare( - b.data.sourceEntityTypes[0]!.title, - ) * direction - ); + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: row.data.linkEntity.metadata.entityTypeIds, + }) + ) { + return false; + } + break; } case "linkedFrom": { - return ( - a.data.sourceEntityLabel.localeCompare( - b.data.sourceEntityLabel, - ) * direction - ); + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: + row.data.sourceEntity.metadata.recordId.entityId, + }) + ) { + return false; + } + break; } - case "link": { - return ( - a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * - direction - ); + case "linkedFromTypes": { + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: row.data.sourceEntity.metadata.entityTypeIds, + }) + ) { + return false; + } + break; } - default: { - const customFieldA = a.data.customFields[field]; - const customFieldB = b.data.customFields[field]; + case "link": { if ( - typeof customFieldA === "number" && - typeof customFieldB === "number" + !isValueIncludedInFilter({ + currentValue, + valueToCheck: + row.data.linkEntity.metadata.recordId.entityId, + }) ) { - return (customFieldA - customFieldB) * direction; + return false; } - return ( - String(customFieldA).localeCompare(String(customFieldB)) * - direction - ); + break; } } - }), - [filterValues, sort, unsortedRows], - ); + } + + return true; + }) + .sort((a, b) => { + const field = sort.fieldId; + const direction = sort.direction === "asc" ? 1 : -1; + + switch (field) { + case "linkTypes": { + const aValue = + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ + a.data.linkEntityTypes[0]!.inverse?.title || + a.data.linkEntityTypes[0]!.title; + + const bValue = + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ + b.data.linkEntityTypes[0]!.inverse?.title || + b.data.linkEntityTypes[0]!.title; + + return aValue.localeCompare(bValue) * direction; + } + case "linkedFromTypes": { + return ( + a.data.sourceEntityTypes[0]!.title.localeCompare( + b.data.sourceEntityTypes[0]!.title, + ) * direction + ); + } + case "linkedFrom": { + return ( + a.data.sourceEntityLabel.localeCompare( + b.data.sourceEntityLabel, + ) * direction + ); + } + case "link": { + return ( + a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * + direction + ); + } + default: { + const customFieldA = a.data.customFields[field]; + const customFieldB = b.data.customFields[field]; + if ( + typeof customFieldA === "number" && + typeof customFieldB === "number" + ) { + return (customFieldA - customFieldB) * direction; + } + return ( + String(customFieldA).localeCompare(String(customFieldB)) * + direction + ); + } + } + }); + }, [filterValues, serverSideSorting, sort, unsortedRows]); /** * Pad the scroll content with placeholder rows for not-yet-loaded links, so @@ -745,8 +795,21 @@ export const IncomingLinksTable = memo( filterValues.linkTypes.has(column.appliesToEntityTypeId), ); - return createColumns(applicableCustomColumns ?? []); - }, [filterValues, customColumns]); + const createdColumns = createColumns(applicableCustomColumns ?? []); + + if (!serverSideSorting) { + return createdColumns; + } + + /** + * When sorting is server-side, only the columns the graph API can sort by + * are sortable. + */ + return createdColumns.map((column) => ({ + ...column, + sortable: serverSortableFieldIds.includes(column.id), + })); + }, [filterValues, customColumns, serverSideSorting]); /** * Whether scrolling to the bottom may trigger a load of the next page. It is @@ -762,8 +825,8 @@ export const IncomingLinksTable = memo( { const [showSearch, setShowSearch] = useState(false); + /** + * When links are fetched here (paginated), sorting is applied server-side, so + * the sort state lives here in order to drive the query. The default mirrors + * what the API can sort the link entities by – only their own label and type + * title are available (the API cannot sort by the target entity), so we + * default to the link entity's label. + */ + const [sort, setSort] = useState>({ + fieldId: "link", + direction: "asc", + }); + + /** + * Translate the table sort into graph-query sorting paths, which apply to the + * query's root (the link entities). Only `link` (the link entity label) and + * `linkTypes` (its type title) are sortable server-side. + */ + const sortingPaths = useMemo(() => { + if (!selfFetchLinks) { + return undefined; + } + + return [ + { + path: sort.fieldId === "linkTypes" ? ["typeTitle"] : ["label"], + ordering: sort.direction === "asc" ? "ascending" : "descending", + nulls: "last", + }, + ]; + }, [selfFetchLinks, sort]); + /** * When the entity is readonly we fetch the link data here (paginated), so it * does not need to be part of the main entity query. When editable, the link @@ -95,6 +129,7 @@ export const OutgoingLinksSection = ({ direction: "outgoing", entityId: entity.metadata.recordId.entityId, skip: !selfFetchLinks, + sortingPaths, }); const rows = useRows({ @@ -117,8 +152,8 @@ export const OutgoingLinksSection = ({ const sortRows = useCallback< SortGridRows - >((unsortedRows, sort) => { - const { columnKey, direction } = sort; + >((unsortedRows, gridSort) => { + const { columnKey, direction } = gridSort; return unsortedRows.toSorted((a, b) => { let firstString = ""; @@ -265,7 +300,10 @@ export const OutgoingLinksSection = ({ onEntityClick={onEntityClick} onTypeClick={onTypeClick} outgoingLinksAndTargets={outgoingLinksAndTargets} + serverSideSorting={selfFetchLinks} + setSort={selfFetchLinks ? setSort : undefined} slideContainerRef={slideContainerRef} + sort={selfFetchLinks ? sort : undefined} /> ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 07760cfdda4..94d994ebafd 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -65,7 +65,20 @@ import type { } from "@local/hash-graph-sdk/ontology"; import type { ReactElement, RefObject } from "react"; -type OutgoingLinksFieldId = "linkTypes" | "linkedTo" | "linkedToTypes" | "link"; +export type OutgoingLinksFieldId = + | "linkTypes" + | "linkedTo" + | "linkedToTypes" + | "link"; + +/** + * The columns that can be sorted server-side. Sorting paths are applied to the + * query's root (the link entities), and the graph API only exposes `label` and + * `typeTitle` as sortable tokens (it cannot traverse to the target entity), so + * only the link entity's label (`link`) and type title (`linkTypes`) are + * available. When sorting is server-side the other columns are not sortable. + */ +const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes", "link"]; export type OutgoingLinksFilterValues = VirtualizedTableFilterValuesByFieldId; @@ -260,6 +273,18 @@ type OutgoingLinksTableProps = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; outgoingLinksAndTargets: LinkEntityAndRightEntity[]; + /** + * When `true`, the table is backed by a paginated server-side query: + * - Sorting is applied by the query (the rows are already ordered), so the + * table does not re-sort locally and only exposes the columns the graph API + * can sort by. `sort`/`setSort` must be provided (controlled) so the parent + * can re-query when the sort changes. + * - Filtering is disabled entirely, because client-side filters would only + * ever see the currently loaded pages. + */ + serverSideSorting?: boolean; + sort?: VirtualizedTableSort; + setSort?: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; }; @@ -275,15 +300,25 @@ export const OutgoingLinksTable = memo( onEntityClick, onTypeClick, outgoingLinksAndTargets, + serverSideSorting = false, + sort: controlledSort, + setSort: controlledSetSort, slideContainerRef, }: OutgoingLinksTableProps) => { - const [sort, setSort] = useState< + const [internalSort, setInternalSort] = useState< VirtualizedTableSort >({ fieldId: "linkedTo", direction: "asc", }); + /** + * When sorting server-side the sort is controlled by the parent (so it can + * re-query); otherwise it is local state. + */ + const sort = controlledSort ?? internalSort; + const setSort = controlledSetSort ?? setInternalSort; + const outputContainerRef = useRef(null); const [outputContainerHeight, setOutputContainerHeight] = useState(400); useLayoutEffect(() => { @@ -542,114 +577,119 @@ export const OutgoingLinksTable = memo( filterDefinitions, }); - const rows = useMemo( - () => - unsortedRows - .filter((row) => { - for (const [fieldId, currentValue] of typedEntries(filterValues)) { - switch (fieldId) { - case "linkTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.linkEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "linkedTo": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.targetEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - case "linkedToTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.targetEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "link": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.linkEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - } - } + const rows = useMemo(() => { + if (serverSideSorting) { + /** + * In self-fetch mode the rows are a server-ordered, paginated page: + * sorting is applied by the query and filtering is disabled entirely + * (client-side filtering would only ever see the loaded pages), so the + * rows are used as-is. + */ + return unsortedRows; + } - return true; - }) - .sort((a, b) => { - const field = sort.fieldId; - const direction = sort.direction === "asc" ? 1 : -1; - - switch (field) { - case "linkTypes": { - return ( - a.data.linkEntityTypes[0]!.title.localeCompare( - b.data.linkEntityTypes[0]!.title, - ) * direction - ); + const filteredRows = unsortedRows.filter((row) => { + for (const [fieldId, currentValue] of typedEntries(filterValues)) { + switch (fieldId) { + case "linkTypes": { + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: row.data.linkEntity.metadata.entityTypeIds, + }) + ) { + return false; } - case "linkedToTypes": { - return ( - a.data.targetEntityTypes[0]!.title.localeCompare( - b.data.targetEntityTypes[0]!.title, - ) * direction - ); - } - case "linkedTo": { - return ( - a.data.targetEntityLabel.localeCompare( - b.data.targetEntityLabel, - ) * direction - ); + break; + } + case "linkedTo": { + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: + row.data.targetEntity.metadata.recordId.entityId, + }) + ) { + return false; } - case "link": { - return ( - a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * - direction - ); + break; + } + case "linkedToTypes": { + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: row.data.targetEntity.metadata.entityTypeIds, + }) + ) { + return false; } - default: { - const customFieldA = a.data.customFields[field]; - const customFieldB = b.data.customFields[field]; - if ( - typeof customFieldA === "number" && - typeof customFieldB === "number" - ) { - return (customFieldA - customFieldB) * direction; - } - return ( - String(customFieldA).localeCompare(String(customFieldB)) * - direction - ); + break; + } + case "link": { + if ( + !isValueIncludedInFilter({ + currentValue, + valueToCheck: row.data.linkEntity.metadata.recordId.entityId, + }) + ) { + return false; } + break; } - }), - [filterValues, sort, unsortedRows], - ); + } + } + + return true; + }); + + return filteredRows.sort((a, b) => { + const field = sort.fieldId; + const direction = sort.direction === "asc" ? 1 : -1; + + switch (field) { + case "linkTypes": { + return ( + a.data.linkEntityTypes[0]!.title.localeCompare( + b.data.linkEntityTypes[0]!.title, + ) * direction + ); + } + case "linkedToTypes": { + return ( + a.data.targetEntityTypes[0]!.title.localeCompare( + b.data.targetEntityTypes[0]!.title, + ) * direction + ); + } + case "linkedTo": { + return ( + a.data.targetEntityLabel.localeCompare(b.data.targetEntityLabel) * + direction + ); + } + case "link": { + return ( + a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * + direction + ); + } + default: { + const customFieldA = a.data.customFields[field]; + const customFieldB = b.data.customFields[field]; + if ( + typeof customFieldA === "number" && + typeof customFieldB === "number" + ) { + return (customFieldA - customFieldB) * direction; + } + return ( + String(customFieldA).localeCompare(String(customFieldB)) * + direction + ); + } + } + }); + }, [filterValues, serverSideSorting, sort, unsortedRows]); const columns = useMemo(() => { const applicableCustomColumns = customColumns?.filter( @@ -658,8 +698,21 @@ export const OutgoingLinksTable = memo( filterValues.linkTypes.has(column.appliesToEntityTypeId), ); - return createColumns(applicableCustomColumns ?? []); - }, [filterValues, customColumns]); + const createdColumns = createColumns(applicableCustomColumns ?? []); + + if (!serverSideSorting) { + return createdColumns; + } + + /** + * When sorting is server-side, only the columns the graph API can sort by + * are sortable. + */ + return createdColumns.map((column) => ({ + ...column, + sortable: serverSortableFieldIds.includes(column.id), + })); + }, [filterValues, customColumns, serverSideSorting]); const height = Math.min( maxLinksTableHeight, @@ -684,11 +737,11 @@ export const OutgoingLinksTable = memo( cursor ? JSON.stringify(cursor) : initialCursorKey; @@ -106,10 +120,17 @@ export const useEntityLinks = ({ direction, entityId, skip = false, + sortingPaths, }: { direction: "outgoing" | "incoming"; entityId: EntityId; skip?: boolean; + /** + * How to sort the links server-side. A `uuid` tiebreaker is always appended + * so that pagination remains stable. Changing this resets pagination (the + * accumulated pages and their cursors are no longer valid). + */ + sortingPaths?: EntityQuerySortingRecord[]; }): { /** Whether the first page is still loading. */ initialLoading: boolean; @@ -136,13 +157,26 @@ export const useEntityLinks = ({ const [pages, setPages] = useState([]); /** - * Reset accumulated pages when the entity or direction changes (the query - * identity, and therefore the cursors, are no longer valid). + * Reset accumulated pages when the entity, direction or sort changes (the + * query identity, and therefore the cursors, are no longer valid). + * + * Done during render rather than in an effect so that the stale cursor is + * never sent alongside the new query (which would either error or produce a + * page that does not belong to the new ordering). */ - useEffect(() => { - setCursor(undefined); - setPages([]); - }, [entityId, direction]); + const resetKey = `${entityId}:${direction}:${JSON.stringify( + sortingPaths ?? null, + )}`; + const previousResetKey = useRef(resetKey); + if (previousResetKey.current !== resetKey) { + previousResetKey.current = resetKey; + if (cursor !== undefined) { + setCursor(undefined); + } + if (pages.length > 0) { + setPages([]); + } + } /** * The endpoint of the link that is the entity being viewed: its left/source @@ -191,9 +225,7 @@ export const useEntityLinks = ({ cursor, limit: linksTablePageSize, includeCount: true, - sortingPaths: [ - { path: ["uuid"], ordering: "ascending", nulls: "last" }, - ], + sortingPaths: [...(sortingPaths ?? []), uuidSortingPath], temporalAxes: currentTimeInstantTemporalAxes, traversalPaths: [ { edges: [{ kind: "has-right-entity", direction: "outgoing" }] }, From 46ff45bb7d02c525e1a74055bd463e7062665a42 Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 16:00:30 +0200 Subject: [PATCH 05/34] Fix seed data win-rate logic --- apps/hash-api/src/seed-data/seed-crm-data.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/hash-api/src/seed-data/seed-crm-data.ts b/apps/hash-api/src/seed-data/seed-crm-data.ts index efd1976325e..01669122f7d 100644 --- a/apps/hash-api/src/seed-data/seed-crm-data.ts +++ b/apps/hash-api/src/seed-data/seed-crm-data.ts @@ -1282,6 +1282,13 @@ const seedCrmData = async () => { }), ); + // Open stages ramp up linearly; closed stages take their literal win + // probability (Closed Won = certain win, Closed Lost = no chance) rather + // than continuing the linear scale, which would otherwise mislabel + // "Closed Lost" as a 100% win. + const openStageCount = stageNames.filter( + (name) => !name.startsWith("Closed"), + ).length; const stages = await batchMap([...stageNames], (name, index) => makeEntity(stageType.schema.$id, { [nameBaseUrl]: value(dt.text, name), @@ -1289,7 +1296,11 @@ const seedCrmData = async () => { [stageOrderProp.metadata.recordId.baseUrl]: value(dt.number, index + 1), [defaultProbabilityProp.metadata.recordId.baseUrl]: value( dt.percentage, - Math.round((index / (stageNames.length - 1)) * 100), + name === "Closed Won" + ? 100 + : name === "Closed Lost" + ? 0 + : Math.round((index / (openStageCount - 1)) * 90), ), [isClosedProp.metadata.recordId.baseUrl]: value( dt.boolean, From 667393e73a71ca6b1e88832f89936d84121fd340 Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 16:14:03 +0200 Subject: [PATCH 06/34] Fix outgoing-links-table scrolling --- .../links-section/incoming-links-section.tsx | 1 - .../incoming-links-table.tsx | 72 ++----------------- .../readonly-outgoing-links-table.tsx | 35 ++++++++- .../links-section/use-entity-links.ts | 2 +- .../src/pages/shared/virtualized-table.tsx | 4 +- 5 files changed, 43 insertions(+), 71 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 4157f0d414d..7d64fcb18c9 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -205,7 +205,6 @@ export const IncomingLinksSection = ({ setSort={selfFetchLinks ? setSort : undefined} slideContainerRef={slideContainerRef} sort={selfFetchLinks ? sort : undefined} - totalLinkCount={selfFetchLinks ? fetchedCount : undefined} /> ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index d918c4ff270..ccc648bfc13 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -279,28 +279,10 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => { ); }); -/** - * A placeholder row standing in for a link that is part of the total count but - * has not yet been fetched. These pad the scroll content out to the total link - * count so the scrollbar reflects the full set, not just the loaded rows. - */ -type PlaceholderRow = { placeholder: true }; - -type IncomingLinkRowOrPlaceholder = IncomingLinkRow | PlaceholderRow; - const createRowContent: CreateVirtualizedRowContentFn< - IncomingLinkRowOrPlaceholder, + IncomingLinkRow, FieldId -> = (_index, row, { columns }) => - "placeholder" in row.data ? ( - <> - {columns.map((column) => ( - - ))} - - ) : ( - - ); +> = (_index, row) => ; type IncomingLinksTableProps = { closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; @@ -327,13 +309,6 @@ type IncomingLinksTableProps = { sort?: VirtualizedTableSort; setSort?: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; - /** - * The total number of links matching the query, including those not yet - * loaded. When greater than the number of loaded links, the scroll content is - * padded with up to one page of placeholder rows so the scrollbar extends - * slightly past the loaded rows to indicate there is more to load. - */ - totalLinkCount?: number; }; export const IncomingLinksTable = memo( @@ -353,7 +328,6 @@ export const IncomingLinksTable = memo( sort: controlledSort, setSort: controlledSetSort, slideContainerRef, - totalLinkCount, }: IncomingLinksTableProps) => { const [internalSort, setInternalSort] = useState< VirtualizedTableSort @@ -746,46 +720,9 @@ export const IncomingLinksTable = memo( }); }, [filterValues, serverSideSorting, sort, unsortedRows]); - /** - * Pad the scroll content with placeholder rows for not-yet-loaded links, so - * the scrollbar extends a little beyond the loaded rows to indicate there is - * more to load. These fill in as further pages load while scrolling (see - * `onRangeChange` below). - * - * The padding is capped at a single page rather than the full remaining - * total: because the query only supports sequential (cursor) pagination, we - * can't load an arbitrary middle window, so we don't allow scrolling far - * past the loaded rows. - */ - const placeholderCount = - totalLinkCount === undefined - ? 0 - : Math.min( - linksTablePageSize, - Math.max(0, totalLinkCount - incomingLinksAndSources.length), - ); - - const paddedRows = useMemo< - VirtualizedTableRow[] - >( - () => - placeholderCount === 0 - ? rows - : [ - ...rows, - ...Array.from({ length: placeholderCount }, (_, index) => ({ - id: `placeholder-${index}`, - data: { placeholder: true } as const, - })), - ], - [placeholderCount, rows], - ); - const height = Math.min( maxLinksTableHeight, - paddedRows.length * linksTableRowHeight + - virtualizedTableHeaderHeight + - 2, + rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, ); const columns = useMemo(() => { @@ -852,9 +789,10 @@ export const IncomingLinksTable = memo( : undefined } setFilterValues={serverSideSorting ? undefined : setFilterValues} - rows={paddedRows} + rows={rows} sort={sort} setSort={setSort} + increaseViewportBy={linksTablePageSize} /> ); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 94d994ebafd..3982e16e2ed 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -33,6 +33,7 @@ import { linksTableRowHeight, maxLinksTableHeight, } from "../shared/table-styling"; +import { linksTablePageSize } from "../use-entity-links"; import type { CreateVirtualizedRowContentFn, @@ -714,6 +715,15 @@ export const OutgoingLinksTable = memo( })); }, [filterValues, customColumns, serverSideSorting]); + /** + * Whether scrolling to the bottom may trigger a load of the next page. It is + * disarmed as soon as a load is triggered and only re-armed when the user + * starts scrolling again – so a single scroll to the bottom loads at most + * one page, and the user must scroll again to load more (rather than the + * table looping while the scroll position stays at the bottom). + */ + const canLoadMoreRef = useRef(true); + const height = Math.min( maxLinksTableHeight, rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, @@ -739,12 +749,35 @@ export const OutgoingLinksTable = memo( createRowContent={createRowContent} filterDefinitions={serverSideSorting ? undefined : filterDefinitions} filterValues={serverSideSorting ? undefined : filterValues} + fixedItemHeight={linksTableRowHeight} + followOutput={false} loadingMore={loadingMore} - onEndReached={onEndReached} + onIsScrolling={(isScrolling) => { + if (isScrolling) { + canLoadMoreRef.current = true; + } + }} + onRangeChange={ + onEndReached + ? ({ endIndex }) => { + // Load the next page once the loaded rows scroll into view, + // before the placeholder rows are reached. + if ( + canLoadMoreRef.current && + !loadingMore && + endIndex >= rows.length - 1 + ) { + canLoadMoreRef.current = false; + onEndReached(); + } + } + : undefined + } setFilterValues={serverSideSorting ? undefined : setFilterValues} rows={rows} sort={sort} setSort={setSort} + increaseViewportBy={linksTablePageSize} /> ); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index f3a4a8a78ee..30503ed9977 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -32,7 +32,7 @@ import type { /** * The number of links fetched per page for the readonly link tables. */ -export const linksTablePageSize = 200; +export const linksTablePageSize = 100; type LinksSubgraph = Subgraph>; diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index 564f594ecd5..0ab44eef093 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -165,6 +165,7 @@ type VirtualizedTableProps< */ fixedItemHeight?: number; rows: VirtualizedTableRow[]; + increaseViewportBy?: number; } & TableSortProps & Partial>; @@ -193,6 +194,7 @@ export const VirtualizedTable = < setFilterValues, sort, setSort, + increaseViewportBy, }: VirtualizedTableProps) => { const fixedHeaderContent = useCallback( () => @@ -258,7 +260,7 @@ export const VirtualizedTable = < fixedFooterContent={fixedFooterContent} fixedHeaderContent={fixedHeaderContent} followOutput={followOutput} - increaseViewportBy={50} + increaseViewportBy={increaseViewportBy ?? 50} itemContent={createRowContent} overscan={{ main: 200, reverse: 200 }} style={heightStyle} From 5497281de86daa88f3a6c5fc0fdda2041745f840 Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 16:27:14 +0200 Subject: [PATCH 07/34] Re-apply logic for readonly tables in entity page --- apps/hash-frontend/src/pages/shared/entity.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 6e7c9a784e6..929e981a69f 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -319,6 +319,10 @@ export const Entity = ({ setDraftLinksToCreate([]); setDraftLinksToArchive([]); + setIncludeLinkDataInQuery( + !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update, + ); + setLoading(false); }, variables: { @@ -586,7 +590,7 @@ export const Entity = ({ {isQueryEntity && shouldShowQueryEditor ? ( Date: Fri, 19 Jun 2026 16:53:54 +0200 Subject: [PATCH 08/34] Better error handling on tables --- .../hash-frontend/src/pages/shared/entity.tsx | 40 ++- .../links-section/incoming-links-section.tsx | 124 +++++++--- .../incoming-links-table.tsx | 79 +++--- .../links-section/outgoing-links-section.tsx | 95 +++++-- .../readonly-outgoing-links-table.tsx | 86 +++---- .../outgoing-links-section/use-rows.ts | 14 +- .../links-section/use-entity-links.ts | 232 +++++++++++++----- 7 files changed, 462 insertions(+), 208 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 929e981a69f..c00f278801d 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -405,6 +405,24 @@ export const Entity = ({ [draftEntitySubgraph], ); + /** + * Whether the user can edit the entity's links. + * + * This is the signal that drives `includeLinkDataInQuery` (see its + * `onCompleted` above): when the user has `update` permission we fetch the + * link data inline so the editor can show the inline link-editing grid. + * + * It is deliberately decoupled from `isReadOnly` below, which additionally + * folds in display-only readonly reasons (fullscreen, archived). An editable + * entity viewed readonly-for-display must NOT also self-fetch its links, or it + * would both double-fetch and lose the inline link-editing grid. + */ + const canEditLinks = + !draftLocalEntity && + !proposedEntitySubgraph && + !!queryEntitySubgraphData?.queryEntitySubgraph.entityPermissions?.[entityId] + ?.update; + const isReadOnly = /** * @todo H-3398 fix Glide grid editor overlays when body isn't fullscreened. @@ -413,10 +431,20 @@ export const Entity = ({ !!document.fullscreenElement || !!draftEntity?.metadata.archived || !!proposedEntitySubgraph || - (!draftLocalEntity && - !queryEntitySubgraphData?.queryEntitySubgraph.entityPermissions?.[ - entityId - ]?.update); + (!draftLocalEntity && !canEditLinks); + + /** + * Self-fetch links from within the link tables only when the user genuinely + * cannot edit links (no `update` permission), never merely because the view + * is readonly-for-display (fullscreen/archived). This is the exact complement + * of `includeLinkDataInQuery`'s permission gate, so the main query and the + * link tables can never both fetch the link data. + * + * While permission is still loading (`canEditLinks` false, but + * `includeLinkDataInQuery` also still false) the link tables self-fetch, which + * is the safe path that avoids double-fetching. + */ + const selfFetchLinks = !canEditLinks && !proposedEntitySubgraph; const entityFromDb = useMemo( () => (dataFromDb ? getRoots(dataFromDb.entitySubgraph)[0] : null), @@ -590,7 +618,7 @@ export const Entity = ({ {isQueryEntity && shouldShowQueryEditor ? ( (() => { + if (selfFetchLinks) { + if (!linkEntities || !fetchedSubgraph) { + return []; + } + + return linkEntities + .map((linkEntity) => { + let leftEntity: LinkEntityAndLeftEntity["leftEntity"]; + try { + leftEntity = + getLeftEntityForLinkEntity( + fetchedSubgraph, + linkEntity.metadata.recordId.entityId, + ) ?? []; + } catch { + /** + * `getLeftEntityForLinkEntity` throws if no source revision overlaps + * the resolved instant of the merged multi-page subgraph; treat that + * as a missing endpoint so the link is filtered out below rather than + * crashing the table. + */ + leftEntity = []; + } + + return { linkEntity: [linkEntity], leftEntity }; + }) + .filter( + /** + * Drop links whose source entity is missing, mirroring the guard the + * editor path applies, so no row with an empty endpoint reaches the + * table (which would throw when building rows). + */ + (incomingLinkAndSource) => !!incomingLinkAndSource.leftEntity[0], + ); + } + + return getIncomingLinkAndSourceEntities( + editorSubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + editorSubgraph.temporalAxes.resolved.variable.axis + ], + ).filter((incomingLinkAndSource) => { + return ( + incomingLinkAndSource.linkEntity[0] && + !draftLinksToArchive.includes( + incomingLinkAndSource.linkEntity[0].entityId, + ) && + !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( + (typeId) => noisySystemTypeIds.includes(typeId as NoisySystemTypeId), + ) && + incomingLinkAndSource.leftEntity[0] && + !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( + systemEntityTypes.claim.entityTypeId, + ) + ); + }); + }, [ + selfFetchLinks, + linkEntities, + fetchedSubgraph, + editorSubgraph, + entity, + draftLinksToArchive, + ]); + + if (selfFetchLinks && error) { + /** + * In the self-fetch path the query errors are surfaced here (the editor + * path's errors are handled by the parent query). Without this, a failed + * query would fall through to the empty state, making it look like the + * entity simply has no links. + */ + return ( + + + Could not load incoming links. Please try again later. + + + ); + } + if ( selfFetchLinks && (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) @@ -130,38 +220,6 @@ export const IncomingLinksSection = ({ ? fetchedDefinitions! : editorDefinitions; - const incomingLinksAndSources: LinkEntityAndLeftEntity[] = selfFetchLinks - ? linkEntities!.map((linkEntity) => ({ - linkEntity: [linkEntity], - leftEntity: - getLeftEntityForLinkEntity( - entitySubgraph, - linkEntity.metadata.recordId.entityId, - ) ?? [], - })) - : getIncomingLinkAndSourceEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ).filter((incomingLinkAndSource) => { - return ( - incomingLinkAndSource.linkEntity[0] && - !draftLinksToArchive.includes( - incomingLinkAndSource.linkEntity[0].entityId, - ) && - !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( - (typeId) => - noisySystemTypeIds.includes(typeId as NoisySystemTypeId), - ) && - incomingLinkAndSource.leftEntity[0] && - !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( - systemEntityTypes.claim.entityTypeId, - ) - ); - }); - const linkCount = selfFetchLinks ? (fetchedCount ?? incomingLinksAndSources.length) : incomingLinksAndSources.length; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index ccc648bfc13..cfdc3786d09 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -3,7 +3,7 @@ import { memo, type ReactElement, type RefObject, - useLayoutEffect, + useCallback, useMemo, useRef, useState, @@ -66,6 +66,7 @@ import type { ClosedMultiEntityTypesDefinitions, ClosedMultiEntityTypesRootMap, } from "@local/hash-graph-sdk/ontology"; +import type { ListRange } from "react-virtuoso"; export type IncomingLinksFieldId = | "linkedFrom" @@ -343,17 +344,6 @@ export const IncomingLinksTable = memo( const sort = controlledSort ?? internalSort; const setSort = controlledSetSort ?? setInternalSort; - const outputContainerRef = useRef(null); - const [outputContainerHeight, setOutputContainerHeight] = useState(400); - useLayoutEffect(() => { - if ( - outputContainerRef.current && - outputContainerRef.current.clientHeight !== outputContainerHeight - ) { - setOutputContainerHeight(outputContainerRef.current.clientHeight); - } - }, [outputContainerHeight]); - const { filterDefinitions, initialFilterValues, @@ -749,13 +739,43 @@ export const IncomingLinksTable = memo( }, [filterValues, customColumns, serverSideSorting]); /** - * Whether scrolling to the bottom may trigger a load of the next page. It is - * disarmed as soon as a load is triggered and only re-armed when the user - * starts scrolling again – so a single scroll to the bottom loads at most - * one page, and the user must scroll again to load more (rather than the - * table looping while the scroll position stays at the bottom). + * Whether scrolling to the bottom may trigger a load of the next page. It + * starts disarmed so that the initial range-change callback Virtuoso fires + * on mount (which reports the rendered range including overscan, and would + * otherwise auto-load page 2 with no user scroll when the first page fits in + * the viewport) cannot trigger a load. It is armed only once the user + * actually scrolls, disarmed again as soon as a load is triggered, and + * re-armed when the user starts scrolling again – so a single scroll to the + * bottom loads at most one page, and the user must scroll again to load more + * (rather than the table looping while the scroll position stays at the + * bottom). */ - const canLoadMoreRef = useRef(true); + const canLoadMoreRef = useRef(false); + + const handleIsScrolling = useCallback((isScrolling: boolean) => { + if (isScrolling) { + canLoadMoreRef.current = true; + } + }, []); + + const handleRangeChange = useMemo( + () => + onEndReached + ? ({ endIndex }: ListRange) => { + // Load the next page once the loaded rows scroll into view, + // before the placeholder rows are reached. + if ( + canLoadMoreRef.current && + !loadingMore && + endIndex >= rows.length - 1 + ) { + canLoadMoreRef.current = false; + onEndReached(); + } + } + : undefined, + [loadingMore, onEndReached, rows.length], + ); return ( @@ -767,27 +787,8 @@ export const IncomingLinksTable = memo( fixedItemHeight={linksTableRowHeight} followOutput={false} loadingMore={loadingMore} - onIsScrolling={(isScrolling) => { - if (isScrolling) { - canLoadMoreRef.current = true; - } - }} - onRangeChange={ - onEndReached - ? ({ endIndex }) => { - // Load the next page once the loaded rows scroll into view, - // before the placeholder rows are reached. - if ( - canLoadMoreRef.current && - !loadingMore && - endIndex >= rows.length - 1 - ) { - canLoadMoreRef.current = false; - onEndReached(); - } - } - : undefined - } + onIsScrolling={handleIsScrolling} + onRangeChange={handleRangeChange} setFilterValues={serverSideSorting ? undefined : setFilterValues} rows={rows} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index 6bfadde8ffc..13d85757cd1 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -7,7 +7,12 @@ import { getOutgoingLinksForEntity, getRightEntityForLinkEntity, } from "@blockprotocol/graph/stdlib"; -import { Chip, FontAwesomeIcon, IconButton } from "@hashintel/design-system"; +import { + Callout, + Chip, + FontAwesomeIcon, + IconButton, +} from "@hashintel/design-system"; import { Grid } from "../../../../../components/grid/grid"; import { createRenderChipCell } from "../../../chip-cell"; @@ -121,6 +126,7 @@ export const OutgoingLinksSection = ({ loadMore, hasMore, count: fetchedCount, + error, linkEntities, subgraph: fetchedSubgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, @@ -132,6 +138,57 @@ export const OutgoingLinksSection = ({ sortingPaths, }); + /** + * The links/targets passed to the readonly table. In the self-fetch path the + * source/target entities come from the merged multi-page subgraph; in the + * editor path they come from the editor subgraph. Memoised so the new array + * identity does not defeat the `memo()`-wrapped table on every parent render. + */ + const outgoingLinksAndTargets = useMemo(() => { + if (selfFetchLinks) { + if (!linkEntities || !fetchedSubgraph) { + return []; + } + + return linkEntities + .map((linkEntity) => { + let rightEntity: LinkEntityAndRightEntity["rightEntity"]; + try { + rightEntity = + getRightEntityForLinkEntity( + fetchedSubgraph, + linkEntity.metadata.recordId.entityId, + ) ?? []; + } catch { + /** + * `getRightEntityForLinkEntity` throws if no target revision overlaps + * the resolved instant of the merged multi-page subgraph; treat that + * as a missing endpoint so the link is filtered out below rather than + * crashing the table. + */ + rightEntity = []; + } + + return { linkEntity: [linkEntity], rightEntity }; + }) + .filter( + /** + * Drop links whose target entity is missing, so no row with an empty + * endpoint reaches the table (which would throw when building rows). + */ + (outgoingLinkAndTarget) => !!outgoingLinkAndTarget.rightEntity[0], + ); + } + + return getOutgoingLinkAndTargetEntities( + editorSubgraph, + entity.metadata.recordId.entityId, + entity.metadata.temporalVersioning[ + editorSubgraph.temporalAxes.resolved.variable.axis + ], + ); + }, [selfFetchLinks, linkEntities, fetchedSubgraph, editorSubgraph, entity]); + const rows = useRows({ closedMultiEntityType, closedMultiEntityTypesDefinitions: editorDefinitions, @@ -176,6 +233,22 @@ export const OutgoingLinksSection = ({ }); }, []); + if (selfFetchLinks && error) { + /** + * In the self-fetch path the query errors are surfaced here (the editor + * path's errors are handled by the parent query). Without this, a failed + * query would fall through to the empty state, making it look like the + * entity simply has no links. + */ + return ( + + + Could not load outgoing links. Please try again later. + + + ); + } + if ( selfFetchLinks && (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) @@ -226,24 +299,6 @@ export const OutgoingLinksSection = ({ return null; } - let outgoingLinksAndTargets: LinkEntityAndRightEntity[] | null = null; - outgoingLinksAndTargets = selfFetchLinks - ? linkEntities!.map((linkEntity) => ({ - linkEntity: [linkEntity], - rightEntity: - getRightEntityForLinkEntity( - entitySubgraph, - linkEntity.metadata.recordId.entityId, - ) ?? [], - })) - : getOutgoingLinkAndTargetEntities( - entitySubgraph, - entity.metadata.recordId.entityId, - entity.metadata.temporalVersioning[ - entitySubgraph.temporalAxes.resolved.variable.axis - ], - ); - return ( - ) : outgoingLinksAndTargets?.length ? ( + ) : outgoingLinksAndTargets.length ? ( (null); - const [outputContainerHeight, setOutputContainerHeight] = useState(400); - useLayoutEffect(() => { - if ( - outputContainerRef.current && - outputContainerRef.current.clientHeight !== outputContainerHeight - ) { - setOutputContainerHeight(outputContainerRef.current.clientHeight); - } - }, [outputContainerHeight]); - const { filterDefinitions, initialFilterValues, @@ -716,13 +699,43 @@ export const OutgoingLinksTable = memo( }, [filterValues, customColumns, serverSideSorting]); /** - * Whether scrolling to the bottom may trigger a load of the next page. It is - * disarmed as soon as a load is triggered and only re-armed when the user - * starts scrolling again – so a single scroll to the bottom loads at most - * one page, and the user must scroll again to load more (rather than the - * table looping while the scroll position stays at the bottom). + * Whether scrolling to the bottom may trigger a load of the next page. It + * starts disarmed so that the initial range-change callback Virtuoso fires + * on mount (which reports the rendered range including overscan, and would + * otherwise auto-load page 2 with no user scroll when the first page fits in + * the viewport) cannot trigger a load. It is armed only once the user + * actually scrolls, disarmed again as soon as a load is triggered, and + * re-armed when the user starts scrolling again – so a single scroll to the + * bottom loads at most one page, and the user must scroll again to load more + * (rather than the table looping while the scroll position stays at the + * bottom). */ - const canLoadMoreRef = useRef(true); + const canLoadMoreRef = useRef(false); + + const handleIsScrolling = useCallback((isScrolling: boolean) => { + if (isScrolling) { + canLoadMoreRef.current = true; + } + }, []); + + const handleRangeChange = useMemo( + () => + onEndReached + ? ({ endIndex }: ListRange) => { + // Load the next page once the loaded rows scroll into view, + // before the placeholder rows are reached. + if ( + canLoadMoreRef.current && + !loadingMore && + endIndex >= rows.length - 1 + ) { + canLoadMoreRef.current = false; + onEndReached(); + } + } + : undefined, + [loadingMore, onEndReached, rows.length], + ); const height = Math.min( maxLinksTableHeight, @@ -752,27 +765,8 @@ export const OutgoingLinksTable = memo( fixedItemHeight={linksTableRowHeight} followOutput={false} loadingMore={loadingMore} - onIsScrolling={(isScrolling) => { - if (isScrolling) { - canLoadMoreRef.current = true; - } - }} - onRangeChange={ - onEndReached - ? ({ endIndex }) => { - // Load the next page once the loaded rows scroll into view, - // before the placeholder rows are reached. - if ( - canLoadMoreRef.current && - !loadingMore && - endIndex >= rows.length - 1 - ) { - canLoadMoreRef.current = false; - onEndReached(); - } - } - : undefined - } + onIsScrolling={handleIsScrolling} + onRangeChange={handleRangeChange} setFilterValues={serverSideSorting ? undefined : setFilterValues} rows={rows} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts index 4cd9b3eb8ab..11c69d00872 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/use-rows.ts @@ -49,11 +49,15 @@ export const useRows = ({ setDraftLinksToArchive, setDraftLinksToCreate, }: UseRowsParams) => { - const markLinkEntityToArchive = createMarkLinkEntityToArchive({ - draftLinksToCreate, - setDraftLinksToCreate, - setDraftLinksToArchive, - }); + const markLinkEntityToArchive = useMemo( + () => + createMarkLinkEntityToArchive({ + draftLinksToCreate, + setDraftLinksToCreate, + setDraftLinksToArchive, + }), + [draftLinksToCreate, setDraftLinksToCreate, setDraftLinksToArchive], + ); const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index 30503ed9977..10a268e9464 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@apollo/client"; +import { type ApolloError, useQuery } from "@apollo/client"; import { useCallback, useMemo, useRef, useState } from "react"; import { getRoots } from "@blockprotocol/graph/stdlib"; @@ -83,25 +83,123 @@ const mergeRecordOfRecords = ( return result; }; -const mergeSubgraphs = (pages: LinkPage[]): LinksSubgraph | undefined => { - const [first, ...rest] = pages; +/** + * Merge a single later page's subgraph into an already-merged subgraph, + * adding its vertices/edges without dropping those already accumulated. + */ +const mergeSubgraphInto = ( + merged: LinksSubgraph, + page: LinkPage, +): LinksSubgraph => { + const vertices = mergeRecordOfRecords( + merged.vertices as Record>, + page.subgraph.vertices as Record>, + ) as LinksSubgraph["vertices"]; + + const edges = mergeRecordOfRecords( + merged.edges as Record>, + page.subgraph.edges as Record>, + ) as LinksSubgraph["edges"]; + + return { ...merged, vertices, edges }; +}; + +const mergeDefinitionsInto = ( + merged: ClosedMultiEntityTypesDefinitions | undefined, + page: LinkPage, +): ClosedMultiEntityTypesDefinitions | undefined => { + if (!page.definitions) { + return merged; + } + if (!merged) { + return page.definitions; + } + return { + dataTypes: { ...merged.dataTypes, ...page.definitions.dataTypes }, + entityTypes: { ...merged.entityTypes, ...page.definitions.entityTypes }, + propertyTypes: { + ...merged.propertyTypes, + ...page.definitions.propertyTypes, + }, + }; +}; + +/** + * The running, incrementally-built accumulation of every page loaded so far. + */ +type Accumulated = { + linkEntities: HashLinkEntity[]; + /** Dedup set keyed on `recordId.entityId`, so appends stay O(page size). */ + seenLinkIds: Set; + subgraph: LinksSubgraph; + typesMap: ClosedMultiEntityTypesRootMap; + definitions?: ClosedMultiEntityTypesDefinitions; + count?: number; + nextCursor: EntityQueryCursor | null; + /** + * Whether the query is exhausted. `true` once a page returns fewer rows than + * the requested page size (so a non-null cursor pointing past the last match + * does not keep `hasMore` true and trigger an empty fetch). + */ + exhausted: boolean; +}; + +/** + * Fold a single freshly-loaded page into the running accumulation. The + * `linkEntities`/`seenLinkIds`/`typesMap` collections are extended in place so + * that loading page `k` costs O(page size) rather than O(total pages loaded so + * far); a new top-level object is returned to carry the updated scalar fields. + */ +const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { + for (const linkEntity of page.linkEntities) { + const linkEntityId = linkEntity.metadata.recordId.entityId; + if (!accumulated.seenLinkIds.has(linkEntityId)) { + accumulated.seenLinkIds.add(linkEntityId); + accumulated.linkEntities.push(linkEntity); + } + } + + Object.assign(accumulated.typesMap, page.typesMap); + + /** + * Treat the result as exhausted when the page returned fewer rows than the + * requested page size (including zero), regardless of whether the API still + * handed back a non-null cursor. + */ + const exhausted = page.linkEntities.length < linksTablePageSize; + + return { + ...accumulated, + definitions: mergeDefinitionsInto(accumulated.definitions, page), + subgraph: mergeSubgraphInto(accumulated.subgraph, page), + count: page.count, + exhausted, + nextCursor: exhausted ? null : page.nextCursor, + }; +}; + +const accumulatePages = (pages: LinkPage[]): Accumulated | undefined => { + const [first] = pages; if (!first) { return undefined; } - return rest.reduce((merged, page) => { - const vertices = mergeRecordOfRecords( - merged.vertices as Record>, - page.subgraph.vertices as Record>, - ) as LinksSubgraph["vertices"]; + let accumulated: Accumulated = { + linkEntities: [], + seenLinkIds: new Set(), + subgraph: first.subgraph, + typesMap: {}, + definitions: undefined, + count: undefined, + nextCursor: null, + exhausted: false, + }; - const edges = mergeRecordOfRecords( - merged.edges as Record>, - page.subgraph.edges as Record>, - ) as LinksSubgraph["edges"]; + for (const page of pages) { + accumulated = appendPage(accumulated, page); + } - return { ...merged, vertices, edges }; - }, first.subgraph); + return accumulated; }; /** @@ -134,6 +232,8 @@ export const useEntityLinks = ({ }): { /** Whether the first page is still loading. */ initialLoading: boolean; + /** Any error from the underlying query, for the caller to surface. */ + error?: ApolloError; /** Whether a subsequent page is being fetched. */ loadingMore: boolean; /** Fetch the next page (no-op if there are no more pages). */ @@ -185,7 +285,7 @@ export const useEntityLinks = ({ const filterEndpoint = direction === "outgoing" ? "leftEntity" : "rightEntity"; - const { loading } = useQuery< + const { loading, error } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables >(queryEntitySubgraphQuery, { @@ -207,6 +307,23 @@ export const useEntityLinks = ({ { parameter: webId }, ], }, + /** + * When viewing a specific draft, scope the matched endpoint to that + * draft. The path is rooted on the link entity, so this resolves + * the *endpoint* entity's own draftId (mirroring + * `generateEntityIdFilter`); without it a draft would match links + * across its live version and all sibling drafts. + */ + ...(draftId + ? [ + { + equal: [ + { path: [filterEndpoint, "draftId"] }, + { parameter: draftId }, + ], + }, + ] + : []), ...(direction === "incoming" ? [ ignoreNoisySystemTypesFilter, @@ -274,57 +391,53 @@ export const useEntityLinks = ({ }, }); + /** + * Incrementally accumulate pages. The previously-processed `pages` array and + * its resulting accumulation are cached in a ref; when `pages` grows by + * append-only (the common infinite-scroll case) only the new pages are folded + * in, keeping each `loadMore` O(page size) rather than O(total pages). A + * reset (`[page]`) or an in-place page replacement (cache-then-network for the + * same cursor) rebuilds from scratch, which is rare and bounded. + */ + const accumulationCache = useRef<{ + pages: LinkPage[]; + result: Accumulated | undefined; + }>({ pages: [], result: undefined }); + const accumulated = useMemo(() => { - if (pages.length === 0) { - return undefined; - } + const cached = accumulationCache.current; - const seenLinkIds = new Set(); - const linkEntities: HashLinkEntity[] = []; - for (const page of pages) { - for (const linkEntity of page.linkEntities) { - const linkEntityId = linkEntity.metadata.recordId.entityId; - if (!seenLinkIds.has(linkEntityId)) { - seenLinkIds.add(linkEntityId); - linkEntities.push(linkEntity); - } - } - } + const isAppendOnlyExtension = + cached.result !== undefined && + pages.length > cached.pages.length && + cached.pages.every((page, index) => pages[index] === page); - const typesMap: ClosedMultiEntityTypesRootMap = {}; - let definitions: ClosedMultiEntityTypesDefinitions | undefined; - for (const page of pages) { - Object.assign(typesMap, page.typesMap); - if (page.definitions) { - definitions = definitions - ? { - dataTypes: { - ...definitions.dataTypes, - ...page.definitions.dataTypes, - }, - entityTypes: { - ...definitions.entityTypes, - ...page.definitions.entityTypes, - }, - propertyTypes: { - ...definitions.propertyTypes, - ...page.definitions.propertyTypes, - }, - } - : page.definitions; + let result: Accumulated | undefined; + if (pages.length === 0) { + result = undefined; + } else if (isAppendOnlyExtension) { + result = cached.result; + for (let index = cached.pages.length; index < pages.length; index++) { + result = appendPage(result!, pages[index]!); } + } else { + result = accumulatePages(pages); } - const lastPage = pages[pages.length - 1]!; + accumulationCache.current = { pages, result }; + + if (!result) { + return undefined; + } - return { - linkEntities, - subgraph: mergeSubgraphs(pages), - typesMap, - definitions, - count: lastPage.count, - nextCursor: lastPage.nextCursor, - }; + /** + * Return a fresh top-level object (and a fresh `linkEntities` array) so + * that consumers depending on referential identity recompute when a page + * is appended. The expensive O(n) work (dedup, subgraph/type merging) has + * already been done incrementally above; this only copies the array of + * entity references. + */ + return { ...result, linkEntities: [...result.linkEntities] }; }, [pages]); const loadMore = useCallback(() => { @@ -335,6 +448,7 @@ export const useEntityLinks = ({ return { initialLoading: loading && pages.length === 0, + error, loadingMore: loading && cursor !== undefined, loadMore, hasMore: !!accumulated?.nextCursor, From 9d6eb373b8314cd78814ec211686a45f2961a4cc Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 17:05:24 +0200 Subject: [PATCH 09/34] Simplify virtuoso table --- .../incoming-links-table.tsx | 4 +-- .../readonly-outgoing-links-table.tsx | 4 +-- .../src/pages/shared/virtualized-table.tsx | 31 ------------------- 3 files changed, 2 insertions(+), 37 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index cfdc3786d09..33f10267458 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -762,8 +762,7 @@ export const IncomingLinksTable = memo( () => onEndReached ? ({ endIndex }: ListRange) => { - // Load the next page once the loaded rows scroll into view, - // before the placeholder rows are reached. + // Load the next page once the loaded rows scroll into view if ( canLoadMoreRef.current && !loadingMore && @@ -784,7 +783,6 @@ export const IncomingLinksTable = memo( createRowContent={createRowContent} filterDefinitions={serverSideSorting ? undefined : filterDefinitions} filterValues={serverSideSorting ? undefined : filterValues} - fixedItemHeight={linksTableRowHeight} followOutput={false} loadingMore={loadingMore} onIsScrolling={handleIsScrolling} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index ff0c9856a8c..7080add5013 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -722,8 +722,7 @@ export const OutgoingLinksTable = memo( () => onEndReached ? ({ endIndex }: ListRange) => { - // Load the next page once the loaded rows scroll into view, - // before the placeholder rows are reached. + // Load the next page once the loaded rows scroll into view if ( canLoadMoreRef.current && !loadingMore && @@ -762,7 +761,6 @@ export const OutgoingLinksTable = memo( createRowContent={createRowContent} filterDefinitions={serverSideSorting ? undefined : filterDefinitions} filterValues={serverSideSorting ? undefined : filterValues} - fixedItemHeight={linksTableRowHeight} followOutput={false} loadingMore={loadingMore} onIsScrolling={handleIsScrolling} diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index 0ab44eef093..92951e00826 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -130,40 +130,11 @@ type VirtualizedTableProps< columns?: VirtualizedTableColumn[]; fixedColumns?: number; EmptyPlaceholder?: () => ReactElement; - /** - * Called when the user scrolls to the end of the loaded rows, for fetching - * the next page of data. - */ onEndReached?: () => void; - /** - * Called when the visible row range changes. Useful for triggering paged - * loading before the very end of the data is reached (e.g. when the scroll - * content is padded with placeholder rows up to a known total count). - */ onRangeChange?: (range: ListRange) => void; - /** - * Called when the user starts (`true`) or stops (`false`) scrolling. Useful - * for gating paged loading to deliberate user scrolls. - */ onIsScrolling?: (isScrolling: boolean) => void; - /** - * Auto-scroll behaviour when new rows are appended to the end. Defaults to - * `"smooth"`. Set to `false` for paged tables, where appending a page should - * not pull the viewport down to the new bottom. - */ followOutput?: FollowOutput; - /** - * Whether a further page is currently being fetched – shows a loading - * indicator at the foot of the table. - */ loadingMore?: boolean; - /** - * When all rows are the same known height, set this so virtuoso uses it - * directly instead of measuring each row. This avoids the scroll position - * recalculating (and jumping) when placeholder rows are swapped for loaded - * data of a slightly different measured height. - */ - fixedItemHeight?: number; rows: VirtualizedTableRow[]; increaseViewportBy?: number; } & TableSortProps & @@ -187,7 +158,6 @@ export const VirtualizedTable = < onIsScrolling, followOutput = "smooth", loadingMore, - fixedItemHeight, rows, filterDefinitions, filterValues, @@ -256,7 +226,6 @@ export const VirtualizedTable = < endReached={onEndReached} rangeChanged={onRangeChange} isScrolling={onIsScrolling} - fixedItemHeight={fixedItemHeight} fixedFooterContent={fixedFooterContent} fixedHeaderContent={fixedHeaderContent} followOutput={followOutput} From be73d4e86aa9a0dbeb6f793857ac20eb2fe5cff0 Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 17:21:19 +0200 Subject: [PATCH 10/34] Do not clobber users edited data when refetching --- .../hash-frontend/src/pages/shared/entity.tsx | 95 ++++++++++++++++--- 1 file changed, 80 insertions(+), 15 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index c00f278801d..a2aa4424215 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery } from "@apollo/client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { getRoots } from "@blockprotocol/graph/stdlib"; import { mustHaveAtLeastOne, splitEntityId } from "@blockprotocol/type-system"; @@ -267,6 +267,28 @@ export const Entity = ({ */ const [includeLinkDataInQuery, setIncludeLinkDataInQuery] = useState(false); + /** + * Tracks whether the first query response has been processed, and whether the + * next response will be the automatic link-data upgrade refetch (triggered by + * flipping `includeLinkDataInQuery` to true). These let `onCompleted` avoid + * resetting draft state on the upgrade refetch when the editor is already + * interactive, while still resetting on genuine reloads. + */ + const hasCompletedInitialLoadRef = useRef(false); + const awaitingLinkDataUpgradeRef = useRef(false); + + /** + * Mirror of the current draft-edit state. The query's `onCompleted` closure + * would otherwise capture stale values, so we read the latest state from here + * to decide whether the user has unsaved changes worth preserving. + */ + const draftStateRef = useRef({ + isDirty, + draftLinksToCreate, + draftLinksToArchive, + }); + draftStateRef.current = { isDirty, draftLinksToCreate, draftLinksToArchive }; + const { data: queryEntitySubgraphData, refetch } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables @@ -298,13 +320,12 @@ export const Entity = ({ returnedEntity.metadata.entityTypeIds, ); - setDraftEntityTypesDetails({ - linkAndDestinationEntitiesClosedMultiEntityTypesMap: - closedMultiEntityTypes, - closedMultiEntityType, - closedMultiEntityTypesDefinitions: definitions, - }); - + /** + * Always refresh the database snapshot. It is the source of truth used for + * diffing unsaved changes and as the reset target, so it must reflect the + * latest response (e.g. the link data added by the upgrade refetch), even + * when we preserve the draft below. + */ setDataFromDb({ entitySubgraph: subgraph, closedMultiEntityType, @@ -313,15 +334,59 @@ export const Entity = ({ closedMultiEntityTypes, }); - setDraftEntitySubgraph(subgraph); + const isInitialLoad = !hasCompletedInitialLoadRef.current; + hasCompletedInitialLoadRef.current = true; + + /** + * The first (link-less) response flips `includeLinkDataInQuery` to true for + * editable entities, which triggers an automatic refetch that adds the link + * data. That refetch's completion must NOT wipe out draft edits the user may + * already have started in the (interactive) editor. + * + * Genuine reloads (initial load, post-save/unarchive `refetch`) are not + * link-data upgrades, so they still reset the draft to match the database. + */ + const isLinkDataUpgrade = awaitingLinkDataUpgradeRef.current; + awaitingLinkDataUpgradeRef.current = false; + + const { + isDirty: draftIsDirty, + draftLinksToCreate: pendingLinksToCreate, + draftLinksToArchive: pendingLinksToArchive, + } = draftStateRef.current; + + const userHasUnsavedChanges = + draftIsDirty || + pendingLinksToCreate.length > 0 || + pendingLinksToArchive.length > 0; + + if (!isLinkDataUpgrade || !userHasUnsavedChanges) { + setDraftEntityTypesDetails({ + linkAndDestinationEntitiesClosedMultiEntityTypesMap: + closedMultiEntityTypes, + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + }); + + setDraftEntitySubgraph(subgraph); - setIsDirty(false); - setDraftLinksToCreate([]); - setDraftLinksToArchive([]); + setIsDirty(false); + setDraftLinksToCreate([]); + setDraftLinksToArchive([]); + } - setIncludeLinkDataInQuery( - !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update, - ); + if (isInitialLoad) { + const canUpdate = + !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update; + + /** + * For editable entities this flips the query variables, triggering the + * link-data upgrade refetch handled above. For readonly entities it stays + * false and the link tables self-fetch instead. + */ + awaitingLinkDataUpgradeRef.current = canUpdate; + setIncludeLinkDataInQuery(canUpdate); + } setLoading(false); }, From 0fdc893e5b20aeef8f1f6452ad4c145683c94f2e Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 17:23:44 +0200 Subject: [PATCH 11/34] Fix canEdit logic --- apps/hash-frontend/src/pages/shared/entity.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index a2aa4424215..4092b467eb4 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -508,8 +508,14 @@ export const Entity = ({ * While permission is still loading (`canEditLinks` false, but * `includeLinkDataInQuery` also still false) the link tables self-fetch, which * is the safe path that avoids double-fetching. + * + * A local draft is excluded: it is editable but not yet persisted, so there + * are no stored links to paginate. Its links live in the in-memory draft and + * must be shown in the editable Glide link-editing grid, not the readonly + * paginated table path. */ - const selfFetchLinks = !canEditLinks && !proposedEntitySubgraph; + const selfFetchLinks = + !canEditLinks && !proposedEntitySubgraph && !draftLocalEntity; const entityFromDb = useMemo( () => (dataFromDb ? getRoots(dataFromDb.entitySubgraph)[0] : null), From 96761bfb775c27be0c31ef0c8753dcbcb81f169b Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 17:30:26 +0200 Subject: [PATCH 12/34] Add a subtle overlay to virtualized loading spinner --- apps/hash-frontend/src/pages/shared/virtualized-table.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index 92951e00826..de410d4190b 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -209,7 +209,11 @@ export const VirtualizedTable = < From 6bc39e5f0fc6ff75fa06fbed8acea820b76477ea Mon Sep 17 00:00:00 2001 From: alex leon Date: Fri, 19 Jun 2026 17:32:42 +0200 Subject: [PATCH 13/34] Reset fetch state if change in entities --- .../hash-frontend/src/pages/shared/entity.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 4092b467eb4..984126f5d5d 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -289,6 +289,27 @@ export const Entity = ({ }); draftStateRef.current = { isDirty, draftLinksToCreate, draftLinksToArchive }; + /** + * When `entityId` changes without the component unmounting (e.g. navigating + * between entities within a slide), the per-entity load state above must be + * reset so the new entity goes through the same first-load flow: fetch + * link-less, then upgrade `includeLinkDataInQuery` based on its own + * permissions. Without this, a previously-loaded readonly entity would leave + * `includeLinkDataInQuery` false while `selfFetchLinks` is also false for the + * new (editable) entity, so its links would never load. + * + * We adjust the state during render (rather than in an effect) so the query + * below reads the reset `includeLinkDataInQuery` immediately, instead of + * firing once with the stale value. + */ + const previousEntityIdRef = useRef(entityId); + if (previousEntityIdRef.current !== entityId) { + previousEntityIdRef.current = entityId; + hasCompletedInitialLoadRef.current = false; + awaitingLinkDataUpgradeRef.current = false; + setIncludeLinkDataInQuery(false); + } + const { data: queryEntitySubgraphData, refetch } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables From efa13e6452c397bdd7c5b0691c2c7bf0fb45bbe9 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 10:26:45 +0200 Subject: [PATCH 14/34] Remove client side sorting from outgoing table --- .../hash-frontend/src/pages/shared/entity.tsx | 140 +------- .../src/pages/shared/entity/entity-editor.tsx | 11 +- .../links-section/incoming-links-section.tsx | 36 +- .../incoming-links-table.tsx | 322 ++---------------- .../links-section/outgoing-links-section.tsx | 41 ++- .../readonly-outgoing-links-table.tsx | 285 ++-------------- 6 files changed, 116 insertions(+), 719 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 984126f5d5d..231618e7077 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -266,49 +266,7 @@ export const Entity = ({ * link data included. */ const [includeLinkDataInQuery, setIncludeLinkDataInQuery] = useState(false); - - /** - * Tracks whether the first query response has been processed, and whether the - * next response will be the automatic link-data upgrade refetch (triggered by - * flipping `includeLinkDataInQuery` to true). These let `onCompleted` avoid - * resetting draft state on the upgrade refetch when the editor is already - * interactive, while still resetting on genuine reloads. - */ const hasCompletedInitialLoadRef = useRef(false); - const awaitingLinkDataUpgradeRef = useRef(false); - - /** - * Mirror of the current draft-edit state. The query's `onCompleted` closure - * would otherwise capture stale values, so we read the latest state from here - * to decide whether the user has unsaved changes worth preserving. - */ - const draftStateRef = useRef({ - isDirty, - draftLinksToCreate, - draftLinksToArchive, - }); - draftStateRef.current = { isDirty, draftLinksToCreate, draftLinksToArchive }; - - /** - * When `entityId` changes without the component unmounting (e.g. navigating - * between entities within a slide), the per-entity load state above must be - * reset so the new entity goes through the same first-load flow: fetch - * link-less, then upgrade `includeLinkDataInQuery` based on its own - * permissions. Without this, a previously-loaded readonly entity would leave - * `includeLinkDataInQuery` false while `selfFetchLinks` is also false for the - * new (editable) entity, so its links would never load. - * - * We adjust the state during render (rather than in an effect) so the query - * below reads the reset `includeLinkDataInQuery` immediately, instead of - * firing once with the stale value. - */ - const previousEntityIdRef = useRef(entityId); - if (previousEntityIdRef.current !== entityId) { - previousEntityIdRef.current = entityId; - hasCompletedInitialLoadRef.current = false; - awaitingLinkDataUpgradeRef.current = false; - setIncludeLinkDataInQuery(false); - } const { data: queryEntitySubgraphData, refetch } = useQuery< QueryEntitySubgraphQuery, @@ -341,12 +299,13 @@ export const Entity = ({ returnedEntity.metadata.entityTypeIds, ); - /** - * Always refresh the database snapshot. It is the source of truth used for - * diffing unsaved changes and as the reset target, so it must reflect the - * latest response (e.g. the link data added by the upgrade refetch), even - * when we preserve the draft below. - */ + setDraftEntityTypesDetails({ + linkAndDestinationEntitiesClosedMultiEntityTypesMap: + closedMultiEntityTypes, + closedMultiEntityType, + closedMultiEntityTypesDefinitions: definitions, + }); + setDataFromDb({ entitySubgraph: subgraph, closedMultiEntityType, @@ -358,43 +317,11 @@ export const Entity = ({ const isInitialLoad = !hasCompletedInitialLoadRef.current; hasCompletedInitialLoadRef.current = true; - /** - * The first (link-less) response flips `includeLinkDataInQuery` to true for - * editable entities, which triggers an automatic refetch that adds the link - * data. That refetch's completion must NOT wipe out draft edits the user may - * already have started in the (interactive) editor. - * - * Genuine reloads (initial load, post-save/unarchive `refetch`) are not - * link-data upgrades, so they still reset the draft to match the database. - */ - const isLinkDataUpgrade = awaitingLinkDataUpgradeRef.current; - awaitingLinkDataUpgradeRef.current = false; - - const { - isDirty: draftIsDirty, - draftLinksToCreate: pendingLinksToCreate, - draftLinksToArchive: pendingLinksToArchive, - } = draftStateRef.current; - - const userHasUnsavedChanges = - draftIsDirty || - pendingLinksToCreate.length > 0 || - pendingLinksToArchive.length > 0; - - if (!isLinkDataUpgrade || !userHasUnsavedChanges) { - setDraftEntityTypesDetails({ - linkAndDestinationEntitiesClosedMultiEntityTypesMap: - closedMultiEntityTypes, - closedMultiEntityType, - closedMultiEntityTypesDefinitions: definitions, - }); - - setDraftEntitySubgraph(subgraph); + setDraftEntitySubgraph(subgraph); - setIsDirty(false); - setDraftLinksToCreate([]); - setDraftLinksToArchive([]); - } + setIsDirty(false); + setDraftLinksToCreate([]); + setDraftLinksToArchive([]); if (isInitialLoad) { const canUpdate = @@ -405,7 +332,6 @@ export const Entity = ({ * link-data upgrade refetch handled above. For readonly entities it stays * false and the link tables self-fetch instead. */ - awaitingLinkDataUpgradeRef.current = canUpdate; setIncludeLinkDataInQuery(canUpdate); } @@ -491,24 +417,6 @@ export const Entity = ({ [draftEntitySubgraph], ); - /** - * Whether the user can edit the entity's links. - * - * This is the signal that drives `includeLinkDataInQuery` (see its - * `onCompleted` above): when the user has `update` permission we fetch the - * link data inline so the editor can show the inline link-editing grid. - * - * It is deliberately decoupled from `isReadOnly` below, which additionally - * folds in display-only readonly reasons (fullscreen, archived). An editable - * entity viewed readonly-for-display must NOT also self-fetch its links, or it - * would both double-fetch and lose the inline link-editing grid. - */ - const canEditLinks = - !draftLocalEntity && - !proposedEntitySubgraph && - !!queryEntitySubgraphData?.queryEntitySubgraph.entityPermissions?.[entityId] - ?.update; - const isReadOnly = /** * @todo H-3398 fix Glide grid editor overlays when body isn't fullscreened. @@ -517,26 +425,10 @@ export const Entity = ({ !!document.fullscreenElement || !!draftEntity?.metadata.archived || !!proposedEntitySubgraph || - (!draftLocalEntity && !canEditLinks); - - /** - * Self-fetch links from within the link tables only when the user genuinely - * cannot edit links (no `update` permission), never merely because the view - * is readonly-for-display (fullscreen/archived). This is the exact complement - * of `includeLinkDataInQuery`'s permission gate, so the main query and the - * link tables can never both fetch the link data. - * - * While permission is still loading (`canEditLinks` false, but - * `includeLinkDataInQuery` also still false) the link tables self-fetch, which - * is the safe path that avoids double-fetching. - * - * A local draft is excluded: it is editable but not yet persisted, so there - * are no stored links to paginate. Its links live in the in-memory draft and - * must be shown in the editable Glide link-editing grid, not the readonly - * paginated table path. - */ - const selfFetchLinks = - !canEditLinks && !proposedEntitySubgraph && !draftLocalEntity; + (!draftLocalEntity && + !queryEntitySubgraphData?.queryEntitySubgraph.entityPermissions?.[ + entityId + ]?.update); const entityFromDb = useMemo( () => (dataFromDb ? getRoots(dataFromDb.entitySubgraph)[0] : null), @@ -710,7 +602,6 @@ export const Entity = ({ {isQueryEntity && shouldShowQueryEditor ? ( { onEntityClick, onTypeClick, readonly, - selfFetchLinks, setDraftLinksToArchive, setDraftLinksToCreate, slideContainerRef, @@ -204,7 +196,6 @@ export const EntityEditor = (props: EntityEditorProps) => { onEntityClick={onEntityClick} onTypeClick={onTypeClick} readonly={readonly} - selfFetchLinks={selfFetchLinks} setDraftLinksToArchive={setDraftLinksToArchive} setDraftLinksToCreate={setDraftLinksToCreate} slideContainerRef={slideContainerRef} @@ -226,7 +217,7 @@ export const EntityEditor = (props: EntityEditorProps) => { } onEntityClick={onEntityClick} onTypeClick={onTypeClick} - selfFetchLinks={selfFetchLinks} + readonly={readonly} slideContainerRef={slideContainerRef} /> diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 41437825de2..49ff695f514 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -32,8 +32,8 @@ type IncomingLinksSectionProps = Pick< | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" | "onEntityClick" | "onTypeClick" - | "selfFetchLinks" | "slideContainerRef" + | "readonly" > & { entity: HashEntity; isLinkEntity: boolean; @@ -50,7 +50,7 @@ export const IncomingLinksSection = ({ linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, onEntityClick, onTypeClick, - selfFetchLinks, + readonly, slideContainerRef, }: IncomingLinksSectionProps) => { /** @@ -72,7 +72,7 @@ export const IncomingLinksSection = ({ * sortable server-side. */ const sortingPaths = useMemo(() => { - if (!selfFetchLinks) { + if (!readonly) { return undefined; } @@ -83,7 +83,7 @@ export const IncomingLinksSection = ({ nulls: "last", }, ]; - }, [selfFetchLinks, sort.direction]); + }, [readonly, sort.direction]); /** * When the entity is readonly we fetch the link data here (paginated), so it @@ -106,7 +106,7 @@ export const IncomingLinksSection = ({ } = useEntityLinks({ direction: "incoming", entityId: entity.metadata.recordId.entityId, - skip: !selfFetchLinks, + skip: !readonly, sortingPaths, }); @@ -117,7 +117,7 @@ export const IncomingLinksSection = ({ * not defeat the `memo()`-wrapped table on every parent render. */ const incomingLinksAndSources = useMemo(() => { - if (selfFetchLinks) { + if (readonly) { if (!linkEntities || !fetchedSubgraph) { return []; } @@ -175,7 +175,7 @@ export const IncomingLinksSection = ({ ); }); }, [ - selfFetchLinks, + readonly, linkEntities, fetchedSubgraph, editorSubgraph, @@ -183,7 +183,7 @@ export const IncomingLinksSection = ({ draftLinksToArchive, ]); - if (selfFetchLinks && error) { + if (readonly && error) { /** * In the self-fetch path the query errors are surfaced here (the editor * path's errors are handled by the parent query). Without this, a failed @@ -200,7 +200,7 @@ export const IncomingLinksSection = ({ } if ( - selfFetchLinks && + readonly && (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) ) { return ( @@ -212,15 +212,15 @@ export const IncomingLinksSection = ({ ); } - const entitySubgraph = selfFetchLinks ? fetchedSubgraph! : editorSubgraph; - const closedMultiEntityTypesMap = selfFetchLinks + const entitySubgraph = readonly ? fetchedSubgraph! : editorSubgraph; + const closedMultiEntityTypesMap = readonly ? (fetchedTypesMap ?? null) : editorTypesMap; - const closedMultiEntityTypesDefinitions = selfFetchLinks + const closedMultiEntityTypesDefinitions = readonly ? fetchedDefinitions! : editorDefinitions; - const linkCount = selfFetchLinks + const linkCount = readonly ? (fetchedCount ?? incomingLinksAndSources.length) : incomingLinksAndSources.length; @@ -251,18 +251,16 @@ export const IncomingLinksSection = ({ closedMultiEntityTypesDefinitions={closedMultiEntityTypesDefinitions} closedMultiEntityTypesMap={closedMultiEntityTypesMap} customEntityLinksColumns={customEntityLinksColumns} - draftLinksToArchive={draftLinksToArchive} entityLabel={entityLabel} entitySubgraph={entitySubgraph} incomingLinksAndSources={incomingLinksAndSources} - loadingMore={selfFetchLinks ? loadingMore : undefined} - onEndReached={selfFetchLinks && hasMore ? loadMore : undefined} + loadingMore={readonly ? loadingMore : undefined} + onEndReached={readonly && hasMore ? loadMore : undefined} onEntityClick={onEntityClick} onTypeClick={onTypeClick} - serverSideSorting={selfFetchLinks} - setSort={selfFetchLinks ? setSort : undefined} + setSort={setSort} slideContainerRef={slideContainerRef} - sort={selfFetchLinks ? sort : undefined} + sort={sort} /> ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index 33f10267458..7b0cb7975c0 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -6,7 +6,6 @@ import { useCallback, useMemo, useRef, - useState, } from "react"; import { EntityOrTypeIcon } from "@hashintel/design-system"; @@ -25,8 +24,6 @@ import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-p import { ClickableCellChip } from "../../../../clickable-cell-chip"; import { VirtualizedTable } from "../../../../virtualized-table"; import { virtualizedTableHeaderHeight } from "../../../../virtualized-table/header"; -import { isValueIncludedInFilter } from "../../../../virtualized-table/header/filter"; -import { useVirtualizedTableFilterState } from "../../../../virtualized-table/use-filter-state"; import { PropertiesTooltip } from "../shared/properties-tooltip"; import { linksTableCellSx, @@ -41,14 +38,7 @@ import type { VirtualizedTableColumn, VirtualizedTableRow, } from "../../../../virtualized-table"; -import type { - VirtualizedTableFilterDefinition, - VirtualizedTableFilterDefinitionsByFieldId, - VirtualizedTableFilterValue, - VirtualizedTableFilterValuesByFieldId, -} from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; -import type { DraftLinksToArchive } from "../../../shared/use-draft-link-state"; import type { CustomEntityLinksColumn } from "../../shared/types"; import type { EntityRootType, @@ -84,8 +74,8 @@ type FieldId = IncomingLinksFieldId; * Only the link entity's label (`link`) is included: the API cannot sort by the * source entity (`linkedFrom`/`linkedFromTypes`), and although it can sort by * the link's `typeTitle`, the "Link type" column displays the *inverse* title, - * so a `typeTitle` sort would not match what is shown. When sorting is - * server-side the other columns are therefore not sortable. + * so a `typeTitle` sort would not match what is shown. All other columns are + * therefore not sortable. */ const serverSortableFieldIds: FieldId[] = ["link"]; @@ -289,7 +279,6 @@ type IncomingLinksTableProps = { closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions; closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null; customEntityLinksColumns?: CustomEntityLinksColumn[]; - draftLinksToArchive: DraftLinksToArchive; entityLabel: string; entitySubgraph: Subgraph>; incomingLinksAndSources: LinkEntityAndLeftEntity[]; @@ -298,17 +287,13 @@ type IncomingLinksTableProps = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; /** - * When `true`, the table is backed by a paginated server-side query: - * - Sorting is applied by the query (the rows are already ordered), so the - * table does not re-sort locally and only exposes the columns the graph API - * can sort by. `sort`/`setSort` must be provided (controlled) so the parent - * can re-query when the sort changes. - * - Filtering is disabled entirely, because client-side filters would only - * ever see the currently loaded pages. + * The table is backed by a paginated server-side query: the rows are a + * server-ordered page, sorting is applied by the query (so the table only + * exposes the columns the graph API can sort by, and `sort`/`setSort` drive a + * re-query when the sort changes), and filtering is applied server-side too. */ - serverSideSorting?: boolean; - sort?: VirtualizedTableSort; - setSort?: (sort: VirtualizedTableSort) => void; + sort: VirtualizedTableSort; + setSort: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; }; @@ -317,7 +302,6 @@ export const IncomingLinksTable = memo( closedMultiEntityTypesDefinitions, closedMultiEntityTypesMap, customEntityLinksColumns: customColumns, - draftLinksToArchive, entityLabel, entitySubgraph, incomingLinksAndSources, @@ -325,62 +309,24 @@ export const IncomingLinksTable = memo( onEndReached, onEntityClick, onTypeClick, - serverSideSorting = false, - sort: controlledSort, - setSort: controlledSetSort, + sort, + setSort, slideContainerRef, }: IncomingLinksTableProps) => { - const [internalSort, setInternalSort] = useState< - VirtualizedTableSort - >({ - fieldId: "linkedFrom", - direction: "asc", - }); - - /** - * When sorting server-side the sort is controlled by the parent (so it can - * re-query); otherwise it is local state. - */ - const sort = controlledSort ?? internalSort; - const setSort = controlledSetSort ?? setInternalSort; - const { - filterDefinitions, - initialFilterValues, - unsortedRows, + rows, + presentLinkEntityTypeIds, }: { - filterDefinitions: VirtualizedTableFilterDefinitionsByFieldId; - initialFilterValues: VirtualizedTableFilterValuesByFieldId; - unsortedRows: VirtualizedTableRow[]; + rows: VirtualizedTableRow[]; + presentLinkEntityTypeIds: Set; } = useMemo(() => { const rowData: VirtualizedTableRow[] = []; - const filterDefs = { - linkTypes: { - header: "Type", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - linkedFrom: { - header: "Name", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - linkedFromTypes: { - header: "Type", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - link: { - header: "Name", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - } as const satisfies VirtualizedTableFilterDefinitionsByFieldId; + /** + * The set of link entity type ids present across the loaded rows, used to + * decide which custom columns apply. + */ + const linkEntityTypeIds = new Set(); for (const { leftEntity: leftEntityRevisions, @@ -391,19 +337,13 @@ export const IncomingLinksTable = memo( throw new Error("Expected at least one link revision"); } - const isMarkedToArchive = draftLinksToArchive.some( - (markedLinkId) => markedLinkId === linkEntity.entityId, - ); - - if (isMarkedToArchive) { - continue; - } - - const linkEntityTypeIds = linkEntity.metadata.entityTypeIds; - const customFields: IncomingLinkRow["customFields"] = {}; for (const customColumn of customColumns ?? []) { - if (linkEntityTypeIds.includes(customColumn.appliesToEntityTypeId)) { + if ( + linkEntity.metadata.entityTypeIds.includes( + customColumn.appliesToEntityTypeId, + ) + ) { customFields[customColumn.id] = customColumn.calculateValue( linkEntity, entitySubgraph, @@ -426,16 +366,7 @@ export const IncomingLinksTable = memo( ); for (const linkType of linkEntityClosedMultiType.allOf) { - const linkEntityTypeId = linkType.$id; - - filterDefs.linkTypes.options[linkEntityTypeId] ??= { - label: linkType.title, - count: 0, - value: linkEntityTypeId, - }; - - filterDefs.linkTypes.options[linkEntityTypeId].count++; - filterDefs.linkTypes.initialValue.add(linkEntityTypeId); + linkEntityTypeIds.add(linkType.$id); } const leftEntity = leftEntityRevisions[0]; @@ -456,39 +387,6 @@ export const IncomingLinksTable = memo( }) : generateEntityLabel(leftEntityClosedMultiType, leftEntity); - filterDefs.linkedFrom.options[leftEntity.metadata.recordId.entityId] ??= - { - label: leftEntityLabel, - count: 0, - value: leftEntity.metadata.recordId.entityId, - }; - filterDefs.linkedFrom.options[leftEntity.metadata.recordId.entityId]! - .count++; - filterDefs.linkedFrom.initialValue.add( - leftEntity.metadata.recordId.entityId, - ); - - for (const leftType of leftEntityClosedMultiType.allOf) { - const leftEntityTypeId = leftType.$id; - - filterDefs.linkedFromTypes.options[leftEntityTypeId] ??= { - label: leftType.title, - count: 0, - value: leftEntityTypeId, - }; - - filterDefs.linkedFromTypes.options[leftEntityTypeId].count++; - filterDefs.linkedFromTypes.initialValue.add(leftEntityTypeId); - } - - filterDefs.link.options[linkEntity.metadata.recordId.entityId] ??= { - label: linkEntityLabel, - count: 0, - value: linkEntity.metadata.recordId.entityId, - }; - filterDefs.link.options[linkEntity.metadata.recordId.entityId]!.count++; - filterDefs.link.initialValue.add(linkEntity.metadata.recordId.entityId); - const linkEntityProperties: IncomingLinkRow["linkEntityProperties"] = {}; for (const [propertyBaseUrl, propertyValue] of typedEntries( @@ -558,23 +456,13 @@ export const IncomingLinksTable = memo( } return { - filterDefinitions: filterDefs, - initialFilterValues: Object.fromEntries( - typedEntries(filterDefs).map( - ([columnId, filterDef]) => - [columnId, filterDef.initialValue] satisfies [ - FieldId, - VirtualizedTableFilterValue, - ], - ), - ) as VirtualizedTableFilterValuesByFieldId, - unsortedRows: rowData, + rows: rowData, + presentLinkEntityTypeIds: linkEntityTypeIds, }; }, [ closedMultiEntityTypesDefinitions, closedMultiEntityTypesMap, customColumns, - draftLinksToArchive, entityLabel, entitySubgraph, incomingLinksAndSources, @@ -583,160 +471,22 @@ export const IncomingLinksTable = memo( slideContainerRef, ]); - const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ - defaultFilterValues: initialFilterValues, - filterDefinitions, - }); - - const rows = useMemo(() => { - if (serverSideSorting) { - /** - * In self-fetch mode the rows are a server-ordered, paginated page: - * sorting is applied by the query and filtering is disabled entirely - * (client-side filtering would only ever see the loaded pages), so the - * rows are used as-is. - */ - return unsortedRows; - } - - return unsortedRows - .filter((row) => { - for (const [fieldId, currentValue] of typedEntries(filterValues)) { - switch (fieldId) { - case "linkTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.linkEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "linkedFrom": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.sourceEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - case "linkedFromTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.sourceEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "link": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.linkEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - } - } - - return true; - }) - .sort((a, b) => { - const field = sort.fieldId; - const direction = sort.direction === "asc" ? 1 : -1; - - switch (field) { - case "linkTypes": { - const aValue = - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ - a.data.linkEntityTypes[0]!.inverse?.title || - a.data.linkEntityTypes[0]!.title; - - const bValue = - /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ - b.data.linkEntityTypes[0]!.inverse?.title || - b.data.linkEntityTypes[0]!.title; - - return aValue.localeCompare(bValue) * direction; - } - case "linkedFromTypes": { - return ( - a.data.sourceEntityTypes[0]!.title.localeCompare( - b.data.sourceEntityTypes[0]!.title, - ) * direction - ); - } - case "linkedFrom": { - return ( - a.data.sourceEntityLabel.localeCompare( - b.data.sourceEntityLabel, - ) * direction - ); - } - case "link": { - return ( - a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * - direction - ); - } - default: { - const customFieldA = a.data.customFields[field]; - const customFieldB = b.data.customFields[field]; - if ( - typeof customFieldA === "number" && - typeof customFieldB === "number" - ) { - return (customFieldA - customFieldB) * direction; - } - return ( - String(customFieldA).localeCompare(String(customFieldB)) * - direction - ); - } - } - }); - }, [filterValues, serverSideSorting, sort, unsortedRows]); - - const height = Math.min( - maxLinksTableHeight, - rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, - ); - const columns = useMemo(() => { - const applicableCustomColumns = customColumns?.filter( - (column) => - typeof filterValues.linkTypes === "object" && - filterValues.linkTypes.has(column.appliesToEntityTypeId), + const applicableCustomColumns = customColumns?.filter((column) => + presentLinkEntityTypeIds.has(column.appliesToEntityTypeId), ); const createdColumns = createColumns(applicableCustomColumns ?? []); - if (!serverSideSorting) { - return createdColumns; - } - /** - * When sorting is server-side, only the columns the graph API can sort by - * are sortable. + * Sorting is applied server-side, so only the columns the graph API can + * sort by are sortable. */ return createdColumns.map((column) => ({ ...column, sortable: serverSortableFieldIds.includes(column.id), })); - }, [filterValues, customColumns, serverSideSorting]); + }, [customColumns, presentLinkEntityTypeIds]); /** * Whether scrolling to the bottom may trigger a load of the next page. It @@ -776,18 +526,20 @@ export const IncomingLinksTable = memo( [loadingMore, onEndReached, rows.length], ); + const height = Math.min( + maxLinksTableHeight, + rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, + ); + return ( & { entity: HashEntity; isLinkEntity: boolean; @@ -75,10 +74,9 @@ export const OutgoingLinksSection = ({ linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, onEntityClick, onTypeClick, - readonly, - selfFetchLinks, setDraftLinksToArchive, setDraftLinksToCreate, + readonly, slideContainerRef, }: OutgoingLinksSectionProps) => { const [showSearch, setShowSearch] = useState(false); @@ -101,7 +99,7 @@ export const OutgoingLinksSection = ({ * `linkTypes` (its type title) are sortable server-side. */ const sortingPaths = useMemo(() => { - if (!selfFetchLinks) { + if (!readonly) { return undefined; } @@ -112,7 +110,7 @@ export const OutgoingLinksSection = ({ nulls: "last", }, ]; - }, [selfFetchLinks, sort]); + }, [readonly, sort]); /** * When the entity is readonly we fetch the link data here (paginated), so it @@ -134,7 +132,7 @@ export const OutgoingLinksSection = ({ } = useEntityLinks({ direction: "outgoing", entityId: entity.metadata.recordId.entityId, - skip: !selfFetchLinks, + skip: !readonly, sortingPaths, }); @@ -145,7 +143,7 @@ export const OutgoingLinksSection = ({ * identity does not defeat the `memo()`-wrapped table on every parent render. */ const outgoingLinksAndTargets = useMemo(() => { - if (selfFetchLinks) { + if (readonly) { if (!linkEntities || !fetchedSubgraph) { return []; } @@ -187,7 +185,7 @@ export const OutgoingLinksSection = ({ editorSubgraph.temporalAxes.resolved.variable.axis ], ); - }, [selfFetchLinks, linkEntities, fetchedSubgraph, editorSubgraph, entity]); + }, [readonly, linkEntities, fetchedSubgraph, editorSubgraph, entity]); const rows = useRows({ closedMultiEntityType, @@ -233,7 +231,7 @@ export const OutgoingLinksSection = ({ }); }, []); - if (selfFetchLinks && error) { + if (readonly && error) { /** * In the self-fetch path the query errors are surfaced here (the editor * path's errors are handled by the parent query). Without this, a failed @@ -250,7 +248,7 @@ export const OutgoingLinksSection = ({ } if ( - selfFetchLinks && + readonly && (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) ) { return ( @@ -262,11 +260,11 @@ export const OutgoingLinksSection = ({ ); } - const entitySubgraph = selfFetchLinks ? fetchedSubgraph! : editorSubgraph; - const closedMultiEntityTypesMap = selfFetchLinks + const entitySubgraph = readonly ? fetchedSubgraph! : editorSubgraph; + const closedMultiEntityTypesMap = readonly ? (fetchedTypesMap ?? null) : editorTypesMap; - const closedMultiEntityTypesDefinitions = selfFetchLinks + const closedMultiEntityTypesDefinitions = readonly ? fetchedDefinitions! : editorDefinitions; @@ -274,7 +272,7 @@ export const OutgoingLinksSection = ({ * When paginated, the link count comes from the query; otherwise it is the * number of outgoing links in the editor subgraph (minus any draft removals). */ - const outgoingLinks = selfFetchLinks + const outgoingLinks = readonly ? null : getOutgoingLinksForEntity( entitySubgraph, @@ -286,7 +284,7 @@ export const OutgoingLinksSection = ({ (outgoingLink) => !draftLinksToArchive.includes(outgoingLink.entityId), ); - const linkCount = selfFetchLinks + const linkCount = readonly ? (fetchedCount ?? linkEntities!.length) : outgoingLinks!.length; @@ -323,7 +321,7 @@ export const OutgoingLinksSection = ({ } > - {rows.length && !readonly && !selfFetchLinks ? ( + {rows.length && !readonly ? ( ) : ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 7080add5013..2051e72a885 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -17,8 +17,6 @@ import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-p import { ClickableCellChip } from "../../../../clickable-cell-chip"; import { VirtualizedTable } from "../../../../virtualized-table"; import { virtualizedTableHeaderHeight } from "../../../../virtualized-table/header"; -import { isValueIncludedInFilter } from "../../../../virtualized-table/header/filter"; -import { useVirtualizedTableFilterState } from "../../../../virtualized-table/use-filter-state"; import { PropertiesTooltip } from "../shared/properties-tooltip"; import { linksTableCellSx, @@ -33,12 +31,7 @@ import type { VirtualizedTableColumn, VirtualizedTableRow, } from "../../../../virtualized-table"; -import type { - VirtualizedTableFilterDefinition, - VirtualizedTableFilterDefinitionsByFieldId, - VirtualizedTableFilterValue, - VirtualizedTableFilterValuesByFieldId, -} from "../../../../virtualized-table/header/filter"; +import type { VirtualizedTableFilterValuesByFieldId } from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; import type { CustomEntityLinksColumn } from "../../shared/types"; import type { @@ -71,7 +64,7 @@ export type OutgoingLinksFieldId = * query's root (the link entities), and the graph API only exposes `label` and * `typeTitle` as sortable tokens (it cannot traverse to the target entity), so * only the link entity's label (`link`) and type title (`linkTypes`) are - * available. When sorting is server-side the other columns are not sortable. + * available. All other columns are not sortable. */ const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes", "link"]; @@ -269,17 +262,13 @@ type OutgoingLinksTableProps = { onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; outgoingLinksAndTargets: LinkEntityAndRightEntity[]; /** - * When `true`, the table is backed by a paginated server-side query: - * - Sorting is applied by the query (the rows are already ordered), so the - * table does not re-sort locally and only exposes the columns the graph API - * can sort by. `sort`/`setSort` must be provided (controlled) so the parent - * can re-query when the sort changes. - * - Filtering is disabled entirely, because client-side filters would only - * ever see the currently loaded pages. + * The table is backed by a paginated server-side query: the rows are a + * server-ordered page, sorting is applied by the query (so the table only + * exposes the columns the graph API can sort by, and `sort`/`setSort` drive a + * re-query when the sort changes), and filtering is applied server-side too. */ - serverSideSorting?: boolean; - sort?: VirtualizedTableSort; - setSort?: (sort: VirtualizedTableSort) => void; + sort: VirtualizedTableSort; + setSort: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; }; @@ -295,62 +284,24 @@ export const OutgoingLinksTable = memo( onEntityClick, onTypeClick, outgoingLinksAndTargets, - serverSideSorting = false, - sort: controlledSort, - setSort: controlledSetSort, + sort, + setSort, slideContainerRef, }: OutgoingLinksTableProps) => { - const [internalSort, setInternalSort] = useState< - VirtualizedTableSort - >({ - fieldId: "linkedTo", - direction: "asc", - }); - - /** - * When sorting server-side the sort is controlled by the parent (so it can - * re-query); otherwise it is local state. - */ - const sort = controlledSort ?? internalSort; - const setSort = controlledSetSort ?? setInternalSort; - const { - filterDefinitions, - initialFilterValues, - unsortedRows, + rows, + presentLinkEntityTypeIds, }: { - filterDefinitions: VirtualizedTableFilterDefinitionsByFieldId; - initialFilterValues: VirtualizedTableFilterValuesByFieldId; - unsortedRows: VirtualizedTableRow[]; + rows: VirtualizedTableRow[]; + presentLinkEntityTypeIds: Set; } = useMemo(() => { const rowData: VirtualizedTableRow[] = []; - const filterDefs = { - linkTypes: { - header: "Type", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - linkedTo: { - header: "Name", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - linkedToTypes: { - header: "Type", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - link: { - header: "Name", - initialValue: new Set(), - options: {} as VirtualizedTableFilterDefinition["options"], - type: "checkboxes", - }, - } as const satisfies VirtualizedTableFilterDefinitionsByFieldId; + /** + * The set of link entity type ids present across the loaded rows, used to + * decide which custom columns apply. + */ + const linkEntityTypeIds = new Set(); for (const { rightEntity: rightEntityRevisions, @@ -391,16 +342,7 @@ export const OutgoingLinksTable = memo( ); for (const linkType of linkEntityClosedMultiType.allOf) { - const linkEntityTypeId = linkType.$id; - - filterDefs.linkTypes.options[linkEntityTypeId] ??= { - label: linkType.title, - count: 0, - value: linkEntityTypeId, - }; - - filterDefs.linkTypes.options[linkEntityTypeId].count++; - filterDefs.linkTypes.initialValue.add(linkEntityTypeId); + linkEntityTypeIds.add(linkType.$id); } const rightEntity = rightEntityRevisions[0]; @@ -421,39 +363,6 @@ export const OutgoingLinksTable = memo( }) : generateEntityLabel(rightEntityClosedMultiType, rightEntity); - filterDefs.linkedTo.options[rightEntity.metadata.recordId.entityId] ??= - { - label: rightEntityLabel, - count: 0, - value: rightEntity.metadata.recordId.entityId, - }; - filterDefs.linkedTo.options[rightEntity.metadata.recordId.entityId]! - .count++; - filterDefs.linkedTo.initialValue.add( - rightEntity.metadata.recordId.entityId, - ); - - for (const rightType of rightEntityClosedMultiType.allOf) { - const rightEntityTypeId = rightType.$id; - - filterDefs.linkedToTypes.options[rightEntityTypeId] ??= { - label: rightType.title, - count: 0, - value: rightEntityTypeId, - }; - - filterDefs.linkedToTypes.options[rightEntityTypeId].count++; - filterDefs.linkedToTypes.initialValue.add(rightEntityTypeId); - } - - filterDefs.link.options[linkEntity.metadata.recordId.entityId] ??= { - label: linkEntityLabel, - count: 0, - value: linkEntity.metadata.recordId.entityId, - }; - filterDefs.link.options[linkEntity.metadata.recordId.entityId]!.count++; - filterDefs.link.initialValue.add(linkEntity.metadata.recordId.entityId); - const linkEntityProperties: OutgoingLinkRow["linkEntityProperties"] = {}; for (const [propertyBaseUrl, propertyValue] of typedEntries( @@ -522,17 +431,8 @@ export const OutgoingLinksTable = memo( } return { - filterDefinitions: filterDefs, - initialFilterValues: Object.fromEntries( - typedEntries(filterDefs).map( - ([columnId, filterDef]) => - [columnId, filterDef.initialValue] satisfies [ - OutgoingLinksFieldId, - VirtualizedTableFilterValue, - ], - ), - ) as OutgoingLinksFilterValues, - unsortedRows: rowData, + rows: rowData, + presentLinkEntityTypeIds: linkEntityTypeIds, }; }, [ closedMultiEntityTypesMap, @@ -553,150 +453,22 @@ export const OutgoingLinksTable = memo( setTimeout(() => setHighlightOutgoingLinks(false), 5_000); }, []); - const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ - defaultFilterValues: { - ...initialFilterValues, - ...(defaultOutgoingLinkFilters ?? {}), - }, - filterDefinitions, - }); - - const rows = useMemo(() => { - if (serverSideSorting) { - /** - * In self-fetch mode the rows are a server-ordered, paginated page: - * sorting is applied by the query and filtering is disabled entirely - * (client-side filtering would only ever see the loaded pages), so the - * rows are used as-is. - */ - return unsortedRows; - } - - const filteredRows = unsortedRows.filter((row) => { - for (const [fieldId, currentValue] of typedEntries(filterValues)) { - switch (fieldId) { - case "linkTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.linkEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "linkedTo": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: - row.data.targetEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - case "linkedToTypes": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.targetEntity.metadata.entityTypeIds, - }) - ) { - return false; - } - break; - } - case "link": { - if ( - !isValueIncludedInFilter({ - currentValue, - valueToCheck: row.data.linkEntity.metadata.recordId.entityId, - }) - ) { - return false; - } - break; - } - } - } - - return true; - }); - - return filteredRows.sort((a, b) => { - const field = sort.fieldId; - const direction = sort.direction === "asc" ? 1 : -1; - - switch (field) { - case "linkTypes": { - return ( - a.data.linkEntityTypes[0]!.title.localeCompare( - b.data.linkEntityTypes[0]!.title, - ) * direction - ); - } - case "linkedToTypes": { - return ( - a.data.targetEntityTypes[0]!.title.localeCompare( - b.data.targetEntityTypes[0]!.title, - ) * direction - ); - } - case "linkedTo": { - return ( - a.data.targetEntityLabel.localeCompare(b.data.targetEntityLabel) * - direction - ); - } - case "link": { - return ( - a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * - direction - ); - } - default: { - const customFieldA = a.data.customFields[field]; - const customFieldB = b.data.customFields[field]; - if ( - typeof customFieldA === "number" && - typeof customFieldB === "number" - ) { - return (customFieldA - customFieldB) * direction; - } - return ( - String(customFieldA).localeCompare(String(customFieldB)) * - direction - ); - } - } - }); - }, [filterValues, serverSideSorting, sort, unsortedRows]); - const columns = useMemo(() => { - const applicableCustomColumns = customColumns?.filter( - (column) => - typeof filterValues.linkTypes === "object" && - filterValues.linkTypes.has(column.appliesToEntityTypeId), + const applicableCustomColumns = customColumns?.filter((column) => + presentLinkEntityTypeIds.has(column.appliesToEntityTypeId), ); const createdColumns = createColumns(applicableCustomColumns ?? []); - if (!serverSideSorting) { - return createdColumns; - } - /** - * When sorting is server-side, only the columns the graph API can sort by - * are sortable. + * Sorting is applied server-side, so only the columns the graph API can + * sort by are sortable. */ return createdColumns.map((column) => ({ ...column, sortable: serverSortableFieldIds.includes(column.id), })); - }, [filterValues, customColumns, serverSideSorting]); + }, [customColumns, presentLinkEntityTypeIds]); /** * Whether scrolling to the bottom may trigger a load of the next page. It @@ -759,13 +531,10 @@ export const OutgoingLinksTable = memo( Date: Mon, 22 Jun 2026 11:16:28 +0200 Subject: [PATCH 15/34] Factor out gql pagination accumulation --- .../entity-editor/entity-editor-context.tsx | 3 - .../links-section/incoming-links-section.tsx | 4 +- .../links-section/outgoing-links-section.tsx | 4 +- .../use-accumulated-cursor-pagination.ts | 186 ++++++++++++++++++ .../links-section/use-entity-links.ts | 180 +++++------------ .../cells/value-cell/array-editor.tsx | 14 +- 6 files changed, 251 insertions(+), 140 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx index 3837c957d4d..bdfee29ef06 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx @@ -41,7 +41,6 @@ export const EntityEditorContextProvider = ({ onEntityUpdated, onTypeClick, readonly, - selfFetchLinks, setDraftLinksToArchive, setDraftLinksToCreate, setEntity, @@ -130,7 +129,6 @@ export const EntityEditorContextProvider = ({ onTypeClick, propertyExpandStatus, readonly, - selfFetchLinks, setDraftLinksToArchive, setDraftLinksToCreate, setEntity, @@ -156,7 +154,6 @@ export const EntityEditorContextProvider = ({ onTypeClick, propertyExpandStatus, readonly, - selfFetchLinks, setDraftLinksToArchive, setDraftLinksToCreate, setEntity, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 49ff695f514..344f2f13451 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -97,7 +97,7 @@ export const IncomingLinksSection = ({ loadingMore, loadMore, hasMore, - count: fetchedCount, + count: totalCount, error, linkEntities, subgraph: fetchedSubgraph, @@ -221,7 +221,7 @@ export const IncomingLinksSection = ({ : editorDefinitions; const linkCount = readonly - ? (fetchedCount ?? incomingLinksAndSources.length) + ? (totalCount ?? incomingLinksAndSources.length) : incomingLinksAndSources.length; if (linkCount === 0 && isLinkEntity) { diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index b5f044657d8..f5db3732af8 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -123,7 +123,7 @@ export const OutgoingLinksSection = ({ loadingMore, loadMore, hasMore, - count: fetchedCount, + count: totalCount, error, linkEntities, subgraph: fetchedSubgraph, @@ -285,7 +285,7 @@ export const OutgoingLinksSection = ({ ); const linkCount = readonly - ? (fetchedCount ?? linkEntities!.length) + ? (totalCount ?? linkEntities!.length) : outgoingLinks!.length; if (linkCount === 0 && isLinkEntity) { diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts new file mode 100644 index 00000000000..629de674a2a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts @@ -0,0 +1,186 @@ +import { useCallback, useMemo, useRef, useState } from "react"; + +/** + * Sentinel key for the first page, which has no originating cursor. + * + * Pages are keyed by the cursor that produced them so that a page is replaced + * (rather than duplicated) if its query resolves more than once - e.g. a cache + * hit followed by a network response for the same cursor. + */ +export const initialCursorKey = "__initial__"; + +/** + * A stable string key for the cursor that produced a page, used to dedupe pages + * (see {@link initialCursorKey}). + */ +export const cursorKeyFor = (cursor: Cursor | undefined): string => + cursor === undefined ? initialCursorKey : JSON.stringify(cursor); + +/** + * Drives cursor-based pagination where each loaded page is folded into a + * running accumulation, exposing a `loadMore` to advance the cursor. + * + * The hook is agnostic to what a "page" or its accumulation contains: callers + * supply + * - `seed`, building the empty accumulator from the first page, and + * - `appendPage`, folding one page into the accumulator (which must expose the + * `nextCursor` to advance to, or `null` when exhausted). + * + * The fold is incremental: as long as `pages` grows by append (the common + * infinite-scroll case) only the newly-added pages are folded in, so loading + * page `k` costs O(page size) rather than O(total pages loaded so far). A reset + * or an in-place page replacement (cache-then-network for the same cursor) + * rebuilds from scratch, which is rare and bounded. + * + * `seed`, `appendPage` and `finalize` must be referentially stable (e.g. + * module-level constants or memoised), as the accumulation only recomputes when + * `pages` changes. + * + * The caller is responsible for issuing the query for the current `cursor` and + * calling `addPage` with the result; the page's `cursorKey` should be derived + * from the `cursorKey` this hook returns (via {@link cursorKeyFor}). + */ +export const useAccumulatedCursorPagination = < + Cursor, + Page extends { cursorKey: string }, + Accumulated extends { nextCursor: Cursor | null }, +>({ + resetKey, + seed, + appendPage, + finalize, +}: { + /** + * When this changes, accumulated pages and the cursor are discarded (the + * query identity, and therefore the cursors, are no longer valid). Done + * during render rather than in an effect so that the stale cursor is never + * sent alongside the new query. + */ + resetKey: string; + /** Build the empty accumulator from the first page. */ + seed: (firstPage: Page) => Accumulated; + /** Fold one page into the running accumulation. */ + appendPage: (accumulated: Accumulated, page: Page) => Accumulated; + /** + * Optional transform applied to the accumulation before it is returned, e.g. + * to hand out fresh references for collections that `appendPage` mutates in + * place. Runs only when the accumulation recomputes. + */ + finalize?: (accumulated: Accumulated) => Accumulated; +}): { + /** The cursor for the page to fetch next, to feed into the query. */ + cursor: Cursor | undefined; + /** The key for the current cursor, to stamp onto the page passed to `addPage`. */ + cursorKey: string; + /** How many pages have been loaded so far. */ + pageCount: number; + /** Record a freshly-loaded page (call from the query's completion handler). */ + addPage: (page: Page) => void; + /** The accumulation of every page loaded so far, or `undefined` if none. */ + accumulated: Accumulated | undefined; + /** Advance the cursor to the next page (no-op if there are no more pages). */ + loadMore: () => void; + /** Whether there are more pages to fetch. */ + hasMore: boolean; +} => { + const [cursor, setCursor] = useState(undefined); + const [pages, setPages] = useState([]); + + const previousResetKey = useRef(resetKey); + if (previousResetKey.current !== resetKey) { + previousResetKey.current = resetKey; + if (cursor !== undefined) { + setCursor(undefined); + } + if (pages.length > 0) { + setPages([]); + } + } + + const addPage = useCallback((page: Page) => { + setPages((previousPages) => { + if (page.cursorKey === initialCursorKey) { + // First page - replace any accumulated pages. + return [page]; + } + + const existingIndex = previousPages.findIndex( + (previousPage) => previousPage.cursorKey === page.cursorKey, + ); + + if (existingIndex !== -1) { + const next = [...previousPages]; + next[existingIndex] = page; + return next; + } + + return [...previousPages, page]; + }); + }, []); + + /** + * The previously-processed `pages` array and its resulting accumulation are + * cached here so that an append-only growth of `pages` only folds in the new + * pages (see the hook docs). + */ + const accumulationCache = useRef<{ + pages: Page[]; + result: Accumulated | undefined; + }>({ pages: [], result: undefined }); + + const seedRef = useRef(seed); + seedRef.current = seed; + const appendPageRef = useRef(appendPage); + appendPageRef.current = appendPage; + const finalizeRef = useRef(finalize); + finalizeRef.current = finalize; + + const accumulated = useMemo(() => { + const cached = accumulationCache.current; + + const isAppendOnlyExtension = + cached.result !== undefined && + pages.length > cached.pages.length && + cached.pages.every((page, index) => pages[index] === page); + + let result: Accumulated | undefined; + if (pages.length === 0) { + result = undefined; + } else if (isAppendOnlyExtension) { + result = cached.result; + for (let index = cached.pages.length; index < pages.length; index++) { + result = appendPageRef.current(result!, pages[index]!); + } + } else { + result = pages.reduce( + (accumulator, page) => appendPageRef.current(accumulator, page), + seedRef.current(pages[0]!), + ); + } + + // Cache the raw (pre-`finalize`) result so later appends build on it. + accumulationCache.current = { pages, result }; + + if (result === undefined) { + return undefined; + } + + return finalizeRef.current ? finalizeRef.current(result) : result; + }, [pages]); + + const loadMore = useCallback(() => { + if (accumulated?.nextCursor) { + setCursor(accumulated.nextCursor); + } + }, [accumulated?.nextCursor]); + + return { + cursor, + cursorKey: cursorKeyFor(cursor), + pageCount: pages.length, + addPage, + accumulated, + loadMore, + hasMore: !!accumulated?.nextCursor, + }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index 10a268e9464..4c993a42413 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -1,5 +1,4 @@ import { type ApolloError, useQuery } from "@apollo/client"; -import { useCallback, useMemo, useRef, useState } from "react"; import { getRoots } from "@blockprotocol/graph/stdlib"; import { type EntityId, splitEntityId } from "@blockprotocol/type-system"; @@ -14,6 +13,8 @@ import { import { queryEntitySubgraphQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import { useAccumulatedCursorPagination } from "./use-accumulated-cursor-pagination"; + import type { QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables, @@ -51,8 +52,6 @@ type LinkPage = { definitions?: ClosedMultiEntityTypesDefinitions; }; -const initialCursorKey = "__initial__"; - /** * Appended to any caller-provided sorting so that pagination is deterministic * (the `uuid` is unique, breaking ties when sorting by a non-unique field such @@ -64,9 +63,6 @@ const uuidSortingPath: EntityQuerySortingRecord = { nulls: "last", }; -const cursorKeyFor = (cursor: EntityQueryCursor | undefined) => - cursor ? JSON.stringify(cursor) : initialCursorKey; - /** * Shallow-merge two `{ [baseId]: { [revisionId]: value } }` records (the shape * of a subgraph's `vertices` and `edges`), so that revisions from later pages @@ -178,29 +174,32 @@ const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { }; }; -const accumulatePages = (pages: LinkPage[]): Accumulated | undefined => { - const [first] = pages; - if (!first) { - return undefined; - } - - let accumulated: Accumulated = { - linkEntities: [], - seenLinkIds: new Set(), - subgraph: first.subgraph, - typesMap: {}, - definitions: undefined, - count: undefined, - nextCursor: null, - exhausted: false, - }; - - for (const page of pages) { - accumulated = appendPage(accumulated, page); - } +/** + * The empty accumulation, seeded with the first page's subgraph as the base to + * merge subsequent pages into. + */ +const seedAccumulated = (firstPage: LinkPage): Accumulated => ({ + linkEntities: [], + seenLinkIds: new Set(), + subgraph: firstPage.subgraph, + typesMap: {}, + definitions: undefined, + count: undefined, + nextCursor: null, + exhausted: false, +}); - return accumulated; -}; +/** + * Return a fresh top-level object (and a fresh `linkEntities` array) so that + * consumers depending on referential identity recompute when a page is + * appended. The expensive O(n) work (dedup, subgraph/type merging) is done + * incrementally by {@link appendPage}, which mutates `linkEntities` in place; + * this only copies the array of entity references. + */ +const finalizeAccumulated = (accumulated: Accumulated): Accumulated => ({ + ...accumulated, + linkEntities: [...accumulated.linkEntities], +}); /** * Fetches an entity's incoming or outgoing links a page at a time, for display @@ -251,32 +250,27 @@ export const useEntityLinks = ({ } => { const [webId, entityUuid, draftId] = splitEntityId(entityId); - const [cursor, setCursor] = useState( - undefined, - ); - const [pages, setPages] = useState([]); - /** - * Reset accumulated pages when the entity, direction or sort changes (the - * query identity, and therefore the cursors, are no longer valid). - * - * Done during render rather than in an effect so that the stale cursor is - * never sent alongside the new query (which would either error or produce a - * page that does not belong to the new ordering). + * Accumulate pages across `loadMore` calls. Changing the entity, direction or + * sort resets the accumulation (the query identity, and therefore the + * cursors, are no longer valid). */ - const resetKey = `${entityId}:${direction}:${JSON.stringify( - sortingPaths ?? null, - )}`; - const previousResetKey = useRef(resetKey); - if (previousResetKey.current !== resetKey) { - previousResetKey.current = resetKey; - if (cursor !== undefined) { - setCursor(undefined); - } - if (pages.length > 0) { - setPages([]); - } - } + const { + cursor, + cursorKey, + pageCount, + addPage, + accumulated, + loadMore, + hasMore, + } = useAccumulatedCursorPagination({ + resetKey: `${entityId}:${direction}:${JSON.stringify( + sortingPaths ?? null, + )}`, + seed: seedAccumulated, + appendPage, + finalize: finalizeAccumulated, + }); /** * The endpoint of the link that is the entity being viewed: its left/source @@ -358,8 +352,8 @@ export const useEntityLinks = ({ data.queryEntitySubgraph, ); - const page: LinkPage = { - cursorKey: cursorKeyFor(cursor), + addPage({ + cursorKey, count: data.queryEntitySubgraph.count ?? undefined, linkEntities: getRoots(response.subgraph).map( (rootEntity) => new HashLinkEntity(rootEntity), @@ -368,90 +362,16 @@ export const useEntityLinks = ({ subgraph: response.subgraph, typesMap: data.queryEntitySubgraph.closedMultiEntityTypes ?? {}, definitions: data.queryEntitySubgraph.definitions ?? undefined, - }; - - setPages((previousPages) => { - if (!cursor) { - // First page – replace any accumulated pages. - return [page]; - } - - const existingIndex = previousPages.findIndex( - (previousPage) => previousPage.cursorKey === page.cursorKey, - ); - - if (existingIndex !== -1) { - const next = [...previousPages]; - next[existingIndex] = page; - return next; - } - - return [...previousPages, page]; }); }, }); - /** - * Incrementally accumulate pages. The previously-processed `pages` array and - * its resulting accumulation are cached in a ref; when `pages` grows by - * append-only (the common infinite-scroll case) only the new pages are folded - * in, keeping each `loadMore` O(page size) rather than O(total pages). A - * reset (`[page]`) or an in-place page replacement (cache-then-network for the - * same cursor) rebuilds from scratch, which is rare and bounded. - */ - const accumulationCache = useRef<{ - pages: LinkPage[]; - result: Accumulated | undefined; - }>({ pages: [], result: undefined }); - - const accumulated = useMemo(() => { - const cached = accumulationCache.current; - - const isAppendOnlyExtension = - cached.result !== undefined && - pages.length > cached.pages.length && - cached.pages.every((page, index) => pages[index] === page); - - let result: Accumulated | undefined; - if (pages.length === 0) { - result = undefined; - } else if (isAppendOnlyExtension) { - result = cached.result; - for (let index = cached.pages.length; index < pages.length; index++) { - result = appendPage(result!, pages[index]!); - } - } else { - result = accumulatePages(pages); - } - - accumulationCache.current = { pages, result }; - - if (!result) { - return undefined; - } - - /** - * Return a fresh top-level object (and a fresh `linkEntities` array) so - * that consumers depending on referential identity recompute when a page - * is appended. The expensive O(n) work (dedup, subgraph/type merging) has - * already been done incrementally above; this only copies the array of - * entity references. - */ - return { ...result, linkEntities: [...result.linkEntities] }; - }, [pages]); - - const loadMore = useCallback(() => { - if (accumulated?.nextCursor) { - setCursor(accumulated.nextCursor); - } - }, [accumulated?.nextCursor]); - return { - initialLoading: loading && pages.length === 0, + initialLoading: loading && pageCount === 0, error, loadingMore: loading && cursor !== undefined, loadMore, - hasMore: !!accumulated?.nextCursor, + hasMore, count: accumulated?.count, linkEntities: accumulated?.linkEntities, subgraph: accumulated?.subgraph, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx index c54f4a81229..606db6ca04f 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx @@ -28,6 +28,7 @@ import { SortableRow } from "./array-editor/sortable-row"; import { getEditorSpecs } from "./editor-specs"; import { isBlankStringOrNullish } from "./utils"; +import type { PropertyRow } from "../../types"; import type { SortableItem } from "./array-editor/types"; import type { ValueCellEditorComponent } from "./types"; import type { @@ -174,7 +175,11 @@ export const ArrayEditor: ValueCellEditorComponent = ({ value, ]; - draftCell.data.propertyRow.valueMetadata = propertyMetadata; + // Cast to the non-draft `PropertyRow` to avoid immer's `Draft` + // recursively expanding the deeply-recursive `PropertyMetadata` type, + // which trips TS2589 ("Type instantiation is excessively deep"). + (draftCell.data.propertyRow as PropertyRow).valueMetadata = + propertyMetadata; }); onChange(newCell); @@ -198,7 +203,9 @@ export const ArrayEditor: ValueCellEditorComponent = ({ .filter((_, index) => indexToRemove !== index) .map(({ value }) => value); - draftCell.data.propertyRow.valueMetadata = propertyMetadata; + // See the note in `addItem` re: the `PropertyRow` cast (TS2589). + (draftCell.data.propertyRow as PropertyRow).valueMetadata = + propertyMetadata; }); onChange(newCell); @@ -237,7 +244,8 @@ export const ArrayEditor: ValueCellEditorComponent = ({ const newMetadata = arrayMove(valueMetadata.value, oldIndex, newIndex); - draftCell.data.propertyRow.valueMetadata = { + // See the note in `addItem` re: the `PropertyRow` cast (TS2589). + (draftCell.data.propertyRow as PropertyRow).valueMetadata = { ...valueMetadata, value: newMetadata, }; From 125adb02befc613b1aec23dd5434ad990c8620df Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 12:16:26 +0200 Subject: [PATCH 16/34] Add client-side sorting to readonly incoming links --- .../links-section/incoming-links-section.tsx | 15 +-- .../incoming-links-table.tsx | 95 +++++++++++++++++-- .../cells/value-cell/single-value-editor.tsx | 7 +- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 344f2f13451..2eea4a1de19 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -54,12 +54,14 @@ export const IncomingLinksSection = ({ slideContainerRef, }: IncomingLinksSectionProps) => { /** - * When links are fetched here (paginated), sorting is applied server-side, so - * the sort state lives here in order to drive the query. The graph API can - * only sort the link entities by their own label (the API cannot sort by the - * source entity, and the link type column shows the inverse title which the - * API cannot sort by), so we default to – and only support – the link - * entity's label. + * The sort state lives here so that, in the readonly case, it can drive the + * paginated query (sorting is applied server-side). The graph API can only + * sort the link entities by their own label (it cannot sort by the source + * entity, and the link type column shows the inverse title which the API + * cannot sort by), so server-side we default to – and only support – the link + * entity's label. When editable, the full set of links is present and the + * table sorts client-side instead (across all columns), still driven by this + * state. */ const [sort, setSort] = useState>({ fieldId: "link", @@ -258,6 +260,7 @@ export const IncomingLinksSection = ({ onEndReached={readonly && hasMore ? loadMore : undefined} onEntityClick={onEntityClick} onTypeClick={onTypeClick} + readonly={readonly} setSort={setSort} slideContainerRef={slideContainerRef} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index 7b0cb7975c0..a15050c1438 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -287,11 +287,17 @@ type IncomingLinksTableProps = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; /** - * The table is backed by a paginated server-side query: the rows are a - * server-ordered page, sorting is applied by the query (so the table only - * exposes the columns the graph API can sort by, and `sort`/`setSort` drive a - * re-query when the sort changes), and filtering is applied server-side too. + * When `true`, the table is backed by a paginated server-side query: the rows + * are a server-ordered page, sorting is applied by the query (so the table + * only exposes the columns the graph API can sort by, and `sort`/`setSort` + * drive a re-query when the sort changes), and filtering is applied + * server-side too. + * + * When `false` (the editable case), the full set of links is already present, + * so sorting is applied client-side instead: every column is sortable and the + * rows are re-ordered locally as `sort` changes. */ + readonly: boolean; sort: VirtualizedTableSort; setSort: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; @@ -309,6 +315,7 @@ export const IncomingLinksTable = memo( onEndReached, onEntityClick, onTypeClick, + readonly, sort, setSort, slideContainerRef, @@ -471,6 +478,70 @@ export const IncomingLinksTable = memo( slideContainerRef, ]); + /** + * When readonly the rows are a server-ordered page and are used as-is. + * Otherwise the full set of links is present, so we sort them client-side + * according to the current `sort`. + */ + const sortedRows = useMemo(() => { + if (readonly) { + return rows; + } + + const direction = sort.direction === "asc" ? 1 : -1; + + return [...rows].sort((a, b) => { + switch (sort.fieldId) { + case "linkTypes": { + const aValue = + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ + a.data.linkEntityTypes[0]!.inverse?.title || + a.data.linkEntityTypes[0]!.title; + + const bValue = + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- we don't want an empty string */ + b.data.linkEntityTypes[0]!.inverse?.title || + b.data.linkEntityTypes[0]!.title; + + return aValue.localeCompare(bValue) * direction; + } + case "linkedFromTypes": { + return ( + a.data.sourceEntityTypes[0]!.title.localeCompare( + b.data.sourceEntityTypes[0]!.title, + ) * direction + ); + } + case "linkedFrom": { + return ( + a.data.sourceEntityLabel.localeCompare(b.data.sourceEntityLabel) * + direction + ); + } + case "link": { + return ( + a.data.linkEntityLabel.localeCompare(b.data.linkEntityLabel) * + direction + ); + } + default: { + const customFieldA = a.data.customFields[sort.fieldId]; + const customFieldB = b.data.customFields[sort.fieldId]; + if ( + typeof customFieldA === "number" && + typeof customFieldB === "number" + ) { + return (customFieldA - customFieldB) * direction; + } + return ( + String(customFieldA).localeCompare(String(customFieldB)) * + direction + ); + } + } + }); + }, [readonly, rows, sort]); + const columns = useMemo(() => { const applicableCustomColumns = customColumns?.filter((column) => presentLinkEntityTypeIds.has(column.appliesToEntityTypeId), @@ -478,6 +549,14 @@ export const IncomingLinksTable = memo( const createdColumns = createColumns(applicableCustomColumns ?? []); + if (!readonly) { + /** + * Sorting is applied client-side, so each column keeps its own + * `sortable` flag. + */ + return createdColumns; + } + /** * Sorting is applied server-side, so only the columns the graph API can * sort by are sortable. @@ -486,7 +565,7 @@ export const IncomingLinksTable = memo( ...column, sortable: serverSortableFieldIds.includes(column.id), })); - }, [customColumns, presentLinkEntityTypeIds]); + }, [customColumns, presentLinkEntityTypeIds, readonly]); /** * Whether scrolling to the bottom may trigger a load of the next page. It @@ -528,7 +607,9 @@ export const IncomingLinksTable = memo( const height = Math.min( maxLinksTableHeight, - rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2, + sortedRows.length * linksTableRowHeight + + virtualizedTableHeaderHeight + + 2, ); return ( @@ -540,7 +621,7 @@ export const IncomingLinksTable = memo( loadingMore={loadingMore} onIsScrolling={handleIsScrolling} onRangeChange={handleRangeChange} - rows={rows} + rows={sortedRows} sort={sort} setSort={setSort} increaseViewportBy={linksTablePageSize} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx index 71f63755d20..07e4d558754 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx @@ -23,6 +23,7 @@ import type { FindDataTypeConversionTargetsQuery, FindDataTypeConversionTargetsQueryVariables, } from "../../../../../../../../graphql/api-types.gen"; +import type { PropertyRow } from "../../types"; import type { ValueCell, ValueCellEditorComponent } from "./types"; import type { ClosedDataType, @@ -145,7 +146,11 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { }); const newCell = produce(cell, (draftCell) => { - draftCell.data.propertyRow.valueMetadata = propertyMetadata; + // Cast to the non-draft `PropertyRow` to avoid immer's `Draft` + // recursively expanding the deeply-recursive `PropertyMetadata` type, + // which trips TS2589 ("Type instantiation is excessively deep"). + (draftCell.data.propertyRow as PropertyRow).valueMetadata = + propertyMetadata; }); onChange(newCell); From 1af367cc0681dd228c5659bb8e9398e058420bba Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 13:18:02 +0200 Subject: [PATCH 17/34] Remove broken link title server side sorting --- .../links-section/incoming-links-section.tsx | 35 ++++--------------- .../incoming-links-table.tsx | 23 +++++++----- .../links-section/outgoing-links-section.tsx | 26 ++++++++------ .../readonly-outgoing-links-table.tsx | 18 ++++++---- 4 files changed, 47 insertions(+), 55 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 2eea4a1de19..6db54dbcdda 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -18,7 +18,6 @@ import type { VirtualizedTableSort } from "../../../virtualized-table/header/sor import type { EntityEditorProps } from "../../entity-editor"; import type { IncomingLinksFieldId } from "./incoming-links-section/incoming-links-table"; import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; -import type { EntityQuerySortingRecord } from "@local/hash-graph-client"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; @@ -54,39 +53,18 @@ export const IncomingLinksSection = ({ slideContainerRef, }: IncomingLinksSectionProps) => { /** - * The sort state lives here so that, in the readonly case, it can drive the - * paginated query (sorting is applied server-side). The graph API can only - * sort the link entities by their own label (it cannot sort by the source - * entity, and the link type column shows the inverse title which the API - * cannot sort by), so server-side we default to – and only support – the link - * entity's label. When editable, the full set of links is present and the - * table sorts client-side instead (across all columns), still driven by this - * state. + * The table sort state. Incoming links have no server-sortable column (see + * `serverSortableFieldIds` in the table – the API can't reach the source + * entity, and its `label` sort uses the empty label property rather than the + * client-generated label shown), so this only drives client-side sorting in + * the editable case. In the readonly/paginated case the rows keep their + * server (uuid) order and no column is sortable. */ const [sort, setSort] = useState>({ fieldId: "link", direction: "asc", }); - /** - * Translate the table sort into graph-query sorting paths, which apply to the - * query's root (the link entities). Only `link` (the link entity label) is - * sortable server-side. - */ - const sortingPaths = useMemo(() => { - if (!readonly) { - return undefined; - } - - return [ - { - path: ["label"], - ordering: sort.direction === "asc" ? "ascending" : "descending", - nulls: "last", - }, - ]; - }, [readonly, sort.direction]); - /** * When the entity is readonly we fetch the link data here (paginated), so it * does not need to be part of the main entity query. When editable, the link @@ -109,7 +87,6 @@ export const IncomingLinksSection = ({ direction: "incoming", entityId: entity.metadata.recordId.entityId, skip: !readonly, - sortingPaths, }); /** diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index a15050c1438..7d82ab6e0cf 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -67,17 +67,22 @@ export type IncomingLinksFieldId = type FieldId = IncomingLinksFieldId; /** - * The columns that can be sorted server-side. Sorting paths are applied to the - * query's root (the link entities), and the graph API only exposes `label` and - * `typeTitle` as sortable tokens (it cannot traverse to the source entity). + * The columns that can be sorted server-side (applied to the query's root, the + * link entities). None can be: + * - the API cannot traverse to the source entity, so `linkedFrom` / + * `linkedFromTypes` are out; + * - the "Link type" column displays the *inverse* title, which the `typeTitle` + * token does not match; + * - the "Link" column shows a client-generated label (see `generateEntityLabel`), + * but the API's `label` token sorts by the entity's label *property*, which is + * empty for typical link entities — so every row ties and only the `uuid` + * tiebreaker orders them (flipping the direction does nothing, and the order + * does not match the displayed label). * - * Only the link entity's label (`link`) is included: the API cannot sort by the - * source entity (`linkedFrom`/`linkedFromTypes`), and although it can sort by - * the link's `typeTitle`, the "Link type" column displays the *inverse* title, - * so a `typeTitle` sort would not match what is shown. All other columns are - * therefore not sortable. + * Every column is therefore sortable only client-side (the editable case); in + * the readonly/paginated case the rows keep their server (uuid) order. */ -const serverSortableFieldIds: FieldId[] = ["link"]; +const serverSortableFieldIds: FieldId[] = []; const staticColumns: VirtualizedTableColumn[] = [ { diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index f5db3732af8..6a166f47674 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -82,35 +82,39 @@ export const OutgoingLinksSection = ({ const [showSearch, setShowSearch] = useState(false); /** - * When links are fetched here (paginated), sorting is applied server-side, so - * the sort state lives here in order to drive the query. The default mirrors - * what the API can sort the link entities by – only their own label and type - * title are available (the API cannot sort by the target entity), so we - * default to the link entity's label. + * The sort state lives here so that, in the readonly case, it can drive the + * paginated query (sorting is applied server-side). The only column the API + * can sort the link entities by is their type title (it cannot sort by the + * target entity, and its `label` sort uses the empty label property rather + * than the client-generated label shown in the "Link" column), so we default + * to – and server-side only support – the link type. When editable, the full + * set of links is present and the table sorts client-side instead (across all + * columns), still driven by this state. */ const [sort, setSort] = useState>({ - fieldId: "link", + fieldId: "linkTypes", direction: "asc", }); /** * Translate the table sort into graph-query sorting paths, which apply to the - * query's root (the link entities). Only `link` (the link entity label) and - * `linkTypes` (its type title) are sortable server-side. + * query's root (the link entities). Only `linkTypes` (the link's type title) + * is sortable server-side; for any other column we apply no server sort (the + * query's uuid tiebreaker still gives a stable, paginatable order). */ const sortingPaths = useMemo(() => { - if (!readonly) { + if (!readonly || sort.fieldId !== "linkTypes") { return undefined; } return [ { - path: sort.fieldId === "linkTypes" ? ["typeTitle"] : ["label"], + path: ["typeTitle"], ordering: sort.direction === "asc" ? "ascending" : "descending", nulls: "last", }, ]; - }, [readonly, sort]); + }, [readonly, sort.fieldId, sort.direction]); /** * When the entity is readonly we fetch the link data here (paginated), so it diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 2051e72a885..45c55653b6c 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -60,13 +60,19 @@ export type OutgoingLinksFieldId = | "link"; /** - * The columns that can be sorted server-side. Sorting paths are applied to the - * query's root (the link entities), and the graph API only exposes `label` and - * `typeTitle` as sortable tokens (it cannot traverse to the target entity), so - * only the link entity's label (`link`) and type title (`linkTypes`) are - * available. All other columns are not sortable. + * The columns that can be sorted server-side (applied to the query's root, the + * link entities). Only the link type title (`linkTypes`) can be: + * - the API cannot traverse to the target entity, so `linkedTo` / + * `linkedToTypes` are out; + * - the "Link" column shows a client-generated label (see `generateEntityLabel`), + * but the API's `label` token sorts by the entity's label *property*, which is + * empty for typical link entities — so every row ties and only the `uuid` + * tiebreaker orders them (flipping the direction does nothing, and the order + * does not match the displayed label). + * + * The "Link" column is therefore sortable only client-side (the editable case). */ -const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes", "link"]; +const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes"]; export type OutgoingLinksFilterValues = VirtualizedTableFilterValuesByFieldId; From b74f4d2db23a1c4b032bf1ffd01f144387bfd8d4 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 13:43:43 +0200 Subject: [PATCH 18/34] Re-apply server side filtering for link tables --- .../links-section/incoming-links-section.tsx | 101 +++++++++++- .../incoming-links-table.tsx | 18 +++ .../links-section/outgoing-links-section.tsx | 28 +++- .../readonly-outgoing-links-table.tsx | 17 ++ .../links-section/use-entity-links.ts | 68 +++++++- .../links-section/use-link-type-filter.ts | 151 ++++++++++++++++++ 6 files changed, 378 insertions(+), 5 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 6db54dbcdda..2871cb173de 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -1,11 +1,12 @@ import { Box, CircularProgress, Stack } from "@mui/material"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getIncomingLinkAndSourceEntities, getLeftEntityForLinkEntity, } from "@blockprotocol/graph/stdlib"; import { Callout, Chip } from "@hashintel/design-system"; +import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; import { noisySystemTypeIds } from "@local/hash-isomorphic-utils/graph-queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; @@ -13,11 +14,13 @@ import { SectionWrapper } from "../../../section-wrapper"; import { LinksSectionEmptyState } from "../../shared/links-section-empty-state"; import { IncomingLinksTable } from "./incoming-links-section/incoming-links-table"; import { useEntityLinks } from "./use-entity-links"; +import { useLinkTypeFilter } from "./use-link-type-filter"; import type { VirtualizedTableSort } from "../../../virtualized-table/header/sort"; import type { EntityEditorProps } from "../../entity-editor"; import type { IncomingLinksFieldId } from "./incoming-links-section/incoming-links-table"; import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; +import type { VersionedUrl } from "@blockprotocol/type-system"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; @@ -65,6 +68,22 @@ export const IncomingLinksSection = ({ direction: "asc", }); + /** + * The link table can be filtered by link type in both cases. The filter state + * and options live here so the selection can be passed to the table's header + * and applied: server-side (`filterTypeIds` → the paginated query) when + * readonly, and client-side (filtering the editor links below) when editable. + * `filterTypeIds` derives only from this hook's state, so feeding it into + * `useEntityLinks` below does not create a render cycle. + */ + const { + captureLinkTypeOptions, + filterDefinitions, + filterValues, + setFilterValues, + filterTypeIds, + } = useLinkTypeFilter(); + /** * When the entity is readonly we fetch the link data here (paginated), so it * does not need to be part of the main entity query. When editable, the link @@ -83,9 +102,12 @@ export const IncomingLinksSection = ({ subgraph: fetchedSubgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap, closedMultiEntityTypesDefinitions: fetchedDefinitions, + typeIds, + typeTitles, } = useEntityLinks({ direction: "incoming", entityId: entity.metadata.recordId.entityId, + filterTypeIds, skip: !readonly, }); @@ -162,6 +184,78 @@ export const IncomingLinksSection = ({ draftLinksToArchive, ]); + /** + * In the editable case there is no server breakdown, so the filter options are + * derived from the (unfiltered) editor links here. Counting per link entity + * type mirrors the server aggregate; the title is the type's forward title, as + * in the readonly case. + */ + const editableLinkTypeBreakdown = useMemo(() => { + if (readonly || !editorTypesMap) { + return undefined; + } + + const typeIdCounts: Record = {}; + const linkTypeTitles: Record = {}; + + for (const { linkEntity } of incomingLinksAndSources) { + const link = linkEntity[0]; + if (!link) { + continue; + } + + let closedType; + try { + closedType = getClosedMultiEntityTypeFromMap( + editorTypesMap, + link.metadata.entityTypeIds, + ); + } catch { + continue; + } + + for (const type of closedType.allOf) { + typeIdCounts[type.$id] = (typeIdCounts[type.$id] ?? 0) + 1; + linkTypeTitles[type.$id] ??= type.title; + } + } + + return { typeIds: typeIdCounts, typeTitles: linkTypeTitles }; + }, [readonly, editorTypesMap, incomingLinksAndSources]); + + useEffect(() => { + captureLinkTypeOptions( + readonly ? typeIds : editableLinkTypeBreakdown?.typeIds, + readonly ? typeTitles : editableLinkTypeBreakdown?.typeTitles, + ); + }, [ + readonly, + captureLinkTypeOptions, + typeIds, + typeTitles, + editableLinkTypeBreakdown, + ]); + + /** + * The rows shown in the table. In the readonly case the fetched links are + * already filtered server-side; in the editable case the full editor links are + * filtered here by the selected link types (`filterTypeIds` is `undefined` + * while every type is selected, so an untouched filter shows everything). + */ + const displayedIncomingLinksAndSources = useMemo(() => { + if (readonly || !filterTypeIds) { + return incomingLinksAndSources; + } + + const selectedTypeIds = new Set(filterTypeIds); + + return incomingLinksAndSources.filter(({ linkEntity }) => + linkEntity[0]?.metadata.entityTypeIds.some((typeId) => + selectedTypeIds.has(typeId), + ), + ); + }, [readonly, filterTypeIds, incomingLinksAndSources]); + if (readonly && error) { /** * In the self-fetch path the query errors are surfaced here (the editor @@ -232,12 +326,15 @@ export const IncomingLinksSection = ({ customEntityLinksColumns={customEntityLinksColumns} entityLabel={entityLabel} entitySubgraph={entitySubgraph} - incomingLinksAndSources={incomingLinksAndSources} + filterDefinitions={filterDefinitions} + filterValues={filterValues} + incomingLinksAndSources={displayedIncomingLinksAndSources} loadingMore={readonly ? loadingMore : undefined} onEndReached={readonly && hasMore ? loadMore : undefined} onEntityClick={onEntityClick} onTypeClick={onTypeClick} readonly={readonly} + setFilterValues={setFilterValues} setSort={setSort} slideContainerRef={slideContainerRef} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index 7d82ab6e0cf..f2096544024 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -40,6 +40,10 @@ import type { } from "../../../../virtualized-table"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; import type { CustomEntityLinksColumn } from "../../shared/types"; +import type { + LinkTypeFilterDefinitions, + LinkTypeFilterValues, +} from "../use-link-type-filter"; import type { EntityRootType, LinkEntityAndLeftEntity, @@ -286,6 +290,14 @@ type IncomingLinksTableProps = { customEntityLinksColumns?: CustomEntityLinksColumn[]; entityLabel: string; entitySubgraph: Subgraph>; + /** + * The link-type filter, applied server-side. Present only in the readonly / + * paginated case (see {@link readonly}); `undefined` while the breakdown that + * populates the options has not yet loaded. + */ + filterDefinitions?: LinkTypeFilterDefinitions; + filterValues?: LinkTypeFilterValues; + setFilterValues?: (filterValues: LinkTypeFilterValues) => void; incomingLinksAndSources: LinkEntityAndLeftEntity[]; loadingMore?: boolean; onEndReached?: () => void; @@ -315,6 +327,9 @@ export const IncomingLinksTable = memo( customEntityLinksColumns: customColumns, entityLabel, entitySubgraph, + filterDefinitions, + filterValues, + setFilterValues, incomingLinksAndSources, loadingMore, onEndReached, @@ -622,6 +637,9 @@ export const IncomingLinksTable = memo( { + captureLinkTypeOptions(typeIds, typeTitles); + }, [captureLinkTypeOptions, typeIds, typeTitles]); + /** * The links/targets passed to the readonly table. In the self-fetch path the * source/target entities come from the merged multi-page subgraph; in the @@ -352,11 +375,14 @@ export const OutgoingLinksSection = ({ customEntityLinksColumns={customEntityLinksColumns} defaultOutgoingLinkFilters={defaultOutgoingLinkFilters} entitySubgraph={entitySubgraph} + filterDefinitions={readonly ? filterDefinitions : undefined} + filterValues={readonly ? filterValues : undefined} loadingMore={readonly ? loadingMore : undefined} onEndReached={readonly && hasMore ? loadMore : undefined} onEntityClick={onEntityClick} onTypeClick={onTypeClick} outgoingLinksAndTargets={outgoingLinksAndTargets} + setFilterValues={readonly ? setFilterValues : undefined} setSort={setSort} slideContainerRef={slideContainerRef} sort={sort} diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 45c55653b6c..d0b4ba567a8 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -34,6 +34,10 @@ import type { import type { VirtualizedTableFilterValuesByFieldId } from "../../../../virtualized-table/header/filter"; import type { VirtualizedTableSort } from "../../../../virtualized-table/header/sort"; import type { CustomEntityLinksColumn } from "../../shared/types"; +import type { + LinkTypeFilterDefinitions, + LinkTypeFilterValues, +} from "../use-link-type-filter"; import type { EntityRootType, LinkEntityAndRightEntity, @@ -262,6 +266,13 @@ type OutgoingLinksTableProps = { customEntityLinksColumns?: CustomEntityLinksColumn[]; defaultOutgoingLinkFilters?: Partial; entitySubgraph: Subgraph>; + /** + * The link-type filter, applied server-side; `undefined` while the breakdown + * that populates the options has not yet loaded. + */ + filterDefinitions?: LinkTypeFilterDefinitions; + filterValues?: LinkTypeFilterValues; + setFilterValues?: (filterValues: LinkTypeFilterValues) => void; loadingMore?: boolean; onEndReached?: () => void; onEntityClick: (entityId: EntityId) => void; @@ -285,6 +296,9 @@ export const OutgoingLinksTable = memo( customEntityLinksColumns: customColumns, defaultOutgoingLinkFilters, entitySubgraph, + filterDefinitions, + filterValues, + setFilterValues, loadingMore, onEndReached, onEntityClick, @@ -537,6 +551,9 @@ export const OutgoingLinksTable = memo( ; + typeTitles?: Record; }; /** @@ -130,6 +144,8 @@ type Accumulated = { subgraph: LinksSubgraph; typesMap: ClosedMultiEntityTypesRootMap; definitions?: ClosedMultiEntityTypesDefinitions; + typeIds?: Record; + typeTitles?: Record; count?: number; nextCursor: EntityQueryCursor | null; /** @@ -168,6 +184,12 @@ const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { ...accumulated, definitions: mergeDefinitionsInto(accumulated.definitions, page), subgraph: mergeSubgraphInto(accumulated.subgraph, page), + /** + * The type breakdown is a full-set aggregate returned identically on every + * page, so the latest page's value replaces (rather than extends) it. + */ + typeIds: page.typeIds ?? accumulated.typeIds, + typeTitles: page.typeTitles ?? accumulated.typeTitles, count: page.count, exhausted, nextCursor: exhausted ? null : page.nextCursor, @@ -184,6 +206,8 @@ const seedAccumulated = (firstPage: LinkPage): Accumulated => ({ subgraph: firstPage.subgraph, typesMap: {}, definitions: undefined, + typeIds: undefined, + typeTitles: undefined, count: undefined, nextCursor: null, exhausted: false, @@ -216,11 +240,19 @@ const finalizeAccumulated = (accumulated: Accumulated): Accumulated => ({ export const useEntityLinks = ({ direction, entityId, + filterTypeIds, skip = false, sortingPaths, }: { direction: "outgoing" | "incoming"; entityId: EntityId; + /** + * If set, restrict the matched links to those whose link entity type is one + * of the given type ids. Applied server-side, so both the returned links and + * the `count` reflect the filter. Changing this resets pagination (the + * accumulated pages and their cursors are no longer valid). + */ + filterTypeIds?: VersionedUrl[]; skip?: boolean; /** * How to sort the links server-side. A `uuid` tiebreaker is always appended @@ -247,6 +279,10 @@ export const useEntityLinks = ({ subgraph?: LinksSubgraph; linkAndDestinationEntitiesClosedMultiEntityTypesMap?: ClosedMultiEntityTypesRootMap; closedMultiEntityTypesDefinitions?: ClosedMultiEntityTypesDefinitions; + /** The count of matching links by their link entity type id. */ + typeIds?: Record; + /** The title of each link entity type present in {@link typeIds}. */ + typeTitles?: Record; } => { const [webId, entityUuid, draftId] = splitEntityId(entityId); @@ -266,7 +302,7 @@ export const useEntityLinks = ({ } = useAccumulatedCursorPagination({ resetKey: `${entityId}:${direction}:${JSON.stringify( sortingPaths ?? null, - )}`, + )}:${JSON.stringify(filterTypeIds ?? null)}`, seed: seedAccumulated, appendPage, finalize: finalizeAccumulated, @@ -331,6 +367,23 @@ export const useEntityLinks = ({ }, ] : []), + /** + * Restrict the matched link entities to the selected link types. + * The path is rooted on the link entity, so `["type", + * "versionedUrl"]` matches the link entity's own type. + */ + ...(filterTypeIds && filterTypeIds.length > 0 + ? [ + { + any: filterTypeIds.map((versionedUrl) => ({ + equal: [ + { path: ["type", "versionedUrl"] }, + { parameter: versionedUrl }, + ], + })), + }, + ] + : []), ], }, cursor, @@ -345,6 +398,13 @@ export const useEntityLinks = ({ includeDrafts: !!draftId, includeEntityTypes: "resolvedWithDataTypeChildren", includePermissions: false, + /** + * Return the breakdown of matching links by link entity type id, and + * the title of each type, so the caller can offer them as filter + * options (see {@link filterTypeIds}). + */ + includeTypeIds: true, + includeTypeTitles: true, }, }, onCompleted: (data) => { @@ -362,6 +422,8 @@ export const useEntityLinks = ({ subgraph: response.subgraph, typesMap: data.queryEntitySubgraph.closedMultiEntityTypes ?? {}, definitions: data.queryEntitySubgraph.definitions ?? undefined, + typeIds: data.queryEntitySubgraph.typeIds ?? undefined, + typeTitles: data.queryEntitySubgraph.typeTitles ?? undefined, }); }, }); @@ -377,5 +439,7 @@ export const useEntityLinks = ({ subgraph: accumulated?.subgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: accumulated?.typesMap, closedMultiEntityTypesDefinitions: accumulated?.definitions, + typeIds: accumulated?.typeIds, + typeTitles: accumulated?.typeTitles, }; }; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts new file mode 100644 index 00000000000..8fa21b7943e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts @@ -0,0 +1,151 @@ +import { useCallback, useMemo, useState } from "react"; + +import { typedEntries } from "@local/advanced-types/typed-entries"; + +import { useVirtualizedTableFilterState } from "../../../virtualized-table/use-filter-state"; + +import type { + VirtualizedTableFilterDefinition, + VirtualizedTableFilterDefinitionsByFieldId, + VirtualizedTableFilterValuesByFieldId, +} from "../../../virtualized-table/header/filter"; +import type { VersionedUrl } from "@blockprotocol/type-system"; + +/** + * The single column the readonly link tables can filter on: the link entity's + * own type. Both the incoming and outgoing tables expose it under this id. + */ +type LinkTypeFilterFieldId = "linkTypes"; + +export type LinkTypeFilterDefinitions = + VirtualizedTableFilterDefinitionsByFieldId; + +export type LinkTypeFilterValues = + VirtualizedTableFilterValuesByFieldId; + +/** + * Derives the link-type filter shared by the incoming and outgoing link tables, + * in both the readonly (server-paginated) and editable (client-side) cases. + * + * The filter options come from the unfiltered link-type breakdown (`typeIds` / + * `typeTitles`) the caller supplies via {@link captureLinkTypeOptions}: the + * server aggregate from {@link useEntityLinks} in the readonly case, or a + * breakdown computed from the editor subgraph's links in the editable case. The + * breakdown is captured once – on the first, necessarily unfiltered, load – and + * never recaptured, so the options (and their counts) stay stable as the user + * narrows the selection (in the readonly case the server breakdown itself + * shrinks to the selected subset once filtered). + * + * The returned `filterTypeIds` derives purely from this hook's own state (the + * captured options and the user's selection), so the readonly caller can pass it + * into {@link useEntityLinks} without creating a render cycle, and the editable + * caller can apply it client-side. It is `undefined` while every type is + * selected (the default), so an untouched filter is a no-op. + */ +export const useLinkTypeFilter = () => { + const [capturedOptions, setCapturedOptions] = useState<{ + typeIds: Record; + typeTitles: Record; + } | null>(null); + + /** + * Record the unfiltered link-type breakdown the first time it is seen. The + * functional update makes this idempotent and one-shot, so later (filtered, or + * draft-edited) breakdowns do not overwrite the stable option list. The caller + * decides which breakdown to pass, and passes `undefined` when there is none + * to offer (e.g. an editable outgoing table, which is not filtered here). + */ + const captureLinkTypeOptions = useCallback( + ( + typeIds?: Record, + typeTitles?: Record, + ) => { + if (!typeIds || !typeTitles || Object.keys(typeIds).length === 0) { + return; + } + + setCapturedOptions((existing) => existing ?? { typeIds, typeTitles }); + }, + [], + ); + + /** + * The filter options and the full set of type ids they cover. The set is kept + * separately (rather than read back off the definition, whose `initialValue` + * widens to the filter union) so it can be reused for both the default value + * and the "is anything deselected" check below. + */ + const optionData = useMemo(() => { + if (!capturedOptions) { + return null; + } + + const options: VirtualizedTableFilterDefinition["options"] = {}; + for (const [versionedUrl, count] of typedEntries(capturedOptions.typeIds)) { + options[versionedUrl] = { + label: capturedOptions.typeTitles[versionedUrl] ?? versionedUrl, + value: versionedUrl, + count, + }; + } + + return { options, allTypeIds: new Set(Object.keys(options)) }; + }, [capturedOptions]); + + const filterDefinitions = useMemo< + LinkTypeFilterDefinitions | undefined + >(() => { + if (!optionData) { + return undefined; + } + + return { + linkTypes: { + header: "Link type", + initialValue: optionData.allTypeIds, + options: optionData.options, + type: "checkboxes", + }, + } satisfies LinkTypeFilterDefinitions; + }, [optionData]); + + const defaultFilterValues = useMemo(() => { + if (!optionData) { + return null; + } + + return { linkTypes: optionData.allTypeIds }; + }, [optionData]); + + const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ + defaultFilterValues, + filterDefinitions, + }); + + const filterTypeIds = useMemo(() => { + const allTypeIds = optionData?.allTypeIds; + const selected = filterValues?.linkTypes; + + if (!allTypeIds || !selected || typeof selected === "string") { + return undefined; + } + + /** + * No type filter is applied while every option is selected (the default); + * only once the user deselects at least one type do we constrain the query. + */ + if (allTypeIds.difference(selected).size === 0) { + return undefined; + } + + return Array.from(selected) as VersionedUrl[]; + }, [optionData, filterValues]); + + return { + captureLinkTypeOptions, + filterDefinitions, + filterValues: filterValues ?? undefined, + setFilterValues, + filterTypeIds, + }; +}; From 86da5188418bb80f0097591c6f182cd020ed442f Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 14:45:46 +0200 Subject: [PATCH 19/34] Fix entity-links filtering --- .../entity-editor/links-section/use-entity-links.ts | 8 +++++++- .../entity-editor/links-section/use-link-type-filter.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index e06522e7fa5..3ff968d59ef 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -371,8 +371,14 @@ export const useEntityLinks = ({ * Restrict the matched link entities to the selected link types. * The path is rooted on the link entity, so `["type", * "versionedUrl"]` matches the link entity's own type. + * + * `undefined` means no filter (every type is selected, the + * default), so the clause is omitted. An empty array means every + * type has been deselected, which must match *nothing* rather than + * everything – an empty `any` resolves to a `FALSE` filter, which + * is exactly that, so the clause is still added. */ - ...(filterTypeIds && filterTypeIds.length > 0 + ...(filterTypeIds ? [ { any: filterTypeIds.map((versionedUrl) => ({ diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts index 8fa21b7943e..98bb43039aa 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts @@ -40,7 +40,10 @@ export type LinkTypeFilterValues = * captured options and the user's selection), so the readonly caller can pass it * into {@link useEntityLinks} without creating a render cycle, and the editable * caller can apply it client-side. It is `undefined` while every type is - * selected (the default), so an untouched filter is a no-op. + * selected (the default), so an untouched filter is a no-op; it is the empty + * array once every type has been *deselected*, which both callers treat as + * "match nothing" (distinct from the `undefined` "match everything"). Any other + * value is the explicit set of selected type ids. */ export const useLinkTypeFilter = () => { const [capturedOptions, setCapturedOptions] = useState<{ From 710cea371cbac24e2f72f5bcdab19dacc6ac7121 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 15:20:57 +0200 Subject: [PATCH 20/34] Filter links when graph edges are clicked --- .../links-section/outgoing-links-section.tsx | 31 +++++++++++++- .../links-section/use-link-type-filter.ts | 40 ++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index ed791afb345..1f92b63c0d1 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -38,6 +38,7 @@ import type { LinkRow, } from "./outgoing-links-section/types"; import type { LinkEntityAndRightEntity } from "@blockprotocol/graph"; +import type { VersionedUrl } from "@blockprotocol/type-system"; import type { EntityQuerySortingRecord } from "@local/hash-graph-client"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; @@ -117,6 +118,34 @@ export const OutgoingLinksSection = ({ ]; }, [readonly, sort.fieldId, sort.direction]); + /** + * When arriving from a clicked graph edge (or anywhere else that supplies + * `defaultOutgoingLinkFilters`), pre-select the edge's link type so the table + * opens narrowed to it. Server-side (the readonly path this table uses) this + * drives a filtered re-fetch via `filterTypeIds`; client-side it would filter + * the in-memory links, like a manual link-type selection. + * + * Only the link type is honoured: the graph query is rooted on the link + * entities and cannot traverse to the target, so the filter's `linkedTo` + * (which target the edge points at) cannot be applied server-side. + * + * Reduced to an order-independent key first so the seed Set's identity is + * stable across renders (otherwise it would reconcile the filter every render). + */ + const defaultLinkTypesKey = + defaultOutgoingLinkFilters?.linkTypes && + typeof defaultOutgoingLinkFilters.linkTypes !== "string" + ? Array.from(defaultOutgoingLinkFilters.linkTypes).sort().join(",") + : null; + + const defaultSelectedLinkTypeIds = useMemo | undefined>( + () => + defaultLinkTypesKey + ? new Set(defaultLinkTypesKey.split(",") as VersionedUrl[]) + : undefined, + [defaultLinkTypesKey], + ); + /** * The readonly link table can be filtered by link type. The filter state and * options live here so the selection can drive the paginated query @@ -130,7 +159,7 @@ export const OutgoingLinksSection = ({ filterValues, setFilterValues, filterTypeIds, - } = useLinkTypeFilter(); + } = useLinkTypeFilter({ defaultSelectedLinkTypeIds }); /** * When the entity is readonly we fetch the link data here (paginated), so it diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts index 98bb43039aa..c8a429526a6 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts @@ -45,7 +45,25 @@ export type LinkTypeFilterValues = * "match nothing" (distinct from the `undefined` "match everything"). Any other * value is the explicit set of selected type ids. */ -export const useLinkTypeFilter = () => { +export const useLinkTypeFilter = ({ + defaultSelectedLinkTypeIds, +}: { + /** + * Link entity type ids to pre-select when the table first opens, with every + * other type deselected – e.g. when arriving from a clicked graph edge whose + * link type should be focused. Any ids not present in the loaded breakdown are + * ignored, and if none are present the filter falls back to "all selected" (a + * no-op) rather than matching nothing. + * + * Because this seeds the same filter state as a manual selection, it drives + * both modes automatically: server-side (readonly) the derived + * {@link filterTypeIds} re-fetches the links narrowed to these types; + * client-side (editable) the caller filters the in-memory links by the same + * value. Pass a referentially stable Set, since changing it reconciles the + * filter state. + */ + defaultSelectedLinkTypeIds?: Set; +} = {}) => { const [capturedOptions, setCapturedOptions] = useState<{ typeIds: Record; typeTitles: Record; @@ -117,8 +135,26 @@ export const useLinkTypeFilter = () => { return null; } + if (defaultSelectedLinkTypeIds) { + /** + * Narrow the default selection to the requested types, keeping only those + * actually present in the breakdown. If none are present we fall back to + * selecting everything, so an unmatched seed leaves the filter a no-op + * rather than hiding every link. + */ + const selected = new Set( + [...defaultSelectedLinkTypeIds].filter((id) => + optionData.allTypeIds.has(id), + ), + ); + + if (selected.size > 0) { + return { linkTypes: selected }; + } + } + return { linkTypes: optionData.allTypeIds }; - }, [optionData]); + }, [optionData, defaultSelectedLinkTypeIds]); const [filterValues, setFilterValues] = useVirtualizedTableFilterState({ defaultFilterValues, From 5d8e7c9d3fb7fb1199785e5f9803a01f0dc2aefb Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 15:30:08 +0200 Subject: [PATCH 21/34] Fix use-accumulated-cursor-pagination --- .../use-accumulated-cursor-pagination.ts | Bin 6482 -> 9053 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts index 629de674a2aa22f860acf6b393a9d59c83524345..3f8f23e3d63c4e3df0638b8c2075b8d4af5681ac 100644 GIT binary patch delta 2895 zcmZ`*(QX?>6m0@hkwG*_hyoJQC5lu#agvtDgdo~V6$Pmc4R1W)@$OykkoB%RGh5e+ ztd;tXgye5Pyn^z?M<5|29{Cr}o!PZFZg`2~^~{-j&pG$Z{{7j%kAD94^Q183Ql_Xh z7XMC^aa1J!QKF4?crxNUmd<(Br)jFAlu~6WFFLgQd%3?+oiX+(hrL5~Q|8?79dXWh zs*9W^h7LlN!rq2=fa;+`9tP6 zb}m8(r)Xe4I2JHzt5pX!scfod_@Inctn(4+-0_G_pKS43aahkU*vwptm4c@d$APTC zjFrxDK3eatCKh~EN2NtMedaL#0ZYxC)@gzavm{ld%W%50e(H~U^Qr8 z6}}?)gtl*NA9p&3ySsvOpR7}hUpH=T>6mk;lbN(MEL^(yV}mN%1?7IB6!*MDD=k#i zMpFoxu!~ZTsmx`7FPgC>b7#$eT!6E>D(z&$T3oF`a@ zqhge6tA-g94n)lvACZboVQpPjsNZS8u80KmnimC5>?r!zQr2G4xprD*550+DnR%Zo zNIKOnElRiU1n7^_1uZJZlZ?GhYty6G7rH2$%A%soVz}MG-Q)`VuY6g)W2rUgX=vh( z6~NWZEZ*DP`=CQD%8w{7$3wVn@!Q*<9Q8ZBBamT4vy(5duyJ%SbZu|{W$A$V&IJ}O zqRj^tKE>vZ7tSUb1YHdirml#~2uciR*A5g7Rdg=?FT|y-Dy0jV)m9+1eTnWZKP)Rr z3lL2w)I}*_F}^n~`R*=Suub#F_1aU2+bpIz7$9jacz-z{k73G}IARHZpykQaq+4Ax zA3aPx?B+_7d$6nAKk?jmS5P+75qIxm_L~Ir$Mt%Bz%(s|GS@d*{s=tC2!{&y=$_v- zKkm#gf7$&0%?hYajVB_mxWv0w+~~gzmUshU8v$b}EsFEHcuQSqiDW2Z41~&TSs_^J37%1us(i%OC#rO9DN+V!2@5ODgkc}hGn%sDwG^VS7=wnu zRA3~bsw?vcJ{|WkhLI{QA^8mn`#xBZ!87y+YAQ`t57#P)fVtr}^ftS94hIl>fa&t4*gK(n zi;r(zRzZh$g!OCYBoidEE1zaAbT_wgtfQ37lKW0;2MYIY#k zur{qLg>0$}6&5npLxXeDV^lWwqd9 zQ%vnuL3KjimhV12IcW?7zqB;&)we@)S;)14+ce09BTpr^7XRF$PM}iszIiDsK+!yq%>diIX}5H_EDv+VnOwu5Gsm;V4h&aGpW%u! z6D@|sW)hKB*E#UNX}K`qO2yD*6D Date: Mon, 22 Jun 2026 15:39:22 +0200 Subject: [PATCH 22/34] Switch use-accumulated-cursor pagination to not be marked as binary --- .../use-accumulated-cursor-pagination.ts | Bin 9053 -> 9053 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts index 3f8f23e3d63c4e3df0638b8c2075b8d4af5681ac..67a942e7db83d6f8cd9e89b2583084986c7d3972 100644 GIT binary patch delta 20 bcmccXcGqo#JrkqCWCx~aj0&68navacP@V>8 delta 20 bcmccXcGqo#Jrg6tWCx~aj0~IAnavacPF@Bm From 46d92a8e7c4aa7b7158f95d6d21bc3441dfc236a Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 15:48:09 +0200 Subject: [PATCH 23/34] Removed unnecessary comments --- .../property-table/cells/value-cell/array-editor.tsx | 5 ----- .../property-table/cells/value-cell/single-value-editor.tsx | 3 --- 2 files changed, 8 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx index 606db6ca04f..73585a0f164 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/array-editor.tsx @@ -175,9 +175,6 @@ export const ArrayEditor: ValueCellEditorComponent = ({ value, ]; - // Cast to the non-draft `PropertyRow` to avoid immer's `Draft` - // recursively expanding the deeply-recursive `PropertyMetadata` type, - // which trips TS2589 ("Type instantiation is excessively deep"). (draftCell.data.propertyRow as PropertyRow).valueMetadata = propertyMetadata; }); @@ -203,7 +200,6 @@ export const ArrayEditor: ValueCellEditorComponent = ({ .filter((_, index) => indexToRemove !== index) .map(({ value }) => value); - // See the note in `addItem` re: the `PropertyRow` cast (TS2589). (draftCell.data.propertyRow as PropertyRow).valueMetadata = propertyMetadata; }); @@ -244,7 +240,6 @@ export const ArrayEditor: ValueCellEditorComponent = ({ const newMetadata = arrayMove(valueMetadata.value, oldIndex, newIndex); - // See the note in `addItem` re: the `PropertyRow` cast (TS2589). (draftCell.data.propertyRow as PropertyRow).valueMetadata = { ...valueMetadata, value: newMetadata, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx index 07e4d558754..a76cf8e65f3 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/properties-section/property-table/cells/value-cell/single-value-editor.tsx @@ -146,9 +146,6 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => { }); const newCell = produce(cell, (draftCell) => { - // Cast to the non-draft `PropertyRow` to avoid immer's `Draft` - // recursively expanding the deeply-recursive `PropertyMetadata` type, - // which trips TS2589 ("Type instantiation is excessively deep"). (draftCell.data.propertyRow as PropertyRow).valueMetadata = propertyMetadata; }); From b2644af267696b59f07adc5f71f8319ce54ae025 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 16:43:23 +0200 Subject: [PATCH 24/34] Simplify comments --- .../hash-frontend/src/pages/shared/entity.tsx | 6 +- .../use-accumulated-cursor-pagination.ts | 111 +++++--------- .../links-section/use-entity-links.ts | 142 ++++++------------ .../links-section/use-link-type-filter.ts | 69 ++------- 4 files changed, 97 insertions(+), 231 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 231618e7077..1b7d4ae67fb 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -63,7 +63,7 @@ interface EntityProps { /** * The default outgoing link filters to apply to the links tables in the entity editor */ - defaultOutgoingLinkFilters?: EntityEditorProps["defaultOutgoingLinkFilters"]; + initialOutgoingLinksFilter?: EntityEditorProps["initialOutgoingLinksFilter"]; /** * To be provided if this is a new entity which hasn't yet been created in the database, @@ -140,7 +140,7 @@ interface EntityProps { } export const Entity = ({ - defaultOutgoingLinkFilters, + initialOutgoingLinksFilter, draftLocalEntity, entityId, isInSlide, @@ -724,7 +724,7 @@ export const Entity = ({ /> ( generation: number, @@ -37,10 +30,7 @@ export const cursorKeyFor = ( cursor === undefined ? initialCursorKey : JSON.stringify(cursor) }`; -/** - * Split a page key produced by {@link cursorKeyFor} back into its generation - * and cursor part. - */ +/** Split a {@link cursorKeyFor} key back into its generation and cursor part. */ const parseCursorKey = ( cursorKey: string, ): { generation: number; cursorPart: string } => { @@ -52,29 +42,22 @@ const parseCursorKey = ( }; /** - * Drives cursor-based pagination where each loaded page is folded into a - * running accumulation, exposing a `loadMore` to advance the cursor. - * - * The hook is agnostic to what a "page" or its accumulation contains: callers - * supply - * - `seed`, building the empty accumulator from the first page, and - * - `appendPage`, folding one page into the accumulator (which must expose the - * `nextCursor` to advance to, or `null` when exhausted). + * Drives cursor-based pagination, folding each loaded page into a running + * accumulation and exposing `loadMore` to advance the cursor. * - * The fold is incremental: as long as `pages` grows by append (the common - * infinite-scroll case) only the newly-added pages are folded in, so loading - * page `k` costs O(page size) rather than O(total pages loaded so far). A reset - * or an in-place page replacement (cache-then-network for the same cursor) - * rebuilds from scratch, which is rare and bounded. + * Callers supply `seed` (build the empty accumulator from the first page) and + * `appendPage` (fold one page in; it must expose `nextCursor`, or `null` when + * exhausted). Both, and `finalize`, must be referentially stable, as the + * accumulation only recomputes when `pages` changes. * - * `seed`, `appendPage` and `finalize` must be referentially stable (e.g. - * module-level constants or memoised), as the accumulation only recomputes when - * `pages` changes. + * The fold is incremental: while `pages` grows by append (infinite scroll) only + * the new pages are folded in, so loading page `k` costs O(page size). A reset + * or in-place page replacement rebuilds from scratch (rare and bounded). * - * The caller is responsible for issuing the query for the current `cursor` and - * calling `addPage` with the result, stamping the page with the `cursorKey` - * this hook returns (it encodes both the cursor and the current generation, so - * a late completion of a superseded query is dropped rather than applied). + * The caller issues the query for the current `cursor` and calls `addPage` with + * the result, stamping it with the `cursorKey` this hook returns (it encodes + * the cursor and generation, so a late completion of a superseded query is + * dropped). */ export const useAccumulatedCursorPagination = < Cursor, @@ -87,10 +70,9 @@ export const useAccumulatedCursorPagination = < finalize, }: { /** - * When this changes, accumulated pages and the cursor are discarded (the - * query identity, and therefore the cursors, are no longer valid). Done - * during render rather than in an effect so that the stale cursor is never - * sent alongside the new query. + * When this changes, the accumulated pages and cursor are discarded + * Done during render rather than in an effect, so the stale cursor + * is never sent with the new query. */ resetKey: string; /** Build the empty accumulator from the first page. */ @@ -98,15 +80,15 @@ export const useAccumulatedCursorPagination = < /** Fold one page into the running accumulation. */ appendPage: (accumulated: Accumulated, page: Page) => Accumulated; /** - * Optional transform applied to the accumulation before it is returned, e.g. - * to hand out fresh references for collections that `appendPage` mutates in - * place. Runs only when the accumulation recomputes. + * Optional transform applied before the accumulation is returned, e.g. to + * hand out fresh references for collections `appendPage` mutates in place. + * Runs only when the accumulation recomputes. */ finalize?: (accumulated: Accumulated) => Accumulated; }): { /** The cursor for the page to fetch next, to feed into the query. */ cursor: Cursor | undefined; - /** The key for the current cursor, to stamp onto the page passed to `addPage`. */ + /** Key for the current cursor, to stamp onto the page passed to `addPage`. */ cursorKey: string; /** How many pages have been loaded so far. */ pageCount: number; @@ -124,22 +106,16 @@ export const useAccumulatedCursorPagination = < /** * Bumped whenever the query identity changes, so pages carry the generation - * they were fetched under. Held in a ref (not state) because it must be - * readable both at render time (to stamp the current `cursorKey`) and inside - * the stable `addPage` callback (to reject pages from a superseded query) - * without re-creating that callback. + * they were fetched under. */ const generationRef = useRef(0); const previousResetKey = useRef(resetKey); if (previousResetKey.current !== resetKey) { previousResetKey.current = resetKey; - /** - * Advance the generation before discarding the stale cursor/pages, so the - * query issued for the new identity stamps its pages with the new - * generation and a late completion of the previous query (which captured - * the old generation) is dropped by `addPage` rather than replacing them. - */ + // Advance the generation before discarding the stale cursor/pages, so the + // new query stamps its pages with the new generation and a late completion + // of the previous query is dropped by `addPage`. generationRef.current += 1; if (cursor !== undefined) { setCursor(undefined); @@ -152,13 +128,7 @@ export const useAccumulatedCursorPagination = < const addPage = useCallback((page: Page) => { const { generation, cursorPart } = parseCursorKey(page.cursorKey); - /** - * Ignore pages from a superseded query identity. Their first page shares - * the `initialCursorKey` cursor part with the current query's first page, - * so without this guard a late completion of the previous query would hit - * the "first page - replace" branch below and clobber the freshly-reset - * pages with stale rows. - */ + // Ignore pages from a superseded query. if (generation !== generationRef.current) { return; } @@ -184,9 +154,8 @@ export const useAccumulatedCursorPagination = < }, []); /** - * The previously-processed `pages` array and its resulting accumulation are - * cached here so that an append-only growth of `pages` only folds in the new - * pages (see the hook docs). + * The last-processed `pages` and its accumulation, cached so an append-only + * growth of `pages` only folds in the new pages (see the hook docs). */ const accumulationCache = useRef<{ pages: Page[]; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index 3ff968d59ef..433dde5e6fd 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -34,18 +34,15 @@ import type { ClosedMultiEntityTypesRootMap, } from "@local/hash-graph-sdk/ontology"; -/** - * The number of links fetched per page for the readonly link tables. - */ +/** Links fetched per page for the readonly link tables. */ export const linksTablePageSize = 100; type LinksSubgraph = Subgraph>; type LinkPage = { /** - * A key identifying which cursor produced this page, so that a page is - * replaced (rather than duplicated) if its query completes more than once - * (e.g. a cache hit followed by a network response). + * Key for the cursor that produced this page, so a page is replaced (not + * duplicated) if its query completes twice (e.g. cache hit then network). */ cursorKey: string; count?: number; @@ -55,21 +52,20 @@ type LinkPage = { typesMap: ClosedMultiEntityTypesRootMap; definitions?: ClosedMultiEntityTypesDefinitions; /** - * The count of matching links by their link entity type id, and the title of - * each such type. These are aggregated server-side over the *full* matching - * set (not just this page), so they describe every link type that can be - * filtered by. They reflect whatever filter is applied to the query, so the - * caller captures them from the unfiltered load to use as stable filter - * options. + * Count of matching links by link entity type id. + * Aggregated server-side over the *full* matching set (not just this page), */ typeIds?: Record; + /** + * list of titles for each entity type id + * Aggregated server-side over the *full* matching set (not just this page), + */ typeTitles?: Record; }; /** - * Appended to any caller-provided sorting so that pagination is deterministic - * (the `uuid` is unique, breaking ties when sorting by a non-unique field such - * as a label or type title). + * Appended to any caller sorting so pagination is deterministic: the unique + * `uuid` breaks ties when sorting by a non-unique field (label, type title). */ const uuidSortingPath: EntityQuerySortingRecord = { path: ["uuid"], @@ -78,9 +74,9 @@ const uuidSortingPath: EntityQuerySortingRecord = { }; /** - * Shallow-merge two `{ [baseId]: { [revisionId]: value } }` records (the shape - * of a subgraph's `vertices` and `edges`), so that revisions from later pages - * are added without dropping those from earlier pages. + * Shallow-merge two `{ [baseId]: { [revisionId]: value } }` records (a + * subgraph's `vertices`/`edges` shape), adding later pages' revisions without + * dropping earlier ones. */ const mergeRecordOfRecords = ( a: Record>, @@ -93,10 +89,7 @@ const mergeRecordOfRecords = ( return result; }; -/** - * Merge a single later page's subgraph into an already-merged subgraph, - * adding its vertices/edges without dropping those already accumulated. - */ +/** Merge a later page's subgraph into the accumulated one. */ const mergeSubgraphInto = ( merged: LinksSubgraph, page: LinkPage, @@ -134,9 +127,7 @@ const mergeDefinitionsInto = ( }; }; -/** - * The running, incrementally-built accumulation of every page loaded so far. - */ +/** The running, incrementally-built accumulation of every page loaded so far. */ type Accumulated = { linkEntities: HashLinkEntity[]; /** Dedup set keyed on `recordId.entityId`, so appends stay O(page size). */ @@ -149,19 +140,13 @@ type Accumulated = { count?: number; nextCursor: EntityQueryCursor | null; /** - * Whether the query is exhausted. `true` once a page returns fewer rows than - * the requested page size (so a non-null cursor pointing past the last match - * does not keep `hasMore` true and trigger an empty fetch). + * `true` once a page returns fewer rows than the page size, so a non-null + * cursor past the last match can't keep `hasMore` true and trigger an empty + * fetch. */ exhausted: boolean; }; -/** - * Fold a single freshly-loaded page into the running accumulation. The - * `linkEntities`/`seenLinkIds`/`typesMap` collections are extended in place so - * that loading page `k` costs O(page size) rather than O(total pages loaded so - * far); a new top-level object is returned to carry the updated scalar fields. - */ const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { for (const linkEntity of page.linkEntities) { const linkEntityId = linkEntity.metadata.recordId.entityId; @@ -173,21 +158,16 @@ const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { Object.assign(accumulated.typesMap, page.typesMap); - /** - * Treat the result as exhausted when the page returned fewer rows than the - * requested page size (including zero), regardless of whether the API still - * handed back a non-null cursor. - */ + // Exhausted once a page returns fewer rows than the page size (incl. zero), + // even if the API still handed back a non-null cursor. const exhausted = page.linkEntities.length < linksTablePageSize; return { ...accumulated, definitions: mergeDefinitionsInto(accumulated.definitions, page), subgraph: mergeSubgraphInto(accumulated.subgraph, page), - /** - * The type breakdown is a full-set aggregate returned identically on every - * page, so the latest page's value replaces (rather than extends) it. - */ + // A full-set aggregate returned identically on every page, so the latest + // page's value replaces (rather than extends) it. typeIds: page.typeIds ?? accumulated.typeIds, typeTitles: page.typeTitles ?? accumulated.typeTitles, count: page.count, @@ -196,10 +176,7 @@ const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { }; }; -/** - * The empty accumulation, seeded with the first page's subgraph as the base to - * merge subsequent pages into. - */ +/** The empty accumulation, seeded with the first page's subgraph to merge into. */ const seedAccumulated = (firstPage: LinkPage): Accumulated => ({ linkEntities: [], seenLinkIds: new Set(), @@ -213,29 +190,17 @@ const seedAccumulated = (firstPage: LinkPage): Accumulated => ({ exhausted: false, }); -/** - * Return a fresh top-level object (and a fresh `linkEntities` array) so that - * consumers depending on referential identity recompute when a page is - * appended. The expensive O(n) work (dedup, subgraph/type merging) is done - * incrementally by {@link appendPage}, which mutates `linkEntities` in place; - * this only copies the array of entity references. - */ const finalizeAccumulated = (accumulated: Accumulated): Accumulated => ({ ...accumulated, linkEntities: [...accumulated.linkEntities], }); /** - * Fetches an entity's incoming or outgoing links a page at a time, for display - * in the readonly link tables. - * - * The query is rooted on the *link entities* (filtered by the endpoint that is - * the entity being viewed) rather than on the entity itself, so that `limit` / - * `cursor` / `includeCount` paginate the links. Each page is accumulated, and - * `loadMore` fetches the next page. + * Fetches an entity's incoming or outgoing links a page at a time, for the + * readonly link tables. * - * This is only used when the link data is readonly; when the entity is editable - * the links are part of the editor subgraph and are not paginated. + * Only used for readonly link data; when editable, links come from the editor + * subgraph and are not paginated. */ export const useEntityLinks = ({ direction, @@ -246,19 +211,10 @@ export const useEntityLinks = ({ }: { direction: "outgoing" | "incoming"; entityId: EntityId; - /** - * If set, restrict the matched links to those whose link entity type is one - * of the given type ids. Applied server-side, so both the returned links and - * the `count` reflect the filter. Changing this resets pagination (the - * accumulated pages and their cursors are no longer valid). - */ + // If set, restrict matched links to these link entity type ids. Applied server-side filterTypeIds?: VersionedUrl[]; skip?: boolean; - /** - * How to sort the links server-side. A `uuid` tiebreaker is always appended - * so that pagination remains stable. Changing this resets pagination (the - * accumulated pages and their cursors are no longer valid). - */ + // How to sort the links server-side; a `uuid` tiebreaker is always appended to keep pagination stable. sortingPaths?: EntityQuerySortingRecord[]; }): { /** Whether the first page is still loading. */ @@ -286,11 +242,6 @@ export const useEntityLinks = ({ } => { const [webId, entityUuid, draftId] = splitEntityId(entityId); - /** - * Accumulate pages across `loadMore` calls. Changing the entity, direction or - * sort resets the accumulation (the query identity, and therefore the - * cursors, are no longer valid). - */ const { cursor, cursorKey, @@ -309,8 +260,8 @@ export const useEntityLinks = ({ }); /** - * The endpoint of the link that is the entity being viewed: its left/source - * entity for outgoing links, its right/target entity for incoming links. + * The link endpoint that is the viewed entity: left/source for outgoing + * links, right/target for incoming. */ const filterEndpoint = direction === "outgoing" ? "leftEntity" : "rightEntity"; @@ -338,11 +289,9 @@ export const useEntityLinks = ({ ], }, /** - * When viewing a specific draft, scope the matched endpoint to that - * draft. The path is rooted on the link entity, so this resolves - * the *endpoint* entity's own draftId (mirroring - * `generateEntityIdFilter`); without it a draft would match links - * across its live version and all sibling drafts. + * When viewing a specific draft, scope the endpoint to that draft + * (mirroring `generateEntityIdFilter`); without it a draft would + * match links across its live version and all sibling drafts. */ ...(draftId ? [ @@ -368,15 +317,11 @@ export const useEntityLinks = ({ ] : []), /** - * Restrict the matched link entities to the selected link types. - * The path is rooted on the link entity, so `["type", - * "versionedUrl"]` matches the link entity's own type. - * - * `undefined` means no filter (every type is selected, the - * default), so the clause is omitted. An empty array means every - * type has been deselected, which must match *nothing* rather than - * everything – an empty `any` resolves to a `FALSE` filter, which - * is exactly that, so the clause is still added. + * Restrict matched links to the selected link types. `undefined` + * means no filter (every type selected, the default), so the clause + * is omitted. An empty array means every type deselected, which must + * match *nothing* - an empty `any` resolves to a `FALSE` filter, so + * the clause is still added. */ ...(filterTypeIds ? [ @@ -404,11 +349,8 @@ export const useEntityLinks = ({ includeDrafts: !!draftId, includeEntityTypes: "resolvedWithDataTypeChildren", includePermissions: false, - /** - * Return the breakdown of matching links by link entity type id, and - * the title of each type, so the caller can offer them as filter - * options (see {@link filterTypeIds}). - */ + // Return the per-type breakdown and titles so the caller can offer + // them as filter options (see {@link filterTypeIds}). includeTypeIds: true, includeTypeTitles: true, }, diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts index c8a429526a6..5dadd6dcf78 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts @@ -11,10 +11,7 @@ import type { } from "../../../virtualized-table/header/filter"; import type { VersionedUrl } from "@blockprotocol/type-system"; -/** - * The single column the readonly link tables can filter on: the link entity's - * own type. Both the incoming and outgoing tables expose it under this id. - */ +/** The one column the link tables filter on: the link entity's own type. */ type LinkTypeFilterFieldId = "linkTypes"; export type LinkTypeFilterDefinitions = @@ -25,43 +22,15 @@ export type LinkTypeFilterValues = /** * Derives the link-type filter shared by the incoming and outgoing link tables, - * in both the readonly (server-paginated) and editable (client-side) cases. + * for both the readonly (server-paginated) and editable (client-side) cases. * - * The filter options come from the unfiltered link-type breakdown (`typeIds` / - * `typeTitles`) the caller supplies via {@link captureLinkTypeOptions}: the - * server aggregate from {@link useEntityLinks} in the readonly case, or a - * breakdown computed from the editor subgraph's links in the editable case. The - * breakdown is captured once – on the first, necessarily unfiltered, load – and - * never recaptured, so the options (and their counts) stay stable as the user - * narrows the selection (in the readonly case the server breakdown itself - * shrinks to the selected subset once filtered). - * - * The returned `filterTypeIds` derives purely from this hook's own state (the - * captured options and the user's selection), so the readonly caller can pass it - * into {@link useEntityLinks} without creating a render cycle, and the editable - * caller can apply it client-side. It is `undefined` while every type is - * selected (the default), so an untouched filter is a no-op; it is the empty - * array once every type has been *deselected*, which both callers treat as - * "match nothing" (distinct from the `undefined` "match everything"). Any other - * value is the explicit set of selected type ids. + * The returned `filterTypeIds` is `undefined` while every type is selected ("match everything", + * the default no-op), an empty array once every type is deselected ("match + * nothing"), or otherwise the explicit set of selected type ids. */ export const useLinkTypeFilter = ({ defaultSelectedLinkTypeIds, }: { - /** - * Link entity type ids to pre-select when the table first opens, with every - * other type deselected – e.g. when arriving from a clicked graph edge whose - * link type should be focused. Any ids not present in the loaded breakdown are - * ignored, and if none are present the filter falls back to "all selected" (a - * no-op) rather than matching nothing. - * - * Because this seeds the same filter state as a manual selection, it drives - * both modes automatically: server-side (readonly) the derived - * {@link filterTypeIds} re-fetches the links narrowed to these types; - * client-side (editable) the caller filters the in-memory links by the same - * value. Pass a referentially stable Set, since changing it reconciles the - * filter state. - */ defaultSelectedLinkTypeIds?: Set; } = {}) => { const [capturedOptions, setCapturedOptions] = useState<{ @@ -70,11 +39,9 @@ export const useLinkTypeFilter = ({ } | null>(null); /** - * Record the unfiltered link-type breakdown the first time it is seen. The - * functional update makes this idempotent and one-shot, so later (filtered, or - * draft-edited) breakdowns do not overwrite the stable option list. The caller - * decides which breakdown to pass, and passes `undefined` when there is none - * to offer (e.g. an editable outgoing table, which is not filtered here). + * Record the link-type breakdown the first time it is seen; the functional + * update keeps it one-shot, so later (filtered) breakdowns don't overwrite the + * stable option list. The caller passes `undefined` when it has none to offer. */ const captureLinkTypeOptions = useCallback( ( @@ -90,12 +57,6 @@ export const useLinkTypeFilter = ({ [], ); - /** - * The filter options and the full set of type ids they cover. The set is kept - * separately (rather than read back off the definition, whose `initialValue` - * widens to the filter union) so it can be reused for both the default value - * and the "is anything deselected" check below. - */ const optionData = useMemo(() => { if (!capturedOptions) { return null; @@ -136,12 +97,8 @@ export const useLinkTypeFilter = ({ } if (defaultSelectedLinkTypeIds) { - /** - * Narrow the default selection to the requested types, keeping only those - * actually present in the breakdown. If none are present we fall back to - * selecting everything, so an unmatched seed leaves the filter a no-op - * rather than hiding every link. - */ + // Keep only requested types present in the breakdown; if none match, + // fall through to selecting everything (a no-op) rather than hiding all. const selected = new Set( [...defaultSelectedLinkTypeIds].filter((id) => optionData.allTypeIds.has(id), @@ -169,10 +126,8 @@ export const useLinkTypeFilter = ({ return undefined; } - /** - * No type filter is applied while every option is selected (the default); - * only once the user deselects at least one type do we constrain the query. - */ + // No filter while every option is selected (the default); only a + // deselection constrains the query. if (allTypeIds.difference(selected).size === 0) { return undefined; } From 78d84c26ace8f0f4a8d4fc0408aead23b11e39b3 Mon Sep 17 00:00:00 2001 From: alex leon Date: Mon, 22 Jun 2026 16:55:11 +0200 Subject: [PATCH 25/34] Show columns on link tables when a filter is applied --- .../hash-frontend/src/pages/shared/entity.tsx | 6 +- .../links-section/incoming-links-section.tsx | 2 +- .../links-section/outgoing-links-section.tsx | 60 +++++++++++-------- 3 files changed, 38 insertions(+), 30 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 1b7d4ae67fb..231618e7077 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -63,7 +63,7 @@ interface EntityProps { /** * The default outgoing link filters to apply to the links tables in the entity editor */ - initialOutgoingLinksFilter?: EntityEditorProps["initialOutgoingLinksFilter"]; + defaultOutgoingLinkFilters?: EntityEditorProps["defaultOutgoingLinkFilters"]; /** * To be provided if this is a new entity which hasn't yet been created in the database, @@ -140,7 +140,7 @@ interface EntityProps { } export const Entity = ({ - initialOutgoingLinksFilter, + defaultOutgoingLinkFilters, draftLocalEntity, entityId, isInSlide, @@ -724,7 +724,7 @@ export const Entity = ({ /> } > - {incomingLinksAndSources.length ? ( + {linkCount > 0 || (readonly && filterTypeIds !== undefined) ? ( } > - {rows.length && !readonly ? ( - - 10 ? 500 : undefined} - rows={rows} - onSearchClose={() => setShowSearch(false)} - showSearch={showSearch} - sortableColumns={["linkTitle", "linkedWith", "expectedEntityTypes"]} - sortRows={sortRows} - /> - - ) : outgoingLinksAndTargets.length ? ( + {!readonly ? ( + rows.length > 0 ? ( + + 10 ? 500 : undefined} + rows={rows} + onSearchClose={() => setShowSearch(false)} + showSearch={showSearch} + sortableColumns={[ + "linkTitle", + "linkedWith", + "expectedEntityTypes", + ]} + sortRows={sortRows} + /> + + ) : ( + + ) + ) : linkCount > 0 || filterTypeIds !== undefined ? ( Date: Mon, 22 Jun 2026 17:25:24 +0200 Subject: [PATCH 26/34] Simpler comments --- .../links-section/incoming-links-section.tsx | 46 ++----------------- .../incoming-links-table.tsx | 43 +---------------- .../links-section/outgoing-links-section.tsx | 42 ++--------------- .../readonly-outgoing-links-table.tsx | 27 ----------- 4 files changed, 10 insertions(+), 148 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index e22ed62625f..163edb65bcb 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -55,27 +55,11 @@ export const IncomingLinksSection = ({ readonly, slideContainerRef, }: IncomingLinksSectionProps) => { - /** - * The table sort state. Incoming links have no server-sortable column (see - * `serverSortableFieldIds` in the table – the API can't reach the source - * entity, and its `label` sort uses the empty label property rather than the - * client-generated label shown), so this only drives client-side sorting in - * the editable case. In the readonly/paginated case the rows keep their - * server (uuid) order and no column is sortable. - */ const [sort, setSort] = useState>({ fieldId: "link", direction: "asc", }); - /** - * The link table can be filtered by link type in both cases. The filter state - * and options live here so the selection can be passed to the table's header - * and applied: server-side (`filterTypeIds` → the paginated query) when - * readonly, and client-side (filtering the editor links below) when editable. - * `filterTypeIds` derives only from this hook's state, so feeding it into - * `useEntityLinks` below does not create a render cycle. - */ const { captureLinkTypeOptions, filterDefinitions, @@ -133,23 +117,15 @@ export const IncomingLinksSection = ({ linkEntity.metadata.recordId.entityId, ) ?? []; } catch { - /** - * `getLeftEntityForLinkEntity` throws if no source revision overlaps - * the resolved instant of the merged multi-page subgraph; treat that - * as a missing endpoint so the link is filtered out below rather than - * crashing the table. - */ + // `getLeftEntityForLinkEntity` throws if no source revision overlaps + // the resolved instant of the merged multi-page subgraph leftEntity = []; } return { linkEntity: [linkEntity], leftEntity }; }) .filter( - /** - * Drop links whose source entity is missing, mirroring the guard the - * editor path applies, so no row with an empty endpoint reaches the - * table (which would throw when building rows). - */ + // Drop links whose source entity is missing, mirroring the guard the editor path applies (incomingLinkAndSource) => !!incomingLinkAndSource.leftEntity[0], ); } @@ -184,12 +160,7 @@ export const IncomingLinksSection = ({ draftLinksToArchive, ]); - /** - * In the editable case there is no server breakdown, so the filter options are - * derived from the (unfiltered) editor links here. Counting per link entity - * type mirrors the server aggregate; the title is the type's forward title, as - * in the readonly case. - */ + // In the editable case the filter options are derived from the (unfiltered) editor links here. const editableLinkTypeBreakdown = useMemo(() => { if (readonly || !editorTypesMap) { return undefined; @@ -239,8 +210,7 @@ export const IncomingLinksSection = ({ /** * The rows shown in the table. In the readonly case the fetched links are * already filtered server-side; in the editable case the full editor links are - * filtered here by the selected link types (`filterTypeIds` is `undefined` - * while every type is selected, so an untouched filter shows everything). + * filtered here by the selected link types */ const displayedIncomingLinksAndSources = useMemo(() => { if (readonly || !filterTypeIds) { @@ -257,12 +227,6 @@ export const IncomingLinksSection = ({ }, [readonly, filterTypeIds, incomingLinksAndSources]); if (readonly && error) { - /** - * In the self-fetch path the query errors are surfaced here (the editor - * path's errors are handled by the parent query). Without this, a failed - * query would fall through to the empty state, making it look like the - * entity simply has no links. - */ return ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index f2096544024..d45462fc786 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -70,24 +70,7 @@ export type IncomingLinksFieldId = type FieldId = IncomingLinksFieldId; -/** - * The columns that can be sorted server-side (applied to the query's root, the - * link entities). None can be: - * - the API cannot traverse to the source entity, so `linkedFrom` / - * `linkedFromTypes` are out; - * - the "Link type" column displays the *inverse* title, which the `typeTitle` - * token does not match; - * - the "Link" column shows a client-generated label (see `generateEntityLabel`), - * but the API's `label` token sorts by the entity's label *property*, which is - * empty for typical link entities — so every row ties and only the `uuid` - * tiebreaker orders them (flipping the direction does nothing, and the order - * does not match the displayed label). - * - * Every column is therefore sortable only client-side (the editable case); in - * the readonly/paginated case the rows keep their server (uuid) order. - */ const serverSortableFieldIds: FieldId[] = []; - const staticColumns: VirtualizedTableColumn[] = [ { label: "Linked from", @@ -290,11 +273,6 @@ type IncomingLinksTableProps = { customEntityLinksColumns?: CustomEntityLinksColumn[]; entityLabel: string; entitySubgraph: Subgraph>; - /** - * The link-type filter, applied server-side. Present only in the readonly / - * paginated case (see {@link readonly}); `undefined` while the breakdown that - * populates the options has not yet loaded. - */ filterDefinitions?: LinkTypeFilterDefinitions; filterValues?: LinkTypeFilterValues; setFilterValues?: (filterValues: LinkTypeFilterValues) => void; @@ -303,17 +281,6 @@ type IncomingLinksTableProps = { onEndReached?: () => void; onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; - /** - * When `true`, the table is backed by a paginated server-side query: the rows - * are a server-ordered page, sorting is applied by the query (so the table - * only exposes the columns the graph API can sort by, and `sort`/`setSort` - * drive a re-query when the sort changes), and filtering is applied - * server-side too. - * - * When `false` (the editable case), the full set of links is already present, - * so sorting is applied client-side instead: every column is sortable and the - * rows are re-ordered locally as `sort` changes. - */ readonly: boolean; sort: VirtualizedTableSort; setSort: (sort: VirtualizedTableSort) => void; @@ -570,17 +537,11 @@ export const IncomingLinksTable = memo( const createdColumns = createColumns(applicableCustomColumns ?? []); if (!readonly) { - /** - * Sorting is applied client-side, so each column keeps its own - * `sortable` flag. - */ + // Sorting is applied client-side, so each column keeps its own `sortable` flag. return createdColumns; } - /** - * Sorting is applied server-side, so only the columns the graph API can - * sort by are sortable. - */ + // Sorting is applied server-side, so only the columns the graph API can sort by are sortable. return createdColumns.map((column) => ({ ...column, sortable: serverSortableFieldIds.includes(column.id), diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index 81b83edd2ba..448a07dc531 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -83,27 +83,11 @@ export const OutgoingLinksSection = ({ }: OutgoingLinksSectionProps) => { const [showSearch, setShowSearch] = useState(false); - /** - * The sort state lives here so that, in the readonly case, it can drive the - * paginated query (sorting is applied server-side). The only column the API - * can sort the link entities by is their type title (it cannot sort by the - * target entity, and its `label` sort uses the empty label property rather - * than the client-generated label shown in the "Link" column), so we default - * to – and server-side only support – the link type. When editable, the full - * set of links is present and the table sorts client-side instead (across all - * columns), still driven by this state. - */ const [sort, setSort] = useState>({ fieldId: "linkTypes", direction: "asc", }); - /** - * Translate the table sort into graph-query sorting paths, which apply to the - * query's root (the link entities). Only `linkTypes` (the link's type title) - * is sortable server-side; for any other column we apply no server sort (the - * query's uuid tiebreaker still gives a stable, paginatable order). - */ const sortingPaths = useMemo(() => { if (!readonly || sort.fieldId !== "linkTypes") { return undefined; @@ -146,13 +130,6 @@ export const OutgoingLinksSection = ({ [defaultLinkTypesKey], ); - /** - * The readonly link table can be filtered by link type. The filter state and - * options live here so the selection can drive the paginated query - * (`filterTypeIds`) and be passed to the table's header. `filterTypeIds` - * derives only from this hook's state, so feeding it into `useEntityLinks` - * below does not create a render cycle with the breakdown the query returns. - */ const { captureLinkTypeOptions, filterDefinitions, @@ -214,22 +191,15 @@ export const OutgoingLinksSection = ({ linkEntity.metadata.recordId.entityId, ) ?? []; } catch { - /** - * `getRightEntityForLinkEntity` throws if no target revision overlaps - * the resolved instant of the merged multi-page subgraph; treat that - * as a missing endpoint so the link is filtered out below rather than - * crashing the table. - */ + // `getRightEntityForLinkEntity` throws if no target revision overlaps + // the resolved instant of the merged multi-page subgraph rightEntity = []; } return { linkEntity: [linkEntity], rightEntity }; }) .filter( - /** - * Drop links whose target entity is missing, so no row with an empty - * endpoint reaches the table (which would throw when building rows). - */ + // Drop links whose source entity is missing, mirroring the guard the editor path applies (outgoingLinkAndTarget) => !!outgoingLinkAndTarget.rightEntity[0], ); } @@ -288,12 +258,6 @@ export const OutgoingLinksSection = ({ }, []); if (readonly && error) { - /** - * In the self-fetch path the query errors are surfaced here (the editor - * path's errors are handled by the parent query). Without this, a failed - * query would fall through to the empty state, making it look like the - * entity simply has no links. - */ return ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index d0b4ba567a8..1237dd9ac7e 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -63,19 +63,6 @@ export type OutgoingLinksFieldId = | "linkedToTypes" | "link"; -/** - * The columns that can be sorted server-side (applied to the query's root, the - * link entities). Only the link type title (`linkTypes`) can be: - * - the API cannot traverse to the target entity, so `linkedTo` / - * `linkedToTypes` are out; - * - the "Link" column shows a client-generated label (see `generateEntityLabel`), - * but the API's `label` token sorts by the entity's label *property*, which is - * empty for typical link entities — so every row ties and only the `uuid` - * tiebreaker orders them (flipping the direction does nothing, and the order - * does not match the displayed label). - * - * The "Link" column is therefore sortable only client-side (the editable case). - */ const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes"]; export type OutgoingLinksFilterValues = @@ -266,10 +253,6 @@ type OutgoingLinksTableProps = { customEntityLinksColumns?: CustomEntityLinksColumn[]; defaultOutgoingLinkFilters?: Partial; entitySubgraph: Subgraph>; - /** - * The link-type filter, applied server-side; `undefined` while the breakdown - * that populates the options has not yet loaded. - */ filterDefinitions?: LinkTypeFilterDefinitions; filterValues?: LinkTypeFilterValues; setFilterValues?: (filterValues: LinkTypeFilterValues) => void; @@ -278,12 +261,6 @@ type OutgoingLinksTableProps = { onEntityClick: (entityId: EntityId) => void; onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void; outgoingLinksAndTargets: LinkEntityAndRightEntity[]; - /** - * The table is backed by a paginated server-side query: the rows are a - * server-ordered page, sorting is applied by the query (so the table only - * exposes the columns the graph API can sort by, and `sort`/`setSort` drive a - * re-query when the sort changes), and filtering is applied server-side too. - */ sort: VirtualizedTableSort; setSort: (sort: VirtualizedTableSort) => void; slideContainerRef?: RefObject; @@ -480,10 +457,6 @@ export const OutgoingLinksTable = memo( const createdColumns = createColumns(applicableCustomColumns ?? []); - /** - * Sorting is applied server-side, so only the columns the graph API can - * sort by are sortable. - */ return createdColumns.map((column) => ({ ...column, sortable: serverSortableFieldIds.includes(column.id), From f0d4c1bbcc5d2c81455672e8f7d30a0abfcbe33e Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 24 Jun 2026 20:58:02 +0200 Subject: [PATCH 27/34] refactor use-accumulate-cursor-pagination --- .../use-accumulated-cursor-pagination.ts | 284 +++++++----------- .../links-section/use-entity-links.ts | 191 +++++------- .../shared/use-mark-link-entity-to-archive.ts | 4 - .../layout/layout-with-header/search-bar.tsx | 2 + 4 files changed, 189 insertions(+), 292 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts index 11f0f4b0f13..ba0d7ef5470 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts @@ -1,220 +1,152 @@ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; /** - * Sentinel cursor part for the first page, which has no originating cursor. - * Pages are keyed by their cursor so a page is replaced (not duplicated) if its - * query resolves twice - e.g. a cache hit then a network response. + * Sentinel key for the first page, which is requested with no cursor. Pages are + * keyed by their cursor so the position a completion would advance to can be + * compared against the request currently in flight. */ -export const initialCursorKey = "__initial__"; +const initialCursorKey = "__initial__"; -/** - * Separator between a page key's generation prefix and cursor part. A key is - * `${generation} ${cursorPart}`; the generation is a leading integer, so the - * cursor part is everything after the *first* separator (the cursor's JSON may - * contain the separator too). - */ -const generationSeparator = " "; +/** A stable string key identifying the cursor a page was fetched with. */ +const cursorKeyOf = (cursor: unknown): string => + cursor === undefined ? initialCursorKey : JSON.stringify(cursor); /** - * A stable key identifying both the cursor that produced a page and the query - * generation it was fetched under. The generation is what marks a late page - * from a superseded query as stale: every query's first page shares - * {@link initialCursorKey}, so without it a late completion of the old query - * couldn't be told from the new query's first page. + * The fold passed to `appendPage`: merge one loaded page into the running + * accumulation, and report how to fetch the page after it — a `getNextPage` + * thunk producing the next request, or `false` when the loaded page was the + * last. */ -export const cursorKeyFor = ( - generation: number, - cursor: Cursor | undefined, -): string => - `${generation}${generationSeparator}${ - cursor === undefined ? initialCursorKey : JSON.stringify(cursor) - }`; - -/** Split a {@link cursorKeyFor} key back into its generation and cursor part. */ -const parseCursorKey = ( - cursorKey: string, -): { generation: number; cursorPart: string } => { - const separatorIndex = cursorKey.indexOf(generationSeparator); - return { - generation: Number(cursorKey.slice(0, separatorIndex)), - cursorPart: cursorKey.slice(separatorIndex + 1), - }; +type AppendFold = (prevAccumulated: T) => { + accumulated: T; + getNextPage: (() => Page) | false; }; /** * Drives cursor-based pagination, folding each loaded page into a running - * accumulation and exposing `loadMore` to advance the cursor. + * accumulation and exposing `loadMore` to advance to the next page. * - * Callers supply `seed` (build the empty accumulator from the first page) and - * `appendPage` (fold one page in; it must expose `nextCursor`, or `null` when - * exhausted). Both, and `finalize`, must be referentially stable, as the - * accumulation only recomputes when `pages` changes. - * - * The fold is incremental: while `pages` grows by append (infinite scroll) only - * the new pages are folded in, so loading page `k` costs O(page size). A reset - * or in-place page replacement rebuilds from scratch (rare and bounded). - * - * The caller issues the query for the current `cursor` and calls `addPage` with - * the result, stamping it with the `cursorKey` this hook returns (it encodes - * the cursor and generation, so a late completion of a superseded query is - * dropped). + * The caller issues the query for the current `page` request (reading the + * cursor to send from it) and, from the query's completion handler, calls + * `appendPage` with a fold that merges the freshly-loaded page into the running + * accumulation and returns a `getNextPage` thunk for the page after it (or + * `false` when there are no more). The fold must be idempotent in the page it + * adds (e.g. dedupe by id) so a cache-then-network double completion folds the + * same page in twice without duplicating its rows. */ export const useAccumulatedCursorPagination = < - Cursor, - Page extends { cursorKey: string }, - Accumulated extends { nextCursor: Cursor | null }, + T, + Page extends { cursor: unknown } = { cursor: string; nextCursor: string }, >({ resetKey, - seed, - appendPage, - finalize, + initial, }: { - /** - * When this changes, the accumulated pages and cursor are discarded - * Done during render rather than in an effect, so the stale cursor - * is never sent with the new query. - */ + /** Identifies the current search/filter. When it changes, the accumulation and current request are discarded; */ resetKey: string; - /** Build the empty accumulator from the first page. */ - seed: (firstPage: Page) => Accumulated; - /** Fold one page into the running accumulation. */ - appendPage: (accumulated: Accumulated, page: Page) => Accumulated; - /** - * Optional transform applied before the accumulation is returned, e.g. to - * hand out fresh references for collections `appendPage` mutates in place. - * Runs only when the accumulation recomputes. - */ - finalize?: (accumulated: Accumulated) => Accumulated; + /** The empty accumulation that the first (and every) page is folded into. Must be referentially stable */ + initial: T; }): { - /** The cursor for the page to fetch next, to feed into the query. */ - cursor: Cursor | undefined; - /** Key for the current cursor, to stamp onto the page passed to `addPage`. */ - cursorKey: string; - /** How many pages have been loaded so far. */ - pageCount: number; - /** Record a freshly-loaded page (call from the query's completion handler). */ - addPage: (page: Page) => void; + /** The current page or `undefined` for the first page (which is fetched with no cursor). */ + page: Page | undefined; /** The accumulation of every page loaded so far, or `undefined` if none. */ - accumulated: Accumulated | undefined; - /** Advance the cursor to the next page (no-op if there are no more pages). */ + accumulated: T | undefined; + /** + * Record a freshly-loaded page (call from the query's completion handler), + * pass the `resetKey` that was current when the query was issued + */ + appendPage: (resetKey: string, fold: AppendFold) => void; + /** Advance to the next page (no-op if there are no more pages). */ loadMore: () => void; /** Whether there are more pages to fetch. */ hasMore: boolean; } => { - const [cursor, setCursor] = useState(undefined); - const [pages, setPages] = useState([]); + const [page, setPage] = useState(undefined); + const [{ accumulated, getNextPage }, setAccumulation] = useState<{ + accumulated: T | undefined; + getNextPage: (() => Page) | false | undefined; + }>({ accumulated: undefined, getNextPage: undefined }); /** - * Bumped whenever the query identity changes, so pages carry the generation - * they were fetched under. + * The render's `resetKey`, also read by `appendPage` to tell a completion for + * the current search/filter from one of a query issued under an earlier + * `resetKey` — which the cursor check alone cannot catch, as both first pages + * share a `cursor` of `undefined`. */ - const generationRef = useRef(0); - - const previousResetKey = useRef(resetKey); - if (previousResetKey.current !== resetKey) { - previousResetKey.current = resetKey; - // Advance the generation before discarding the stale cursor/pages, so the - // new query stamps its pages with the new generation and a late completion - // of the previous query is dropped by `addPage`. - generationRef.current += 1; - if (cursor !== undefined) { - setCursor(undefined); + const resetKeyRef = useRef(resetKey); + if (resetKeyRef.current !== resetKey) { + resetKeyRef.current = resetKey; + // Discard the stale request and accumulation during render, so the new + // query is never issued with the old cursor. + if (page !== undefined) { + setPage(undefined); } - if (pages.length > 0) { - setPages([]); + if (accumulated !== undefined || getNextPage !== undefined) { + setAccumulation({ accumulated: undefined, getNextPage: undefined }); } } - const addPage = useCallback((page: Page) => { - const { generation, cursorPart } = parseCursorKey(page.cursorKey); - - // Ignore pages from a superseded query. - if (generation !== generationRef.current) { - return; - } - - setPages((previousPages) => { - if (cursorPart === initialCursorKey) { - // First page - replace any accumulated pages. - return [page]; - } - - const existingIndex = previousPages.findIndex( - (previousPage) => previousPage.cursorKey === page.cursorKey, - ); - - if (existingIndex !== -1) { - const next = [...previousPages]; - next[existingIndex] = page; - return next; - } - - return [...previousPages, page]; - }); - }, []); - /** - * The last-processed `pages` and its accumulation, cached so an append-only - * growth of `pages` only folds in the new pages (see the hook docs). + * The request currently being fetched, read by `appendPage` to tell a page + * for the active query from a stale completion of a superseded one. */ - const accumulationCache = useRef<{ - pages: Page[]; - result: Accumulated | undefined; - }>({ pages: [], result: undefined }); - - const seedRef = useRef(seed); - seedRef.current = seed; - const appendPageRef = useRef(appendPage); - appendPageRef.current = appendPage; - const finalizeRef = useRef(finalize); - finalizeRef.current = finalize; - - const accumulated = useMemo(() => { - const cached = accumulationCache.current; - - const isAppendOnlyExtension = - cached.result !== undefined && - pages.length > cached.pages.length && - cached.pages.every((page, index) => pages[index] === page); - - let result: Accumulated | undefined; - if (pages.length === 0) { - result = undefined; - } else if (isAppendOnlyExtension) { - result = cached.result; - for (let index = cached.pages.length; index < pages.length; index++) { - result = appendPageRef.current(result!, pages[index]!); + const requestRef = useRef(page); + requestRef.current = page; + const initialRef = useRef(initial); + initialRef.current = initial; + + const appendPage = useCallback( + (requestResetKey: string, fold: AppendFold) => { + // Drop a completion of a query issued under an earlier `resetKey`. + if (requestResetKey !== resetKeyRef.current) { + return; } - } else { - result = pages.reduce( - (accumulator, page) => appendPageRef.current(accumulator, page), - seedRef.current(pages[0]!), - ); - } - // Cache the raw (pre-`finalize`) result so later appends build on it. - accumulationCache.current = { pages, result }; - - if (result === undefined) { - return undefined; - } - - return finalizeRef.current ? finalizeRef.current(result) : result; - }, [pages]); + setAccumulation((previous) => { + const folded = fold(previous.accumulated ?? initialRef.current); + + // `getNextPage` reports the page to fetch after the one that just + // completed. If that next page is the request already in flight, this is + // a late re-completion of the page before it (the cursor has since + // advanced), so the already-folded `previous` is kept to avoid + // regressing the cursor. Otherwise it is the active request's own + // completion (incl. a cache-then-network re-completion of it, which the + // idempotent fold absorbs), so accept it and record where to advance to + // next. + if ( + folded.getNextPage !== false && + cursorKeyOf(folded.getNextPage().cursor) === + cursorKeyOf(requestRef.current?.cursor) + ) { + return previous; + } + + return { + accumulated: folded.accumulated, + getNextPage: folded.getNextPage, + }; + }); + }, + [], + ); + + const getNextPageRef = useRef(getNextPage); + getNextPageRef.current = getNextPage; const loadMore = useCallback(() => { - if (accumulated?.nextCursor) { - setCursor(accumulated.nextCursor); + const next = getNextPageRef.current; + if (next === undefined || next === false) { + return; } - }, [accumulated?.nextCursor]); + setPage(next()); + }, []); + + const hasMore = getNextPage !== undefined && getNextPage !== false; return { - cursor, - cursorKey: cursorKeyFor(generationRef.current, cursor), - pageCount: pages.length, - addPage, + page, accumulated, + appendPage, loadMore, - hasMore: !!accumulated?.nextCursor, + hasMore, }; }; diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index 433dde5e6fd..4188a2ee11b 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -39,30 +39,6 @@ export const linksTablePageSize = 100; type LinksSubgraph = Subgraph>; -type LinkPage = { - /** - * Key for the cursor that produced this page, so a page is replaced (not - * duplicated) if its query completes twice (e.g. cache hit then network). - */ - cursorKey: string; - count?: number; - linkEntities: HashLinkEntity[]; - nextCursor: EntityQueryCursor | null; - subgraph: LinksSubgraph; - typesMap: ClosedMultiEntityTypesRootMap; - definitions?: ClosedMultiEntityTypesDefinitions; - /** - * Count of matching links by link entity type id. - * Aggregated server-side over the *full* matching set (not just this page), - */ - typeIds?: Record; - /** - * list of titles for each entity type id - * Aggregated server-side over the *full* matching set (not just this page), - */ - typeTitles?: Record; -}; - /** * Appended to any caller sorting so pagination is deterministic: the unique * `uuid` breaks ties when sorting by a non-unique field (label, type title). @@ -92,16 +68,16 @@ const mergeRecordOfRecords = ( /** Merge a later page's subgraph into the accumulated one. */ const mergeSubgraphInto = ( merged: LinksSubgraph, - page: LinkPage, + subgraph: LinksSubgraph, ): LinksSubgraph => { const vertices = mergeRecordOfRecords( merged.vertices as Record>, - page.subgraph.vertices as Record>, + subgraph.vertices as Record>, ) as LinksSubgraph["vertices"]; const edges = mergeRecordOfRecords( merged.edges as Record>, - page.subgraph.edges as Record>, + subgraph.edges as Record>, ) as LinksSubgraph["edges"]; return { ...merged, vertices, edges }; @@ -109,20 +85,20 @@ const mergeSubgraphInto = ( const mergeDefinitionsInto = ( merged: ClosedMultiEntityTypesDefinitions | undefined, - page: LinkPage, + definitions?: ClosedMultiEntityTypesDefinitions, ): ClosedMultiEntityTypesDefinitions | undefined => { - if (!page.definitions) { + if (!definitions) { return merged; } if (!merged) { - return page.definitions; + return definitions; } return { - dataTypes: { ...merged.dataTypes, ...page.definitions.dataTypes }, - entityTypes: { ...merged.entityTypes, ...page.definitions.entityTypes }, + dataTypes: { ...merged.dataTypes, ...definitions.dataTypes }, + entityTypes: { ...merged.entityTypes, ...definitions.entityTypes }, propertyTypes: { ...merged.propertyTypes, - ...page.definitions.propertyTypes, + ...definitions.propertyTypes, }, }; }; @@ -132,68 +108,26 @@ type Accumulated = { linkEntities: HashLinkEntity[]; /** Dedup set keyed on `recordId.entityId`, so appends stay O(page size). */ seenLinkIds: Set; - subgraph: LinksSubgraph; + /** `undefined` until the first page supplies the subgraph to merge into. */ + subgraph: LinksSubgraph | undefined; typesMap: ClosedMultiEntityTypesRootMap; definitions?: ClosedMultiEntityTypesDefinitions; typeIds?: Record; typeTitles?: Record; count?: number; - nextCursor: EntityQueryCursor | null; - /** - * `true` once a page returns fewer rows than the page size, so a non-null - * cursor past the last match can't keep `hasMore` true and trigger an empty - * fetch. - */ - exhausted: boolean; }; -const appendPage = (accumulated: Accumulated, page: LinkPage): Accumulated => { - for (const linkEntity of page.linkEntities) { - const linkEntityId = linkEntity.metadata.recordId.entityId; - if (!accumulated.seenLinkIds.has(linkEntityId)) { - accumulated.seenLinkIds.add(linkEntityId); - accumulated.linkEntities.push(linkEntity); - } - } - - Object.assign(accumulated.typesMap, page.typesMap); - - // Exhausted once a page returns fewer rows than the page size (incl. zero), - // even if the API still handed back a non-null cursor. - const exhausted = page.linkEntities.length < linksTablePageSize; - - return { - ...accumulated, - definitions: mergeDefinitionsInto(accumulated.definitions, page), - subgraph: mergeSubgraphInto(accumulated.subgraph, page), - // A full-set aggregate returned identically on every page, so the latest - // page's value replaces (rather than extends) it. - typeIds: page.typeIds ?? accumulated.typeIds, - typeTitles: page.typeTitles ?? accumulated.typeTitles, - count: page.count, - exhausted, - nextCursor: exhausted ? null : page.nextCursor, - }; -}; - -/** The empty accumulation, seeded with the first page's subgraph to merge into. */ -const seedAccumulated = (firstPage: LinkPage): Accumulated => ({ +/** The empty accumulation; the first page supplies the subgraph to merge into. */ +const initialAccumulated: Accumulated = { linkEntities: [], seenLinkIds: new Set(), - subgraph: firstPage.subgraph, + subgraph: undefined, typesMap: {}, definitions: undefined, typeIds: undefined, typeTitles: undefined, count: undefined, - nextCursor: null, - exhausted: false, -}); - -const finalizeAccumulated = (accumulated: Accumulated): Accumulated => ({ - ...accumulated, - linkEntities: [...accumulated.linkEntities], -}); +}; /** * Fetches an entity's incoming or outgoing links a page at a time, for the @@ -242,22 +176,18 @@ export const useEntityLinks = ({ } => { const [webId, entityUuid, draftId] = splitEntityId(entityId); - const { - cursor, - cursorKey, - pageCount, - addPage, - accumulated, - loadMore, - hasMore, - } = useAccumulatedCursorPagination({ - resetKey: `${entityId}:${direction}:${JSON.stringify( - sortingPaths ?? null, - )}:${JSON.stringify(filterTypeIds ?? null)}`, - seed: seedAccumulated, - appendPage, - finalize: finalizeAccumulated, - }); + const resetKey = `${entityId}:${direction}:${JSON.stringify( + sortingPaths ?? null, + )}:${JSON.stringify(filterTypeIds ?? null)}`; + + const { page, appendPage, accumulated, loadMore, hasMore } = + useAccumulatedCursorPagination< + Accumulated, + { cursor: EntityQueryCursor | undefined } + >({ + resetKey, + initial: initialAccumulated, + }); /** * The link endpoint that is the viewed entity: left/source for outgoing @@ -337,7 +267,7 @@ export const useEntityLinks = ({ : []), ], }, - cursor, + cursor: page?.cursor, limit: linksTablePageSize, includeCount: true, sortingPaths: [...(sortingPaths ?? []), uuidSortingPath], @@ -360,26 +290,63 @@ export const useEntityLinks = ({ data.queryEntitySubgraph, ); - addPage({ - cursorKey, - count: data.queryEntitySubgraph.count ?? undefined, - linkEntities: getRoots(response.subgraph).map( - (rootEntity) => new HashLinkEntity(rootEntity), - ), - nextCursor: data.queryEntitySubgraph.cursor ?? null, - subgraph: response.subgraph, - typesMap: data.queryEntitySubgraph.closedMultiEntityTypes ?? {}, - definitions: data.queryEntitySubgraph.definitions ?? undefined, - typeIds: data.queryEntitySubgraph.typeIds ?? undefined, - typeTitles: data.queryEntitySubgraph.typeTitles ?? undefined, + const newLinkEntities = getRoots(response.subgraph).map( + (rootEntity) => new HashLinkEntity(rootEntity), + ); + + appendPage(resetKey, (prevAccumulated) => { + const linkEntities = [...prevAccumulated.linkEntities]; + const seenLinkIds = new Set(prevAccumulated.seenLinkIds); + for (const linkEntity of newLinkEntities) { + const linkEntityId = linkEntity.metadata.recordId.entityId; + if (!seenLinkIds.has(linkEntityId)) { + seenLinkIds.add(linkEntityId); + linkEntities.push(linkEntity); + } + } + + // Exhausted once a page returns fewer rows than the page size (incl. zero), + // even if the API still handed back a non-null cursor; `getNextPage` is + // then `false` so pagination stops. + const exhausted = newLinkEntities.length < linksTablePageSize; + const nextCursor = exhausted + ? null + : (data.queryEntitySubgraph.cursor ?? null); + + return { + accumulated: { + linkEntities, + seenLinkIds, + subgraph: prevAccumulated.subgraph + ? mergeSubgraphInto(prevAccumulated.subgraph, response.subgraph) + : response.subgraph, + typesMap: { + ...prevAccumulated.typesMap, + ...(data.queryEntitySubgraph.closedMultiEntityTypes ?? {}), + }, + definitions: mergeDefinitionsInto( + prevAccumulated.definitions, + data.queryEntitySubgraph.definitions ?? undefined, + ), + // A full-set aggregate returned identically on every page, so the latest + // page's value replaces (rather than extends) it. + typeIds: + data.queryEntitySubgraph.typeIds ?? prevAccumulated.typeIds, + typeTitles: + data.queryEntitySubgraph.typeTitles ?? prevAccumulated.typeTitles, + count: data.queryEntitySubgraph.count ?? undefined, + }, + getNextPage: + nextCursor === null ? false : () => ({ cursor: nextCursor }), + }; }); }, }); return { - initialLoading: loading && pageCount === 0, error, - loadingMore: loading && cursor !== undefined, + initialLoading: loading && accumulated === undefined, + loadingMore: loading && page !== undefined, loadMore, hasMore, count: accumulated?.count, diff --git a/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts b/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts index a78927770f1..da6a5a422dd 100644 --- a/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts +++ b/apps/hash-frontend/src/pages/shared/entity/shared/use-mark-link-entity-to-archive.ts @@ -8,10 +8,6 @@ import type { EntityId } from "@blockprotocol/type-system"; * Create a function that marks a link entity for archival: if the link is an * as-yet-uncreated draft it is removed from the draft-create list, otherwise it * is added to the draft-archive list. - * - * This was previously a hook that read the draft link state from - * `useEntityEditor`; it is now a plain factory so that the draft state can be - * passed in as props/row data instead of via context. */ export const createMarkLinkEntityToArchive = ({ diff --git a/apps/hash-frontend/src/shared/layout/layout-with-header/search-bar.tsx b/apps/hash-frontend/src/shared/layout/layout-with-header/search-bar.tsx index 506c3c9fdcb..2e221c8632c 100644 --- a/apps/hash-frontend/src/shared/layout/layout-with-header/search-bar.tsx +++ b/apps/hash-frontend/src/shared/layout/layout-with-header/search-bar.tsx @@ -265,6 +265,7 @@ export const SearchBar: FunctionComponent = () => { traversalPaths: [], includeDrafts: false, includePermissions: false, + limit: 100, }, }, skip: !submittedQuery, @@ -278,6 +279,7 @@ export const SearchBar: FunctionComponent = () => { request: { filter: queryFilter, temporalAxes: currentTimeInstantTemporalAxes, + limit: 100, }, }, skip: !submittedQuery, From 98eb69bdd9730140615d6ea8aea95b15c5562a8e Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 24 Jun 2026 21:19:34 +0200 Subject: [PATCH 28/34] Adjust overscan behaviour --- .../incoming-links-table.tsx | 2 -- .../readonly-outgoing-links-table.tsx | 2 -- .../use-accumulated-cursor-pagination.ts | 29 ++++++++++--------- .../src/pages/shared/virtualized-table.tsx | 4 +-- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx index d45462fc786..cc3096f117e 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section/incoming-links-table.tsx @@ -31,7 +31,6 @@ import { linksTableRowHeight, maxLinksTableHeight, } from "../shared/table-styling"; -import { linksTablePageSize } from "../use-entity-links"; import type { CreateVirtualizedRowContentFn, @@ -608,7 +607,6 @@ export const IncomingLinksTable = memo( rows={sortedRows} sort={sort} setSort={setSort} - increaseViewportBy={linksTablePageSize} /> ); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx index 1237dd9ac7e..336903f8b02 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section/readonly-outgoing-links-table.tsx @@ -24,7 +24,6 @@ import { linksTableRowHeight, maxLinksTableHeight, } from "../shared/table-styling"; -import { linksTablePageSize } from "../use-entity-links"; import type { CreateVirtualizedRowContentFn, @@ -534,7 +533,6 @@ export const OutgoingLinksTable = memo( rows={rows} sort={sort} setSort={setSort} - increaseViewportBy={linksTablePageSize} /> ); diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts index ba0d7ef5470..b831dca2d11 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-accumulated-cursor-pagination.ts @@ -5,11 +5,11 @@ import { useCallback, useRef, useState } from "react"; * keyed by their cursor so the position a completion would advance to can be * compared against the request currently in flight. */ -const initialCursorKey = "__initial__"; +const initialPageKey = "__initial__"; -/** A stable string key identifying the cursor a page was fetched with. */ -const cursorKeyOf = (cursor: unknown): string => - cursor === undefined ? initialCursorKey : JSON.stringify(cursor); +/** A stable string key identifying a page by its full request shape. */ +const pageKeyOf = (page: unknown): string => + page === undefined ? initialPageKey : JSON.stringify(page); /** * The fold passed to `appendPage`: merge one loaded page into the running @@ -69,8 +69,8 @@ export const useAccumulatedCursorPagination = < /** * The render's `resetKey`, also read by `appendPage` to tell a completion for * the current search/filter from one of a query issued under an earlier - * `resetKey` — which the cursor check alone cannot catch, as both first pages - * share a `cursor` of `undefined`. + * `resetKey` — which the page-identity check alone cannot catch, as the first + * request under either `resetKey` is the same (`undefined`) page. */ const resetKeyRef = useRef(resetKey); if (resetKeyRef.current !== resetKey) { @@ -106,16 +106,17 @@ export const useAccumulatedCursorPagination = < // `getNextPage` reports the page to fetch after the one that just // completed. If that next page is the request already in flight, this is - // a late re-completion of the page before it (the cursor has since - // advanced), so the already-folded `previous` is kept to avoid - // regressing the cursor. Otherwise it is the active request's own - // completion (incl. a cache-then-network re-completion of it, which the - // idempotent fold absorbs), so accept it and record where to advance to - // next. + // a late re-completion of the page before it (the request has since + // advanced past it), so the already-folded `previous` is kept to avoid + // regressing to an earlier page. Otherwise it is the active request's + // own completion (incl. a cache-then-network re-completion of it, which + // the idempotent fold absorbs), so accept it and record where to advance + // to next. The comparison is on the page's full identity, not just its + // cursor, so the first page of a later section (cursor `undefined`) is + // not mistaken for the in-flight first request (also cursor `undefined`). if ( folded.getNextPage !== false && - cursorKeyOf(folded.getNextPage().cursor) === - cursorKeyOf(requestRef.current?.cursor) + pageKeyOf(folded.getNextPage()) === pageKeyOf(requestRef.current) ) { return previous; } diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index de410d4190b..2421ac784f1 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -136,7 +136,6 @@ type VirtualizedTableProps< followOutput?: FollowOutput; loadingMore?: boolean; rows: VirtualizedTableRow[]; - increaseViewportBy?: number; } & TableSortProps & Partial>; @@ -164,7 +163,6 @@ export const VirtualizedTable = < setFilterValues, sort, setSort, - increaseViewportBy, }: VirtualizedTableProps) => { const fixedHeaderContent = useCallback( () => @@ -233,7 +231,7 @@ export const VirtualizedTable = < fixedFooterContent={fixedFooterContent} fixedHeaderContent={fixedHeaderContent} followOutput={followOutput} - increaseViewportBy={increaseViewportBy ?? 50} + increaseViewportBy={200} itemContent={createRowContent} overscan={{ main: 200, reverse: 200 }} style={heightStyle} From 356160fb6f0710abef4c393961a4e56df9ba18e6 Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 24 Jun 2026 21:27:02 +0200 Subject: [PATCH 29/34] Fix rebase --- .../links-section/incoming-links-section.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 163edb65bcb..0b5d689caa2 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -7,7 +7,6 @@ import { } from "@blockprotocol/graph/stdlib"; import { Callout, Chip } from "@hashintel/design-system"; import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity"; -import { noisySystemTypeIds } from "@local/hash-isomorphic-utils/graph-queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { SectionWrapper } from "../../../section-wrapper"; @@ -22,7 +21,6 @@ import type { IncomingLinksFieldId } from "./incoming-links-section/incoming-lin import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph"; import type { VersionedUrl } from "@blockprotocol/type-system"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; -import type { NoisySystemTypeId } from "@local/hash-isomorphic-utils/graph-queries"; type IncomingLinksSectionProps = Pick< EntityEditorProps, @@ -71,9 +69,7 @@ export const IncomingLinksSection = ({ /** * When the entity is readonly we fetch the link data here (paginated), so it * does not need to be part of the main entity query. When editable, the link - * data is part of the editor subgraph and is not paginated. The noisy-type / - * claim exclusions below are applied server-side in the paginated case (so the - * count is accurate), and client-side for the editor subgraph. + * data is part of the editor subgraph and is not paginated. */ const { initialLoading, @@ -142,9 +138,6 @@ export const IncomingLinksSection = ({ !draftLinksToArchive.includes( incomingLinkAndSource.linkEntity[0].entityId, ) && - !incomingLinkAndSource.linkEntity[0].metadata.entityTypeIds.some( - (typeId) => noisySystemTypeIds.includes(typeId as NoisySystemTypeId), - ) && incomingLinkAndSource.leftEntity[0] && !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes( systemEntityTypes.claim.entityTypeId, From 86d82fee73fed2a512c53c1f4509a933328c381f Mon Sep 17 00:00:00 2001 From: alex leon Date: Wed, 24 Jun 2026 21:34:50 +0200 Subject: [PATCH 30/34] Show loading state until we know whether the entity is readonly or not --- apps/hash-frontend/src/pages/shared/entity.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index 231618e7077..e45aeaf7d0e 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -333,6 +333,20 @@ export const Entity = ({ * false and the link tables self-fetch instead. */ setIncludeLinkDataInQuery(canUpdate); + + if (canUpdate) { + /** + * The entity is editable, so its link tables read from the editor + * subgraph – but this first response was fetched without link data, + * because we didn't know the user's permissions until it returned. + * Rather than dropping the loading state now (which would render the + * editor with empty link tables) and then changing the tables once the + * link-data refetch lands, we keep showing the loading state until that + * refetch completes – at which point this `onCompleted` runs again with + * `isInitialLoad` false and falls through to `setLoading(false)`. + */ + return; + } } setLoading(false); From b71d9281c9a86e1cdc829bb801c7c57148f08ae1 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 25 Jun 2026 16:41:54 +0100 Subject: [PATCH 31/34] update for new Graph summarize query --- .../links-section/use-entity-links.ts | 216 +++++++++++------- 1 file changed, 132 insertions(+), 84 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts index 4188a2ee11b..29ce99572ba 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts @@ -1,4 +1,5 @@ import { type ApolloError, useQuery } from "@apollo/client"; +import { useMemo } from "react"; import { getRoots } from "@blockprotocol/graph/stdlib"; import { @@ -17,16 +18,20 @@ import { import { queryEntitySubgraphQuery } from "@local/hash-isomorphic-utils/graphql/queries/entity.queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import { summarizeEntitiesQuery } from "../../../../../graphql/queries/knowledge/entity.queries"; import { useAccumulatedCursorPagination } from "./use-accumulated-cursor-pagination"; import type { QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables, + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables, } from "../../../../../graphql/api-types.gen"; import type { EntityRootType, Subgraph } from "@blockprotocol/graph"; import type { EntityQueryCursor, EntityQuerySortingRecord, + Filter, } from "@local/hash-graph-client"; import type { HashEntity } from "@local/hash-graph-sdk/entity"; import type { @@ -112,9 +117,6 @@ type Accumulated = { subgraph: LinksSubgraph | undefined; typesMap: ClosedMultiEntityTypesRootMap; definitions?: ClosedMultiEntityTypesDefinitions; - typeIds?: Record; - typeTitles?: Record; - count?: number; }; /** The empty accumulation; the first page supplies the subgraph to merge into. */ @@ -124,9 +126,6 @@ const initialAccumulated: Accumulated = { subgraph: undefined, typesMap: {}, definitions: undefined, - typeIds: undefined, - typeTitles: undefined, - count: undefined, }; /** @@ -196,6 +195,86 @@ export const useEntityLinks = ({ const filterEndpoint = direction === "outgoing" ? "leftEntity" : "rightEntity"; + /** + * The filter clauses common to every query: scope to the viewed entity (and + * draft) via the relevant link endpoint, and (for incoming links) exclude + * noisy system types and claims. This deliberately omits any + * {@link filterTypeIds} clause. + */ + const baseFilterClauses = useMemo(() => { + const clauses: Filter[] = [ + { + equal: [{ path: [filterEndpoint, "uuid"] }, { parameter: entityUuid }], + }, + { + equal: [{ path: [filterEndpoint, "webId"] }, { parameter: webId }], + }, + ]; + + /** + * When viewing a specific draft, scope the endpoint to that draft + * (mirroring `generateEntityIdFilter`); without it a draft would match + * links across its live version and all sibling drafts. + */ + if (draftId) { + clauses.push({ + equal: [{ path: [filterEndpoint, "draftId"] }, { parameter: draftId }], + }); + } + + if (direction === "incoming") { + clauses.push(ignoreNoisySystemTypesFilter, { + notEqual: [ + { path: ["leftEntity", "type", "versionedUrl"] }, + { parameter: systemEntityTypes.claim.entityTypeId }, + ], + }); + } + + return clauses; + }, [direction, draftId, entityUuid, filterEndpoint, webId]); + + /** + * Restrict matched links to the selected link types. `undefined` means no + * filter (every type selected, the default), so the clause is omitted. An + * empty array means every type deselected, which must match *nothing* - an + * empty `any` resolves to a `FALSE` filter, so the clause is still added. + */ + const typeFilterClause = useMemo(() => { + if (!filterTypeIds) { + return null; + } + + return { + any: filterTypeIds.map((versionedUrl) => ({ + equal: [ + { path: ["type", "versionedUrl"] }, + { parameter: versionedUrl }, + ], + })), + }; + }, [filterTypeIds]); + + /** The full filter for the matched links, including any type filter. */ + const filter = useMemo( + () => ({ + all: typeFilterClause + ? [...baseFilterClauses, typeFilterClause] + : baseFilterClauses, + }), + [baseFilterClauses, typeFilterClause], + ); + + /** + * The same filter without any type filter, used to offer the type filter + * options: we want the types present in the result set *before* the user + * narrows by type. + */ + const filterWithoutTypeFilter = useMemo( + () => ({ all: baseFilterClauses }), + [baseFilterClauses], + ); + const { loading, error } = useQuery< QueryEntitySubgraphQuery, QueryEntitySubgraphQueryVariables @@ -204,72 +283,9 @@ export const useEntityLinks = ({ skip, variables: { request: { - filter: { - all: [ - { - equal: [ - { path: [filterEndpoint, "uuid"] }, - { parameter: entityUuid }, - ], - }, - { - equal: [ - { path: [filterEndpoint, "webId"] }, - { parameter: webId }, - ], - }, - /** - * When viewing a specific draft, scope the endpoint to that draft - * (mirroring `generateEntityIdFilter`); without it a draft would - * match links across its live version and all sibling drafts. - */ - ...(draftId - ? [ - { - equal: [ - { path: [filterEndpoint, "draftId"] }, - { parameter: draftId }, - ], - }, - ] - : []), - ...(direction === "incoming" - ? [ - ignoreNoisySystemTypesFilter, - { - notEqual: [ - { path: ["leftEntity", "type", "versionedUrl"] }, - { - parameter: systemEntityTypes.claim.entityTypeId, - }, - ], - }, - ] - : []), - /** - * Restrict matched links to the selected link types. `undefined` - * means no filter (every type selected, the default), so the clause - * is omitted. An empty array means every type deselected, which must - * match *nothing* - an empty `any` resolves to a `FALSE` filter, so - * the clause is still added. - */ - ...(filterTypeIds - ? [ - { - any: filterTypeIds.map((versionedUrl) => ({ - equal: [ - { path: ["type", "versionedUrl"] }, - { parameter: versionedUrl }, - ], - })), - }, - ] - : []), - ], - }, + filter, cursor: page?.cursor, limit: linksTablePageSize, - includeCount: true, sortingPaths: [...(sortingPaths ?? []), uuidSortingPath], temporalAxes: currentTimeInstantTemporalAxes, traversalPaths: [ @@ -279,10 +295,6 @@ export const useEntityLinks = ({ includeDrafts: !!draftId, includeEntityTypes: "resolvedWithDataTypeChildren", includePermissions: false, - // Return the per-type breakdown and titles so the caller can offer - // them as filter options (see {@link filterTypeIds}). - includeTypeIds: true, - includeTypeTitles: true, }, }, onCompleted: (data) => { @@ -328,13 +340,6 @@ export const useEntityLinks = ({ prevAccumulated.definitions, data.queryEntitySubgraph.definitions ?? undefined, ), - // A full-set aggregate returned identically on every page, so the latest - // page's value replaces (rather than extends) it. - typeIds: - data.queryEntitySubgraph.typeIds ?? prevAccumulated.typeIds, - typeTitles: - data.queryEntitySubgraph.typeTitles ?? prevAccumulated.typeTitles, - count: data.queryEntitySubgraph.count ?? undefined, }, getNextPage: nextCursor === null ? false : () => ({ cursor: nextCursor }), @@ -343,18 +348,61 @@ export const useEntityLinks = ({ }, }); + /** + * The total number of matching links. Fetched separately from the paginated + * query above so it reflects the *full* matching set (with the type filter + * applied), not just the current page. + */ + const { data: countData } = useQuery< + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { + fetchPolicy: "cache-and-network", + skip, + variables: { + request: { + filter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: !!draftId, + includeCount: true, + }, + }, + }); + + /** + * The per-type breakdown and titles offered as type filter options. Fetched + * with the type filter omitted, so the caller can always offer every type + * present in the (otherwise) matching set, even once a subset is selected. + */ + const { data: typesData } = useQuery< + SummarizeEntitiesQuery, + SummarizeEntitiesQueryVariables + >(summarizeEntitiesQuery, { + fetchPolicy: "cache-and-network", + skip, + variables: { + request: { + filter: filterWithoutTypeFilter, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: !!draftId, + includeTypeIds: true, + includeTypeTitles: true, + }, + }, + }); + return { error, initialLoading: loading && accumulated === undefined, loadingMore: loading && page !== undefined, loadMore, hasMore, - count: accumulated?.count, + count: countData?.summarizeEntities.count ?? undefined, linkEntities: accumulated?.linkEntities, subgraph: accumulated?.subgraph, linkAndDestinationEntitiesClosedMultiEntityTypesMap: accumulated?.typesMap, closedMultiEntityTypesDefinitions: accumulated?.definitions, - typeIds: accumulated?.typeIds, - typeTitles: accumulated?.typeTitles, + typeIds: typesData?.summarizeEntities.typeIds ?? undefined, + typeTitles: typesData?.summarizeEntities.typeTitles ?? undefined, }; }; From a4c385454040cc08a99c297ac072a99fa7f9dfc7 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 25 Jun 2026 16:42:12 +0100 Subject: [PATCH 32/34] increase virtualized table viewport --- apps/hash-frontend/src/pages/shared/virtualized-table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx index 2421ac784f1..4406120e358 100644 --- a/apps/hash-frontend/src/pages/shared/virtualized-table.tsx +++ b/apps/hash-frontend/src/pages/shared/virtualized-table.tsx @@ -231,9 +231,9 @@ export const VirtualizedTable = < fixedFooterContent={fixedFooterContent} fixedHeaderContent={fixedHeaderContent} followOutput={followOutput} - increaseViewportBy={200} + increaseViewportBy={2000} itemContent={createRowContent} - overscan={{ main: 200, reverse: 200 }} + overscan={{ main: 1000, reverse: 1000 }} style={heightStyle} /> From 7db06fb38bc5de5f5afe520f6ecfcfb37c08accc Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 25 Jun 2026 16:42:30 +0100 Subject: [PATCH 33/34] allow properties to load in while waiting for links to be resolved --- .../hash-frontend/src/pages/shared/entity.tsx | 30 ++++++++----------- .../src/pages/shared/entity/entity-editor.tsx | 7 +++++ .../links-section/incoming-links-section.tsx | 10 +++++-- .../links-section/outgoing-links-section.tsx | 10 +++++-- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx index e45aeaf7d0e..77b75c52784 100644 --- a/apps/hash-frontend/src/pages/shared/entity.tsx +++ b/apps/hash-frontend/src/pages/shared/entity.tsx @@ -267,6 +267,8 @@ export const Entity = ({ */ const [includeLinkDataInQuery, setIncludeLinkDataInQuery] = useState(false); const hasCompletedInitialLoadRef = useRef(false); + const [hasRootLinkDataBeenResolved, setHasRootLinkDataBeenResolved] = + useState(false); const { data: queryEntitySubgraphData, refetch } = useQuery< QueryEntitySubgraphQuery, @@ -323,30 +325,22 @@ export const Entity = ({ setDraftLinksToCreate([]); setDraftLinksToArchive([]); - if (isInitialLoad) { - const canUpdate = - !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update; + const canUpdate = + !!data.queryEntitySubgraph.entityPermissions?.[entityId]?.update; + if (isInitialLoad) { /** * For editable entities this flips the query variables, triggering the * link-data upgrade refetch handled above. For readonly entities it stays * false and the link tables self-fetch instead. */ setIncludeLinkDataInQuery(canUpdate); - - if (canUpdate) { - /** - * The entity is editable, so its link tables read from the editor - * subgraph – but this first response was fetched without link data, - * because we didn't know the user's permissions until it returned. - * Rather than dropping the loading state now (which would render the - * editor with empty link tables) and then changing the tables once the - * link-data refetch lands, we keep showing the loading state until that - * refetch completes – at which point this `onCompleted` runs again with - * `isInitialLoad` false and falls through to `setLoading(false)`. - */ - return; - } + } else if (canUpdate) { + /** + * If this is _not_ the initial load, and the entity is editable, + * this is the result of a subsequent fetch with the link traversal included. + */ + setHasRootLinkDataBeenResolved(true); } setLoading(false); @@ -630,6 +624,7 @@ export const Entity = ({ JSON.stringify(entityFromDb?.metadata.entityTypeIds.toSorted()), ); }} + hasRootLinkDataBeenResolved={hasRootLinkDataBeenResolved} isDirty={isDirty} isInSlide={isInSlide} onEntityClick={(clickedEntityId) => @@ -756,6 +751,7 @@ export const Entity = ({ ), ); }} + hasRootLinkDataBeenResolved={hasRootLinkDataBeenResolved} isDirty={isDirty} onEntityClick={(clickedEntityId) => pushToSlideStack({ diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx index 8c78f159a3b..49c4e684d69 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx @@ -56,6 +56,10 @@ export interface EntityEditorProps extends DraftLinkState { * Whether the entity is dirty (has unsaved changes) */ isDirty: boolean; + /** + * When the component is not in readonly, whether the link data (if any) is now included in the subgraph. + */ + hasRootLinkDataBeenResolved: boolean; /** * The label of the entity being edited */ @@ -121,6 +125,7 @@ export const EntityEditor = (props: EntityEditorProps) => { draftLinksToCreate, entityLabel, entitySubgraph, + hasRootLinkDataBeenResolved, linkAndDestinationEntitiesClosedMultiEntityTypesMap, onEntityClick, onTypeClick, @@ -193,6 +198,7 @@ export const EntityEditor = (props: EntityEditorProps) => { linkAndDestinationEntitiesClosedMultiEntityTypesMap={ linkAndDestinationEntitiesClosedMultiEntityTypesMap } + hasRootLinkDataBeenResolved={hasRootLinkDataBeenResolved} onEntityClick={onEntityClick} onTypeClick={onTypeClick} readonly={readonly} @@ -211,6 +217,7 @@ export const EntityEditor = (props: EntityEditorProps) => { entityLabel={entityLabel} entitySubgraph={entitySubgraph} isLinkEntity={isLinkEntity} + hasRootLinkDataBeenResolved={hasRootLinkDataBeenResolved} key={`incoming-${entity.metadata.recordId.editionId}`} linkAndDestinationEntitiesClosedMultiEntityTypesMap={ linkAndDestinationEntitiesClosedMultiEntityTypesMap diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx index 0b5d689caa2..4bbcbfacd02 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/incoming-links-section.tsx @@ -29,6 +29,7 @@ type IncomingLinksSectionProps = Pick< | "draftLinksToArchive" | "entityLabel" | "entitySubgraph" + | "hasRootLinkDataBeenResolved" | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" | "onEntityClick" | "onTypeClick" @@ -47,6 +48,7 @@ export const IncomingLinksSection = ({ entityLabel, entitySubgraph: editorSubgraph, isLinkEntity, + hasRootLinkDataBeenResolved, linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, onEntityClick, onTypeClick, @@ -230,8 +232,12 @@ export const IncomingLinksSection = ({ } if ( - readonly && - (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) + (!readonly && !hasRootLinkDataBeenResolved) || + (readonly && + (initialLoading || + !linkEntities || + !fetchedSubgraph || + !fetchedDefinitions)) ) { return ( diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx index 448a07dc531..221c3f6c7c9 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/outgoing-links-section.tsx @@ -51,6 +51,7 @@ type OutgoingLinksSectionProps = Pick< | "draftLinksToArchive" | "draftLinksToCreate" | "entitySubgraph" + | "hasRootLinkDataBeenResolved" | "linkAndDestinationEntitiesClosedMultiEntityTypesMap" | "onEntityClick" | "onTypeClick" @@ -73,6 +74,7 @@ export const OutgoingLinksSection = ({ entity, entitySubgraph: editorSubgraph, isLinkEntity, + hasRootLinkDataBeenResolved, linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap, onEntityClick, onTypeClick, @@ -268,8 +270,12 @@ export const OutgoingLinksSection = ({ } if ( - readonly && - (initialLoading || !linkEntities || !fetchedSubgraph || !fetchedDefinitions) + (!readonly && !hasRootLinkDataBeenResolved) || + (readonly && + (initialLoading || + !linkEntities || + !fetchedSubgraph || + !fetchedDefinitions)) ) { return ( From 0d994fce47c730766bf0a93ade728c6ed51522d9 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 25 Jun 2026 16:53:40 +0100 Subject: [PATCH 34/34] lint fix --- .../pages/shared/entity/entity-editor/entity-editor-context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx index bdfee29ef06..6c6d8754812 100644 --- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx +++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/entity-editor-context.tsx @@ -16,7 +16,7 @@ import type { PropsWithChildren } from "react"; export type TableExpandStatus = Record; -interface Props extends EntityEditorProps { +interface Props extends Omit { entity: HashEntity; isLocalDraftOnly: boolean; propertyExpandStatus: TableExpandStatus;