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,
diff --git a/apps/hash-frontend/src/pages/shared/entity.tsx b/apps/hash-frontend/src/pages/shared/entity.tsx
index 98660ced0a1..77b75c52784 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";
@@ -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,25 @@ 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 `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
+ * user can edit, the query variables change and the query refetches with the
+ * link data included.
+ */
+ const [includeLinkDataInQuery, setIncludeLinkDataInQuery] = useState(false);
+ const hasCompletedInitialLoadRef = useRef(false);
+ const [hasRootLinkDataBeenResolved, setHasRootLinkDataBeenResolved] =
+ useState(false);
+
const { data: queryEntitySubgraphData, refetch } = useQuery<
QueryEntitySubgraphQuery,
QueryEntitySubgraphQueryVariables
@@ -296,12 +316,33 @@ export const Entity = ({
closedMultiEntityTypes,
});
+ const isInitialLoad = !hasCompletedInitialLoadRef.current;
+ hasCompletedInitialLoadRef.current = true;
+
setDraftEntitySubgraph(subgraph);
setIsDirty(false);
setDraftLinksToCreate([]);
setDraftLinksToArchive([]);
+ 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);
+ } 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);
},
variables: {
@@ -325,18 +366,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 `isReadOnly`).
+ */
+ ...(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" }],
},
@@ -570,6 +624,7 @@ export const Entity = ({
JSON.stringify(entityFromDb?.metadata.entityTypeIds.toSorted()),
);
}}
+ hasRootLinkDataBeenResolved={hasRootLinkDataBeenResolved}
isDirty={isDirty}
isInSlide={isInSlide}
onEntityClick={(clickedEntityId) =>
@@ -696,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 864fe6f9ee0..49c4e684d69 100644
--- a/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx
+++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor.tsx
@@ -1,4 +1,4 @@
-import { Box } from "@mui/material";
+import { Box, Stack } from "@mui/material";
import { useMemo } from "react";
import { getRoots } from "@blockprotocol/graph/stdlib";
@@ -8,7 +8,8 @@ import { EntityEditorContextProvider } from "./entity-editor/entity-editor-conte
import { FilePreviewSection } from "./entity-editor/file-preview-section";
import { HistorySection } from "./entity-editor/history-section";
import { LinkSection } from "./entity-editor/link-section";
-import { LinksSection } from "./entity-editor/links-section";
+import { IncomingLinksSection } from "./entity-editor/links-section/incoming-links-section";
+import { OutgoingLinksSection } from "./entity-editor/links-section/outgoing-links-section";
import { PropertiesSection } from "./entity-editor/properties-section";
import { TypesSection } from "./entity-editor/types-section";
import { useEntityEditorTab } from "./shared/entity-editor-tabs";
@@ -55,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
*/
@@ -111,7 +116,24 @@ export interface EntityEditorProps extends DraftLinkState {
}
export const EntityEditor = (props: EntityEditorProps) => {
- const { entitySubgraph } = props;
+ const {
+ closedMultiEntityType,
+ closedMultiEntityTypesDefinitions,
+ customEntityLinksColumns,
+ defaultOutgoingLinkFilters,
+ draftLinksToArchive,
+ draftLinksToCreate,
+ entityLabel,
+ entitySubgraph,
+ hasRootLinkDataBeenResolved,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap,
+ onEntityClick,
+ onTypeClick,
+ readonly,
+ setDraftLinksToArchive,
+ setDraftLinksToCreate,
+ slideContainerRef,
+ } = props;
const entity = useMemo(() => {
const roots = getRoots(entitySubgraph);
@@ -159,7 +181,53 @@ export const EntityEditor = (props: EntityEditorProps) => {
-
+
+
+
+
+
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;
diff --git a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section.tsx b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section.tsx
deleted file mode 100644
index 3ad017e7fc6..00000000000
--- a/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Stack } from "@mui/material";
-
-import {
- getIncomingLinkAndSourceEntities,
- getOutgoingLinksForEntity,
-} from "@blockprotocol/graph/stdlib";
-import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
-
-import { useEntityEditor } from "./entity-editor-context";
-import { IncomingLinksSection } from "./links-section/incoming-links-section";
-import { OutgoingLinksSection } from "./links-section/outgoing-links-section";
-
-export const LinksSection = ({ isLinkEntity }: { isLinkEntity: boolean }) => {
- 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..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
@@ -1,23 +1,266 @@
-import { Stack } from "@mui/material";
+import { Box, CircularProgress, Stack } from "@mui/material";
+import { useEffect, useMemo, useState } from "react";
-import { Chip } from "@hashintel/design-system";
+import {
+ getIncomingLinkAndSourceEntities,
+ getLeftEntityForLinkEntity,
+} from "@blockprotocol/graph/stdlib";
+import { Callout, Chip } from "@hashintel/design-system";
+import { getClosedMultiEntityTypeFromMap } from "@local/hash-graph-sdk/entity";
+import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";
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";
-interface IncomingLinksSectionProps {
- incomingLinksAndSources: LinkEntityAndLeftEntity[];
+type IncomingLinksSectionProps = Pick<
+ EntityEditorProps,
+ | "closedMultiEntityTypesDefinitions"
+ | "customEntityLinksColumns"
+ | "draftLinksToArchive"
+ | "entityLabel"
+ | "entitySubgraph"
+ | "hasRootLinkDataBeenResolved"
+ | "linkAndDestinationEntitiesClosedMultiEntityTypesMap"
+ | "onEntityClick"
+ | "onTypeClick"
+ | "slideContainerRef"
+ | "readonly"
+> & {
+ entity: HashEntity;
isLinkEntity: boolean;
-}
+};
export const IncomingLinksSection = ({
- incomingLinksAndSources,
+ closedMultiEntityTypesDefinitions: editorDefinitions,
+ customEntityLinksColumns,
+ draftLinksToArchive,
+ entity,
+ entityLabel,
+ entitySubgraph: editorSubgraph,
isLinkEntity,
+ hasRootLinkDataBeenResolved,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap,
+ onEntityClick,
+ onTypeClick,
+ readonly,
+ slideContainerRef,
}: IncomingLinksSectionProps) => {
- if (incomingLinksAndSources.length === 0 && isLinkEntity) {
+ const [sort, setSort] = useState>({
+ fieldId: "link",
+ direction: "asc",
+ });
+
+ 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
+ * data is part of the editor subgraph and is not paginated.
+ */
+ const {
+ initialLoading,
+ loadingMore,
+ loadMore,
+ hasMore,
+ count: totalCount,
+ error,
+ linkEntities,
+ subgraph: fetchedSubgraph,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap,
+ closedMultiEntityTypesDefinitions: fetchedDefinitions,
+ typeIds,
+ typeTitles,
+ } = useEntityLinks({
+ direction: "incoming",
+ entityId: entity.metadata.recordId.entityId,
+ filterTypeIds,
+ skip: !readonly,
+ });
+
+ /**
+ * The links/sources passed to the readonly table. In the self-fetch path the
+ * source 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 incomingLinksAndSources = useMemo(() => {
+ if (readonly) {
+ 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
+ leftEntity = [];
+ }
+
+ return { linkEntity: [linkEntity], leftEntity };
+ })
+ .filter(
+ // Drop links whose source entity is missing, mirroring the guard the editor path applies
+ (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.leftEntity[0] &&
+ !incomingLinkAndSource.leftEntity[0].metadata.entityTypeIds.includes(
+ systemEntityTypes.claim.entityTypeId,
+ )
+ );
+ });
+ }, [
+ readonly,
+ linkEntities,
+ fetchedSubgraph,
+ editorSubgraph,
+ entity,
+ draftLinksToArchive,
+ ]);
+
+ // In the editable case the filter options are derived from the (unfiltered) editor links here.
+ 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
+ */
+ 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) {
+ return (
+
+
+ Could not load incoming links. Please try again later.
+
+
+ );
+ }
+
+ if (
+ (!readonly && !hasRootLinkDataBeenResolved) ||
+ (readonly &&
+ (initialLoading ||
+ !linkEntities ||
+ !fetchedSubgraph ||
+ !fetchedDefinitions))
+ ) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ const entitySubgraph = readonly ? fetchedSubgraph! : editorSubgraph;
+ const closedMultiEntityTypesMap = readonly
+ ? (fetchedTypesMap ?? null)
+ : editorTypesMap;
+ const closedMultiEntityTypesDefinitions = readonly
+ ? fetchedDefinitions!
+ : editorDefinitions;
+
+ const linkCount = readonly
+ ? (totalCount ?? incomingLinksAndSources.length)
+ : incomingLinksAndSources.length;
+
+ 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.
@@ -34,13 +277,31 @@ export const IncomingLinksSection = ({
}
>
- {incomingLinksAndSources.length ? (
-
+ {linkCount > 0 || (readonly && filterTypeIds !== 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 5df01a2e36b..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
@@ -2,10 +2,10 @@ import { Box, Stack, TableCell, Typography } from "@mui/material";
import {
memo,
type ReactElement,
- useLayoutEffect,
+ type RefObject,
+ useCallback,
useMemo,
useRef,
- useState,
} from "react";
import { EntityOrTypeIcon } from "@hashintel/design-system";
@@ -24,9 +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 { useEntityEditor } from "../../entity-editor-context";
import { PropertiesTooltip } from "../shared/properties-tooltip";
import {
linksTableCellSx,
@@ -40,24 +37,39 @@ 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 { CustomEntityLinksColumn } from "../../shared/types";
-import type { LinkEntityAndLeftEntity } from "@blockprotocol/graph";
+import type {
+ LinkTypeFilterDefinitions,
+ LinkTypeFilterValues,
+} from "../use-link-type-filter";
+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";
+import type { ListRange } from "react-virtuoso";
+
+export type IncomingLinksFieldId =
+ | "linkedFrom"
+ | "linkTypes"
+ | "linkedFromTypes"
+ | "link";
-type FieldId = "linkedFrom" | "linkTypes" | "linkedFromTypes" | "link";
+type FieldId = IncomingLinksFieldId;
+const serverSortableFieldIds: FieldId[] = [];
const staticColumns: VirtualizedTableColumn[] = [
{
label: "Linked from",
@@ -118,10 +130,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)) {
@@ -138,6 +152,7 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => {
@@ -223,6 +238,7 @@ const TableRow = memo(({ row }: { row: IncomingLinkRow }) => {
row.onEntityClick(row.linkEntity.entityId)}
@@ -251,75 +267,59 @@ const createRowContent: CreateVirtualizedRowContentFn<
> = (_index, row) => ;
type IncomingLinksTableProps = {
+ closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions;
+ closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null;
+ customEntityLinksColumns?: CustomEntityLinksColumn[];
+ entityLabel: string;
+ entitySubgraph: Subgraph>;
+ filterDefinitions?: LinkTypeFilterDefinitions;
+ filterValues?: LinkTypeFilterValues;
+ setFilterValues?: (filterValues: LinkTypeFilterValues) => void;
incomingLinksAndSources: LinkEntityAndLeftEntity[];
+ loadingMore?: boolean;
+ onEndReached?: () => void;
+ onEntityClick: (entityId: EntityId) => void;
+ onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void;
+ readonly: boolean;
+ sort: VirtualizedTableSort;
+ setSort: (sort: VirtualizedTableSort) => void;
+ slideContainerRef?: RefObject;
};
export const IncomingLinksTable = memo(
- ({ incomingLinksAndSources }: IncomingLinksTableProps) => {
- const [sort, setSort] = useState>({
- fieldId: "linkedFrom",
- direction: "asc",
- });
-
+ ({
+ closedMultiEntityTypesDefinitions,
+ closedMultiEntityTypesMap,
+ customEntityLinksColumns: customColumns,
+ entityLabel,
+ entitySubgraph,
+ filterDefinitions,
+ filterValues,
+ setFilterValues,
+ incomingLinksAndSources,
+ loadingMore,
+ onEndReached,
+ onEntityClick,
+ onTypeClick,
+ readonly,
+ sort,
+ setSort,
+ slideContainerRef,
+ }: IncomingLinksTableProps) => {
const {
- linkAndDestinationEntitiesClosedMultiEntityTypesMap:
- closedMultiEntityTypesMap,
- closedMultiEntityTypesDefinitions,
- customEntityLinksColumns: customColumns,
- draftLinksToArchive,
- entitySubgraph,
- onEntityClick,
- onTypeClick,
- } = useEntityEditor();
-
- 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,
- 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,
@@ -330,19 +330,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,
@@ -365,16 +359,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];
@@ -395,39 +380,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(
@@ -472,11 +424,13 @@ export const IncomingLinksTable = memo(
inverse: type.inverse,
};
}),
+ entityLabel,
linkEntity,
linkEntityLabel,
linkEntityProperties,
onEntityClick,
onTypeClick,
+ slideContainerRef,
sourceEntity: leftEntity,
sourceEntityLabel: leftEntityLabel,
sourceEntityProperties,
@@ -495,164 +449,149 @@ 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,
onEntityClick,
onTypeClick,
+ slideContainerRef,
]);
- const [filterValues, setFilterValues] = useVirtualizedTableFilterState({
- defaultFilterValues: initialFilterValues,
- filterDefinitions,
- });
+ /**
+ * 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 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;
- }
- }
+ 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]);
- 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
- );
+ const columns = useMemo(() => {
+ const applicableCustomColumns = customColumns?.filter((column) =>
+ presentLinkEntityTypeIds.has(column.appliesToEntityTypeId),
+ );
+
+ 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.
+ return createdColumns.map((column) => ({
+ ...column,
+ sortable: serverSortableFieldIds.includes(column.id),
+ }));
+ }, [customColumns, presentLinkEntityTypeIds, readonly]);
+
+ /**
+ * 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(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
+ if (
+ canLoadMoreRef.current &&
+ !loadingMore &&
+ endIndex >= rows.length - 1
+ ) {
+ canLoadMoreRef.current = false;
+ onEndReached();
}
}
- }),
- [filterValues, sort, unsortedRows],
+ : undefined,
+ [loadingMore, onEndReached, rows.length],
);
const height = Math.min(
maxLinksTableHeight,
- rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2,
+ sortedRows.length * linksTableRowHeight +
+ virtualizedTableHeaderHeight +
+ 2,
);
- const columns = useMemo(() => {
- const applicableCustomColumns = customColumns?.filter(
- (column) =>
- typeof filterValues.linkTypes === "object" &&
- filterValues.linkTypes.has(column.appliesToEntityTypeId),
- );
-
- return createColumns(applicableCustomColumns ?? []);
- }, [filterValues, customColumns]);
-
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 c46dbee8ac4..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
@@ -1,15 +1,23 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
-import { Paper, Stack } from "@mui/material";
-import { useCallback, useState } from "react";
+import { Box, CircularProgress, Paper, Stack } from "@mui/material";
+import { useCallback, useEffect, useMemo, useState } from "react";
-import { getOutgoingLinkAndTargetEntities } from "@blockprotocol/graph/stdlib";
-import { Chip, FontAwesomeIcon, IconButton } from "@hashintel/design-system";
+import {
+ getOutgoingLinkAndTargetEntities,
+ getOutgoingLinksForEntity,
+ getRightEntityForLinkEntity,
+} from "@blockprotocol/graph/stdlib";
+import {
+ Callout,
+ Chip,
+ FontAwesomeIcon,
+ IconButton,
+} from "@hashintel/design-system";
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";
@@ -17,35 +25,218 @@ 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 { useLinkTypeFilter } from "./use-link-type-filter";
import type { SortGridRows } from "../../../../../components/grid/grid";
+import type { VirtualizedTableSort } from "../../../virtualized-table/header/sort";
+import type { EntityEditorProps } from "../../entity-editor";
+import type { OutgoingLinksFieldId } from "./outgoing-links-section/readonly-outgoing-links-table";
import type {
LinkColumn,
LinkColumnKey,
LinkRow,
} from "./outgoing-links-section/types";
-import type { Entity } from "@blockprotocol/type-system";
+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";
-interface OutgoingLinksSectionPropsProps {
+type OutgoingLinksSectionProps = Pick<
+ EntityEditorProps,
+ | "closedMultiEntityType"
+ | "closedMultiEntityTypesDefinitions"
+ | "customEntityLinksColumns"
+ | "defaultOutgoingLinkFilters"
+ | "draftLinksToArchive"
+ | "draftLinksToCreate"
+ | "entitySubgraph"
+ | "hasRootLinkDataBeenResolved"
+ | "linkAndDestinationEntitiesClosedMultiEntityTypesMap"
+ | "onEntityClick"
+ | "onTypeClick"
+ | "setDraftLinksToArchive"
+ | "setDraftLinksToCreate"
+ | "slideContainerRef"
+ | "readonly"
+> & {
+ entity: HashEntity;
isLinkEntity: boolean;
- outgoingLinks: Entity[];
-}
+};
export const OutgoingLinksSection = ({
+ closedMultiEntityType,
+ closedMultiEntityTypesDefinitions: editorDefinitions,
+ customEntityLinksColumns,
+ defaultOutgoingLinkFilters,
+ draftLinksToArchive,
+ draftLinksToCreate,
+ entity,
+ entitySubgraph: editorSubgraph,
isLinkEntity,
- outgoingLinks,
-}: OutgoingLinksSectionPropsProps) => {
+ hasRootLinkDataBeenResolved,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap: editorTypesMap,
+ onEntityClick,
+ onTypeClick,
+ setDraftLinksToArchive,
+ setDraftLinksToCreate,
+ readonly,
+ slideContainerRef,
+}: OutgoingLinksSectionProps) => {
const [showSearch, setShowSearch] = useState(false);
- const { entitySubgraph, entity, readonly } = useEntityEditor();
+ const [sort, setSort] = useState>({
+ fieldId: "linkTypes",
+ direction: "asc",
+ });
+
+ const sortingPaths = useMemo(() => {
+ if (!readonly || sort.fieldId !== "linkTypes") {
+ return undefined;
+ }
+
+ return [
+ {
+ path: ["typeTitle"],
+ ordering: sort.direction === "asc" ? "ascending" : "descending",
+ nulls: "last",
+ },
+ ];
+ }, [readonly, sort.fieldId, sort.direction]);
- const rows = useRows();
- const createGetCellContent = useCreateGetCellContent();
+ /**
+ * 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],
+ );
+
+ const {
+ captureLinkTypeOptions,
+ filterDefinitions,
+ filterValues,
+ setFilterValues,
+ filterTypeIds,
+ } = useLinkTypeFilter({ defaultSelectedLinkTypeIds });
+
+ /**
+ * 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 {
+ initialLoading,
+ loadingMore,
+ loadMore,
+ hasMore,
+ count: totalCount,
+ error,
+ linkEntities,
+ subgraph: fetchedSubgraph,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap: fetchedTypesMap,
+ closedMultiEntityTypesDefinitions: fetchedDefinitions,
+ typeIds,
+ typeTitles,
+ } = useEntityLinks({
+ direction: "outgoing",
+ entityId: entity.metadata.recordId.entityId,
+ filterTypeIds,
+ skip: !readonly,
+ sortingPaths,
+ });
+
+ useEffect(() => {
+ 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
+ * 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 (readonly) {
+ 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
+ rightEntity = [];
+ }
+
+ return { linkEntity: [linkEntity], rightEntity };
+ })
+ .filter(
+ // Drop links whose source entity is missing, mirroring the guard the editor path applies
+ (outgoingLinkAndTarget) => !!outgoingLinkAndTarget.rightEntity[0],
+ );
+ }
+
+ return getOutgoingLinkAndTargetEntities(
+ editorSubgraph,
+ entity.metadata.recordId.entityId,
+ entity.metadata.temporalVersioning[
+ editorSubgraph.temporalAxes.resolved.variable.axis
+ ],
+ );
+ }, [readonly, linkEntities, fetchedSubgraph, editorSubgraph, entity]);
+
+ 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
- >((unsortedRows, sort) => {
- const { columnKey, direction } = sort;
+ >((unsortedRows, gridSort) => {
+ const { columnKey, direction } = gridSort;
return unsortedRows.toSorted((a, b) => {
let firstString = "";
@@ -68,24 +259,69 @@ export const OutgoingLinksSection = ({
});
}, []);
- if (outgoingLinks.length === 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.
- * If they happen to have ended up with some via a different client / process, we show them.
- */
- return null;
+ if (readonly && error) {
+ return (
+
+
+ Could not load outgoing links. Please try again later.
+
+
+ );
+ }
+
+ if (
+ (!readonly && !hasRootLinkDataBeenResolved) ||
+ (readonly &&
+ (initialLoading ||
+ !linkEntities ||
+ !fetchedSubgraph ||
+ !fetchedDefinitions))
+ ) {
+ return (
+
+
+
+
+
+ );
}
- const outgoingLinksAndTargets = readonly
- ? getOutgoingLinkAndTargetEntities(
+ const entitySubgraph = readonly ? fetchedSubgraph! : editorSubgraph;
+ const closedMultiEntityTypesMap = readonly
+ ? (fetchedTypesMap ?? null)
+ : editorTypesMap;
+ const closedMultiEntityTypesDefinitions = readonly
+ ? fetchedDefinitions!
+ : editorDefinitions;
+
+ /**
+ * 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 = readonly
+ ? null
+ : getOutgoingLinksForEntity(
entitySubgraph,
entity.metadata.recordId.entityId,
entity.metadata.temporalVersioning[
entitySubgraph.temporalAxes.resolved.variable.axis
],
- )
- : null;
+ ).filter(
+ (outgoingLink) => !draftLinksToArchive.includes(outgoingLink.entityId),
+ );
+
+ const linkCount = readonly
+ ? (totalCount ?? linkEntities!.length)
+ : outgoingLinks!.length;
+
+ 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.
+ * If they happen to have ended up with some via a different client / process, we show them.
+ */
+ return null;
+ }
return (
{!!rows.length && (
@@ -111,28 +347,53 @@ export const OutgoingLinksSection = ({
}
>
- {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 ? (
+
) : (
)}
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 ea1e6e0d73b..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
@@ -1,12 +1,5 @@
import { Box, Stack, TableCell, Typography } from "@mui/material";
-import {
- memo,
- useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { EntityOrTypeIcon } from "@hashintel/design-system";
import { typedEntries } from "@local/advanced-types/typed-entries";
@@ -24,9 +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 { useEntityEditor } from "../../entity-editor-context";
import { PropertiesTooltip } from "../shared/properties-tooltip";
import {
linksTableCellSx,
@@ -40,24 +30,39 @@ 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 { LinkEntityAndRightEntity } from "@blockprotocol/graph";
+import type {
+ LinkTypeFilterDefinitions,
+ LinkTypeFilterValues,
+} from "../use-link-type-filter";
+import type {
+ EntityRootType,
+ LinkEntityAndRightEntity,
+ Subgraph,
+} from "@blockprotocol/graph";
import type {
Entity,
EntityId,
PartialEntityType,
VersionedUrl,
} from "@blockprotocol/type-system";
-import type { ReactElement } from "react";
+import type { HashEntity } from "@local/hash-graph-sdk/entity";
+import type {
+ ClosedMultiEntityTypesDefinitions,
+ ClosedMultiEntityTypesRootMap,
+} from "@local/hash-graph-sdk/ontology";
+import type { ReactElement, RefObject } from "react";
+import type { ListRange } from "react-virtuoso";
+
+export type OutgoingLinksFieldId =
+ | "linkTypes"
+ | "linkedTo"
+ | "linkedToTypes"
+ | "link";
-type OutgoingLinksFieldId = "linkTypes" | "linkedTo" | "linkedToTypes" | "link";
+const serverSortableFieldIds: OutgoingLinksFieldId[] = ["linkTypes"];
export type OutgoingLinksFilterValues =
VirtualizedTableFilterValuesByFieldId;
@@ -122,6 +127,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 }) => {
@@ -162,6 +168,7 @@ const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => {
@@ -209,6 +216,7 @@ const TableRow = memo(({ row }: { row: OutgoingLinkRow }) => {
@@ -239,77 +247,57 @@ const createRowContent: CreateVirtualizedRowContentFn<
> = (_index, row) => ;
type OutgoingLinksTableProps = {
+ closedMultiEntityTypesDefinitions: ClosedMultiEntityTypesDefinitions;
+ closedMultiEntityTypesMap: ClosedMultiEntityTypesRootMap | null;
+ customEntityLinksColumns?: CustomEntityLinksColumn[];
+ defaultOutgoingLinkFilters?: Partial;
+ entitySubgraph: Subgraph>;
+ filterDefinitions?: LinkTypeFilterDefinitions;
+ filterValues?: LinkTypeFilterValues;
+ setFilterValues?: (filterValues: LinkTypeFilterValues) => void;
+ loadingMore?: boolean;
+ onEndReached?: () => void;
+ onEntityClick: (entityId: EntityId) => void;
+ onTypeClick: (kind: "dataType" | "entityType", itemId: VersionedUrl) => void;
outgoingLinksAndTargets: LinkEntityAndRightEntity[];
+ sort: VirtualizedTableSort;
+ setSort: (sort: VirtualizedTableSort) => void;
+ slideContainerRef?: RefObject;
};
export const OutgoingLinksTable = memo(
- ({ outgoingLinksAndTargets }: OutgoingLinksTableProps) => {
- const [sort, setSort] = useState<
- VirtualizedTableSort
- >({
- fieldId: "linkedTo",
- direction: "asc",
- });
-
- const {
- closedMultiEntityTypesDefinitions,
- linkAndDestinationEntitiesClosedMultiEntityTypesMap:
- closedMultiEntityTypesMap,
- entitySubgraph,
- customEntityLinksColumns: customColumns,
- defaultOutgoingLinkFilters,
- onEntityClick,
- onTypeClick,
- } = useEntityEditor();
-
- const outputContainerRef = useRef(null);
- const [outputContainerHeight, setOutputContainerHeight] = useState(400);
- useLayoutEffect(() => {
- if (
- outputContainerRef.current &&
- outputContainerRef.current.clientHeight !== outputContainerHeight
- ) {
- setOutputContainerHeight(outputContainerRef.current.clientHeight);
- }
- }, [outputContainerHeight]);
-
+ ({
+ closedMultiEntityTypesDefinitions,
+ closedMultiEntityTypesMap,
+ customEntityLinksColumns: customColumns,
+ defaultOutgoingLinkFilters,
+ entitySubgraph,
+ filterDefinitions,
+ filterValues,
+ setFilterValues,
+ loadingMore,
+ onEndReached,
+ onEntityClick,
+ onTypeClick,
+ outgoingLinksAndTargets,
+ sort,
+ setSort,
+ slideContainerRef,
+ }: OutgoingLinksTableProps) => {
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,
@@ -350,16 +338,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];
@@ -380,39 +359,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(
@@ -462,6 +408,7 @@ export const OutgoingLinksTable = memo(
linkEntityProperties,
onEntityClick,
onTypeClick,
+ slideContainerRef,
targetEntity: rightEntity,
targetEntityLabel: rightEntityLabel,
targetEntityProperties,
@@ -480,17 +427,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,
@@ -500,6 +438,7 @@ export const OutgoingLinksTable = memo(
outgoingLinksAndTargets,
onEntityClick,
onTypeClick,
+ slideContainerRef,
]);
const [highlightOutgoingLinks, setHighlightOutgoingLinks] = useState(
@@ -510,133 +449,57 @@ export const OutgoingLinksTable = memo(
setTimeout(() => setHighlightOutgoingLinks(false), 5_000);
}, []);
- const [filterValues, setFilterValues] = useVirtualizedTableFilterState({
- defaultFilterValues: {
- ...initialFilterValues,
- ...(defaultOutgoingLinkFilters ?? {}),
- },
- filterDefinitions,
- });
+ const columns = useMemo(() => {
+ const applicableCustomColumns = customColumns?.filter((column) =>
+ presentLinkEntityTypeIds.has(column.appliesToEntityTypeId),
+ );
- 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 createdColumns = createColumns(applicableCustomColumns ?? []);
+
+ return createdColumns.map((column) => ({
+ ...column,
+ sortable: serverSortableFieldIds.includes(column.id),
+ }));
+ }, [customColumns, presentLinkEntityTypeIds]);
+
+ /**
+ * 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(false);
+
+ const handleIsScrolling = useCallback((isScrolling: boolean) => {
+ if (isScrolling) {
+ canLoadMoreRef.current = true;
+ }
+ }, []);
- 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
- );
- }
- 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
- );
+ const handleRangeChange = useMemo(
+ () =>
+ onEndReached
+ ? ({ endIndex }: ListRange) => {
+ // Load the next page once the loaded rows scroll into view
+ if (
+ canLoadMoreRef.current &&
+ !loadingMore &&
+ endIndex >= rows.length - 1
+ ) {
+ canLoadMoreRef.current = false;
+ onEndReached();
}
}
- }),
- [filterValues, sort, unsortedRows],
+ : undefined,
+ [loadingMore, onEndReached, rows.length],
);
- const columns = useMemo(() => {
- const applicableCustomColumns = customColumns?.filter(
- (column) =>
- typeof filterValues.linkTypes === "object" &&
- filterValues.linkTypes.has(column.appliesToEntityTypeId),
- );
-
- return createColumns(applicableCustomColumns ?? []);
- }, [filterValues, customColumns]);
-
const height = Math.min(
maxLinksTableHeight,
rows.length * linksTableRowHeight + virtualizedTableHeaderHeight + 2,
@@ -663,6 +526,10 @@ export const OutgoingLinksTable = memo(
filterDefinitions={filterDefinitions}
filterValues={filterValues}
setFilterValues={setFilterValues}
+ followOutput={false}
+ loadingMore={loadingMore}
+ onIsScrolling={handleIsScrolling}
+ onRangeChange={handleRangeChange}
rows={rows}
sort={sort}
setSort={setSort}
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..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
@@ -10,28 +10,54 @@ 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,
- draftLinksToCreate,
- onEntityClick,
- } = useEntityEditor();
-
- const markLinkEntityToArchive = useMarkLinkEntityToArchive();
+export const useRows = ({
+ closedMultiEntityType,
+ closedMultiEntityTypesDefinitions,
+ draftLinksToArchive,
+ draftLinksToCreate,
+ entity,
+ entitySubgraph,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap,
+ onEntityClick,
+ readonly,
+ setDraftLinksToArchive,
+ setDraftLinksToCreate,
+}: UseRowsParams) => {
+ const markLinkEntityToArchive = useMemo(
+ () =>
+ createMarkLinkEntityToArchive({
+ draftLinksToCreate,
+ setDraftLinksToCreate,
+ setDraftLinksToArchive,
+ }),
+ [draftLinksToCreate, setDraftLinksToCreate, setDraftLinksToArchive],
+ );
const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired();
@@ -206,6 +232,10 @@ export const useRows = () => {
isList: linkSchema.maxItems === undefined || linkSchema.maxItems > 1,
expectedEntityTypes,
entitySubgraph,
+ entity,
+ readonly,
+ draftLinksToCreate,
+ setDraftLinksToCreate,
markLinkAsArchived: markLinkEntityToArchive,
onEntityClick,
retryErroredUpload,
@@ -217,8 +247,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 (
+ page === undefined ? initialPageKey : JSON.stringify(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.
+ */
+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 to the next page.
+ *
+ * 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 = <
+ T,
+ Page extends { cursor: unknown } = { cursor: string; nextCursor: string },
+>({
+ resetKey,
+ initial,
+}: {
+ /** Identifies the current search/filter. When it changes, the accumulation and current request are discarded; */
+ resetKey: string;
+ /** The empty accumulation that the first (and every) page is folded into. Must be referentially stable */
+ initial: T;
+}): {
+ /** 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: 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 [page, setPage] = useState(undefined);
+ const [{ accumulated, getNextPage }, setAccumulation] = useState<{
+ accumulated: T | undefined;
+ getNextPage: (() => Page) | false | undefined;
+ }>({ accumulated: undefined, getNextPage: undefined });
+
+ /**
+ * 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 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) {
+ 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 (accumulated !== undefined || getNextPage !== undefined) {
+ setAccumulation({ accumulated: undefined, getNextPage: undefined });
+ }
+ }
+
+ /**
+ * 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 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;
+ }
+
+ 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 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 &&
+ pageKeyOf(folded.getNextPage()) === pageKeyOf(requestRef.current)
+ ) {
+ return previous;
+ }
+
+ return {
+ accumulated: folded.accumulated,
+ getNextPage: folded.getNextPage,
+ };
+ });
+ },
+ [],
+ );
+
+ const getNextPageRef = useRef(getNextPage);
+ getNextPageRef.current = getNextPage;
+
+ const loadMore = useCallback(() => {
+ const next = getNextPageRef.current;
+ if (next === undefined || next === false) {
+ return;
+ }
+ setPage(next());
+ }, []);
+
+ const hasMore = getNextPage !== undefined && getNextPage !== false;
+
+ return {
+ page,
+ accumulated,
+ appendPage,
+ loadMore,
+ 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
new file mode 100644
index 00000000000..29ce99572ba
--- /dev/null
+++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-entity-links.ts
@@ -0,0 +1,408 @@
+import { type ApolloError, useQuery } from "@apollo/client";
+import { useMemo } from "react";
+
+import { getRoots } from "@blockprotocol/graph/stdlib";
+import {
+ type EntityId,
+ splitEntityId,
+ type VersionedUrl,
+} from "@blockprotocol/type-system";
+import {
+ deserializeQueryEntitySubgraphResponse,
+ HashLinkEntity,
+} from "@local/hash-graph-sdk/entity";
+import {
+ currentTimeInstantTemporalAxes,
+ ignoreNoisySystemTypesFilter,
+} from "@local/hash-isomorphic-utils/graph-queries";
+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 {
+ ClosedMultiEntityTypesDefinitions,
+ ClosedMultiEntityTypesRootMap,
+} from "@local/hash-graph-sdk/ontology";
+
+/** Links fetched per page for the readonly link tables. */
+export const linksTablePageSize = 100;
+
+type LinksSubgraph = Subgraph>;
+
+/**
+ * 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"],
+ ordering: "ascending",
+ nulls: "last",
+};
+
+/**
+ * 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>,
+ b: Record>,
+): Record> => {
+ const result: Record> = { ...a };
+ for (const [baseId, revisions] of Object.entries(b)) {
+ result[baseId] = { ...(result[baseId] ?? {}), ...revisions };
+ }
+ return result;
+};
+
+/** Merge a later page's subgraph into the accumulated one. */
+const mergeSubgraphInto = (
+ merged: LinksSubgraph,
+ subgraph: LinksSubgraph,
+): LinksSubgraph => {
+ const vertices = mergeRecordOfRecords(
+ merged.vertices as Record>,
+ subgraph.vertices as Record>,
+ ) as LinksSubgraph["vertices"];
+
+ const edges = mergeRecordOfRecords(
+ merged.edges as Record>,
+ subgraph.edges as Record>,
+ ) as LinksSubgraph["edges"];
+
+ return { ...merged, vertices, edges };
+};
+
+const mergeDefinitionsInto = (
+ merged: ClosedMultiEntityTypesDefinitions | undefined,
+ definitions?: ClosedMultiEntityTypesDefinitions,
+): ClosedMultiEntityTypesDefinitions | undefined => {
+ if (!definitions) {
+ return merged;
+ }
+ if (!merged) {
+ return definitions;
+ }
+ return {
+ dataTypes: { ...merged.dataTypes, ...definitions.dataTypes },
+ entityTypes: { ...merged.entityTypes, ...definitions.entityTypes },
+ propertyTypes: {
+ ...merged.propertyTypes,
+ ...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;
+ /** `undefined` until the first page supplies the subgraph to merge into. */
+ subgraph: LinksSubgraph | undefined;
+ typesMap: ClosedMultiEntityTypesRootMap;
+ definitions?: ClosedMultiEntityTypesDefinitions;
+};
+
+/** The empty accumulation; the first page supplies the subgraph to merge into. */
+const initialAccumulated: Accumulated = {
+ linkEntities: [],
+ seenLinkIds: new Set(),
+ subgraph: undefined,
+ typesMap: {},
+ definitions: undefined,
+};
+
+/**
+ * Fetches an entity's incoming or outgoing links a page at a time, for the
+ * readonly link tables.
+ *
+ * Only used for readonly link data; when editable, links come from the editor
+ * subgraph and are not paginated.
+ */
+export const useEntityLinks = ({
+ direction,
+ entityId,
+ filterTypeIds,
+ skip = false,
+ sortingPaths,
+}: {
+ direction: "outgoing" | "incoming";
+ entityId: EntityId;
+ // 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 to keep pagination stable.
+ sortingPaths?: EntityQuerySortingRecord[];
+}): {
+ /** 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). */
+ 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;
+ /** 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);
+
+ 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
+ * links, right/target for incoming.
+ */
+ 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
+ >(queryEntitySubgraphQuery, {
+ fetchPolicy: "cache-and-network",
+ skip,
+ variables: {
+ request: {
+ filter,
+ cursor: page?.cursor,
+ limit: linksTablePageSize,
+ sortingPaths: [...(sortingPaths ?? []), uuidSortingPath],
+ temporalAxes: currentTimeInstantTemporalAxes,
+ 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 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,
+ ),
+ },
+ getNextPage:
+ nextCursor === null ? false : () => ({ cursor: nextCursor }),
+ };
+ });
+ },
+ });
+
+ /**
+ * 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: countData?.summarizeEntities.count ?? undefined,
+ linkEntities: accumulated?.linkEntities,
+ subgraph: accumulated?.subgraph,
+ linkAndDestinationEntitiesClosedMultiEntityTypesMap: accumulated?.typesMap,
+ closedMultiEntityTypesDefinitions: accumulated?.definitions,
+ typeIds: typesData?.summarizeEntities.typeIds ?? undefined,
+ typeTitles: typesData?.summarizeEntities.typeTitles ?? undefined,
+ };
+};
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..5dadd6dcf78
--- /dev/null
+++ b/apps/hash-frontend/src/pages/shared/entity/entity-editor/links-section/use-link-type-filter.ts
@@ -0,0 +1,145 @@
+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 one column the link tables filter on: the link entity's own type. */
+type LinkTypeFilterFieldId = "linkTypes";
+
+export type LinkTypeFilterDefinitions =
+ VirtualizedTableFilterDefinitionsByFieldId;
+
+export type LinkTypeFilterValues =
+ VirtualizedTableFilterValuesByFieldId;
+
+/**
+ * Derives the link-type filter shared by the incoming and outgoing link tables,
+ * for both the readonly (server-paginated) and editable (client-side) cases.
+ *
+ * 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,
+}: {
+ defaultSelectedLinkTypeIds?: Set;
+} = {}) => {
+ const [capturedOptions, setCapturedOptions] = useState<{
+ typeIds: Record;
+ typeTitles: Record;
+ } | null>(null);
+
+ /**
+ * 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(
+ (
+ typeIds?: Record,
+ typeTitles?: Record,
+ ) => {
+ if (!typeIds || !typeTitles || Object.keys(typeIds).length === 0) {
+ return;
+ }
+
+ setCapturedOptions((existing) => existing ?? { typeIds, typeTitles });
+ },
+ [],
+ );
+
+ 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;
+ }
+
+ if (defaultSelectedLinkTypeIds) {
+ // 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),
+ ),
+ );
+
+ if (selected.size > 0) {
+ return { linkTypes: selected };
+ }
+ }
+
+ return { linkTypes: optionData.allTypeIds };
+ }, [optionData, defaultSelectedLinkTypeIds]);
+
+ 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 filter while every option is selected (the default); only a
+ // deselection constrains 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,
+ };
+};
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..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
@@ -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,8 @@ export const ArrayEditor: ValueCellEditorComponent = ({
value,
];
- draftCell.data.propertyRow.valueMetadata = propertyMetadata;
+ (draftCell.data.propertyRow as PropertyRow).valueMetadata =
+ propertyMetadata;
});
onChange(newCell);
@@ -198,7 +200,8 @@ export const ArrayEditor: ValueCellEditorComponent = ({
.filter((_, index) => indexToRemove !== index)
.map(({ value }) => value);
- draftCell.data.propertyRow.valueMetadata = propertyMetadata;
+ (draftCell.data.propertyRow as PropertyRow).valueMetadata =
+ propertyMetadata;
});
onChange(newCell);
@@ -237,7 +240,7 @@ export const ArrayEditor: ValueCellEditorComponent = ({
const newMetadata = arrayMove(valueMetadata.value, oldIndex, newIndex);
- draftCell.data.propertyRow.valueMetadata = {
+ (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 71f63755d20..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
@@ -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,8 @@ export const SingleValueEditor: ValueCellEditorComponent = (props) => {
});
const newCell = produce(cell, (draftCell) => {
- draftCell.data.propertyRow.valueMetadata = propertyMetadata;
+ (draftCell.data.propertyRow as PropertyRow).valueMetadata =
+ propertyMetadata;
});
onChange(newCell);
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..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
@@ -1,12 +1,24 @@
-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.
+ */
+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 +34,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..4406120e358 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,11 @@ type VirtualizedTableProps<
columns?: VirtualizedTableColumn[];
fixedColumns?: number;
EmptyPlaceholder?: () => ReactElement;
+ onEndReached?: () => void;
+ onRangeChange?: (range: ListRange) => void;
+ onIsScrolling?: (isScrolling: boolean) => void;
+ followOutput?: FollowOutput;
+ loadingMore?: boolean;
rows: VirtualizedTableRow[];
} & TableSortProps &
Partial>;
@@ -147,6 +152,11 @@ export const VirtualizedTable = <
columns,
fixedColumns,
EmptyPlaceholder,
+ onEndReached,
+ onRangeChange,
+ onIsScrolling,
+ followOutput = "smooth",
+ loadingMore,
rows,
filterDefinitions,
filterValues,
@@ -188,17 +198,42 @@ export const VirtualizedTable = <
const context = useMemo(() => ({ columns: columns ?? [] }), [columns]);
+ const fixedFooterContent = useMemo(() => {
+ if (!loadingMore) {
+ return undefined;
+ }
+
+ return () => (
+
+ |
+
+ |
+
+ );
+ }, [columns?.length, loadingMore]);
+
return (
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,