From 6ef7125e3e8cdbc2f0478654040705808aee7fc9 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan <37743469+CiaranMn@users.noreply.github.com> Date: Tue, 12 Nov 2024 13:56:24 +0000 Subject: [PATCH] H-3504, H-3561: Entities visualizer performance and fixes (#5633) --- .../src/components/grid/grid.tsx | 35 +- .../hash-frontend/src/pages/entities.page.tsx | 6 +- .../src/pages/shared/entities-table.tsx | 626 ++++-------------- .../entities-table/use-entities-table.tsx | 613 ++++++----------- .../use-entities-table/types.ts | 131 ++++ .../use-entities-table/worker.ts | 444 +++++++++++++ .../src/pages/shared/entities-visualizer.tsx | 453 +++++++++++++ .../pages/shared/entity-graph-visualizer.tsx | 4 +- .../shared/entity-type-page/entities-tab.tsx | 4 +- .../graph-visualizer/graph-container.tsx | 4 +- .../graph-container/path-finder-control.tsx | 182 +++-- .../path-finder-control/worker.ts | 35 +- .../graph-container/search-control.tsx | 30 +- .../graph-container/shared/config-control.tsx | 3 +- .../shared/control-components.tsx | 5 +- .../graph-container/shared/filter-control.tsx | 36 +- .../shared/simple-autocomplete.tsx | 6 +- .../shared/use-set-draw-settings.ts | 8 +- .../{table-views.tsx => visualizer-views.tsx} | 9 +- .../[[...type-kind]].page/types-table.tsx | 151 ++--- .../src/pages/use-what-changed.ts | 28 + .../hash-frontend/src/shared/table-content.ts | 12 + .../hash-frontend/src/shared/table-header.tsx | 21 +- .../src/shared/use-user-or-org.ts | 2 +- .../src/create-apollo-client.ts | 3 + .../src/generate-entity-label.ts | 6 +- package.json | 6 +- 27 files changed, 1788 insertions(+), 1075 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/types.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer.tsx rename apps/hash-frontend/src/pages/shared/{table-views.tsx => visualizer-views.tsx} (72%) create mode 100644 apps/hash-frontend/src/pages/use-what-changed.ts create mode 100644 apps/hash-frontend/src/shared/table-content.ts diff --git a/apps/hash-frontend/src/components/grid/grid.tsx b/apps/hash-frontend/src/components/grid/grid.tsx index 89609c118de..b6c60997548 100644 --- a/apps/hash-frontend/src/components/grid/grid.tsx +++ b/apps/hash-frontend/src/components/grid/grid.tsx @@ -125,10 +125,11 @@ export const Grid = ({ }, []); const [sorts, setSorts] = useState>[]>( - columns.map((column) => ({ - columnKey: column.id as Extract, - direction: "asc", - })), + () => + columns.map((column) => ({ + columnKey: column.id as Extract, + direction: "asc", + })), ); const [previousSortedColumnKey, setPreviousSortedColumnKey] = useState< @@ -138,6 +139,32 @@ export const Grid = ({ string | undefined >(initialSortedColumnKey ?? columns[0]?.id); + useEffect(() => { + const currentSortColumns = new Set(sorts.map((sort) => sort.columnKey)); + const newSortColumns = new Set(columns.map((column) => column.id)); + + if ( + initialSortedColumnKey && + (!currentSortedColumnKey || !newSortColumns.has(initialSortedColumnKey)) + ) { + setCurrentSortedColumnKey(initialSortedColumnKey); + } + + if ( + currentSortColumns.size === newSortColumns.size && + currentSortColumns.isSubsetOf(newSortColumns) + ) { + return; + } + + setSorts( + columns.map((column) => ({ + columnKey: column.id as Extract, + direction: "asc", + })), + ); + }, [columns, currentSortedColumnKey, initialSortedColumnKey, sorts]); + const [openFilterColumnKey, setOpenFilterColumnKey] = useState(); const handleSortClick = useCallback( diff --git a/apps/hash-frontend/src/pages/entities.page.tsx b/apps/hash-frontend/src/pages/entities.page.tsx index 7f7c2219cf3..113539e02ab 100644 --- a/apps/hash-frontend/src/pages/entities.page.tsx +++ b/apps/hash-frontend/src/pages/entities.page.tsx @@ -47,7 +47,7 @@ import { Tabs } from "../shared/ui/tabs"; import { useUserPermissionsOnEntityType } from "../shared/use-user-permissions-on-entity-type"; import type { Breadcrumb } from "./shared/breadcrumbs"; import { CreateButton } from "./shared/create-button"; -import { EntitiesTable } from "./shared/entities-table"; +import { EntitiesVisualizer } from "./shared/entities-visualizer"; import { TopContextBar } from "./shared/top-context-bar"; import { useEnabledFeatureFlags } from "./shared/use-enabled-feature-flags"; import { useActiveWorkspace } from "./shared/workspace-context"; @@ -366,8 +366,8 @@ const EntitiesPage: NextPageWithLayout = () => { - diff --git a/apps/hash-frontend/src/pages/shared/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-table.tsx index f5bf4de2381..ba540a14c68 100644 --- a/apps/hash-frontend/src/pages/shared/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-table.tsx @@ -1,8 +1,13 @@ -import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import type { + EntityType, + PropertyType, + VersionedUrl, +} from "@blockprotocol/type-system/slim"; import type { CustomCell, Item, NumberCell, + SizedGridColumn, TextCell, } from "@glideapps/glide-data-grid"; import { GridCellKind } from "@glideapps/glide-data-grid"; @@ -10,21 +15,18 @@ import type { Entity } from "@local/hash-graph-sdk/entity"; import type { EntityId } from "@local/hash-graph-types/entity"; import type { BaseUrl } from "@local/hash-graph-types/ontology"; import { gridRowHeight } from "@local/hash-isomorphic-utils/data-grid"; -import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { includesPageEntityTypeId } from "@local/hash-isomorphic-utils/page-entity-type-ids"; -import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; -import type { PageProperties } from "@local/hash-isomorphic-utils/system-types/shared"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; -import { - extractEntityUuidFromEntityId, - extractOwnedByIdFromEntityId, -} from "@local/hash-subgraph"; -import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; -import { Box, useTheme } from "@mui/material"; +import { extractEntityUuidFromEntityId } from "@local/hash-subgraph"; +import { Box, Stack, useTheme } from "@mui/material"; import { useRouter } from "next/router"; -import type { FunctionComponent, ReactElement, RefObject } from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + FunctionComponent, + MutableRefObject, + ReactElement, + RefObject, +} from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { GridProps } from "../../components/grid/grid"; import { @@ -36,255 +38,94 @@ import type { BlankCell } from "../../components/grid/utils"; import { blankCell } from "../../components/grid/utils"; import type { CustomIcon } from "../../components/grid/utils/custom-grid-icons"; import type { ColumnFilter } from "../../components/grid/utils/filtering"; -import { useEntityTypeEntitiesContext } from "../../shared/entity-type-entities-context"; -import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; import { HEADER_HEIGHT } from "../../shared/layout/layout-with-header/page-header"; +import { tableContentSx } from "../../shared/table-content"; import type { FilterState } from "../../shared/table-header"; -import { TableHeader, tableHeaderHeight } from "../../shared/table-header"; -import type { MinimalActor } from "../../shared/use-actors"; +import { tableHeaderHeight } from "../../shared/table-header"; import { isAiMachineActor } from "../../shared/use-actors"; -import { useEntityTypeEntities } from "../../shared/use-entity-type-entities"; -import type { - CustomColumn, - EntityEditorProps, -} from "../[shortname]/entities/[entity-uuid].page/entity-editor"; -import { useAuthenticatedUser } from "./auth-info-context"; import type { ChipCellProps } from "./chip-cell"; import { createRenderChipCell } from "./chip-cell"; -import { GridView } from "./entities-table/grid-view"; import type { TextIconCell } from "./entities-table/text-icon-cell"; import { createRenderTextIconCell } from "./entities-table/text-icon-cell"; -import type { TypeEntitiesRow } from "./entities-table/use-entities-table"; import { useEntitiesTable } from "./entities-table/use-entities-table"; -import { EntityEditorSlideStack } from "./entity-editor-slide-stack"; -import { EntityGraphVisualizer } from "./entity-graph-visualizer"; -import { TypeSlideOverStack } from "./entity-type-page/type-slide-over-stack"; -import type { - DynamicNodeSizing, - GraphVizConfig, - GraphVizFilters, -} from "./graph-visualizer"; -import { generateEntityRootedSubgraph } from "./subgraphs"; -import { TableHeaderToggle } from "./table-header-toggle"; -import type { TableView } from "./table-views"; -import { tableViewIcons } from "./table-views"; +import type { TypeEntitiesRow } from "./entities-table/use-entities-table/types"; import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; import type { UrlCellProps } from "./url-cell"; import { createRenderUrlCell } from "./url-cell"; -/** - * @todo: avoid having to maintain this list, potentially by - * adding an `isFile` boolean to the generated ontology IDs file. - */ -const allFileEntityTypeOntologyIds = [ - systemEntityTypes.file, - systemEntityTypes.image, - systemEntityTypes.documentFile, - systemEntityTypes.docxDocument, - systemEntityTypes.pdfDocument, - systemEntityTypes.presentationFile, - systemEntityTypes.pptxPresentation, -]; - -const allFileEntityTypeIds = allFileEntityTypeOntologyIds.map( - ({ entityTypeId }) => entityTypeId, -) as VersionedUrl[]; - -const allFileEntityTypeBaseUrl = allFileEntityTypeOntologyIds.map( - ({ entityTypeBaseUrl }) => entityTypeBaseUrl, -); - const noneString = "none"; const firstColumnLeftPadding = 16; +const emptyTableData = { + columns: [], + rows: [], + filterData: { + createdByActors: [], + lastEditedByActors: [], + entityTypeTitles: {}, + noSourceCount: 0, + noTargetCount: 0, + sources: [], + targets: [], + webs: {}, + }, +}; + export const EntitiesTable: FunctionComponent<{ - customColumns?: CustomColumn[]; - defaultFilter?: FilterState; - defaultGraphConfig?: GraphVizConfig; - defaultGraphFilters?: GraphVizFilters; - defaultView?: TableView; - disableEntityOpenInNew?: boolean; + currentlyDisplayedColumnsRef: MutableRefObject; + currentlyDisplayedRowsRef: RefObject; disableTypeClick?: boolean; - /** - * If the user activates fullscreen, whether to fullscreen the whole page or a specific element, e.g. the graph only. - * Currently only used in the context of the graph visualizer, but the table could be usefully fullscreened as well. - */ - fullScreenMode?: "document" | "element"; - hideFilters?: boolean; + entities: Entity[]; + entityTypes: EntityType[]; + filterState: FilterState; + handleEntityClick: ( + entityId: EntityId, + modalContainerRef?: RefObject, + ) => void; + hasSomeLinks: boolean; hidePropertiesColumns?: boolean; hideColumns?: (keyof TypeEntitiesRow)[]; - loadingComponent?: ReactElement; + loading: boolean; + loadingComponent: ReactElement; + isViewingOnlyPages: boolean; maxHeight?: string | number; + propertyTypes: PropertyType[]; readonly?: boolean; + selectedRows: TypeEntitiesRow[]; + setSelectedRows: (rows: TypeEntitiesRow[]) => void; + setSelectedEntityType: (params: { entityTypeId: VersionedUrl }) => void; + setShowSearch: (showSearch: boolean) => void; + showSearch: boolean; + subgraph: Subgraph; }> = ({ - customColumns, - defaultFilter, - defaultGraphConfig, - defaultGraphFilters, - defaultView = "Table", - disableEntityOpenInNew, + currentlyDisplayedColumnsRef, + currentlyDisplayedRowsRef, disableTypeClick, - fullScreenMode, + entities, + entityTypes, + filterState, + hasSomeLinks, + handleEntityClick, hideColumns, - hideFilters, hidePropertiesColumns = false, + loading: entityDataLoading, loadingComponent, + isViewingOnlyPages, maxHeight, + propertyTypes, readonly, + selectedRows, + setSelectedRows, + showSearch, + setShowSearch, + setSelectedEntityType, + subgraph, }) => { const router = useRouter(); - const { authenticatedUser } = useAuthenticatedUser(); - - const [filterState, setFilterState] = useState( - defaultFilter ?? { - includeGlobal: false, - limitToWebs: false, - }, - ); - const [showSearch, setShowSearch] = useState(false); - - const [selectedEntityType, setSelectedEntityType] = useState<{ - entityTypeId: VersionedUrl; - slideContainerRef?: RefObject; - } | null>(null); - - const { - entityTypeBaseUrl, - entityTypeId, - entities: lastLoadedEntities, - entityTypes, - hadCachedContent, - loading, - propertyTypes, - subgraph: subgraphPossiblyWithoutLinks, - } = useEntityTypeEntitiesContext(); - - const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); - - const isDisplayingFilesOnly = useMemo( - () => - /** - * To allow the `Grid` view to come into view on first render where - * possible, we check whether `entityTypeId` or `entityTypeBaseUrl` - * matches a `File` entity type from a statically defined list. - */ - (entityTypeId && allFileEntityTypeIds.includes(entityTypeId)) || - (entityTypeBaseUrl && - allFileEntityTypeBaseUrl.includes(entityTypeBaseUrl)) || - /** - * Otherwise we check the fetched `entityTypes` as a fallback. - */ - (entityTypes?.length && - entityTypes.every( - ({ $id }) => isSpecialEntityTypeLookup?.[$id]?.isFile, - )), - [entityTypeBaseUrl, entityTypeId, entityTypes, isSpecialEntityTypeLookup], - ); - - const supportGridView = isDisplayingFilesOnly; - - const [view, setView] = useState( - isDisplayingFilesOnly ? "Grid" : defaultView, - ); - - useEffect(() => { - if (isDisplayingFilesOnly) { - setView("Grid"); - } else { - setView(defaultView); - } - }, [defaultView, isDisplayingFilesOnly]); - - const { subgraph: subgraphWithLinkedEntities } = useEntityTypeEntities({ - entityTypeBaseUrl, - entityTypeId, - graphResolveDepths: { - constrainsLinksOn: { outgoing: 255 }, - constrainsLinkDestinationsOn: { outgoing: 255 }, - constrainsPropertiesOn: { outgoing: 255 }, - constrainsValuesOn: { outgoing: 255 }, - inheritsFrom: { outgoing: 255 }, - isOfType: { outgoing: 1 }, - hasLeftEntity: { outgoing: 1, incoming: 1 }, - hasRightEntity: { outgoing: 1, incoming: 1 }, - }, - }); - - /** - The subgraphWithLinkedEntities can take a long time to load with many entities. - If absent, we pass the subgraph without linked entities so that there is _some_ data to load into the slideover, - which will be missing links until they load in by specifically fetching selectedEntity.entityId - */ - const subgraph = subgraphWithLinkedEntities ?? subgraphPossiblyWithoutLinks; - - const entities = useMemo( - /** - * If a network request is in process and there is no cached content for the request, return undefined. - * There may be stale data in the context related to an earlier request with different variables. - */ - () => (loading && !hadCachedContent ? undefined : lastLoadedEntities), - [hadCachedContent, loading, lastLoadedEntities], - ); - - const { isViewingOnlyPages, hasSomeLinks } = useMemo(() => { - let isViewingPages = true; - let hasLinks = false; - for (const entity of entities ?? []) { - if (!includesPageEntityTypeId(entity.metadata.entityTypeIds)) { - isViewingPages = false; - } - if (entity.linkData) { - hasLinks = true; - } - - if (hasLinks && !isViewingPages) { - break; - } - } - return { isViewingOnlyPages: isViewingPages, hasSomeLinks: hasLinks }; - }, [entities]); - - useEffect(() => { - if (isViewingOnlyPages && filterState.includeArchived === undefined) { - setFilterState((prev) => ({ ...prev, includeArchived: false })); - } - }, [isViewingOnlyPages, filterState]); - - const internalWebIds = useMemo(() => { - return [ - authenticatedUser.accountId, - ...authenticatedUser.memberOf.map(({ org }) => org.accountGroupId), - ]; - }, [authenticatedUser]); - - const filteredEntities = useMemo( - () => - entities?.filter( - (entity) => - (filterState.includeGlobal - ? true - : internalWebIds.includes( - extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId), - )) && - (filterState.includeArchived === undefined || - filterState.includeArchived || - !includesPageEntityTypeId(entity.metadata.entityTypeIds) - ? true - : simplifyProperties(entity.properties as PageProperties) - .archived !== true) && - (filterState.limitToWebs - ? filterState.limitToWebs.includes( - extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId), - ) - : true), - ), - [entities, filterState, internalWebIds], - ); - - const { columns, rows } = useEntitiesTable({ - entities: filteredEntities, + const { tableData, loading: tableDataCalculating } = useEntitiesTable({ + entities, entityTypes, propertyTypes, subgraph, @@ -295,34 +136,23 @@ export const EntitiesTable: FunctionComponent<{ isViewingOnlyPages, }); - const [selectedRows, setSelectedRows] = useState([]); - - const [selectedEntity, setSelectedEntity] = useState<{ - entityId: EntityId; - options?: Pick; - slideContainerRef?: RefObject; - subgraph: Subgraph; - } | null>(null); - - const handleEntityClick = useCallback( - ( - entityId: EntityId, - modalContainerRef?: RefObject, - options?: Pick, - ) => { - if (subgraph) { - const entitySubgraph = generateEntityRootedSubgraph(entityId, subgraph); - - setSelectedEntity({ - options, - entityId, - slideContainerRef: modalContainerRef, - subgraph: entitySubgraph, - }); - } + const { + columns, + rows, + filterData: { + createdByActors, + lastEditedByActors, + entityTypeTitles, + noSourceCount, + noTargetCount, + sources, + targets, + webs, }, - [subgraph], - ); + } = tableData ?? emptyTableData; + + // eslint-disable-next-line no-param-reassign + currentlyDisplayedColumnsRef.current = columns; const theme = useTheme(); @@ -634,6 +464,7 @@ export const EntitiesTable: FunctionComponent<{ handleEntityClick, router, isViewingOnlyPages, + setSelectedEntityType, theme.palette, ], ); @@ -651,6 +482,21 @@ export const EntitiesTable: FunctionComponent<{ return difference; } + if (sort.columnKey === "entityTypes") { + const entityType1 = a.entityTypes + .map(({ title }) => title) + .sort() + .join(", "); + const entityType2 = b.entityTypes + .map(({ title }) => title) + .sort() + .join(", "); + return ( + entityType1.localeCompare(entityType2) * + (sort.direction === "asc" ? 1 : -1) + ); + } + const isActorSort = ["lastEditedBy", "createdBy"].includes( sort.columnKey, ); @@ -709,91 +555,6 @@ export const EntitiesTable: FunctionComponent<{ Set<"archived" | "not-archived"> >(new Set(["archived", "not-archived"])); - const { - createdByActors, - lastEditedByActors, - entityTypeTitles, - noSourceCount, - noTargetCount, - sources, - targets, - webs, - } = useMemo(() => { - const lastEditedBySet = new Set(); - const createdBySet = new Set(); - const entityTypeTitleCount: { - [entityTypeTitle: string]: number | undefined; - } = {}; - - let noSource = 0; - let noTarget = 0; - - const sourcesByEntityId: { - [entityId: string]: { - count: number; - entityId: string; - label: string; - }; - } = {}; - const targetsByEntityId: { - [entityId: string]: { - count: number; - entityId: string; - label: string; - }; - } = {}; - - const webCountById: { [web: string]: number } = {}; - for (const row of rows ?? []) { - if (row.lastEditedBy && row.lastEditedBy !== "loading") { - lastEditedBySet.add(row.lastEditedBy); - } - if (row.createdBy && row.createdBy !== "loading") { - createdBySet.add(row.createdBy); - } - - if (row.sourceEntity) { - sourcesByEntityId[row.sourceEntity.entityId] ??= { - count: 0, - entityId: row.sourceEntity.entityId, - label: row.sourceEntity.label, - }; - sourcesByEntityId[row.sourceEntity.entityId]!.count++; - } else { - noSource++; - } - - if (row.targetEntity) { - targetsByEntityId[row.targetEntity.entityId] ??= { - count: 0, - entityId: row.targetEntity.entityId, - label: row.targetEntity.label, - }; - targetsByEntityId[row.targetEntity.entityId]!.count++; - } else { - noTarget++; - } - - for (const entityType of row.entityTypes) { - entityTypeTitleCount[entityType.title] ??= 0; - entityTypeTitleCount[entityType.title]!++; - } - - webCountById[row.web] ??= 0; - webCountById[row.web]!++; - } - return { - lastEditedByActors: [...lastEditedBySet], - createdByActors: [...createdBySet], - entityTypeTitles: entityTypeTitleCount, - webs: webCountById, - noSourceCount: noSource, - noTargetCount: noTarget, - sources: Object.values(sourcesByEntityId), - targets: Object.values(targetsByEntityId), - }; - }, [rows]); - const [selectedEntityTypeTitles, setSelectedEntityTypeTitles] = useState< Set >(new Set(Object.keys(entityTypeTitles))); @@ -1022,158 +783,61 @@ export const EntitiesTable: FunctionComponent<{ ], ); - const currentlyDisplayedRowsRef = useRef(null); - const maximumTableHeight = maxHeight ?? `calc(100vh - (${ HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 185 + tableHeaderHeight }px + ${theme.spacing(5)} + ${theme.spacing(5)}))`; - const isPrimaryEntity = useCallback( - (entity: { metadata: Pick }) => - entityTypeBaseUrl - ? entity.metadata.entityTypeIds.some( - (typeId) => extractBaseUrl(typeId) === entityTypeBaseUrl, - ) - : entityTypeId - ? entity.metadata.entityTypeIds.includes(entityTypeId) - : false, - [entityTypeId, entityTypeBaseUrl], - ); + if (entityDataLoading || tableDataCalculating) { + return ( + + {loadingComponent} + + ); + } return ( - <> - {selectedEntityType && ( - setSelectedEntityType(null)} - slideContainerRef={selectedEntityType.slideContainerRef} - /> - )} - {selectedEntity ? ( - setSelectedEntity(null)} - onSubmit={() => { - throw new Error(`Editing not yet supported from this screen`); - }} - readonly - /* - If we've been given a specific DOM element to contain the modal, pass it here. - This is for use when attaching to the body is not suitable (e.g. a specific DOM element is full-screened). - */ - slideContainerRef={selectedEntity.slideContainerRef} - /> - ) : null} - - - selectedRows.some( - ({ entityId }) => - entity.metadata.recordId.entityId === entityId, - ), - ) ?? [] - } - title="Entities" - columns={columns} - currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} - // getAdditionalCsvData={getEntitiesTableAdditionalCsvData} - hideExportToCsv={view !== "Table"} - endAdornment={ - ({ - icon: tableViewIcons[optionValue], - label: `${optionValue} view`, - value: optionValue, - }))} - /> - } - filterState={filterState} - setFilterState={setFilterState} - toggleSearch={ - view === "Table" ? () => setShowSearch(true) : undefined - } - onBulkActionCompleted={() => setSelectedRows([])} - /> - {!subgraph ? null : view === "Graph" ? ( - - - - ) : view === "Grid" ? ( - - ) : ( - setShowSearch(false)} - columns={columns} - columnFilters={columnFilters} - dataLoading={loading} - rows={rows} - enableCheckboxSelection={!readonly} - selectedRows={selectedRows} - onSelectedRowsChange={(updatedSelectedRows) => - setSelectedRows(updatedSelectedRows) - } - sortRows={sortRows} - firstColumnLeftPadding={firstColumnLeftPadding} - height={` + setShowSearch(false)} + columns={columns} + columnFilters={columnFilters} + dataLoading={false} + rows={rows} + enableCheckboxSelection={!readonly} + selectedRows={selectedRows} + onSelectedRowsChange={(updatedSelectedRows) => + setSelectedRows(updatedSelectedRows) + } + sortRows={sortRows} + firstColumnLeftPadding={firstColumnLeftPadding} + height={` min( ${maximumTableHeight}, calc( ${gridHeaderHeightWithBorder}px + - (${rows?.length ? rows.length : 1} * ${gridRowHeight}px) + + (${rows.length ? rows.length : 1} * ${gridRowHeight}px) + ${gridHorizontalScrollbarHeight}px) )`} - createGetCellContent={createGetCellContent} - customRenderers={[ - createRenderTextIconCell({ firstColumnLeftPadding }), - createRenderUrlCell({ firstColumnLeftPadding }), - createRenderChipCell({ firstColumnLeftPadding }), - ]} - freezeColumns={1} - currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} - /> - )} - - + createGetCellContent={createGetCellContent} + customRenderers={[ + createRenderTextIconCell({ firstColumnLeftPadding }), + createRenderUrlCell({ firstColumnLeftPadding }), + createRenderChipCell({ firstColumnLeftPadding }), + ]} + freezeColumns={1} + currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} + /> ); }; diff --git a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table.tsx index bfa6d2ff1d1..d4bf09b77f1 100644 --- a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table.tsx @@ -3,132 +3,30 @@ import type { PropertyType, VersionedUrl, } from "@blockprotocol/type-system"; -import { extractVersion } from "@blockprotocol/type-system"; -import type { SizedGridColumn } from "@glideapps/glide-data-grid"; -import { typedEntries, typedKeys } from "@local/advanced-types/typed-entries"; import type { Entity } from "@local/hash-graph-sdk/entity"; -import type { EntityId } from "@local/hash-graph-types/entity"; -import type { - BaseUrl, - PropertyTypeWithMetadata, -} from "@local/hash-graph-types/ontology"; -import { - generateEntityLabel, - generateLinkEntityLabel, -} from "@local/hash-isomorphic-utils/generate-entity-label"; -import { includesPageEntityTypeId } from "@local/hash-isomorphic-utils/page-entity-type-ids"; -import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; -import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; -import type { PageProperties } from "@local/hash-isomorphic-utils/system-types/shared"; +import type { AccountId } from "@local/hash-graph-types/account"; +import type { PropertyTypeWithMetadata } from "@local/hash-graph-types/ontology"; +import type { OwnedById } from "@local/hash-graph-types/web"; +import { serializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; -import { linkEntityTypeUrl } from "@local/hash-subgraph"; +import { extractOwnedByIdFromEntityId } from "@local/hash-subgraph"; import { - getEntityRevision, getEntityTypeById, getPropertyTypesForEntityType, } from "@local/hash-subgraph/stdlib"; import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; -import { format } from "date-fns"; -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { gridHeaderBaseFont } from "../../../components/grid/grid"; import { useGetOwnerForEntity } from "../../../components/hooks/use-get-owner-for-entity"; import type { MinimalActor } from "../../../shared/use-actors"; import { useActors } from "../../../shared/use-actors"; - -const columnDefinitionsByKey: Record< - keyof TypeEntitiesRow, - { - title: string; - id: string; - width: number; - } -> = { - entityTypes: { - title: "Entity Type", - id: "entityTypes", - width: 200, - }, - web: { - title: "Web", - id: "web", - width: 200, - }, - sourceEntity: { - title: "Source", - id: "sourceEntity", - width: 200, - }, - targetEntity: { - title: "Target", - id: "targetEntity", - width: 200, - }, - archived: { - title: "Archived", - id: "archived", - width: 200, - }, - lastEdited: { - title: "Last Edited", - id: "lastEdited", - width: 200, - }, - lastEditedBy: { - title: "Last Edited By", - id: "lastEditedBy", - width: 200, - }, - created: { - title: "Created", - id: "created", - width: 200, - }, - createdBy: { - title: "Created By", - id: "createdBy", - width: 200, - }, -}; - -export interface TypeEntitiesRow { - rowId: string; - entityId: EntityId; - entity: Entity; - entityIcon?: string; - entityLabel: string; - entityTypes: { - entityTypeId: VersionedUrl; - icon?: string; - isLink: boolean; - title: string; - }[]; - archived?: boolean; - lastEdited: string; - lastEditedBy?: MinimalActor | "loading"; - created: string; - createdBy?: MinimalActor | "loading"; - sourceEntity?: { - entityId: EntityId; - label: string; - icon?: string; - isLink: boolean; - }; - targetEntity?: { - entityId: EntityId; - label: string; - icon?: string; - isLink: boolean; - }; - web: string; - properties?: { - [k: string]: string; - }; - applicableProperties: BaseUrl[]; - /** @todo: get rid of this by typing `columnId` */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any; -} +import type { + EntitiesTableData, + GenerateEntitiesTableDataRequestMessage, + TypeEntitiesRow, +} from "./use-entities-table/types"; +import { isGenerateEntitiesTableDataResultMessage } from "./use-entities-table/types"; let canvas: HTMLCanvasElement | undefined = undefined; @@ -143,6 +41,13 @@ const getTextWidth = (text: string) => { return metrics.width; }; +type PropertiesByEntityTypeId = { + [entityTypeId: VersionedUrl]: { + propertyType: PropertyTypeWithMetadata; + width: number; + }[]; +}; + export const useEntitiesTable = (params: { entities?: Entity[]; entityTypes?: EntityType[]; @@ -153,336 +58,232 @@ export const useEntitiesTable = (params: { hidePageArchivedColumn?: boolean; hidePropertiesColumns: boolean; isViewingOnlyPages?: boolean; -}) => { +}): { loading: boolean; tableData: EntitiesTableData | null } => { const { entities, entityTypes, subgraph, + hasSomeLinks, hideColumns, hidePageArchivedColumn = false, hidePropertiesColumns, isViewingOnlyPages = false, + propertyTypes, } = params; - const editorActorIds = useMemo( - () => - entities?.flatMap(({ metadata }) => [ - metadata.provenance.edition.createdById, - metadata.provenance.createdById, - ]), - [entities], - ); + const [worker, setWorker] = useState(null); - const { actors, loading: actorsLoading } = useActors({ - accountIds: editorActorIds, - }); + useEffect(() => { + const webWorker = new Worker( + new URL("./use-entities-table/worker.ts", import.meta.url), + ); + setWorker(webWorker); + + return () => { + webWorker.terminate(); + }; + }, []); const getOwnerForEntity = useGetOwnerForEntity(); - const entitiesHaveSameType = useMemo( - () => - !!entities && - !!entities.length && - entities - .map(({ metadata: { entityTypeIds } }) => - entityTypeIds - .toSorted() - .map((entityTypeId) => extractBaseUrl(entityTypeId)) - .join(","), - ) - .every((value, _i, all) => value === all[0]), - [entities], - ); - - const usedPropertyTypesByEntityTypeId = useMemo<{ - [entityTypeId: VersionedUrl]: PropertyTypeWithMetadata[]; + const { + editorActorIds, + entitiesHaveSameType, + entityTypesWithMultipleVersionsPresent, + usedPropertyTypesByEntityTypeId, + } = useMemo<{ + editorActorIds: AccountId[]; + entitiesHaveSameType: boolean; + entityTypesWithMultipleVersionsPresent: VersionedUrl[]; + usedPropertyTypesByEntityTypeId: PropertiesByEntityTypeId; }>(() => { if (!entities || !subgraph) { - return {}; + return { + editorActorIds: [], + entitiesHaveSameType: false, + entityTypesWithMultipleVersionsPresent: [], + usedPropertyTypesByEntityTypeId: {}, + }; } - return Object.fromEntries( - entities.flatMap((entity) => - entity.metadata.entityTypeIds.map((entityTypeId) => { - const entityType = getEntityTypeById(subgraph, entityTypeId); - - if (!entityType) { - // eslint-disable-next-line no-console - console.warn( - `Could not find entityType with id ${entityTypeId}, it may be loading...`, - ); - return [entityTypeId, []]; - } - - return [ - entityType.schema.$id, - [ - ...getPropertyTypesForEntityType( - entityType.schema, - subgraph, - ).values(), - ], - ]; - }), - ), - ); - }, [entities, subgraph]); + const propertyMap: PropertiesByEntityTypeId = {}; - const entityTypesWithMultipleVersionsPresent = useMemo(() => { const typesWithMultipleVersions: VersionedUrl[] = []; - const baseUrlsSeen = new Set(); - for (const entityTypeId of typedKeys(usedPropertyTypesByEntityTypeId)) { - const baseUrl = extractBaseUrl(entityTypeId); - if (baseUrlsSeen.has(baseUrl)) { - typesWithMultipleVersions.push(entityTypeId); - } else { - baseUrlsSeen.add(baseUrl); + const firstSeenTypeByBaseUrl: { [baseUrl: string]: VersionedUrl } = {}; + const actorIds: AccountId[] = []; + + for (const entity of entities) { + actorIds.push( + entity.metadata.provenance.edition.createdById, + entity.metadata.provenance.createdById, + ); + + for (const entityTypeId of entity.metadata.entityTypeIds) { + if (propertyMap[entityTypeId]) { + continue; + } + + const baseUrl = extractBaseUrl(entityTypeId); + if (firstSeenTypeByBaseUrl[baseUrl]) { + typesWithMultipleVersions.push(entityTypeId); + typesWithMultipleVersions.push(firstSeenTypeByBaseUrl[baseUrl]); + } else { + firstSeenTypeByBaseUrl[baseUrl] = entityTypeId; + } + + const entityType = getEntityTypeById(subgraph, entityTypeId); + if (!entityType) { + // eslint-disable-next-line no-console + console.warn( + `Could not find entityType with id ${entityTypeId}, it may be loading...`, + ); + continue; + } + + const propertyTypesForEntity = getPropertyTypesForEntityType( + entityType.schema, + subgraph, + ); + + propertyMap[entityTypeId] ??= []; + + for (const propertyType of propertyTypesForEntity.values()) { + propertyMap[entityTypeId].push({ + propertyType, + width: getTextWidth(propertyType.schema.title) + 70, + }); + } } } - return typesWithMultipleVersions; - }, [usedPropertyTypesByEntityTypeId]); - - return useMemo(() => { - const propertyColumnsMap = new Map(); - - for (const propertyType of Object.values( - usedPropertyTypesByEntityTypeId, - ).flat()) { - const propertyTypeBaseUrl = extractBaseUrl(propertyType.schema.$id); - - if (!propertyColumnsMap.has(propertyTypeBaseUrl)) { - propertyColumnsMap.set(propertyTypeBaseUrl, { - id: propertyTypeBaseUrl, - title: propertyType.schema.title, - width: getTextWidth(propertyType.schema.title) + 70, - }); + + return { + editorActorIds: actorIds, + entitiesHaveSameType: Object.keys(firstSeenTypeByBaseUrl).length === 1, + entityTypesWithMultipleVersionsPresent: typesWithMultipleVersions, + usedPropertyTypesByEntityTypeId: propertyMap, + }; + }, [entities, subgraph]); + + const { actors, loading: actorsLoading } = useActors({ + accountIds: editorActorIds, + }); + + const actorsByAccountId: Record = + useMemo(() => { + if (!actors) { + return {}; } - } - const propertyColumns = Array.from(propertyColumnsMap.values()); - - const columns: SizedGridColumn[] = [ - { - title: entitiesHaveSameType - ? (entityTypes?.find( - ({ $id }) => $id === entities?.[0]?.metadata.entityTypeIds[0], - )?.title ?? "Entity") - : "Entity", - id: "entityLabel", - width: 300, - grow: 1, - }, - ]; - - const columnsToHide = hideColumns ?? []; - if (!isViewingOnlyPages || hidePageArchivedColumn) { - columnsToHide.push("archived"); - } - for (const [columnKey, definition] of typedEntries( - columnDefinitionsByKey, - )) { - if (!columnsToHide.includes(columnKey)) { - columns.push(definition); + const actorsByAccount: Record = {}; + + for (const actor of actors) { + actorsByAccount[actor.accountId] = actor; } + + return actorsByAccount; + }, [actors]); + + const webNameByOwnedById = useMemo(() => { + if (!entities) { + return {}; } - if (!hidePropertiesColumns) { - columns.push( - ...propertyColumns.sort((a, b) => a.title.localeCompare(b.title)), - ); + const webNameByOwner: Record = {}; + + for (const entity of entities) { + const owner = getOwnerForEntity(entity); + webNameByOwner[ + extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId) + ] = owner.shortname; } - const rows: TypeEntitiesRow[] | undefined = - subgraph && entityTypes - ? entities?.map((entity) => { - const entityLabel = generateEntityLabel(subgraph, entity); - - const currentEntitysTypes = entityTypes.filter((type) => - entity.metadata.entityTypeIds.includes(type.$id), - ); - - const entityIcon = currentEntitysTypes[0]?.icon; - - const { shortname: entityNamespace } = getOwnerForEntity({ - entityId: entity.metadata.recordId.entityId, - }); - - const entityId = entity.metadata.recordId.entityId; - - const isPage = includesPageEntityTypeId( - entity.metadata.entityTypeIds, - ); - - /** - * @todo: consider displaying handling this differently for pages, where - * updates on nested blocks/text entities may be a better indicator of - * when a page has been last edited. - */ - const lastEdited = format( - new Date( - entity.metadata.temporalVersioning.decisionTime.start.limit, - ), - "yyyy-MM-dd HH:mm", - ); - - const lastEditedBy = actorsLoading - ? "loading" - : actors?.find( - ({ accountId }) => - accountId === - entity.metadata.provenance.edition.createdById, - ); - - const created = format( - new Date(entity.metadata.provenance.createdAtDecisionTime), - "yyyy-MM-dd HH:mm", - ); - - const createdBy = actorsLoading - ? "loading" - : actors?.find( - ({ accountId }) => - accountId === entity.metadata.provenance.createdById, - ); - - const applicableProperties = currentEntitysTypes.flatMap( - (entityType) => - usedPropertyTypesByEntityTypeId[entityType.$id]!.map( - (propertyType) => extractBaseUrl(propertyType.schema.$id), - ), - ); - - let sourceEntity: TypeEntitiesRow["sourceEntity"]; - let targetEntity: TypeEntitiesRow["targetEntity"]; - if (entity.linkData) { - const source = getEntityRevision( - subgraph, - entity.linkData.leftEntityId, - ); - const target = getEntityRevision( - subgraph, - entity.linkData.rightEntityId, - ); - - const sourceEntityLabel = !source - ? entity.linkData.leftEntityId - : source.linkData - ? generateLinkEntityLabel(subgraph, source) - : generateEntityLabel(subgraph, source); - - /** - * @todo H-3363 use closed schema to get entity's icon - */ - const sourceEntityType = source - ? getEntityTypeById(subgraph, source.metadata.entityTypeIds[0]) - : undefined; - - sourceEntity = { - entityId: entity.linkData.leftEntityId, - label: sourceEntityLabel, - icon: sourceEntityType?.schema.icon, - isLink: !!source?.linkData, - }; - - const targetEntityLabel = !target - ? entity.linkData.leftEntityId - : target.linkData - ? generateLinkEntityLabel(subgraph, target) - : generateEntityLabel(subgraph, target); - - /** - * @todo H-3363 use closed schema to get entity's icon - */ - const targetEntityType = target - ? getEntityTypeById(subgraph, target.metadata.entityTypeIds[0]) - : undefined; - - targetEntity = { - entityId: entity.linkData.rightEntityId, - label: targetEntityLabel, - icon: targetEntityType?.schema.icon, - isLink: !!target?.linkData, - }; - } - - return { - rowId: entityId, - entityId, - entity, - entityLabel, - entityIcon, - entityTypes: currentEntitysTypes.map((entityType) => { - /** - * @todo H-3363 use closed schema to take account of indirectly inherited link entity types - */ - const isLink = !!entityType.allOf?.some( - (allOf) => allOf.$ref === linkEntityTypeUrl, - ); - - let entityTypeLabel = entityType.title; - if ( - entityTypesWithMultipleVersionsPresent.includes( - entityType.$id, - ) - ) { - entityTypeLabel += ` v${extractVersion(entityType.$id)}`; - } - - return { - title: entityTypeLabel, - entityTypeId: entityType.$id, - icon: entityType.icon, - isLink, - }; - }), - web: `@${entityNamespace}`, - archived: isPage - ? simplifyProperties(entity.properties as PageProperties) - .archived - : undefined, - lastEdited, - lastEditedBy, - created, - createdBy, - /** @todo: uncomment this when we have additional types for entities */ - // additionalTypes: "", - sourceEntity, - targetEntity, - applicableProperties, - ...propertyColumns.reduce((fields, column) => { - if (column.id) { - const propertyValue = entity.properties[column.id as BaseUrl]; - - const value = - typeof propertyValue === "undefined" - ? "" - : typeof propertyValue === "number" - ? propertyValue - : stringifyPropertyValue(propertyValue); - - return { ...fields, [column.id]: value }; - } - - return fields; - }, {}), - }; - }) - : undefined; - - return { columns, rows }; + return webNameByOwner; + }, [entities, getOwnerForEntity]); + + const [tableData, setTableData] = useState(null); + const [waitingTableData, setWaitingTableData] = useState(true); + + const accumulatedDataRef = useRef<{ + requestId: string; + rows: TypeEntitiesRow[]; + }>({ requestId: "none", rows: [] }); + + useEffect(() => { + if (!worker) { + return; + } + + worker.onmessage = ({ data }) => { + if (isGenerateEntitiesTableDataResultMessage(data)) { + const { done, requestId, result } = data; + setWaitingTableData(false); + + if (accumulatedDataRef.current.requestId !== requestId) { + accumulatedDataRef.current = { requestId, rows: result.rows }; + } else { + accumulatedDataRef.current.rows.push(...result.rows); + } + + if (done) { + setTableData({ + ...result, + rows: accumulatedDataRef.current.rows, + }); + accumulatedDataRef.current.rows = []; + } + } + }; + }, [worker]); + + useEffect(() => { + if (entities && entityTypes && subgraph && !actorsLoading) { + const serializedSubgraph = serializeSubgraph(subgraph); + + if (!worker) { + throw new Error("No worker available"); + } + + worker.postMessage({ + type: "generateEntitiesTableData", + params: { + actorsByAccountId, + entities: entities.map((entity) => entity.toJSON()), + entitiesHaveSameType, + entityTypesWithMultipleVersionsPresent, + entityTypes, + propertyTypes, + subgraph: serializedSubgraph, + hasSomeLinks, + hideColumns, + hidePageArchivedColumn, + hidePropertiesColumns, + isViewingOnlyPages, + usedPropertyTypesByEntityTypeId, + webNameByOwnedById, + }, + } satisfies GenerateEntitiesTableDataRequestMessage); + } }, [ - actors, + actorsByAccountId, actorsLoading, - entitiesHaveSameType, entities, entityTypes, + entitiesHaveSameType, entityTypesWithMultipleVersionsPresent, - getOwnerForEntity, - isViewingOnlyPages, + hasSomeLinks, hideColumns, hidePageArchivedColumn, hidePropertiesColumns, + isViewingOnlyPages, + propertyTypes, subgraph, usedPropertyTypesByEntityTypeId, + webNameByOwnedById, + worker, ]); + + return { + tableData, + loading: waitingTableData, + }; }; diff --git a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/types.ts b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/types.ts new file mode 100644 index 00000000000..aa36e6368ab --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/types.ts @@ -0,0 +1,131 @@ +import type { + EntityType, + PropertyType, + VersionedUrl, +} from "@blockprotocol/type-system/slim"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import type { SerializedEntity } from "@local/hash-graph-sdk/entity"; +import type { AccountId } from "@local/hash-graph-types/account"; +import type { EntityId } from "@local/hash-graph-types/entity"; +import type { + BaseUrl, + PropertyTypeWithMetadata, +} from "@local/hash-graph-types/ontology"; +import type { OwnedById } from "@local/hash-graph-types/web"; +import type { SerializedSubgraph } from "@local/hash-subgraph"; + +import type { MinimalActor } from "../../../../shared/use-actors"; + +export interface TypeEntitiesRow { + rowId: string; + entityId: EntityId; + entityIcon?: string; + entityLabel: string; + entityTypes: { + entityTypeId: VersionedUrl; + icon?: string; + isLink: boolean; + title: string; + }[]; + archived?: boolean; + lastEdited: string; + lastEditedBy?: MinimalActor | "loading"; + created: string; + createdBy?: MinimalActor | "loading"; + sourceEntity?: { + entityId: EntityId; + label: string; + icon?: string; + isLink: boolean; + }; + targetEntity?: { + entityId: EntityId; + label: string; + icon?: string; + isLink: boolean; + }; + web: string; + properties?: { + [k: string]: string; + }; + applicableProperties: BaseUrl[]; + + /** @todo: get rid of this by typing `columnId` */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export type SourceOrTargetFilterData = { + count: number; + entityId: string; + label: string; +}; + +export type PropertiesByEntityTypeId = { + [entityTypeId: VersionedUrl]: { + propertyType: PropertyTypeWithMetadata; + width: number; + }[]; +}; + +export type GenerateEntitiesTableDataParams = { + actorsByAccountId: Record; + entities: SerializedEntity[]; + entitiesHaveSameType: boolean; + entityTypesWithMultipleVersionsPresent: VersionedUrl[]; + entityTypes: EntityType[]; + propertyTypes?: PropertyType[]; + subgraph: SerializedSubgraph; + hasSomeLinks?: boolean; + hideColumns?: (keyof TypeEntitiesRow)[]; + hidePageArchivedColumn?: boolean; + hidePropertiesColumns: boolean; + isViewingOnlyPages?: boolean; + usedPropertyTypesByEntityTypeId: PropertiesByEntityTypeId; + webNameByOwnedById: Record; +}; + +export type ActorTableData = { accountId: AccountId; displayName?: string }; + +export type EntitiesTableData = { + columns: SizedGridColumn[]; + filterData: { + createdByActors: ActorTableData[]; + lastEditedByActors: ActorTableData[]; + entityTypeTitles: { [entityTypeTitle: string]: number | undefined }; + noSourceCount: number; + noTargetCount: number; + sources: SourceOrTargetFilterData[]; + targets: SourceOrTargetFilterData[]; + webs: { [web: string]: number | undefined }; + }; + rows: TypeEntitiesRow[]; +}; + +export type GenerateEntitiesTableDataRequestMessage = { + type: "generateEntitiesTableData"; + params: GenerateEntitiesTableDataParams; +}; + +export const isGenerateEntitiesTableDataRequestMessage = ( + message: unknown, +): message is GenerateEntitiesTableDataRequestMessage => + typeof message === "object" && + message !== null && + (message as Record).type === + ("generateEntitiesTableData" satisfies GenerateEntitiesTableDataRequestMessage["type"]); + +export type GenerateEntitiesTableDataResultMessage = { + done: boolean; + type: "generateEntitiesTableDataResult"; + requestId: string; + result: EntitiesTableData; +}; + +export const isGenerateEntitiesTableDataResultMessage = ( + message: unknown, +): message is GenerateEntitiesTableDataResultMessage => + typeof message === "object" && + message !== null && + (message as Record).type === + ("generateEntitiesTableDataResult" satisfies GenerateEntitiesTableDataResultMessage["type"]); diff --git a/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts new file mode 100644 index 00000000000..6e0a96f762f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-table/use-entities-table/worker.ts @@ -0,0 +1,444 @@ +import { extractVersion } from "@blockprotocol/type-system"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import { typedEntries } from "@local/advanced-types/typed-entries"; +import { Entity } from "@local/hash-graph-sdk/entity"; +import type { BaseUrl } from "@local/hash-graph-types/ontology"; +import { + generateEntityLabel, + generateLinkEntityLabel, +} from "@local/hash-isomorphic-utils/generate-entity-label"; +import { generateUuid } from "@local/hash-isomorphic-utils/generate-uuid"; +import { includesPageEntityTypeId } from "@local/hash-isomorphic-utils/page-entity-type-ids"; +import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; +import { sleep } from "@local/hash-isomorphic-utils/sleep"; +import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; +import { deserializeSubgraph } from "@local/hash-isomorphic-utils/subgraph-mapping"; +import type { PageProperties } from "@local/hash-isomorphic-utils/system-types/shared"; +import { + extractOwnedByIdFromEntityId, + linkEntityTypeUrl, +} from "@local/hash-subgraph"; +import { + getEntityRevision, + getEntityTypeById, +} from "@local/hash-subgraph/stdlib"; +import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; +import { format } from "date-fns"; + +import type { + ActorTableData, + EntitiesTableData, + GenerateEntitiesTableDataParams, + GenerateEntitiesTableDataResultMessage, + TypeEntitiesRow, +} from "./types"; +import { isGenerateEntitiesTableDataRequestMessage } from "./types"; + +const columnDefinitionsByKey: Record< + keyof TypeEntitiesRow, + { + title: string; + id: string; + width: number; + } +> = { + entityTypes: { + title: "Entity Type", + id: "entityTypes", + width: 200, + }, + web: { + title: "Web", + id: "web", + width: 200, + }, + sourceEntity: { + title: "Source", + id: "sourceEntity", + width: 200, + }, + targetEntity: { + title: "Target", + id: "targetEntity", + width: 200, + }, + archived: { + title: "Archived", + id: "archived", + width: 200, + }, + lastEdited: { + title: "Last Edited", + id: "lastEdited", + width: 200, + }, + lastEditedBy: { + title: "Last Edited By", + id: "lastEditedBy", + width: 200, + }, + created: { + title: "Created", + id: "created", + width: 200, + }, + createdBy: { + title: "Created By", + id: "createdBy", + width: 200, + }, +}; + +let activeRequestId: string | null; + +const isCancelled = async (requestId: string) => { + await sleep(0); + return activeRequestId !== requestId; +}; + +const generateTableData = async ( + params: GenerateEntitiesTableDataParams, + requestId: string, +): Promise => { + const { + actorsByAccountId, + entities, + entitiesHaveSameType, + entityTypesWithMultipleVersionsPresent, + entityTypes, + usedPropertyTypesByEntityTypeId, + subgraph: serializedSubgraph, + hideColumns, + hidePageArchivedColumn, + hidePropertiesColumns, + isViewingOnlyPages, + webNameByOwnedById, + } = params; + + if (await isCancelled(requestId)) { + return "cancelled"; + } + + const subgraph = deserializeSubgraph(serializedSubgraph); + + const lastEditedBySet = new Set(); + const createdBySet = new Set(); + const entityTypeTitleCount: { + [entityTypeTitle: string]: number | undefined; + } = {}; + + let noSource = 0; + let noTarget = 0; + + const sourcesByEntityId: { + [entityId: string]: { + count: number; + entityId: string; + label: string; + }; + } = {}; + const targetsByEntityId: { + [entityId: string]: { + count: number; + entityId: string; + label: string; + }; + } = {}; + + const webCountById: { [web: string]: number } = {}; + + const propertyColumnsMap = new Map(); + + for (const { propertyType, width } of Object.values( + usedPropertyTypesByEntityTypeId, + ).flat()) { + const propertyTypeBaseUrl = extractBaseUrl(propertyType.schema.$id); + + if (!propertyColumnsMap.has(propertyTypeBaseUrl)) { + propertyColumnsMap.set(propertyTypeBaseUrl, { + id: propertyTypeBaseUrl, + title: propertyType.schema.title, + width: width + 70, + }); + } + } + const propertyColumns = Array.from(propertyColumnsMap.values()); + + const columns: SizedGridColumn[] = [ + { + title: entitiesHaveSameType + ? (entityTypes.find( + ({ $id }) => + entities[0] && + $id === new Entity(entities[0]).metadata.entityTypeIds[0], + )?.title ?? "Entity") + : "Entity", + id: "entityLabel", + width: 300, + grow: 1, + }, + ]; + + const columnsToHide = hideColumns ?? []; + if (!isViewingOnlyPages || hidePageArchivedColumn) { + columnsToHide.push("archived"); + } + + for (const [columnKey, definition] of typedEntries(columnDefinitionsByKey)) { + if (!columnsToHide.includes(columnKey)) { + columns.push(definition); + } + } + + if (!hidePropertiesColumns) { + columns.push( + ...propertyColumns.sort((a, b) => a.title.localeCompare(b.title)), + ); + } + + const rows: TypeEntitiesRow[] = []; + for (const serializedEntity of entities) { + if (await isCancelled(requestId)) { + return "cancelled"; + } + + const entity = new Entity(serializedEntity); + + const entityLabel = generateEntityLabel(subgraph, entity); + + const currentEntitysTypes = entityTypes.filter((type) => + entity.metadata.entityTypeIds.includes(type.$id), + ); + + const entityIcon = currentEntitysTypes[0]?.icon; + + const entityNamespace = + webNameByOwnedById[ + extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId) + ] ?? "loading"; + + const entityId = entity.metadata.recordId.entityId; + + const isPage = includesPageEntityTypeId(entity.metadata.entityTypeIds); + + /** + * @todo: consider displaying handling this differently for pages, where + * updates on nested blocks/text entities may be a better indicator of + * when a page has been last edited. + */ + const lastEdited = format( + new Date(entity.metadata.temporalVersioning.decisionTime.start.limit), + "yyyy-MM-dd HH:mm", + ); + + const lastEditedBy = + actorsByAccountId[entity.metadata.provenance.edition.createdById]; + + if (lastEditedBy) { + lastEditedBySet.add({ + accountId: lastEditedBy.accountId, + displayName: lastEditedBy.displayName, + }); + } + + const created = format( + new Date(entity.metadata.provenance.createdAtDecisionTime), + "yyyy-MM-dd HH:mm", + ); + + const createdBy = actorsByAccountId[entity.metadata.provenance.createdById]; + + if (createdBy) { + createdBySet.add(createdBy); + } + + const applicableProperties = currentEntitysTypes.flatMap((entityType) => + usedPropertyTypesByEntityTypeId[entityType.$id]!.map(({ propertyType }) => + extractBaseUrl(propertyType.schema.$id), + ), + ); + + let sourceEntity: TypeEntitiesRow["sourceEntity"]; + let targetEntity: TypeEntitiesRow["targetEntity"]; + if (entity.linkData) { + const source = getEntityRevision(subgraph, entity.linkData.leftEntityId); + const target = getEntityRevision(subgraph, entity.linkData.rightEntityId); + + const sourceEntityLabel = !source + ? entity.linkData.leftEntityId + : source.linkData + ? generateLinkEntityLabel(subgraph, source) + : generateEntityLabel(subgraph, source); + + /** + * @todo H-3363 use closed schema to get entity's icon + */ + const sourceEntityType = source + ? getEntityTypeById(subgraph, source.metadata.entityTypeIds[0]) + : undefined; + + sourceEntity = { + entityId: entity.linkData.leftEntityId, + label: sourceEntityLabel, + icon: sourceEntityType?.schema.icon, + isLink: !!source?.linkData, + }; + + sourcesByEntityId[sourceEntity.entityId] ??= { + count: 0, + entityId: sourceEntity.entityId, + label: sourceEntity.label, + }; + sourcesByEntityId[sourceEntity.entityId]!.count++; + + const targetEntityLabel = !target + ? entity.linkData.leftEntityId + : target.linkData + ? generateLinkEntityLabel(subgraph, target) + : generateEntityLabel(subgraph, target); + + /** + * @todo H-3363 use closed schema to get entity's icon + */ + const targetEntityType = target + ? getEntityTypeById(subgraph, target.metadata.entityTypeIds[0]) + : undefined; + + targetEntity = { + entityId: entity.linkData.rightEntityId, + label: targetEntityLabel, + icon: targetEntityType?.schema.icon, + isLink: !!target?.linkData, + }; + + targetsByEntityId[targetEntity.entityId] ??= { + count: 0, + entityId: targetEntity.entityId, + label: targetEntity.label, + }; + targetsByEntityId[targetEntity.entityId]!.count++; + } else { + noSource += 1; + noTarget += 1; + } + + for (const entityType of currentEntitysTypes) { + entityTypeTitleCount[entityType.title] ??= 0; + entityTypeTitleCount[entityType.title]!++; + } + + const web = `@${entityNamespace}`; + webCountById[web] ??= 0; + webCountById[web]++; + + rows.push({ + rowId: entityId, + entityId, + entityLabel, + entityIcon, + entityTypes: currentEntitysTypes.map((entityType) => { + /** + * @todo H-3363 use closed schema to take account of indirectly inherited link entity types + */ + const isLink = !!entityType.allOf?.some( + (allOf) => allOf.$ref === linkEntityTypeUrl, + ); + + let entityTypeLabel = entityType.title; + if (entityTypesWithMultipleVersionsPresent.includes(entityType.$id)) { + entityTypeLabel += ` v${extractVersion(entityType.$id)}`; + } + + return { + title: entityTypeLabel, + entityTypeId: entityType.$id, + icon: entityType.icon, + isLink, + }; + }), + web, + archived: isPage + ? simplifyProperties(entity.properties as PageProperties).archived + : undefined, + lastEdited, + lastEditedBy: lastEditedBy ?? "loading", + created, + createdBy: createdBy ?? "loading", + sourceEntity, + targetEntity, + applicableProperties, + ...propertyColumns.reduce((fields, column) => { + if (column.id) { + const propertyValue = entity.properties[column.id as BaseUrl]; + + const value = + typeof propertyValue === "undefined" + ? "" + : typeof propertyValue === "number" + ? propertyValue + : stringifyPropertyValue(propertyValue); + + return { ...fields, [column.id]: value }; + } + + return fields; + }, {}), + }); + } + + return { + columns, + rows, + filterData: { + lastEditedByActors: [...lastEditedBySet], + createdByActors: [...createdBySet], + entityTypeTitles: entityTypeTitleCount, + webs: webCountById, + noSourceCount: noSource, + noTargetCount: noTarget, + sources: Object.values(sourcesByEntityId), + targets: Object.values(targetsByEntityId), + }, + }; +}; + +// eslint-disable-next-line no-restricted-globals +self.onmessage = async ({ data }) => { + if (isGenerateEntitiesTableDataRequestMessage(data)) { + const params = data.params; + + const requestId = generateUuid(); + activeRequestId = requestId; + + const result = await generateTableData(params, requestId); + + if (result !== "cancelled") { + /** + * Split the rows into chunks to avoid the message being too large. + */ + const chunkSize = 20_000; + const chunkedRows: TypeEntitiesRow[][] = []; + for (let i = 0; i < result.rows.length; i += chunkSize) { + chunkedRows.push(result.rows.slice(i, i + chunkSize)); + } + + for (const [index, rows] of chunkedRows.entries()) { + // eslint-disable-next-line no-restricted-globals + self.postMessage({ + type: "generateEntitiesTableDataResult", + requestId, + done: index === chunkedRows.length - 1, + result: { + ...result, + rows, + }, + } satisfies GenerateEntitiesTableDataResultMessage); + } + } else { + // eslint-disable-next-line no-console + console.info( + `Table data generation request ${requestId} cancelled, superseded by a subsequent request`, + ); + } + } +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx new file mode 100644 index 00000000000..06df25b7be6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -0,0 +1,453 @@ +import type { VersionedUrl } from "@blockprotocol/type-system/slim"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import { LoadingSpinner } from "@hashintel/design-system"; +import type { Entity } from "@local/hash-graph-sdk/entity"; +import type { EntityId } from "@local/hash-graph-types/entity"; +import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; +import { includesPageEntityTypeId } from "@local/hash-isomorphic-utils/page-entity-type-ids"; +import { simplifyProperties } from "@local/hash-isomorphic-utils/simplify-properties"; +import type { PageProperties } from "@local/hash-isomorphic-utils/system-types/shared"; +import type { EntityRootType, Subgraph } from "@local/hash-subgraph"; +import { extractOwnedByIdFromEntityId } from "@local/hash-subgraph"; +import { extractBaseUrl } from "@local/hash-subgraph/type-system-patch"; +import { Box, Stack, useTheme } from "@mui/material"; +import type { FunctionComponent, ReactElement, RefObject } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useEntityTypeEntitiesContext } from "../../shared/entity-type-entities-context"; +import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; +import { HEADER_HEIGHT } from "../../shared/layout/layout-with-header/page-header"; +import { tableContentSx } from "../../shared/table-content"; +import type { FilterState } from "../../shared/table-header"; +import { TableHeader, tableHeaderHeight } from "../../shared/table-header"; +import { useEntityTypeEntities } from "../../shared/use-entity-type-entities"; +import { useMemoCompare } from "../../shared/use-memo-compare"; +import type { + CustomColumn, + EntityEditorProps, +} from "../[shortname]/entities/[entity-uuid].page/entity-editor"; +import { useAuthenticatedUser } from "./auth-info-context"; +import { EntitiesTable } from "./entities-table"; +import { GridView } from "./entities-table/grid-view"; +import type { TypeEntitiesRow } from "./entities-table/use-entities-table/types"; +import { EntityEditorSlideStack } from "./entity-editor-slide-stack"; +import { EntityGraphVisualizer } from "./entity-graph-visualizer"; +import { TypeSlideOverStack } from "./entity-type-page/type-slide-over-stack"; +import type { + DynamicNodeSizing, + GraphVizConfig, + GraphVizFilters, +} from "./graph-visualizer"; +import { generateEntityRootedSubgraph } from "./subgraphs"; +import { TableHeaderToggle } from "./table-header-toggle"; +import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; +import type { VisualizerView } from "./visualizer-views"; +import { visualizerViewIcons } from "./visualizer-views"; + +/** + * @todo: avoid having to maintain this list, potentially by + * adding an `isFile` boolean to the generated ontology IDs file. + */ +const allFileEntityTypeOntologyIds = [ + systemEntityTypes.file, + systemEntityTypes.image, + systemEntityTypes.documentFile, + systemEntityTypes.docxDocument, + systemEntityTypes.pdfDocument, + systemEntityTypes.presentationFile, + systemEntityTypes.pptxPresentation, +]; + +const allFileEntityTypeIds = allFileEntityTypeOntologyIds.map( + ({ entityTypeId }) => entityTypeId, +) as VersionedUrl[]; + +const allFileEntityTypeBaseUrl = allFileEntityTypeOntologyIds.map( + ({ entityTypeBaseUrl }) => entityTypeBaseUrl, +); + +export const EntitiesVisualizer: FunctionComponent<{ + customColumns?: CustomColumn[]; + defaultFilter?: FilterState; + defaultGraphConfig?: GraphVizConfig; + defaultGraphFilters?: GraphVizFilters; + defaultView?: VisualizerView; + disableEntityOpenInNew?: boolean; + disableTypeClick?: boolean; + /** + * If the user activates fullscreen, whether to fullscreen the whole page or a specific element, e.g. the graph only. + * Currently only used in the context of the graph visualizer, but the table could be usefully fullscreened as well. + */ + fullScreenMode?: "document" | "element"; + hideFilters?: boolean; + hideColumns?: (keyof TypeEntitiesRow)[]; + loadingComponent?: ReactElement; + maxHeight?: string | number; + readonly?: boolean; +}> = ({ + customColumns, + defaultFilter, + defaultGraphConfig, + defaultGraphFilters, + defaultView = "Table", + disableEntityOpenInNew, + disableTypeClick, + fullScreenMode, + hideColumns, + hideFilters, + loadingComponent: customLoadingComponent, + maxHeight, + readonly, +}) => { + const theme = useTheme(); + + const { authenticatedUser } = useAuthenticatedUser(); + + const [filterState, setFilterState] = useState( + defaultFilter ?? { + includeGlobal: false, + limitToWebs: false, + }, + ); + + const loadingComponent = customLoadingComponent ?? ( + + ); + + const [selectedEntityType, setSelectedEntityType] = useState<{ + entityTypeId: VersionedUrl; + slideContainerRef?: RefObject; + } | null>(null); + + const { + entityTypeBaseUrl, + entityTypeId, + entities: lastLoadedEntities, + entityTypes, + hadCachedContent, + loading, + propertyTypes, + subgraph: subgraphPossiblyWithoutLinks, + } = useEntityTypeEntitiesContext(); + + const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); + + const isDisplayingFilesOnly = useMemo( + () => + /** + * To allow the `Grid` view to come into view on first render where + * possible, we check whether `entityTypeId` or `entityTypeBaseUrl` + * matches a `File` entity type from a statically defined list. + */ + (entityTypeId && allFileEntityTypeIds.includes(entityTypeId)) || + (entityTypeBaseUrl && + allFileEntityTypeBaseUrl.includes(entityTypeBaseUrl)) || + /** + * Otherwise we check the fetched `entityTypes` as a fallback. + */ + (entityTypes?.length && + entityTypes.every( + ({ $id }) => isSpecialEntityTypeLookup?.[$id]?.isFile, + )), + [entityTypeBaseUrl, entityTypeId, entityTypes, isSpecialEntityTypeLookup], + ); + + const supportGridView = isDisplayingFilesOnly; + + const [view, setView] = useState( + isDisplayingFilesOnly ? "Grid" : defaultView, + ); + + useEffect(() => { + if (isDisplayingFilesOnly) { + setView("Grid"); + } else { + setView(defaultView); + } + }, [defaultView, isDisplayingFilesOnly]); + + const { subgraph: subgraphWithLinkedEntities } = useEntityTypeEntities({ + entityTypeBaseUrl, + entityTypeId, + graphResolveDepths: { + constrainsLinksOn: { outgoing: 255 }, + constrainsLinkDestinationsOn: { outgoing: 255 }, + constrainsPropertiesOn: { outgoing: 255 }, + constrainsValuesOn: { outgoing: 255 }, + inheritsFrom: { outgoing: 255 }, + isOfType: { outgoing: 1 }, + hasLeftEntity: { outgoing: 1, incoming: 1 }, + hasRightEntity: { outgoing: 1, incoming: 1 }, + }, + }); + + /** + The subgraphWithLinkedEntities can take a long time to load with many entities. + If absent, we pass the subgraph without linked entities so that there is _some_ data to load into the slideover, + which will be missing links until they load in by specifically fetching selectedEntity.entityId + */ + const subgraph = subgraphWithLinkedEntities ?? subgraphPossiblyWithoutLinks; + + const entities = useMemo( + /** + * If a network request is in process and there is no cached content for the request, return undefined. + * There may be stale data in the context related to an earlier request with different variables. + */ + () => (loading && !hadCachedContent ? undefined : lastLoadedEntities), + [hadCachedContent, loading, lastLoadedEntities], + ); + + const { isViewingOnlyPages, hasSomeLinks } = useMemo(() => { + let isViewingPages = true; + let hasLinks = false; + for (const entity of entities ?? []) { + if (!includesPageEntityTypeId(entity.metadata.entityTypeIds)) { + isViewingPages = false; + } + if (entity.linkData) { + hasLinks = true; + } + + if (hasLinks && !isViewingPages) { + break; + } + } + return { isViewingOnlyPages: isViewingPages, hasSomeLinks: hasLinks }; + }, [entities]); + + useEffect(() => { + if (isViewingOnlyPages && filterState.includeArchived === undefined) { + setFilterState((prev) => ({ ...prev, includeArchived: false })); + } + }, [isViewingOnlyPages, filterState]); + + const internalWebIds = useMemoCompare( + () => { + return [ + authenticatedUser.accountId, + ...authenticatedUser.memberOf.map(({ org }) => org.accountGroupId), + ]; + }, + [authenticatedUser], + (oldValue, newValue) => { + const oldSet = new Set(oldValue); + const newSet = new Set(newValue); + return oldSet.size === newSet.size && oldSet.isSubsetOf(newSet); + }, + ); + + const filteredEntities = useMemo(() => { + return entities?.filter( + (entity) => + (filterState.includeGlobal + ? true + : internalWebIds.includes( + extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId), + )) && + (filterState.includeArchived === undefined || + filterState.includeArchived || + !includesPageEntityTypeId(entity.metadata.entityTypeIds) + ? true + : simplifyProperties(entity.properties as PageProperties).archived !== + true) && + (filterState.limitToWebs + ? filterState.limitToWebs.includes( + extractOwnedByIdFromEntityId(entity.metadata.recordId.entityId), + ) + : true), + ); + }, [entities, filterState, internalWebIds]); + + const [selectedEntity, setSelectedEntity] = useState<{ + entityId: EntityId; + options?: Pick; + slideContainerRef?: RefObject; + subgraph: Subgraph; + } | null>(null); + + const handleEntityClick = useCallback( + ( + entityId: EntityId, + modalContainerRef?: RefObject, + options?: Pick, + ) => { + if (subgraph) { + const entitySubgraph = generateEntityRootedSubgraph(entityId, subgraph); + + setSelectedEntity({ + options, + entityId, + slideContainerRef: modalContainerRef, + subgraph: entitySubgraph, + }); + } + }, + [subgraph], + ); + + const currentlyDisplayedColumnsRef = useRef(null); + const currentlyDisplayedRowsRef = useRef(null); + + const maximumTableHeight = + maxHeight ?? + `calc(100vh - (${ + HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 185 + tableHeaderHeight + }px + ${theme.spacing(5)} + ${theme.spacing(5)}))`; + + const isPrimaryEntity = useCallback( + (entity: { metadata: Pick }) => + entityTypeBaseUrl + ? entity.metadata.entityTypeIds.some( + (typeId) => extractBaseUrl(typeId) === entityTypeBaseUrl, + ) + : entityTypeId + ? entity.metadata.entityTypeIds.includes(entityTypeId) + : false, + [entityTypeId, entityTypeBaseUrl], + ); + + const [showTableSearch, setShowTableSearch] = useState(false); + + const [selectedTableRows, setSelectedTableRows] = useState( + [], + ); + + return ( + <> + {selectedEntityType && ( + setSelectedEntityType(null)} + slideContainerRef={selectedEntityType.slideContainerRef} + /> + )} + {selectedEntity ? ( + setSelectedEntity(null)} + onSubmit={() => { + throw new Error(`Editing not yet supported from this screen`); + }} + readonly + /* + If we've been given a specific DOM element to contain the modal, pass it here. + This is for use when attaching to the body is not suitable (e.g. a specific DOM element is full-screened). + */ + slideContainerRef={selectedEntity.slideContainerRef} + /> + ) : null} + + + selectedTableRows.some( + ({ entityId }) => + entity.metadata.recordId.entityId === entityId, + ), + ) ?? [] + } + title="Entities" + currentlyDisplayedColumnsRef={currentlyDisplayedColumnsRef} + currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} + hideExportToCsv={view !== "Table"} + endAdornment={ + ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} + /> + } + filterState={filterState} + setFilterState={setFilterState} + toggleSearch={ + view === "Table" ? () => setShowTableSearch(true) : undefined + } + onBulkActionCompleted={() => null} + /> + {!subgraph ? ( + + {loadingComponent} + + ) : view === "Graph" ? ( + + + + ) : view === "Grid" ? ( + + ) : ( + + )} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx index dbc41363438..19f5f14bcf0 100644 --- a/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-graph-visualizer.tsx @@ -79,7 +79,7 @@ export const EntityGraphVisualizer = memo( * Whether this entity should receive a special highlight. */ isPrimaryEntity?: (entity: T) => boolean; - loadingComponent?: ReactElement; + loadingComponent: ReactElement; subgraphWithTypes: Subgraph; }) => { const { palette } = useTheme(); @@ -261,7 +261,7 @@ export const EntityGraphVisualizer = memo( return ( - {loading && loadingComponent && ( + {loading && ( { /> ) : ( - + )} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx index 8e4021cc26a..e8757ccfe8c 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container.tsx @@ -128,8 +128,6 @@ export const GraphContainer = memo( - + - + {typeOptions.length > 1 && ( + + )} { - const { includeByNodeTypeId } = filters; + const { visibleNodesByTypeId, visibleTypesByTypeId, visibleNodeIds } = + useMemo(() => { + const { includeByNodeTypeId } = filters; - const visibleNodes: NodesByTypeId = {}; - const visibleTypes: TypesByTypeId = {}; + const visibleNodes: NodesByTypeId = {}; + const visibleTypes: TypesByTypeId = {}; + const visibleIds = new Set(); - for (const node of nodes) { - const { nodeTypeId, nodeTypeLabel, nodeId } = node; + for (const node of nodes) { + const { nodeTypeId = "none", nodeTypeLabel = "No type", nodeId } = node; - if (!node.nodeTypeId || !includeByNodeTypeId?.[node.nodeTypeId]) { - continue; - } + if (node.nodeTypeId && !includeByNodeTypeId?.[node.nodeTypeId]) { + continue; + } + + visibleIds.add(nodeId); - if (nodeTypeId && nodeTypeLabel) { - visibleTypes[nodeTypeId] ??= { - label: nodeTypeLabel, - typeId: nodeTypeId, - valueForSelector: nodeTypeId, - }; + if (nodeTypeId && nodeTypeLabel) { + visibleTypes[nodeTypeId] ??= { + label: nodeTypeLabel, + typeId: nodeTypeId, + valueForSelector: nodeTypeId, + }; - visibleNodes[nodeTypeId] ??= []; - visibleNodes[nodeTypeId].push({ ...node, valueForSelector: nodeId }); + visibleNodes[nodeTypeId] ??= []; + visibleNodes[nodeTypeId].push({ ...node, valueForSelector: nodeId }); + } } - } - return { - visibleNodesByTypeId: visibleNodes, - visibleTypesByTypeId: visibleTypes, - }; - }, [filters, nodes]); + return { + visibleNodeIds: visibleIds, + visibleNodesByTypeId: visibleNodes, + visibleTypesByTypeId: visibleTypes, + }; + }, [filters, nodes]); + + const hasMultipleTypes = Object.keys(visibleTypesByTypeId).length > 1; + const firstType = Object.values(visibleTypesByTypeId)[0] ?? null; const [startNode, setStartNode] = useState(null); const [startType, setStartType] = useState( config.pathfinding?.startTypeId ? (visibleTypesByTypeId[config.pathfinding.startTypeId] ?? null) - : null, + : hasMultipleTypes + ? null + : firstType, ); const [endNode, setEndNode] = useState(null); - const [endType, setEndType] = useState(null); + const [endType, setEndType] = useState( + hasMultipleTypes ? null : firstType, + ); const [viaNode, setViaNode] = useState(null); - const [viaType, setViaType] = useState(null); + const [viaType, setViaType] = useState( + hasMultipleTypes ? null : firstType, + ); + + useEffect(() => { + if (!visibleTypesByTypeId[startType?.typeId ?? ""]) { + setStartType(null); + setStartNode(null); + } else if (!visibleNodeIds.has(startNode?.nodeId ?? "")) { + setStartNode(null); + } + + if (!visibleTypesByTypeId[endType?.typeId ?? ""]) { + setEndType(null); + setEndNode(null); + } else if (!visibleNodeIds.has(endNode?.nodeId ?? "")) { + setEndNode(null); + } + + if (!visibleTypesByTypeId[viaType?.typeId ?? ""]) { + setViaType(null); + setViaNode(null); + } else if (!visibleNodeIds.has(viaNode?.nodeId ?? "")) { + setViaNode(null); + } + }, [ + startNode, + startType, + endType, + endNode, + viaType, + viaNode, + visibleTypesByTypeId, + visibleNodeIds, + ]); const [maxSimplePathDepth, setMaxSimplePathDepth] = useState(3); const [allowRepeatedNodeTypes, setAllowRepeatedNodeTypes] = useState(false); @@ -186,13 +234,24 @@ const PathFinderPanel: FunctionComponent<{ [highlightPath], ); - const worker = useMemo( - () => - new Worker(new URL("./path-finder-control/worker.ts", import.meta.url)), - [], - ); + const [worker, setWorker] = useState(null); + + useEffect(() => { + const webWorker = new Worker( + new URL("./path-finder-control/worker.ts", import.meta.url), + ); + setWorker(webWorker); + + return () => { + webWorker.terminate(); + }; + }, []); useEffect(() => { + if (!worker) { + return; + } + worker.onmessage = ({ data }) => { if ( "type" in data && @@ -202,7 +261,6 @@ const PathFinderPanel: FunctionComponent<{ setSimplePaths((data as GenerateSimplePathsResultMessage).result); setWaitingSimplePathResult(false); } - return () => worker.terminate(); }; }, [worker]); @@ -226,6 +284,9 @@ const PathFinderPanel: FunctionComponent<{ }, }; + if (!worker) { + throw new Error("No worker available"); + } worker.postMessage(request); }, [ allowRepeatedNodeTypes, @@ -256,8 +317,13 @@ const PathFinderPanel: FunctionComponent<{ const shortestPathByKey: { [key: string]: string[] | null } = {}; + const endOptions: NodeData[] = []; + const viaOptions: NodeData[] = []; + if (startNode) { - for (const node of endNodes) { + for (const originalNode of endNodes) { + const node = { ...originalNode }; + let shortestPath: string[] | null = null; if (viaNode) { const firstPart = dijkstra.bidirectional( @@ -303,10 +369,13 @@ const PathFinderPanel: FunctionComponent<{ } node.shortestPathTo = `(${pathLength})`; + endOptions.push(node); } if (endNode) { - for (const node of viaNodes) { + for (const originalNode of viaNodes) { + const node = { ...originalNode }; + let shortestPath: string[] | null | undefined = shortestPathByKey[ generatePathKey({ @@ -343,11 +412,12 @@ const PathFinderPanel: FunctionComponent<{ } node.shortestPathVia = `(${pathLength})`; + viaOptions.push(node); } } } - return { endNodeOptions: endNodes, viaNodeOptions: viaNodes }; + return { endNodeOptions: endOptions, viaNodeOptions: viaOptions }; }, [ endNode, endType, @@ -410,7 +480,13 @@ const PathFinderPanel: FunctionComponent<{ position="left" title="Path finder" > - + - + {!config.pathfinding?.hideVia && ( + + )} { + await sleep(0); + return requestId !== activeRequestId; +}; + +const generateSimplePaths = async ({ allowRepeatedNodeTypes, endNode, graph: serializedGraph, maxSimplePathDepth, + requestId, simplePathSort, startNode, viaNode, -}: GenerateSimplePathsParams) => { +}: GenerateSimplePathsParams & { requestId: string }) => { const graph = new MultiDirectedGraph(); graph.import(serializedGraph); @@ -32,6 +42,10 @@ const generateSimplePaths = ({ // eslint-disable-next-line no-labels pathsLoop: for (const path of unfilteredSimplePaths) { + if (await isCancelled(requestId)) { + return "cancelled"; + } + const seenTypes = new Set(); const labelParts: string[] = []; @@ -100,14 +114,27 @@ const generateSimplePaths = ({ }; // eslint-disable-next-line no-restricted-globals -self.onmessage = ({ data }) => { +self.onmessage = async ({ data }) => { if ( "type" in data && data.type === ("generateSimplePaths" satisfies GenerateSimplePathsRequestMessage["type"]) ) { const { params } = data as GenerateSimplePathsRequestMessage; - const result = generateSimplePaths(params); + + const requestId = generateUuid(); + activeRequestId = requestId; + + const result = await generateSimplePaths({ ...params, requestId }); + + if (result === "cancelled") { + // eslint-disable-next-line no-console + console.info( + `Pathfinding request ${requestId} cancelled, superseded by a subsequent request`, + ); + return; + } + // eslint-disable-next-line no-restricted-globals self.postMessage({ type: "generateSimplePathsResult", diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx index 6b2467953b3..c3d6507bf95 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/search-control.tsx @@ -36,7 +36,7 @@ const Search = ({ for (const node of nodes) { const { nodeId } = node; - if (!node.nodeTypeId || !includeByNodeTypeId?.[node.nodeTypeId]) { + if (node.nodeTypeId && !includeByNodeTypeId?.[node.nodeTypeId]) { continue; } @@ -70,18 +70,38 @@ const Search = ({ }; const inputRef = useRef(null); + const panelRef = useRef(null); useEffect(() => { - if (open && inputRef.current) { - inputRef.current.focus(); + const panel = panelRef.current; + + if (open && inputRef.current && panel) { + /** + * Once the panel is fully transitioned in, focus the input. + * Doing so any earlier will cause the dropdn + */ + panel.ontransitionend = () => { + inputRef.current?.focus(); + }; } + + return () => { + if (panel) { + panel.ontransitionend = null; + } + }; }, [open]); return ( - + palette.gray[30] }} diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx index cc5e93433ff..5cf46685d75 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/config-control.tsx @@ -102,6 +102,7 @@ export type GraphVizConfig< pathfinding?: { startTypeId?: string; endTypeId?: string; + hideVia?: boolean; }; }; @@ -451,7 +452,7 @@ export const ConfigControl = () => { /> setConfigPanelOpen(true)} - sx={[controlButtonSx, { position: "absolute", top: 8, right: 46 }]} + sx={[controlButtonSx, { position: "absolute", top: 8, right: 13 }]} > diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx index 38ebe74f182..66b37e13f1a 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/control-components.tsx @@ -1,7 +1,7 @@ import { IconButton } from "@hashintel/design-system"; import { Box, Stack, type Theme, Tooltip, Typography } from "@mui/material"; import type { SystemStyleObject } from "@mui/system"; -import type { PropsWithChildren, ReactElement } from "react"; +import type { PropsWithChildren, ReactElement, RefObject } from "react"; import { ArrowRightToLineIcon } from "../../../../../shared/icons/arrow-right-to-line-icon"; import { CircleInfoIcon } from "../../../../../shared/icons/circle-info-icon"; @@ -83,16 +83,19 @@ export const ControlPanel = ({ children, onClose, open, + panelRef, position, title, }: PropsWithChildren<{ open: boolean; onClose: () => void; position: "left" | "right"; + panelRef?: RefObject; title: string; }>) => { return ( void; -}> = ({ isFiltered, nodeTypesInData, open, onClose }) => { +}> = ({ defaultFilters, isFiltered, nodeTypesInData, open, onClose }) => { const { filters, setFilters } = useGraphContext(); return ( @@ -73,14 +74,18 @@ const FilterPanel: FunctionComponent<{ */ ...filters.includeByNodeTypeId, /** - * Reset all types in the current graph to visible. + * Reset all types in the current graph to the default if provided */ - ...Object.values(nodeTypesInData).reduce< - Record - >((acc, type) => { - acc[type.nodeTypeId] = true; - return acc; - }, {}), + ...(defaultFilters + ? defaultFilters.includeByNodeTypeId /** + * Otherwise all types in the current graph to visible. + */ + : Object.values(nodeTypesInData).reduce< + Record + >((acc, type) => { + acc[type.nodeTypeId] = true; + return acc; + }, {})), }, }); }} @@ -96,7 +101,13 @@ const FilterPanel: FunctionComponent<{ ); }; -export const FilterControl = ({ nodes }: { nodes: GraphVizNode[] }) => { +export const FilterControl = ({ + defaultFilters, + nodes, +}: { + defaultFilters?: GraphVizFilters; + nodes: GraphVizNode[]; +}) => { const { filters, filterPanelOpen, setFilters, setFilterPanelOpen } = useGraphContext(); @@ -166,9 +177,14 @@ export const FilterControl = ({ nodes }: { nodes: GraphVizNode[] }) => { ); }, [filters.includeByNodeTypeId, nodeTypesInData]); + if (!Object.keys(nodeTypesInData).length) { + return null; + } + return ( <> { /> setFilterPanelOpen(true)} - sx={[controlButtonSx, { position: "absolute", top: 8, right: 13 }]} + sx={[controlButtonSx, { position: "absolute", top: 8, right: 46 }]} > { const listComponent = options.length > 200 ? VirtualizedListComp : undefined; + const { graphContainerRef } = useGraphContext(); + return ( autoFocus={!!autoFocus} @@ -107,6 +110,7 @@ export const SimpleAutocomplete = < }, }, popper: { + container: graphContainerRef.current, sx: { "& > div:first-of-type": { boxShadow: "none", @@ -156,7 +160,7 @@ export const SimpleAutocomplete = < option.label + (suffixKey && option[suffixKey] ? ` ${option[suffixKey]}` : ""); - const regex = /(\[[0-9]+]|\([0-9]+\)|[^[\]()]+)/g; + const regex = /(\[[0-9]+]|\([0-9]+\)|\(None\)|\[None]|[^[\]()]+)/g; const parts = label.match(regex); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts index f43abfd80e6..8f9a282bdd5 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/shared/use-set-draw-settings.ts @@ -124,10 +124,10 @@ export const useSetDrawSettings = < return; } - if ( - graphState.selectedNodeId === data.nodeId || - graphState.hoveredNodeId === data.nodeId - ) { + if (graphState.hoveredNodeId === data.nodeId) { + /** + * There's an inbuilt hover label renderer which we don't want to override + */ return; } diff --git a/apps/hash-frontend/src/pages/shared/table-views.tsx b/apps/hash-frontend/src/pages/shared/visualizer-views.tsx similarity index 72% rename from apps/hash-frontend/src/pages/shared/table-views.tsx rename to apps/hash-frontend/src/pages/shared/visualizer-views.tsx index ba6cc1a803a..6b64dfeddec 100644 --- a/apps/hash-frontend/src/pages/shared/table-views.tsx +++ b/apps/hash-frontend/src/pages/shared/visualizer-views.tsx @@ -5,11 +5,14 @@ import type { ReactElement } from "react"; import { ChartNetworkRegularIcon } from "../../shared/icons/chart-network-regular-icon"; import { GridSolidIcon } from "../../shared/icons/grid-solid-icon"; -const tableViews = ["Table", "Graph", "Grid"] as const; +const visualizerViews = ["Table", "Graph", "Grid"] as const; -export type TableView = (typeof tableViews)[number]; +export type VisualizerView = (typeof visualizerViews)[number]; -export const tableViewIcons: Record> = { +export const visualizerViewIcons: Record< + VisualizerView, + ReactElement +> = { Table: ( = ({ types, kind }) => { const router = useRouter(); - const [view, setView] = useState("Table"); + const [view, setView] = useState("Table"); const [showSearch, setShowSearch] = useState(false); @@ -179,6 +180,9 @@ export const TypesTable: FunctionComponent<{ [filterState.includeArchived, kind], ); + const currentlyDisplayedColumnsRef = useRef(null); + currentlyDisplayedColumnsRef.current = typesTableColumns; + const { users } = useUsers(); const { orgs } = useOrgs(); @@ -350,49 +354,36 @@ export const TypesTable: FunctionComponent<{ switch (column.id) { case "title": { - if (row.kind === "entity-type" || row.kind === "link-type") { - return { - kind: GridCellKind.Custom, - readonly: true, - allowOverlay: false, - copyData: row.title, - cursor: "pointer", - data: { - kind: "chip-cell", - chips: [ - { - icon: row.icon - ? { entityTypeIcon: row.icon } - : { - inbuiltIcon: - row.kind === "link-type" - ? "bpLink" - : "bpAsterisk", - }, - text: row.title, - onClick: () => { - setSelectedEntityType({ entityTypeId: row.typeId }); - }, - iconFill: theme.palette.blue[70], - }, - ], - color: "white", - variant: "outlined", - }, - }; - } else { - return { - kind: GridCellKind.Custom, - readonly: true, - allowOverlay: false, - copyData: row.title, - data: { - kind: "text-icon-cell", - icon: "bpAsterisk", - value: row.title, - }, - }; - } + return { + kind: GridCellKind.Custom, + readonly: true, + allowOverlay: false, + copyData: row.title, + cursor: "pointer", + data: { + kind: "chip-cell", + chips: [ + { + icon: row.icon + ? { entityTypeIcon: row.icon } + : { + inbuiltIcon: + row.kind === "link-type" ? "bpLink" : "bpAsterisk", + }, + text: row.title, + onClick: + row.kind === "entity-type" || row.kind === "link-type" + ? () => { + setSelectedEntityType({ entityTypeId: row.typeId }); + } + : undefined, + iconFill: theme.palette.blue[70], + }, + ], + color: "white", + variant: "outlined", + }, + }; } case "kind": return { @@ -512,20 +503,20 @@ export const TypesTable: FunctionComponent<{ ({ - icon: tableViewIcons[optionValue], - label: `${optionValue} view`, - value: optionValue, - }), - )} + options={( + ["Table", "Graph"] as const satisfies VisualizerView[] + ).map((optionValue) => ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} /> } internalWebIds={internalWebIds} itemLabelPlural="types" items={types} title={typesTablesToTitle[kind]} - columns={typesTableColumns} + currentlyDisplayedColumnsRef={currentlyDisplayedColumnsRef} currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} filterState={filterState} setFilterState={setFilterState} @@ -535,24 +526,25 @@ export const TypesTable: FunctionComponent<{ onBulkActionCompleted={() => setSelectedRows([])} /> {view === "Table" ? ( - setShowSearch(false)} - columns={typesTableColumns} - dataLoading={!types} - rows={filteredRows} - enableCheckboxSelection - selectedRows={selectedRows} - currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} - onSelectedRowsChange={(updatedSelectedRows) => - setSelectedRows(updatedSelectedRows) - } - sortable - sortRows={sortRows} - firstColumnLeftPadding={firstColumnLeftPadding} - createGetCellContent={createGetCellContent} - // define max height if there are lots of rows - height={` + + setShowSearch(false)} + columns={typesTableColumns} + dataLoading={!types} + rows={filteredRows} + enableCheckboxSelection + selectedRows={selectedRows} + currentlyDisplayedRowsRef={currentlyDisplayedRowsRef} + onSelectedRowsChange={(updatedSelectedRows) => + setSelectedRows(updatedSelectedRows) + } + sortable + sortRows={sortRows} + firstColumnLeftPadding={firstColumnLeftPadding} + createGetCellContent={createGetCellContent} + // define max height if there are lots of rows + height={` min( ${maxTableHeight}, calc( @@ -563,14 +555,15 @@ export const TypesTable: FunctionComponent<{ ${gridHorizontalScrollbarHeight}px ) )`} - customRenderers={[ - createRenderTextIconCell({ firstColumnLeftPadding }), - createRenderChipCell({ firstColumnLeftPadding }), - ]} - freezeColumns={1} - /> + customRenderers={[ + createRenderTextIconCell({ firstColumnLeftPadding }), + createRenderChipCell({ firstColumnLeftPadding }), + ]} + freezeColumns={1} + /> + ) : ( - + { + const previousDepsRef = useRef(); + + useEffect(() => { + if (previousDepsRef.current) { + const now = new Date(); + for (const [index, dep] of dependencies.entries()) { + if (dep !== previousDepsRef.current[index]) { + // eslint-disable-next-line no-console + console.info( + `[${now.toISOString()}]: Dependency at index ${index} (${labels[index]}) changed.`, + ); + } + } + } + previousDepsRef.current = dependencies; + }, [dependencies, labels]); +}; diff --git a/apps/hash-frontend/src/shared/table-content.ts b/apps/hash-frontend/src/shared/table-content.ts new file mode 100644 index 00000000000..994f926b3a4 --- /dev/null +++ b/apps/hash-frontend/src/shared/table-content.ts @@ -0,0 +1,12 @@ +import type { Theme } from "@mui/material"; +import type { SystemStyleObject } from "@mui/system"; + +export const tableContentSx: (theme: Theme) => SystemStyleObject = ({ + palette, +}) => ({ + border: `1px solid ${palette.gray[30]}`, + borderTop: "none", + borderBottomLeftRadius: 8, + borderBottomRightRadius: 8, + background: palette.common.white, +}); diff --git a/apps/hash-frontend/src/shared/table-header.tsx b/apps/hash-frontend/src/shared/table-header.tsx index 280698fab63..89fb37f2754 100644 --- a/apps/hash-frontend/src/shared/table-header.tsx +++ b/apps/hash-frontend/src/shared/table-header.tsx @@ -43,7 +43,7 @@ import { useCallback, useMemo, useState } from "react"; import type { Row } from "../components/grid/utils/rows"; import type { MinimalUser } from "../lib/user-and-org"; -import type { TypeEntitiesRow } from "../pages/shared/entities-table/use-entities-table"; +import type { TypeEntitiesRow } from "../pages/shared/entities-table/use-entities-table/types"; import type { TypesTableRow } from "../pages/types/[[...type-kind]].page/types-table"; import { EarthAmericasRegularIcon } from "./icons/earth-americas-regular"; import { FilterListIcon } from "./icons/filter-list-icon"; @@ -132,7 +132,7 @@ type TableHeaderProps = { filterState: FilterState; endAdornment?: ReactNode; title: string; - columns: SizedGridColumn[]; + currentlyDisplayedColumnsRef: MutableRefObject; currentlyDisplayedRowsRef: MutableRefObject; setFilterState: Dispatch>; toggleSearch?: () => void; @@ -146,7 +146,7 @@ const commonChipSx = { } as const satisfies SxProps; export const TableHeader: FunctionComponent = ({ - columns, + currentlyDisplayedColumnsRef, currentlyDisplayedRowsRef, endAdornment, filterState, @@ -190,11 +190,16 @@ export const TableHeader: FunctionComponent = ({ return null; } + const currentlyDisplayedColumns = currentlyDisplayedColumnsRef.current; + if (!currentlyDisplayedColumns) { + return null; + } + // Entity metadata columns (i.e. what's already being displayed in the entities table) - const columnRowKeys = columns.map(({ id }) => id).flat(); + const columnRowKeys = currentlyDisplayedColumns.map(({ id }) => id).flat(); - const tableContentColumnTitles = columns.map((column) => + const tableContentColumnTitles = currentlyDisplayedColumns.map((column) => /** * If the column is the entity label column, add the word "label" to the * column title. Otherwise we'd end up with an "Entity" or "Page" column title, @@ -220,6 +225,10 @@ export const TableHeader: FunctionComponent = ({ return (row as TypesTableRow).archived ? "Yes" : "No"; } else if (key === "sourceEntity" || key === "targetEntity") { return (row as TypeEntitiesRow).sourceEntity?.label ?? ""; + } else if (key === "entityTypes") { + return (row as TypeEntitiesRow).entityTypes + .map((type) => type.title) + .join(", "); } else { return stringifyPropertyValue(value); } @@ -229,7 +238,7 @@ export const TableHeader: FunctionComponent = ({ ]; return { title, content }; - }, [title, columns, currentlyDisplayedRowsRef]); + }, [title, currentlyDisplayedColumnsRef, currentlyDisplayedRowsRef]); return ( { diff --git a/libs/@local/hash-isomorphic-utils/src/create-apollo-client.ts b/libs/@local/hash-isomorphic-utils/src/create-apollo-client.ts index 9dab6e4e445..589eec51fb9 100644 --- a/libs/@local/hash-isomorphic-utils/src/create-apollo-client.ts +++ b/libs/@local/hash-isomorphic-utils/src/create-apollo-client.ts @@ -117,6 +117,9 @@ export const createApolloClient = (params?: { FlowRun: { keyFields: ["flowRunId"] }, }, }), + connectToDevTools: + process.env.NEXT_PUBLIC_VERCEL_ENV === "development" || + process.env.NODE_ENV === "development", credentials: "include", link, name: params?.name, diff --git a/libs/@local/hash-isomorphic-utils/src/generate-entity-label.ts b/libs/@local/hash-isomorphic-utils/src/generate-entity-label.ts index b9f2c0c2499..84548811afa 100644 --- a/libs/@local/hash-isomorphic-utils/src/generate-entity-label.ts +++ b/libs/@local/hash-isomorphic-utils/src/generate-entity-label.ts @@ -51,7 +51,7 @@ const getFallbackLabel = ({ return `${entityTypeName}${ includeHexChars - ? `-${extractEntityUuidFromEntityId(entityId).slice(0, 3)}` + ? `-${extractEntityUuidFromEntityId(entityId).slice(-4, -1)}` : "" }`; }; @@ -94,9 +94,7 @@ export function generateEntityLabel( const entityToLabel = entity ?? getRoots(entitySubgraph!)[0]!; if (!("properties" in entityToLabel)) { - throw new Error( - `Either one of 'entity' or an entity rooted subgraph must be provided`, - ); + throw new Error("No 'properties' object found in entity to label"); } let entityType: EntityTypeWithMetadata | undefined; diff --git a/package.json b/package.json index f84acee8a27..2ffe05fef2f 100644 --- a/package.json +++ b/package.json @@ -21,13 +21,13 @@ "dev:backend:search-loader": "turbo dev --log-order stream --filter '@apps/hash-search-loader' --", "dev:frontend": "turbo dev --log-order stream --filter '@apps/hash-frontend' --", "start": "turbo run start start:healthcheck --filter @apps/hash-graph --filter @rust/type-fetcher --filter @apps/hash-api --filter @apps/hash-ai-worker-ts --filter @apps/hash-integration-worker --filter @apps/hash-frontend --env-mode=loose", - "start:graph": "turbo run start start:healthcheck --filter @apps/hash-graph --filter @rust/type-fetcher --env-mode=loose", + "start:graph": "turbo run start start:healthcheck --filter @apps/hash-graph --filter @rust/hash-graph-type-fetcher --env-mode=loose", "start:backend": "turbo run start start:healthcheck --filter @apps/hash-api --env-mode=loose", "start:frontend": "turbo run start start:healthcheck --filter @apps/hash-frontend --env-mode=loose", - "start:graph:test-server": "turbo run start start:healthcheck --filter @rust/test-server", + "start:graph:test-server": "turbo run start start:healthcheck --filter @rust/hash-graph-test-server", "start:worker": "turbo run start start:healthcheck --filter '@apps/hash-*-worker*' --env-mode=loose", "start:test": "turbo run start:test start:test:healthcheck --env-mode=loose", - "graph:reset-database": "yarn workspace @rust/graph-http-tests reset-database", + "graph:reset-database": "yarn workspace @rust/hash-graph-http-tests reset-database", "external-services": "turbo deploy --filter '@apps/hash-external-services' --", "external-services:offline": "turbo deploy:offline --filter '@apps/hash-external-services' --", "external-services:test": "turbo deploy:test --filter '@apps/hash-external-services' --",