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,