diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index 7e03fb9ff56..cfc40db8eea 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -1,4 +1,3 @@ -import { useQuery } from "@apollo/client"; import { Box, Stack, useTheme } from "@mui/material"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -9,20 +8,28 @@ import { getClosedMultiEntityTypeFromMap, type HashEntity, } from "@local/hash-graph-sdk/entity"; -import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; -import { countEntitiesQuery } from "../../graphql/queries/knowledge/entity.queries"; 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 { TableHeader, tableHeaderHeight } from "../../shared/table-header"; -import { generateUseEntityTypeEntitiesFilter } from "../../shared/use-entity-type-entities"; +import { BulkActionsDropdown } from "../../shared/table-header/bulk-actions-dropdown"; import { useMemoCompare } from "../../shared/use-memo-compare"; -import { usePollInterval } from "../../shared/use-poll-interval"; import { useAuthenticatedUser } from "./auth-info-context"; +import { createDefaultFilterState } from "./entities-visualizer/data/types"; +import { useAvailableTypes } from "./entities-visualizer/data/use-available-types"; import { EntitiesTable } from "./entities-visualizer/entities-table"; import { GridView } from "./entities-visualizer/entities-table/grid-view"; +import { + TableToolbar, + toolbarHeight, +} from "./entities-visualizer/entities-table/table-toolbar"; +import { FilterRibbon } from "./entities-visualizer/header/filter-ribbon"; +import { QueryCount } from "./entities-visualizer/header/query-count"; +import { + VisualizerHeader, + visualizerHeaderHeight, +} from "./entities-visualizer/header/visualizer-header"; import { useEntitiesVisualizerData } from "./entities-visualizer/use-entities-visualizer-data"; import { EntityGraphVisualizer } from "./entity-graph-visualizer"; import { useSlideStack } from "./slide-stack"; @@ -31,21 +38,12 @@ import { TOP_CONTEXT_BAR_HEIGHT } from "./top-context-bar"; import { visualizerViewIcons } from "./visualizer-views"; import type { ColumnSort } from "../../components/grid/utils/sorting"; -import type { - CountEntitiesQuery, - CountEntitiesQueryVariables, -} from "../../graphql/api-types.gen"; -import type { FilterState } from "../../shared/table-header"; +import type { EntitiesFilterState } from "./entities-visualizer/data/types"; import type { EntitiesTableRow, SortableEntitiesTableColumnKey, } from "./entities-visualizer/types"; import type { EntityEditorProps } from "./entity/entity-editor"; -import type { - DynamicNodeSizing, - GraphVizConfig, - GraphVizFilters, -} from "./graph-visualizer"; import type { VisualizerView } from "./visualizer-views"; import type { BaseUrl, @@ -63,7 +61,7 @@ import type { NullOrdering, Ordering, } from "@local/hash-graph-client"; -import type { FunctionComponent, ReactElement } from "react"; +import type { Dispatch, FunctionComponent, SetStateAction } from "react"; /** * @todo: avoid having to maintain this list, potentially by @@ -135,22 +133,6 @@ const generateGraphSort = ( }; export const EntitiesVisualizer: FunctionComponent<{ - /** - * The default filter to apply - */ - defaultFilter?: FilterState; - /** - * The default graph configuration to apply - */ - defaultGraphConfig?: GraphVizConfig; - /** - * The default graph filters to apply - */ - defaultGraphFilters?: GraphVizFilters; - /** - * The default visualizer view - */ - defaultView?: VisualizerView; /** * Limit the entities displayed to only those matching any version of this type */ @@ -159,45 +141,11 @@ export const EntitiesVisualizer: FunctionComponent<{ * Limit the entities displayed to only those matching this exact type version */ entityTypeId?: VersionedUrl; - /** - * 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"; - /** - * Hide the internal/external and archived filter controls - */ - hideFilters?: boolean; /** * Hide specific columns from the table */ hideColumns?: (keyof EntitiesTableRow)[]; - /** - * A custom component to display while loading data - */ - loadingComponent?: ReactElement; - /** - * The maximum height of the visualizer - */ - maxHeight?: string | number; - /** - * Whether to display in readonly mode (functionality such as archiving entities will be disabled) - */ - readonly?: boolean; -}> = ({ - defaultFilter, - defaultGraphConfig, - defaultGraphFilters, - defaultView = "Table", - entityTypeBaseUrl, - entityTypeId, - fullScreenMode, - hideColumns, - hideFilters, - loadingComponent: customLoadingComponent, - maxHeight, - readonly, -}) => { +}> = ({ entityTypeBaseUrl, entityTypeId, hideColumns }) => { const theme = useTheme(); const { authenticatedUser } = useAuthenticatedUser(); @@ -217,36 +165,46 @@ export const EntitiesVisualizer: FunctionComponent<{ }, ); - const [filterState, _setFilterState] = useState( - defaultFilter ?? { - includeArchived: false, - includeGlobal: false, - limitToWebs: false, - }, + const [filterState, _setFilterState] = useState(() => + createDefaultFilterState(internalWebIds), ); const [cursor, setCursor] = useState(); - const [activeConversionsWithoutTitle, setActiveConversions] = useState<{ + const [activeConversionsWithoutTitle, _setActiveConversions] = useState<{ [columnBaseUrl: BaseUrl]: VersionedUrl; } | null>(null); + const setActiveConversions = useCallback< + Dispatch< + SetStateAction<{ + [columnBaseUrl: BaseUrl]: VersionedUrl; + } | null> + > + >( + (newConversionsOrUpdater) => { + _setActiveConversions(newConversionsOrUpdater); + setCursor(undefined); + }, + [setCursor], + ); + const setFilterState = useCallback( ( newFilterStateOrUpdater: - | FilterState - | ((prev: FilterState) => FilterState), + | EntitiesFilterState + | ((prev: EntitiesFilterState) => EntitiesFilterState), ) => { - if (typeof newFilterStateOrUpdater === "function") { - _setFilterState(newFilterStateOrUpdater(filterState)); - } else { - _setFilterState(newFilterStateOrUpdater); - } + _setFilterState((prev) => + typeof newFilterStateOrUpdater === "function" + ? newFilterStateOrUpdater(prev) + : newFilterStateOrUpdater, + ); setCursor(undefined); }, - [filterState, setCursor], + [setCursor], ); - const [view, _setView] = useState(defaultView); + const [view, _setView] = useState("Table"); const setView = useCallback( (newView: VisualizerView) => { @@ -256,42 +214,25 @@ export const EntitiesVisualizer: FunctionComponent<{ [setCursor], ); - const pollInterval = usePollInterval(); - - /** - * We want to show the count of entities in external webs, and need to query this count separately: - * 1. When the user is requesting entities in their web only, the count for the main query doesn't include external webs. - * 2. When the user is requesting all entities, the count for the main query includes BOTH internal and external entities. - * - * So we need the count of external entities in both cases. - */ - const { data: externalWebsOnlyCountData } = useQuery< - CountEntitiesQuery, - CountEntitiesQueryVariables - >(countEntitiesQuery, { - pollInterval, - variables: { - request: { - filter: generateUseEntityTypeEntitiesFilter({ - excludeWebIds: internalWebIds, - entityTypeBaseUrl, - entityTypeIds: entityTypeId ? [entityTypeId] : undefined, - includeArchived: !!filterState.includeArchived, - }), - temporalAxes: currentTimeInstantTemporalAxes, - includeDrafts: false, - }, - }, - fetchPolicy: "network-only", - }); - - const [sort, setSort] = useState< + const [sort, _setSort] = useState< ColumnSort & { convertTo?: BaseUrl } >({ columnKey: "entityLabel", direction: "asc", }); + const setSort = useCallback( + ( + newSort: ColumnSort & { + convertTo?: BaseUrl; + }, + ) => { + _setSort(newSort); + setCursor(undefined); + }, + [setCursor], + ); + const graphSort = useMemo( () => generateGraphSort(sort.columnKey, sort.direction, sort.convertTo), [sort], @@ -309,13 +250,10 @@ export const EntitiesVisualizer: FunctionComponent<{ cursor, entityTypeBaseUrl, entityTypeIds: entityTypeId ? [entityTypeId] : undefined, + filterState, hideColumns, - /** - * Translate into archived filter in query - */ - includeArchived: !!filterState.includeArchived, + internalWebIds, limit: view === "Graph" ? undefined : 500, - webIds: filterState.includeGlobal ? undefined : internalWebIds, sort: graphSort, view, }); @@ -325,16 +263,11 @@ export const EntitiesVisualizer: FunctionComponent<{ const { count: totalCountFromEntityRequest, - createdByIds, cursor: nextCursor, definitions, - editionCreatedByIds, entities, closedMultiEntityTypes: closedMultiEntityTypesRootMap, - refetch: refetchWithoutLinks, subgraph, - typeIds, - typeTitles, webIds, } = visualizerData; @@ -402,22 +335,7 @@ export const EntitiesVisualizer: FunctionComponent<{ } }, [entitiesData]); - const internalEntitiesCount = - externalWebsOnlyCountData?.countEntities == null || - totalCountFromEntityRequest == null || - entitiesData.loading - ? undefined - : filterState.includeGlobal - ? totalCountFromEntityRequest - externalWebsOnlyCountData.countEntities - : totalCountFromEntityRequest; - - const totalResultCount = filterState.includeGlobal - ? (totalCountFromEntityRequest ?? null) - : (internalEntitiesCount ?? null); - - const loadingComponent = customLoadingComponent ?? ( - - ); + const totalResultCount = totalCountFromEntityRequest ?? null; const { isSpecialEntityTypeLookup } = useEntityTypesContextRequired(); @@ -452,9 +370,9 @@ export const EntitiesVisualizer: FunctionComponent<{ if (isDisplayingFilesOnly) { setView("Grid"); } else { - setView(defaultView); + setView("Table"); } - }, [defaultView, isDisplayingFilesOnly, setView]); + }, [isDisplayingFilesOnly, setView]); const isViewingOnlyPages = entityTypeBaseUrl === systemEntityTypes.page.entityTypeBaseUrl || @@ -506,13 +424,13 @@ export const EntitiesVisualizer: FunctionComponent<{ return () => observer.disconnect(); }, []); - const tableHeight = - maxHeight ?? - `min(600px, calc(100vh - ${ - contentTop != null - ? `${contentTop}px - ${theme.spacing(5)}` - : `(${HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 230 + tableHeaderHeight}px + ${theme.spacing(5)} + ${theme.spacing(5)})` - }))`; + const tableHeight = `min(600px, calc(100vh - ${ + contentTop != null + ? `${contentTop}px - ${theme.spacing(5)}` + : `(${ + HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 230 + visualizerHeaderHeight + }px + ${theme.spacing(5)} + ${theme.spacing(5)})` + }))`; const isPrimaryEntity = useCallback( (entity: { metadata: Pick }) => @@ -536,79 +454,103 @@ export const EntitiesVisualizer: FunctionComponent<{ setCursor(nextCursor ?? undefined); }, [nextCursor]); + const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; + + const { types: availableTypes, loading: availableTypesLoading } = + useAvailableTypes({ + filterState, + internalWebIds, + entityTypeBaseUrl, + entityTypeIds: entityTypeId ? [entityTypeId] : undefined, + }); + + const selectedEntities = useMemo(() => { + if (view !== "Table" || selectedTableRows.length === 0 || !entities) { + return []; + } + + const selectedEntityIds = new Set( + selectedTableRows.map(({ entityId }) => entityId), + ); + + return entities.filter((entity) => + selectedEntityIds.has(entity.metadata.recordId.entityId), + ); + }, [entities, selectedTableRows, view]); + + const handleBulkActionCompleted = useCallback(() => { + void entitiesData.refetch(); + setSelectedTableRows([]); + }, [entitiesData]); + + const showLoading = !subgraph || !closedMultiEntityTypesRootMap; + return ( - ({ - icon: visualizerViewIcons[optionValue], - label: `${optionValue} view`, - value: optionValue, - }))} - /> - } - filterState={filterState} - hideExportToCsv={view !== "Table"} - hideFilters={hideFilters} - itemLabelPlural={isViewingOnlyPages ? "pages" : "entities"} - loading={dataLoading} - onBulkActionCompleted={() => { - void refetchWithoutLinks(); - }} - numberOfExternalItems={ - externalWebsOnlyCountData?.countEntities ?? undefined - } - numberOfUserWebItems={internalEntitiesCount} - selectedItems={ - entities?.filter((entity) => - selectedTableRows.some( - ({ entityId }) => entity.metadata.recordId.entityId === entityId, - ), - ) ?? [] + 0 ? ( + + ) : ( + setFilterState(updater)} + /> + ) } - setFilterState={setFilterState} - title="Entities" - toggleSearch={ - view === "Table" - ? () => setShowTableSearch(!showTableSearch) - : undefined + right={ + <> + + ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} + /> + } /> - {!subgraph || !closedMultiEntityTypesRootMap ? ( + {showLoading ? ( - {loadingComponent} + + + ) : view === "Graph" ? ( + } isPrimaryEntity={isPrimaryEntity} onEntityClick={handleEntityClick} /> @@ -616,34 +558,41 @@ export const EntitiesVisualizer: FunctionComponent<{ ) : view === "Grid" ? ( ) : ( - + <> + + + )} ); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/build-filter.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/build-filter.ts new file mode 100644 index 00000000000..01a3cdf4576 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/build-filter.ts @@ -0,0 +1,179 @@ +import { ignoreNoisySystemTypesFilter } from "@local/hash-isomorphic-utils/graph-queries"; +import { systemPropertyTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; + +import type { EntitiesFilterState } from "./types"; +import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; +import type { Filter } from "@local/hash-graph-client"; + +const MATCH_NOTHING_WEB_ID = "00000000-0000-0000-0000-000000000000" as WebId; + +const buildArchivedClauses = (includeArchived: boolean): Filter[] => { + if (includeArchived) { + return []; + } + + return [ + { + notEqual: [{ path: ["archived"] }, { parameter: true }], + }, + { + any: [ + { + exists: { + path: [ + "properties", + systemPropertyTypes.archived.propertyTypeBaseUrl, + ], + }, + }, + { + equal: [ + { + path: [ + "properties", + systemPropertyTypes.archived.propertyTypeBaseUrl, + ], + }, + { parameter: false }, + ], + }, + ], + }, + ]; +}; + +const buildWebClause = ( + webState: EntitiesFilterState["web"], + internalWebIds: WebId[], +): Filter | null => { + if (!webState.includeOtherWebs) { + const selected = internalWebIds.filter((id) => + webState.selectedInternalWebIds.has(id), + ); + + const webIdsToMatch = selected.length ? selected : [MATCH_NOTHING_WEB_ID]; + + return { + any: webIdsToMatch.map((webId) => ({ + equal: [{ path: ["webId"] }, { parameter: webId }], + })), + }; + } + + const uncheckedInternalWebIds = internalWebIds.filter( + (id) => !webState.selectedInternalWebIds.has(id), + ); + + if (uncheckedInternalWebIds.length === 0) { + return null; + } + + return { + all: uncheckedInternalWebIds.map((webId) => ({ + notEqual: [{ path: ["webId"] }, { parameter: webId }], + })), + }; +}; + +const buildTypeClause = ({ + pinnedEntityTypeBaseUrl, + pinnedEntityTypeIds, + selectedTypeIds, +}: { + pinnedEntityTypeBaseUrl?: BaseUrl; + pinnedEntityTypeIds?: VersionedUrl[]; + selectedTypeIds: Set | null; +}): { clause: Filter | null; isPinned: boolean } => { + if (pinnedEntityTypeBaseUrl) { + return { + clause: { + equal: [ + { path: ["type", "baseUrl"] }, + { parameter: pinnedEntityTypeBaseUrl }, + ], + }, + isPinned: true, + }; + } + + if (pinnedEntityTypeIds?.length) { + return { + clause: { + any: pinnedEntityTypeIds.map((entityTypeId) => ({ + equal: [ + { path: ["type", "versionedUrl"] }, + { parameter: entityTypeId }, + ], + })), + }, + isPinned: true, + }; + } + + if (selectedTypeIds === null) { + return { clause: null, isPinned: false }; + } + + const typeIds = Array.from(selectedTypeIds); + + if (typeIds.length === 0) { + return { + clause: { + equal: [{ path: ["type", "versionedUrl"] }, { parameter: "" }], + }, + isPinned: false, + }; + } + + return { + clause: { + any: typeIds.map((entityTypeId) => ({ + equal: [ + { path: ["type", "versionedUrl"] }, + { parameter: entityTypeId }, + ], + })), + }, + isPinned: false, + }; +}; + +export const buildEntitiesFilter = ({ + filterState, + internalWebIds, + pinnedEntityTypeBaseUrl, + pinnedEntityTypeIds, +}: { + filterState: EntitiesFilterState; + internalWebIds: WebId[]; + pinnedEntityTypeBaseUrl?: BaseUrl; + pinnedEntityTypeIds?: VersionedUrl[]; +}): Filter => { + const clauses: Filter[] = []; + + clauses.push(...buildArchivedClauses(filterState.includeArchived)); + + const webClause = buildWebClause(filterState.web, internalWebIds); + if (webClause) { + clauses.push(webClause); + } + + const { clause: typeClause, isPinned: isTypePinned } = buildTypeClause({ + pinnedEntityTypeBaseUrl, + pinnedEntityTypeIds, + selectedTypeIds: filterState.type.selectedTypeIds, + }); + + if (typeClause) { + clauses.push(typeClause); + } + + const userPickedSpecificTypes = + !isTypePinned && filterState.type.selectedTypeIds !== null; + + if (!isTypePinned && !userPickedSpecificTypes) { + clauses.push(ignoreNoisySystemTypesFilter); + } + + return { all: clauses }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/traversal-paths.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/traversal-paths.ts new file mode 100644 index 00000000000..a2f1bb8fbac --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/traversal-paths.ts @@ -0,0 +1,34 @@ +import type { VisualizerView } from "../../visualizer-views"; +import type { TraversalPath } from "@local/hash-graph-client"; + +/** + * Graph view resolves links into and out of the displayed entities so link + * endpoints render even when the entity filter is narrow. + */ +const graphViewTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "incoming" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, +]; + +/** + * Table / Grid views only need to resolve a link entity's own source and + * target endpoints. + */ +const tableViewTraversalPaths: TraversalPath[] = [ + { + edges: [ + { kind: "has-left-entity", direction: "outgoing" }, + { kind: "has-right-entity", direction: "outgoing" }, + ], + }, +]; + +export const traversalPathsForView = ( + view: VisualizerView, +): TraversalPath[] => { + return view === "Graph" ? graphViewTraversalPaths : tableViewTraversalPaths; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts new file mode 100644 index 00000000000..27f3c4ad08f --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts @@ -0,0 +1,23 @@ +import type { VersionedUrl, WebId } from "@blockprotocol/type-system"; + +export type EntitiesFilterState = { + web: { + selectedInternalWebIds: Set; + includeOtherWebs: boolean; + }; + type: { + selectedTypeIds: Set | null; + }; + includeArchived: boolean; +}; + +export const createDefaultFilterState = ( + internalWebIds: WebId[], +): EntitiesFilterState => ({ + web: { + selectedInternalWebIds: new Set(internalWebIds), + includeOtherWebs: false, + }, + type: { selectedTypeIds: null }, + includeArchived: false, +}); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/use-available-types.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/use-available-types.ts new file mode 100644 index 00000000000..5760de9204b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/use-available-types.ts @@ -0,0 +1,91 @@ +import { useQuery } from "@apollo/client"; +import { useMemo } from "react"; + +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; + +import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries"; +import { buildEntitiesFilter } from "./build-filter"; + +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../../graphql/api-types.gen"; +import type { EntitiesFilterState } from "./types"; +import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; + +export type AvailableType = { + entityTypeId: VersionedUrl; + title: string; + count: number; +}; + +export const useAvailableTypes = ({ + filterState, + internalWebIds, + entityTypeBaseUrl, + entityTypeIds, +}: { + filterState: EntitiesFilterState; + internalWebIds: WebId[]; + entityTypeBaseUrl?: BaseUrl; + entityTypeIds?: VersionedUrl[]; +}): { types: AvailableType[]; loading: boolean } => { + const skip = !!entityTypeBaseUrl || !!entityTypeIds?.length; + + const filterStateWithoutType = useMemo( + () => ({ + ...filterState, + type: { selectedTypeIds: null }, + }), + [filterState], + ); + + const filter = useMemo( + () => + buildEntitiesFilter({ + filterState: filterStateWithoutType, + internalWebIds, + }), + [filterStateWithoutType, internalWebIds], + ); + + const { data, loading } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + skip, + fetchPolicy: "cache-and-network", + variables: { + request: { + limit: 1, + filter, + includeTypeIds: true, + includeTypeTitles: true, + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includePermissions: false, + traversalPaths: [], + }, + }, + }); + + const types = useMemo(() => { + if (skip || !data) { + return []; + } + const typeIds = data.queryEntitySubgraph.typeIds ?? {}; + const typeTitles = data.queryEntitySubgraph.typeTitles ?? {}; + return Object.entries(typeIds) + .map(([entityTypeId, count]) => { + const versionedUrl = entityTypeId as VersionedUrl; + return { + entityTypeId: versionedUrl, + title: typeTitles[versionedUrl] ?? entityTypeId, + count, + }; + }) + .sort((a, b) => a.title.localeCompare(b.title)); + }, [data, skip]); + + return { types, loading: skip ? false : loading }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx index 6b254cff823..80cd11493f3 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx @@ -7,8 +7,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { extractBaseUrl, extractEntityUuidFromEntityId, - extractVersion, - extractWebIdFromEntityId, isBaseUrl, } from "@blockprotocol/type-system"; import { ArrowDownRegularIcon, LoadingSpinner } from "@hashintel/design-system"; @@ -41,7 +39,6 @@ import type { } from "../../../components/grid/grid"; import type { BlankCell } from "../../../components/grid/utils"; import type { CustomIcon } from "../../../components/grid/utils/custom-grid-icons"; -import type { ColumnFilter } from "../../../components/grid/utils/filtering"; import type { FindDataTypeConversionTargetsQuery, FindDataTypeConversionTargetsQueryVariables, @@ -50,13 +47,9 @@ import type { ChipCellProps } from "../chip-cell"; import type { UrlCellProps } from "../url-cell"; import type { TextIconCell } from "./entities-table/text-icon-cell"; import type { - ActorTableFilterData, - EntitiesTableColumnKey, EntitiesTableData, EntitiesTableRow, - EntityTypeTableFilterData, SortableEntitiesTableColumnKey, - WebTableFilterData, } from "./types"; import type { EntitiesVisualizerData } from "./use-entities-visualizer-data"; import type { @@ -97,16 +90,7 @@ const emptyTableData: EntitiesTableData = { }; export const EntitiesTable: FunctionComponent< - Pick< - EntitiesVisualizerData, - | "createdByIds" - | "definitions" - | "editionCreatedByIds" - | "subgraph" - | "typeIds" - | "typeTitles" - | "webIds" - > & { + Pick & { activeConversions: { [columnBaseUrl: BaseUrl]: { dataTypeId: VersionedUrl; @@ -121,7 +105,6 @@ export const EntitiesTable: FunctionComponent< isViewingOnlyPages: boolean; maxHeight: string | number; loadMoreRows?: () => void; - readonly?: boolean; selectedRows: EntitiesTableRow[]; setActiveConversions: Dispatch< SetStateAction<{ @@ -143,18 +126,15 @@ export const EntitiesTable: FunctionComponent< } > = ({ activeConversions, - createdByIds, currentlyDisplayedColumnsRef, currentlyDisplayedRowsRef, definitions, disableTypeClick, - editionCreatedByIds, handleEntityClick, loading: entityDataLoading, isViewingOnlyPages, maxHeight, loadMoreRows, - readonly, selectedRows, setActiveConversions, setSelectedRows, @@ -165,22 +145,27 @@ export const EntitiesTable: FunctionComponent< sort, tableData, totalResultCount, - typeIds, - typeTitles, webIds, }) => { const router = useRouter(); const getOwnerForEntity = useGetOwnerForEntity(); - const editorActorIds = useMemo(() => { - const editorIds = new Set([ - ...typedKeys(editionCreatedByIds ?? {}), - ...typedKeys(createdByIds ?? {}), - ]); + const { + columns, + entityTypesWithMultipleVersionsPresent, + rows, + visibleDataTypeIdsByPropertyBaseUrl, + } = tableData ?? emptyTableData; + const editorActorIds = useMemo(() => { + const editorIds = new Set(); + for (const row of rows) { + editorIds.add(row.lastEditedById); + editorIds.add(row.createdById); + } return [...editorIds]; - }, [createdByIds, editionCreatedByIds]); + }, [rows]); const { actors } = useActors({ accountIds: editorActorIds, @@ -216,13 +201,6 @@ export const EntitiesTable: FunctionComponent< return webNameByOwner; }, [getOwnerForEntity, webIds]); - const { - columns, - entityTypesWithMultipleVersionsPresent, - rows, - visibleDataTypeIdsByPropertyBaseUrl, - } = tableData ?? emptyTableData; - const visibleDataTypeIds = useMemoCompare( () => { return Array.from( @@ -667,203 +645,12 @@ export const EntitiesTable: FunctionComponent< ], ); - const { createdByActors, entityTypeFilters, lastEditedByActors, webs } = - useMemo<{ - createdByActors: ActorTableFilterData[]; - lastEditedByActors: ActorTableFilterData[]; - entityTypeFilters: EntityTypeTableFilterData[]; - webs: WebTableFilterData[]; - }>(() => { - const createdBy: ActorTableFilterData[] = []; - for (const [actorId, count] of typedEntries(createdByIds ?? {})) { - const actor = actorsByAccountId[actorId]; - createdBy.push({ - actorId, - count, - displayName: actor?.displayName ?? actorId, - }); - } - - const editedBy: ActorTableFilterData[] = []; - for (const [actorId, count] of typedEntries(editionCreatedByIds ?? {})) { - const actor = actorsByAccountId[actorId]; - editedBy.push({ - actorId, - count, - displayName: actor?.displayName ?? actorId, - }); - } - - const types: EntityTypeTableFilterData[] = []; - for (const [entityTypeId, count] of typedEntries(typeIds ?? {})) { - const title = typeTitles?.[entityTypeId]; - - if (!title) { - throw new Error( - `Could not find title for entity type ${entityTypeId}`, - ); - } - - types.push({ - count, - entityTypeId, - title, - }); - } - - const webCounts: WebTableFilterData[] = []; - for (const [webId, count] of typedEntries(webIds ?? {})) { - const webname = webNameByWebId[webId] ?? webId; - webCounts.push({ - count, - shortname: `@${webname}`, - webId, - }); - } - - return { - createdByActors: createdBy, - entityTypeFilters: types, - lastEditedByActors: editedBy, - webs: webCounts, - }; - }, [ - actorsByAccountId, - createdByIds, - editionCreatedByIds, - typeIds, - typeTitles, - webIds, - webNameByWebId, - ]); - - const [selectedEntityTypeIds, setSelectedEntityTypeIds] = useState< - Set - >(new Set(entityTypeFilters.map(({ entityTypeId }) => entityTypeId))); - - useEffect(() => { - setSelectedEntityTypeIds( - new Set(entityTypeFilters.map(({ entityTypeId }) => entityTypeId)), - ); - }, [entityTypeFilters]); - - const [selectedLastEditedByAccountIds, setSelectedLastEditedByAccountIds] = - useState>( - new Set(lastEditedByActors.map(({ actorId }) => actorId)), - ); - - const [selectedCreatedByAccountIds, setSelectedCreatedByAccountIds] = - useState>( - new Set(createdByActors.map(({ actorId }) => actorId)), - ); - - useEffect(() => { - setSelectedLastEditedByAccountIds( - new Set(lastEditedByActors.map(({ actorId }) => actorId)), - ); - }, [lastEditedByActors]); - - useEffect(() => { - setSelectedCreatedByAccountIds( - new Set(createdByActors.map(({ actorId }) => actorId)), - ); - }, [createdByActors]); - - const [selectedWebs, setSelectedWebs] = useState>( - new Set(webs.map(({ webId }) => webId)), - ); - - useEffect(() => { - setSelectedWebs(new Set(webs.map(({ webId }) => webId))); - }, [webs]); - - const columnFilters = useMemo< - ColumnFilter[] - >( - () => [ - { - columnKey: "webId", - filterItems: webs.map(({ shortname, webId, count: _count }) => ({ - id: webId, - label: shortname, - // @todo H-3841 –- rethink filtering - // count, - })), - selectedFilterItemIds: selectedWebs, - setSelectedFilterItemIds: setSelectedWebs, - isRowFiltered: (row) => - !selectedWebs.has(extractWebIdFromEntityId(row.entityId)), - }, - { - columnKey: "entityTypes", - filterItems: entityTypeFilters.map( - ({ entityTypeId, count: _count, title }) => ({ - id: entityTypeId, - label: title, - // @todo H-3841 –- rethink filtering - // count, - labelSuffix: entityTypesWithMultipleVersionsPresent.has( - entityTypeId, - ) - ? `v${extractVersion(entityTypeId).toString()}` - : undefined, - }), - ), - selectedFilterItemIds: selectedEntityTypeIds, - setSelectedFilterItemIds: setSelectedEntityTypeIds, - isRowFiltered: (row) => { - return !row.entityTypes.some(({ entityTypeId }) => - selectedEntityTypeIds.has(entityTypeId), - ); - }, - }, - { - columnKey: "lastEditedById", - filterItems: lastEditedByActors.map((actor) => ({ - id: actor.actorId, - label: actor.displayName ?? "Unknown Actor", - })), - selectedFilterItemIds: selectedLastEditedByAccountIds, - setSelectedFilterItemIds: setSelectedLastEditedByAccountIds, - isRowFiltered: (row) => - row.lastEditedById && row.lastEditedById !== "loading" - ? !selectedLastEditedByAccountIds.has(row.lastEditedById) - : false, - }, - { - columnKey: "createdById", - filterItems: createdByActors.map((actor) => ({ - id: actor.actorId, - label: actor.displayName ?? "Unknown Actor", - })), - selectedFilterItemIds: selectedCreatedByAccountIds, - setSelectedFilterItemIds: setSelectedCreatedByAccountIds, - isRowFiltered: (row) => - row.createdById && row.createdById !== "loading" - ? !selectedCreatedByAccountIds.has(row.createdById) - : false, - }, - ], - [ - createdByActors, - entityTypeFilters, - entityTypesWithMultipleVersionsPresent, - lastEditedByActors, - selectedEntityTypeIds, - selectedCreatedByAccountIds, - selectedLastEditedByAccountIds, - selectedWebs, - webs, - ], - ); - const sortableColumns: SortableEntitiesTableColumnKey[] = useMemo(() => { return [ "archived", "created", "entityLabel", "entityTypes", - "entityLabel", "lastEdited", ...columns.map((column) => column.id).filter((key) => isBaseUrl(key)), ]; @@ -965,17 +752,16 @@ export const EntitiesTable: FunctionComponent< const loadMoreRowHeight = 60; return ( - + ({ + alignItems: "center", + justifyContent: "center", background: palette.common.white, borderTop: `1px solid ${palette.gray[20]}`, height: loadMoreRowHeight, diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/generate-csv-file.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/generate-csv-file.ts new file mode 100644 index 00000000000..39e4c55e5c5 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/generate-csv-file.ts @@ -0,0 +1,48 @@ +import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; + +import type { EntitiesTableRow } from "../types"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; + +export type CsvFile = { + title: string; + content: string[][]; +}; + +export const generateEntitiesCsvFile = ({ + columns, + rows, + title, +}: { + columns: SizedGridColumn[]; + rows: EntitiesTableRow[]; + title: string; +}): CsvFile => { + const columnRowKeys = columns.map(({ id }) => id); + + const tableContentColumnTitles = columns.map((column) => + column.id === "entityLabel" ? `${column.title} label` : column.title, + ); + + const content: string[][] = [ + tableContentColumnTitles, + ...rows.map((row) => + columnRowKeys.map((key) => { + const value = row[key as keyof EntitiesTableRow]; + + if (typeof value === "string") { + return value; + } else if (key === "archived") { + return row.archived ? "Yes" : "No"; + } else if (key === "sourceEntity" || key === "targetEntity") { + return row.sourceEntity?.label ?? ""; + } else if (key === "entityTypes") { + return row.entityTypes.map((type) => type.title).join(", "); + } else { + return stringifyPropertyValue(value); + } + }), + ), + ]; + + return { title, content }; +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx new file mode 100644 index 00000000000..fd80e5c1aaf --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx @@ -0,0 +1,150 @@ +import { Box, ListItemText, Menu, Tooltip } from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; +import { useMemo } from "react"; + +import { isBaseUrl } from "@blockprotocol/type-system"; +import { CaretDownSolidIcon } from "@hashintel/design-system"; + +import { ArrowDownAZRegularIcon } from "../../../../shared/icons/arrow-down-a-z-regular-icon"; +import { ArrowUpZARegularIcon } from "../../../../shared/icons/arrow-up-a-z-regular-icon"; +import { TableHeaderButton } from "../../../../shared/table-header/table-header-button"; +import { MenuItem } from "../../../../shared/ui"; + +import type { GridSort } from "../../../../components/grid/grid"; +import type { + EntitiesTableColumnKey, + SortableEntitiesTableColumnKey, +} from "../types"; +import type { BaseUrl } from "@blockprotocol/type-system"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import type { FunctionComponent } from "react"; + +type SortControlProps = { + columns: SizedGridColumn[]; + sort: GridSort; + setSort: ( + sort: GridSort & { + convertTo?: BaseUrl; + }, + ) => void; +}; + +const staticSortOptions: { + columnKey: Extract< + SortableEntitiesTableColumnKey, + "entityLabel" | "lastEdited" | "created" | "entityTypes" | "archived" + >; + label: string; +}[] = [ + { columnKey: "entityLabel", label: "Entity" }, + { columnKey: "lastEdited", label: "Last Edited" }, + { columnKey: "created", label: "Created" }, + { columnKey: "entityTypes", label: "Entity Type" }, + { columnKey: "archived", label: "Archived" }, +]; + +export const SortControl: FunctionComponent = ({ + columns, + sort, + setSort, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-sort-control", + }); + + const options = useMemo(() => { + const propertyColumnOptions: { + columnKey: SortableEntitiesTableColumnKey; + label: string; + }[] = []; + + for (const column of columns) { + const columnId = column.id as EntitiesTableColumnKey | undefined; + if (columnId && isBaseUrl(columnId)) { + propertyColumnOptions.push({ + columnKey: columnId, + label: column.title, + }); + } + } + + return [...staticSortOptions, ...propertyColumnOptions]; + }, [columns]); + + const activeLabel = + options.find((option) => option.columnKey === sort.columnKey)?.label ?? + sort.columnKey; + + const handleSelect = (columnKey: SortableEntitiesTableColumnKey) => { + if (columnKey === sort.columnKey) { + setSort({ + columnKey, + direction: sort.direction === "asc" ? "desc" : "asc", + }); + } else { + setSort({ columnKey, direction: "asc" }); + } + popupState.close(); + }; + + const DirectionIcon = + sort.direction === "asc" ? ArrowDownAZRegularIcon : ArrowUpZARegularIcon; + + return ( + + + } + endIcon={ + + } + sx={{ borderRadius: "4px", px: 1.25 }} + > + Sort: {activeLabel} + + + + {options.map((option) => { + const isActive = option.columnKey === sort.columnKey; + return ( + handleSelect(option.columnKey)} + sx={{ minWidth: 220 }} + > + + {isActive && ( + + + + )} + + ); + })} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/table-toolbar.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/table-toolbar.tsx new file mode 100644 index 00000000000..8601dcf4239 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/table-toolbar.tsx @@ -0,0 +1,120 @@ +import { Box, Tooltip, type SxProps } from "@mui/material"; +import { unparse } from "papaparse"; +import { useCallback } from "react"; + +import { IconButton } from "@hashintel/design-system"; + +import { MagnifyingGlassRegularIcon } from "../../../../shared/icons/magnifying-glass-regular-icon"; +import { TableHeaderButton } from "../../../../shared/table-header/table-header-button"; +import { generateEntitiesCsvFile } from "./generate-csv-file"; +import { SortControl } from "./sort-control"; + +import type { GridSort } from "../../../../components/grid/grid"; +import type { + EntitiesTableRow, + SortableEntitiesTableColumnKey, +} from "../types"; +import type { BaseUrl } from "@blockprotocol/type-system"; +import type { SizedGridColumn } from "@glideapps/glide-data-grid"; +import type { FunctionComponent, MutableRefObject, RefObject } from "react"; + +export const toolbarHeight = 44; + +const groupSx: SxProps = { + display: "flex", + alignItems: "center", + columnGap: 1, +}; + +type TableToolbarProps = { + csvFileTitle: string; + currentlyDisplayedColumnsRef: MutableRefObject; + currentlyDisplayedRowsRef: RefObject; + displayedColumns: SizedGridColumn[]; + showSearch: boolean; + setShowSearch: (showSearch: boolean) => void; + sort: GridSort; + setSort: ( + sort: GridSort & { + convertTo?: BaseUrl; + }, + ) => void; +}; + +export const TableToolbar: FunctionComponent = ({ + csvFileTitle, + currentlyDisplayedColumnsRef, + currentlyDisplayedRowsRef, + displayedColumns, + showSearch, + setShowSearch, + sort, + setSort, +}) => { + const handleExportToCsv = useCallback(() => { + const columns = currentlyDisplayedColumnsRef.current; + const rows = currentlyDisplayedRowsRef.current; + + if (!columns || !rows) { + return; + } + + const { title, content } = generateEntitiesCsvFile({ + columns, + rows, + title: csvFileTitle, + }); + + const stringifiedContent = unparse(content); + + const blob = new Blob([stringifiedContent], { + type: "text/csv;charset=utf-8;", + }); + + const link = document.createElement("a"); + link.href = URL.createObjectURL(blob); + link.setAttribute("download", `${title}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [csvFileTitle, currentlyDisplayedColumnsRef, currentlyDisplayedRowsRef]); + + return ( + palette.common.white, + borderLeftWidth: 1, + borderRightWidth: 1, + borderBottomWidth: 1, + borderStyle: "solid", + borderColor: ({ palette }) => palette.gray[30], + px: 1.5, + py: 0.5, + gap: 1.5, + minHeight: toolbarHeight, + }} + > + + + setShowSearch(!showSearch)}> + + + + + + Export + + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/use-entities-table/generate-table-data-from-rows.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/use-entities-table/generate-table-data-from-rows.ts index edb126b7756..328b4247139 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/use-entities-table/generate-table-data-from-rows.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/use-entities-table/generate-table-data-from-rows.ts @@ -251,7 +251,7 @@ export const generateTableDataFromRows = ( } if (!propertyColumnsMap.has(baseUrl)) { - const width = getTextWidth(propertyType.title) + 85; + const width = getTextWidth(propertyType.title) + 100; propertyColumnsMap.set(baseUrl, { id: baseUrl, diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/add-filters-menu.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/add-filters-menu.tsx new file mode 100644 index 00000000000..bef24719a60 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/add-filters-menu.tsx @@ -0,0 +1,56 @@ +import { Box, ListItemText, Menu } from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; + +import { Chip } from "@hashintel/design-system"; + +import { PlusRegularIcon } from "../../../../shared/icons/plus-regular"; +import { MenuItem } from "../../../../shared/ui"; +import { dashedPillSx } from "./pill-styles"; + +import type { FunctionComponent } from "react"; + +type AddFiltersMenuProps = { + onAddIncludeArchived: () => void; +}; + +export const AddFiltersMenu: FunctionComponent = ({ + onAddIncludeArchived, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-add-filters-menu", + }); + + const handleSelectIncludeArchived = () => { + onAddIncludeArchived(); + popupState.close(); + }; + + return ( + + palette.primary.main }} + /> + } + label="Add filter" + sx={dashedPillSx} + {...bindTrigger(popupState)} + /> + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/clear-filters-button.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/clear-filters-button.tsx new file mode 100644 index 00000000000..a681d0beeae --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/clear-filters-button.tsx @@ -0,0 +1,23 @@ +import { XMarkRegularIcon } from "@hashintel/design-system"; + +import { Button } from "../../../../shared/ui"; + +import type { FunctionComponent } from "react"; + +type ClearFiltersButtonProps = { + onClear: () => void; +}; + +export const ClearFiltersButton: FunctionComponent = ({ + onClear, +}) => ( + +); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-pill.tsx new file mode 100644 index 00000000000..1c6d34d07c2 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-pill.tsx @@ -0,0 +1,66 @@ +import { Box, Typography } from "@mui/material"; +import { bindTrigger } from "material-ui-popup-state/hooks"; + +import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; + +import { activePillSx, defaultPillSx } from "./pill-styles"; + +import type { SvgIconProps } from "@mui/material"; +import type { PopupState } from "material-ui-popup-state/hooks"; +import type { ComponentType, FunctionComponent } from "react"; + +type FilterPillProps = { + icon: ComponentType; + prefix: string; + value: string; + active: boolean; + popupState: PopupState; +}; + +export const FilterPill: FunctionComponent = ({ + icon: Icon, + prefix, + value, + active, + popupState, +}) => ( + palette.primary.main }} />} + label={ + + + active ? palette.blue[70] : palette.gray[60], + }} + > + {prefix} + + + active ? palette.blue[90] : palette.gray[80], + }} + > + {value} + + + + } + sx={active ? activePillSx : defaultPillSx} + {...bindTrigger(popupState)} + /> +); diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx new file mode 100644 index 00000000000..d23844d1585 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx @@ -0,0 +1,102 @@ +import { Box } from "@mui/material"; + +import { createDefaultFilterState } from "../data/types"; +import { AddFiltersMenu } from "./add-filters-menu"; +import { ClearFiltersButton } from "./clear-filters-button"; +import { IncludeArchivedPill } from "./include-archived-pill"; +import { TypeFilterPill } from "./type-filter-pill"; +import { WebFilterPill } from "./web-filter-pill"; + +import type { EntitiesFilterState } from "../data/types"; +import type { AvailableType } from "../data/use-available-types"; +import type { WebId } from "@blockprotocol/type-system"; +import type { FunctionComponent } from "react"; + +type FilterRibbonProps = { + availableTypes: AvailableType[]; + availableTypesLoading: boolean; + filterState: EntitiesFilterState; + internalWebIds: WebId[]; + isTypePinned: boolean; + setFilterState: ( + updater: (prev: EntitiesFilterState) => EntitiesFilterState, + ) => void; +}; + +const isWebFilterDefault = ( + web: EntitiesFilterState["web"], + internalWebIds: WebId[], +) => { + if (web.includeOtherWebs) { + return false; + } + + return internalWebIds.every((id) => web.selectedInternalWebIds.has(id)); +}; + +const isTypeFilterDefault = ( + type: EntitiesFilterState["type"], + availableTypes: AvailableType[], +) => { + if (type.selectedTypeIds === null) { + return true; + } + + return availableTypes.every(({ entityTypeId }) => + type.selectedTypeIds!.has(entityTypeId), + ); +}; + +export const FilterRibbon: FunctionComponent = ({ + availableTypes, + availableTypesLoading, + filterState, + internalWebIds, + isTypePinned, + setFilterState, +}) => { + const setIncludeArchived = (includeArchived: boolean) => + setFilterState((prev) => ({ ...prev, includeArchived })); + + const webIsDefault = isWebFilterDefault(filterState.web, internalWebIds); + const typeIsDefault = + isTypePinned || isTypeFilterDefault(filterState.type, availableTypes); + const archivedIsDefault = !filterState.includeArchived; + + const filtersAreDefault = webIsDefault && typeIsDefault && archivedIsDefault; + + const handleClear = () => { + setFilterState(() => createDefaultFilterState(internalWebIds)); + }; + + const allExtraFiltersEnabled = filterState.includeArchived; + + return ( + + + setFilterState((prev) => ({ ...prev, web: updater(prev.web) })) + } + /> + {!isTypePinned && ( + + setFilterState((prev) => ({ ...prev, type: updater(prev.type) })) + } + /> + )} + {filterState.includeArchived && ( + setIncludeArchived(false)} /> + )} + {!allExtraFiltersEnabled && ( + setIncludeArchived(true)} /> + )} + {!filtersAreDefault && } + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/include-archived-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/include-archived-pill.tsx new file mode 100644 index 00000000000..8d28105a336 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/include-archived-pill.tsx @@ -0,0 +1,51 @@ +import { Box } from "@mui/material"; + +import { Chip, IconButton, XMarkRegularIcon } from "@hashintel/design-system"; + +import { BoxArchiveIcon } from "../../../../shared/icons/box-archive-icon"; +import { activePillSx } from "./pill-styles"; + +import type { FunctionComponent } from "react"; + +type IncludeArchivedPillProps = { + onRemove: () => void; +}; + +export const IncludeArchivedPill: FunctionComponent< + IncludeArchivedPillProps +> = ({ onRemove }) => { + return ( + + palette.blue[70] }} /> + } + label={ + + Include archived + palette.blue[70], + "&:hover": { + color: ({ palette }) => palette.blue[90], + background: "transparent", + }, + }} + > + + + + } + sx={activePillSx} + /> + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/pill-styles.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/pill-styles.ts new file mode 100644 index 00000000000..40be96add8b --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/pill-styles.ts @@ -0,0 +1,37 @@ +import { chipClasses } from "@mui/material"; + +import type { SxProps, Theme } from "@mui/material"; + +const basePillSx = { + height: 26, + borderRadius: "4px", + background: ({ palette }: Theme) => palette.gray[5], + [`.${chipClasses.label}`]: { + fontSize: 13, + color: ({ palette }: Theme) => palette.gray[70], + }, +} satisfies SxProps; + +export const defaultPillSx: SxProps = { + ...basePillSx, + border: ({ palette }: Theme) => `1px solid ${palette.gray[30]}`, +}; + +export const dashedPillSx: SxProps = { + ...basePillSx, + border: ({ palette }: Theme) => `1px dashed ${palette.gray[30]}`, +}; + +export const activePillSx: SxProps = { + height: 26, + borderRadius: "4px", + border: ({ palette }: Theme) => `1px solid ${palette.blue[40]}`, + background: ({ palette }: Theme) => palette.blue[15], + [`.${chipClasses.label}`]: { + fontSize: 13, + color: ({ palette }: Theme) => palette.blue[90], + }, + "&:hover": { + background: ({ palette }: Theme) => palette.blue[20], + }, +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx new file mode 100644 index 00000000000..08a79ceac0d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx @@ -0,0 +1,43 @@ +import { Box, useTheme } from "@mui/material"; + +import { LoadingSpinner } from "@hashintel/design-system"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; + +import type { FunctionComponent } from "react"; + +type QueryCountProps = { + count: number | null | undefined; + loading: boolean; +}; + +export const QueryCount: FunctionComponent = ({ + count, + loading, +}) => { + const theme = useTheme(); + + return ( + palette.gray[70], + fontSize: 13, + fontWeight: 500, + justifyContent: "flex-end", + }} + > + {loading ? ( + <> + + Loading + + ) : count != null ? ( + `${formatNumber(count)} ${count === 1 ? "entity" : "entities"}` + ) : ( + "–" + )} + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx new file mode 100644 index 00000000000..0321a4dc378 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx @@ -0,0 +1,403 @@ +import { Box, ListItemText, Menu, Typography } from "@mui/material"; +import { bindMenu, usePopupState } from "material-ui-popup-state/hooks"; +import { useCallback, useMemo, useState } from "react"; + +import { MenuCheckboxItem, TextField } from "@hashintel/design-system"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; + +import { AsteriskLightIcon } from "../../../../shared/icons/asterisk-light-icon"; +import { FilterPill } from "./filter-pill"; + +import type { EntitiesFilterState } from "../data/types"; +import type { AvailableType } from "../data/use-available-types"; +import type { VersionedUrl } from "@blockprotocol/type-system"; +import type { FunctionComponent } from "react"; + +type TypeFilterPillProps = { + availableTypes: AvailableType[]; + loading: boolean; + typeState: EntitiesFilterState["type"]; + setTypeState: ( + updater: (prev: EntitiesFilterState["type"]) => EntitiesFilterState["type"], + ) => void; +}; + +const isAllSelected = ({ + selectedTypeIds, + allAvailableIds, +}: { + selectedTypeIds: Set | null; + allAvailableIds: VersionedUrl[]; +}) => { + if (selectedTypeIds === null) { + return true; + } + if (allAvailableIds.length === 0) { + return false; + } + if (selectedTypeIds.size !== allAvailableIds.length) { + return false; + } + return allAvailableIds.every((id) => selectedTypeIds.has(id)); +}; + +const buildLabel = ({ + availableTypes, + selectedTypeIds, + allAvailableIds, +}: { + availableTypes: AvailableType[]; + selectedTypeIds: Set | null; + allAvailableIds: VersionedUrl[]; +}): string => { + if (isAllSelected({ selectedTypeIds, allAvailableIds })) { + return "All types"; + } + + const count = selectedTypeIds?.size ?? 0; + + if (count === 0) { + return "No types"; + } + + if (count === 1) { + const [only] = selectedTypeIds!; + const match = availableTypes.find((type) => type.entityTypeId === only); + return match?.title ?? "1 type"; + } + + return `${count} types`; +}; + +type TypeFilterMenuItemProps = { + entityTypeId: VersionedUrl; + title: string; + count: number; + checked: boolean; + onToggle: (entityTypeId: VersionedUrl) => void; + onSelectOnly: (entityTypeId: VersionedUrl) => void; +}; + +const TypeFilterMenuItem: FunctionComponent = ({ + entityTypeId, + title, + count, + checked, + onToggle, + onSelectOnly, +}) => ( + onToggle(entityTypeId)} + sx={{ + minWidth: 260, + "&:hover .type-filter-only-button": { + visibility: "visible", + }, + "&:hover .type-filter-count": { + visibility: "hidden", + }, + }} + > + + + palette.gray[50], + fontSize: 12, + }} + > + {formatNumber(count)} + + { + event.stopPropagation(); + onSelectOnly(entityTypeId); + }} + sx={{ + visibility: "hidden", + position: "absolute", + right: 0, + top: "50%", + transform: "translateY(-50%)", + color: ({ palette }) => palette.blue[70], + fontSize: 12, + fontWeight: 600, + cursor: "pointer", + "&:hover": { textDecoration: "underline" }, + }} + > + Only + + + +); + +const TypeFilterMessage: FunctionComponent<{ text: string }> = ({ text }) => ( + + palette.gray[60], fontSize: 13 }}> + {text} + + +); + +export const TypeFilterPill: FunctionComponent = ({ + availableTypes, + loading, + typeState, + setTypeState, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-type-filter-pill", + }); + + const [searchQuery, setSearchQuery] = useState(""); + + const allAvailableIds = useMemo( + () => availableTypes.map((type) => type.entityTypeId), + [availableTypes], + ); + + const allSelected = isAllSelected({ + selectedTypeIds: typeState.selectedTypeIds, + allAvailableIds, + }); + + const isChecked = useCallback( + (entityTypeId: VersionedUrl) => { + if (typeState.selectedTypeIds === null) { + return true; + } + return typeState.selectedTypeIds.has(entityTypeId); + }, + [typeState.selectedTypeIds], + ); + + const toggle = useCallback( + (entityTypeId: VersionedUrl) => { + setTypeState((prev) => { + const current = + prev.selectedTypeIds ?? new Set(allAvailableIds); + const next = new Set(current); + if (next.has(entityTypeId)) { + next.delete(entityTypeId); + } else { + next.add(entityTypeId); + } + if ( + next.size === allAvailableIds.length && + allAvailableIds.every((id) => next.has(id)) + ) { + return { selectedTypeIds: null }; + } + return { selectedTypeIds: next }; + }); + }, + [allAvailableIds, setTypeState], + ); + + const selectOnly = useCallback( + (entityTypeId: VersionedUrl) => { + setTypeState(() => ({ + selectedTypeIds: new Set([entityTypeId]), + })); + }, + [setTypeState], + ); + + const selectAll = useCallback(() => { + setTypeState(() => ({ selectedTypeIds: null })); + }, [setTypeState]); + + const label = buildLabel({ + availableTypes, + selectedTypeIds: typeState.selectedTypeIds, + allAvailableIds, + }); + + const unknownSelectedIds = useMemo(() => { + if (typeState.selectedTypeIds === null) { + return []; + } + const availableIdSet = new Set(allAvailableIds); + return [...typeState.selectedTypeIds].filter( + (id) => !availableIdSet.has(id), + ); + }, [typeState.selectedTypeIds, allAvailableIds]); + + const filteredTypes = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) { + return availableTypes; + } + return availableTypes.filter((type) => + type.title.toLowerCase().includes(query), + ); + }, [availableTypes, searchQuery]); + + const isActive = !allSelected; + + const renderListContent = () => { + const showEmpty = + filteredTypes.length === 0 && unknownSelectedIds.length === 0 && !loading; + + if (showEmpty) { + return ( + + ); + } + + const showLoading = loading && availableTypes.length === 0; + + if (showLoading) { + return ; + } + + const showUnknownTypes = !searchQuery; + + return ( + <> + {showUnknownTypes && + unknownSelectedIds.map((id) => ( + toggle(id)} + sx={{ minWidth: 260 }} + > + palette.gray[60], + }, + }} + /> + + ))} + {filteredTypes.map(({ entityTypeId, title, count }) => ( + + ))} + + ); + }; + + return ( + + + { + setSearchQuery(""); + }, + }} + > + palette.common.white, + zIndex: 1, + }} + > + setSearchQuery(event.target.value)} + onKeyDown={(event) => { + // Prevent MUI Menu auto-focus / typeahead from stealing keys. + event.stopPropagation(); + }} + sx={{ + "& .MuiOutlinedInput-root": { + fontSize: 13, + }, + "& .MuiOutlinedInput-input": { + py: 0.75, + }, + }} + /> + + palette.gray[60], fontSize: 11 }} + > + {availableTypes.length} type + {availableTypes.length === 1 ? "" : "s"} + + + allSelected ? palette.gray[40] : palette.blue[70], + fontSize: 11, + fontWeight: 500, + "&:hover": { + textDecoration: allSelected ? "none" : "underline", + }, + }} + > + Select all + + + + + {renderListContent()} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx new file mode 100644 index 00000000000..f55d69325c1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx @@ -0,0 +1,50 @@ +import { Box } from "@mui/material"; + +import type { FunctionComponent, ReactNode } from "react"; + +export const visualizerHeaderHeight = 52; + +type VisualizerHeaderProps = { + left: ReactNode; + right: ReactNode; +}; + +export const VisualizerHeader: FunctionComponent = ({ + left, + right, +}) => { + return ( + palette.gray[20], + borderWidth: 1, + borderStyle: "solid", + borderColor: ({ palette }) => palette.gray[30], + px: 1.5, + py: 1, + borderTopLeftRadius: "6px", + borderTopRightRadius: "6px", + gap: 1.5, + minHeight: visualizerHeaderHeight, + }} + > + + {left} + + + {right} + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/web-filter-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/web-filter-pill.tsx new file mode 100644 index 00000000000..d6a74974529 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/web-filter-pill.tsx @@ -0,0 +1,149 @@ +import { Box, Divider, ListItemText, Menu } from "@mui/material"; +import { bindMenu, usePopupState } from "material-ui-popup-state/hooks"; +import { useCallback, useMemo } from "react"; + +import { MenuCheckboxItem } from "@hashintel/design-system"; + +import { useGetOwnerForEntity } from "../../../../components/hooks/use-get-owner-for-entity"; +import { HouseRegularIcon } from "../../../../shared/icons/house-regular-icon"; +import { FilterPill } from "./filter-pill"; + +import type { EntitiesFilterState } from "../data/types"; +import type { WebId } from "@blockprotocol/type-system"; +import type { FunctionComponent } from "react"; + +type WebFilterPillProps = { + internalWebIds: WebId[]; + webState: EntitiesFilterState["web"]; + setWebState: ( + updater: (prev: EntitiesFilterState["web"]) => EntitiesFilterState["web"], + ) => void; +}; + +const buildLabel = ({ + internalWebIds, + selectedInternalWebIds, + includeOtherWebs, +}: { + internalWebIds: WebId[]; + selectedInternalWebIds: Set; + includeOtherWebs: boolean; +}): string => { + const selectedCount = internalWebIds.filter((id) => + selectedInternalWebIds.has(id), + ).length; + const totalCount = internalWebIds.length; + const allSelected = selectedCount === totalCount; + + if (includeOtherWebs) { + if (allSelected) { + return "Any web"; + } + if (selectedCount === 0) { + return "Other webs"; + } + return `Other webs + ${selectedCount} own`; + } + + if (allSelected) { + return totalCount === 1 ? "Your web" : "Your webs"; + } + if (selectedCount === 0) { + return "No webs"; + } + return `${selectedCount} of ${totalCount} webs`; +}; + +export const WebFilterPill: FunctionComponent = ({ + internalWebIds, + webState, + setWebState, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-web-filter-pill", + }); + + const getOwnerForEntity = useGetOwnerForEntity(); + + const webItems = useMemo( + () => + internalWebIds.map((webId) => { + const { shortname } = getOwnerForEntity({ webId }); + return { + webId, + label: shortname ? `@${shortname}` : webId, + }; + }), + [internalWebIds, getOwnerForEntity], + ); + + const toggleInternalWeb = useCallback( + (webId: WebId) => { + setWebState((prev) => { + const next = new Set(prev.selectedInternalWebIds); + if (next.has(webId)) { + next.delete(webId); + } else { + next.add(webId); + } + return { ...prev, selectedInternalWebIds: next }; + }); + }, + [setWebState], + ); + + const toggleOtherWebs = useCallback(() => { + setWebState((prev) => ({ + ...prev, + includeOtherWebs: !prev.includeOtherWebs, + })); + }, [setWebState]); + + const label = buildLabel({ + internalWebIds, + selectedInternalWebIds: webState.selectedInternalWebIds, + includeOtherWebs: webState.includeOtherWebs, + }); + + const allInternalSelected = + webState.selectedInternalWebIds.size === internalWebIds.length && + internalWebIds.every((id) => webState.selectedInternalWebIds.has(id)); + + const isActive = !allInternalSelected || webState.includeOtherWebs; + + return ( + + + + {webItems.map(({ webId, label: itemLabel }) => ( + toggleInternalWeb(webId)} + sx={{ minWidth: 220 }} + > + + + ))} + + + + + + + ); +}; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/types.ts b/apps/hash-frontend/src/pages/shared/entities-visualizer/types.ts index 068c1cd0ad6..4803662d9fd 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/types.ts @@ -83,16 +83,6 @@ export type SortableEntitiesTableColumnKey = > | BaseUrl; -export const filterableEntitiesTableColumnKeys: EntitiesTableColumnKey[] = [ - "entityTypes", - "webId", - "createdById", - "lastEditedById", -] as const; - -export type FilterableEntitiesColumnKey = - (typeof filterableEntitiesTableColumnKeys)[number]; - export interface EntitiesTableColumn extends SizedGridColumn { id: EntitiesTableColumnKey; } @@ -114,12 +104,6 @@ export type SourceOrTargetFilterData = { }; }; -export type ActorTableFilterData = { - actorId: ActorEntityUuid; - displayName?: string; - count: number; -}; - export type EntityTypeTableFilterData = { entityTypeId: VersionedUrl; title: string; diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx index d8b67c18c9b..25bd0bb7c6a 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/use-entities-visualizer-data.tsx @@ -1,3 +1,4 @@ +import { useQuery } from "@apollo/client"; import { useMemo } from "react"; import { getRoots } from "@blockprotocol/graph/stdlib"; @@ -6,12 +7,20 @@ import { deserializeQueryEntitySubgraphResponse, type HashEntity, } from "@local/hash-graph-sdk/entity"; +import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries"; -import { useEntityTypeEntities } from "../../../shared/use-entity-type-entities"; +import { queryEntitySubgraphQuery } from "../../../graphql/queries/knowledge/entity.queries"; +import { apolloClient } from "../../../lib/apollo-client"; +import { buildEntitiesFilter } from "./data/build-filter"; +import { traversalPathsForView } from "./data/traversal-paths"; import { useEntitiesTableData } from "./use-entities-table-data"; -import type { QueryEntitySubgraphQuery } from "../../../graphql/api-types.gen"; +import type { + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables, +} from "../../../graphql/api-types.gen"; import type { VisualizerView } from "../visualizer-views"; +import type { EntitiesFilterState } from "./data/types"; import type { EntitiesTableData, EntitiesTableRow, @@ -30,9 +39,7 @@ export type EntitiesVisualizerData = Partial< QueryEntitySubgraphQuery["queryEntitySubgraph"], | "closedMultiEntityTypes" | "count" - | "createdByIds" | "definitions" - | "editionCreatedByIds" | "cursor" | "typeIds" | "typeTitles" @@ -40,13 +47,7 @@ export type EntitiesVisualizerData = Partial< > > & { entities?: HashEntity[]; - // Whether or not cached content was available immediately for the context data hadCachedContent: boolean; - /** - * Whether or not a network request is in process. - * Note that if is hasCachedContent is true, data for the given query is available before loading is complete. - * The cached content will be replaced automatically and the value updated when the network request completes. - */ loading: boolean; refetch: () => Promise>; subgraph?: Subgraph>; @@ -59,10 +60,10 @@ export const useEntitiesVisualizerData = (params: { cursor?: EntityQueryCursor; entityTypeBaseUrl?: BaseUrl; entityTypeIds?: VersionedUrl[]; + filterState: EntitiesFilterState; hideColumns?: (keyof EntitiesTableRow)[]; - includeArchived: boolean; + internalWebIds: WebId[]; limit?: number; - webIds?: WebId[]; sort?: EntityQuerySortingRecord; view: VisualizerView; }): EntitiesVisualizerData => { @@ -71,72 +72,72 @@ export const useEntitiesVisualizerData = (params: { cursor, entityTypeBaseUrl, entityTypeIds, - includeArchived, - limit, + filterState, hideColumns, - webIds: webIdsParam, + internalWebIds, + limit, sort, view, } = params; const { tableData, updateTableData } = useEntitiesTableData({ hideColumns, - hideArchivedColumn: !includeArchived, + hideArchivedColumn: !filterState.includeArchived, }); - const { - closedMultiEntityTypes, - count, - createdByIds, - cursor: nextCursor, - definitions, - editionCreatedByIds, - entities, - hadCachedContent, - loading, - refetch, - subgraph, - typeIds, - typeTitles, - webIds, - } = useEntityTypeEntities( - { + const variables = useMemo( + () => ({ + request: { + conversions, + cursor, + limit, + includeCount: true, + includeTypeIds: true, + includeTypeTitles: true, + includeWebIds: true, + filter: buildEntitiesFilter({ + filterState, + internalWebIds, + pinnedEntityTypeBaseUrl: entityTypeBaseUrl, + pinnedEntityTypeIds: entityTypeIds, + }), + traversalPaths: traversalPathsForView(view), + sortingPaths: sort ? [sort] : undefined, + /** + * @todo H-2633 when we use entity archival via timestamp, this will + * need varying to include archived entities. + */ + temporalAxes: currentTimeInstantTemporalAxes, + includeDrafts: false, + includeEntityTypes: "resolvedWithDataTypeChildren", + includePermissions: false, + }, + }), + [ conversions, cursor, entityTypeBaseUrl, entityTypeIds, - includeArchived, + filterState, + internalWebIds, limit, - webIds: webIdsParam, - traversalPaths: - view === "Graph" - ? /** - * The graph view gets all entities in the selected web anyway, so it will have all the links regardless. - * We skip asking the graph to resolve them. - * This does mean that links to entities outside the users' webs are not reflected in the graph view, - * unless they have clicked to include entities from other webs. - */ - [] - : /** - * The table view only needs outgoing: 1 for each, in order to be able to display the source and target of links. - */ - [ - { - edges: [ - { kind: "has-left-entity", direction: "outgoing" }, - { kind: "has-right-entity", direction: "outgoing" }, - ], - }, - ], sort, - }, - (data) => { + view, + ], + ); + + const { data, loading, refetch } = useQuery< + QueryEntitySubgraphQuery, + QueryEntitySubgraphQueryVariables + >(queryEntitySubgraphQuery, { + fetchPolicy: "cache-and-network", + onCompleted: (completedData) => { if (view === "Graph") { return; } const newSubgraph = deserializeQueryEntitySubgraphResponse( - data.queryEntitySubgraph, + completedData.queryEntitySubgraph, ).subgraph; const newEntities = getRoots(newSubgraph); @@ -144,22 +145,38 @@ export const useEntitiesVisualizerData = (params: { updateTableData({ appliedPaginationCursor: cursor ?? null, closedMultiEntityTypesRootMap: - data.queryEntitySubgraph.closedMultiEntityTypes ?? {}, - definitions: data.queryEntitySubgraph.definitions, + completedData.queryEntitySubgraph.closedMultiEntityTypes ?? {}, + definitions: completedData.queryEntitySubgraph.definitions, entities: newEntities, subgraph: newSubgraph, }); }, + variables, + }); + + const hadCachedContent = useMemo( + () => + !!apolloClient.readQuery({ query: queryEntitySubgraphQuery, variables }), + [variables], + ); + + const subgraph = useMemo( + () => + data?.queryEntitySubgraph + ? deserializeQueryEntitySubgraphResponse(data.queryEntitySubgraph) + .subgraph + : undefined, + [data?.queryEntitySubgraph], + ); + + const entities = useMemo( + () => (subgraph ? getRoots(subgraph) : undefined), + [subgraph], ); return useMemo( () => ({ - closedMultiEntityTypes, - count, - createdByIds, - cursor: nextCursor, - definitions, - editionCreatedByIds, + ...data?.queryEntitySubgraph, entities, hadCachedContent, loading, @@ -167,27 +184,16 @@ export const useEntitiesVisualizerData = (params: { subgraph, tableData, updateTableData, - typeIds, - typeTitles, - webIds, }), [ - closedMultiEntityTypes, - count, - createdByIds, - nextCursor, - definitions, - editionCreatedByIds, + data?.queryEntitySubgraph, entities, hadCachedContent, loading, refetch, subgraph, tableData, - typeIds, - typeTitles, updateTableData, - webIds, ], ); }; diff --git a/tests/hash-playwright/tests/features/entities-page.spec.ts b/tests/hash-playwright/tests/features/entities-page.spec.ts index 7b0360a82bd..4c496ac8923 100644 --- a/tests/hash-playwright/tests/features/entities-page.spec.ts +++ b/tests/hash-playwright/tests/features/entities-page.spec.ts @@ -29,5 +29,5 @@ test("user can visit a page listing entities of a type", async ({ page }) => { ); }); - await expect(page.getByText(/^([1-9]\d*) in your webs$/)).toBeVisible(); + await expect(page.getByText(/^([1-9]\d*) (entities)$/)).toBeVisible(); });