From 23840c54ce6b3e228426b249570d4943ae025811 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Thu, 28 May 2026 14:12:33 +0300 Subject: [PATCH 01/17] Refactor entities visualizer: unified filter ribbon, header, and table toolbar Reorganize the entities visualizer around a single typed filter state (EntitiesFilterState) with a pure buildEntitiesFilter mapping it to the Graph API filter. Replace the shared TableHeader with a VisualizerHeader shell that hosts a FilterRibbon (web pill, type pill, include-archived pill, add-filters menu) on the left and QueryCount + view toggle on the right. Bulk actions take over the left slot when rows are selected. Move table-only actions (export, search, centralized sort) into a new TableToolbar sub-header. Drop all client-side column filters (webId, entityTypes, lastEditedById, createdById) so every filter is now server-side. Switch traversal paths per view, fix cursor-reset on sort and conversions changes, and shrink EntitiesVisualizer's public props to { entityTypeBaseUrl?, entityTypeId?, hideColumns? }. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/pages/shared/entities-visualizer.tsx | 405 ++++++++---------- .../entities-visualizer/data/build-filter.ts | 185 ++++++++ .../data/traversal-paths.ts | 34 ++ .../shared/entities-visualizer/data/types.ts | 16 + .../data/use-available-types.ts | 100 +++++ .../entities-visualizer/entities-table.tsx | 243 +---------- .../entities-table/generate-csv-file.ts | 52 +++ .../entities-table/sort-control.tsx | 148 +++++++ .../entities-table/table-toolbar.tsx | 111 +++++ .../header/add-filters-menu.tsx | 86 ++++ .../header/filter-ribbon.tsx | 63 +++ .../header/include-archived-pill.tsx | 63 +++ .../header/query-count.tsx | 40 ++ .../header/type-filter-pill.tsx | 217 ++++++++++ .../header/visualizer-header.tsx | 42 ++ .../header/web-filter-pill.tsx | 189 ++++++++ .../pages/shared/entities-visualizer/types.ts | 16 - .../use-entities-visualizer-data.tsx | 162 +++---- 18 files changed, 1624 insertions(+), 548 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/data/build-filter.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/data/traversal-paths.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/data/use-available-types.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/generate-csv-file.ts create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/table-toolbar.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/add-filters-menu.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/include-archived-pill.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/web-filter-pill.tsx diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index 7e03fb9ff56..a9bb6fdbd31 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,24 @@ 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 { 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 } 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 +34,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 +57,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 +129,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 +137,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 +161,51 @@ export const EntitiesVisualizer: FunctionComponent<{ }, ); - const [filterState, _setFilterState] = useState( - defaultFilter ?? { - includeArchived: false, - includeGlobal: false, - limitToWebs: false, + const [filterState, _setFilterState] = useState(() => ({ + web: { + selectedInternalWebIds: new Set(internalWebIds), + includeOtherWebs: false, }, - ); + type: { selectedTypeIds: null }, + includeArchived: false, + })); 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 +215,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 +251,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 +264,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 +336,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 +371,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 +425,11 @@ 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,53 +453,81 @@ export const EntitiesVisualizer: FunctionComponent<{ setCursor(nextCursor ?? undefined); }, [nextCursor]); - 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) => + const viewToggle = ( + ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} + /> + ); + + const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; + + const { types: availableTypes, loading: availableTypesLoading } = + useAvailableTypes({ + filterState, + internalWebIds, + entityTypeBaseUrl, + entityTypeIds: entityTypeId ? [entityTypeId] : undefined, + }); + + const filterRibbon = ( + setFilterState(updater)} + /> + ); + + const selectedEntities = useMemo( + () => + view === "Table" && selectedTableRows.length > 0 && entities + ? entities.filter((entity) => selectedTableRows.some( ({ entityId }) => entity.metadata.recordId.entityId === entityId, ), - ) ?? [] - } - setFilterState={setFilterState} - title="Entities" - toggleSearch={ - view === "Table" - ? () => setShowTableSearch(!showTableSearch) - : undefined + ) + : [], + [entities, selectedTableRows, view], + ); + + const handleBulkActionCompleted = useCallback(() => { + void entitiesData.refetch(); + setSelectedTableRows([]); + }, [entitiesData]); + + const headerLeft = + selectedEntities.length > 0 ? ( + + ) : ( + filterRibbon + ); + + return ( + + + + {viewToggle} + } /> @@ -598,17 +543,18 @@ export const EntitiesVisualizer: FunctionComponent<{ tableContentSx, ]} > - {loadingComponent} + + + ) : view === "Graph" ? ( + } isPrimaryEntity={isPrimaryEntity} onEntityClick={handleEntityClick} /> @@ -616,34 +562,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..7637599207d --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/build-filter.ts @@ -0,0 +1,185 @@ +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"; + +/** + * A `WebId` value that can never match a real web. Used to emit a clause that + * the Graph will accept but match nothing against, for the "include only my + * selected internal webs" branch when the user has unchecked every one of + * their webs. + */ +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..586f5f907fc --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts @@ -0,0 +1,16 @@ +import type { VersionedUrl, WebId } from "@blockprotocol/type-system"; + +export type EntitiesFilterState = { + web: { + selectedInternalWebIds: Set; + includeOtherWebs: boolean; + }; + type: { + /** + * `null` means "all types selected" (the default). An explicit `Set` is + * recorded only after the user unchecks something. + */ + selectedTypeIds: Set | null; + }; + includeArchived: boolean; +}; 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..c8d22a3ce47 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/use-available-types.ts @@ -0,0 +1,100 @@ +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; +}; + +/** + * Drives the option list for the type-filter pill. Runs a minimal subgraph + * request that applies the current web + archived filters but deliberately + * ignores the user's type filter -- otherwise unchecking a type would remove + * it from the dropdown and the user could never re-check it. + * + * Skipped entirely when the visualizer's type is pinned by route prop, since + * the pill is not rendered in that case. + */ +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..8062add2125 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,196 +645,6 @@ 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", @@ -968,14 +756,13 @@ export const EntitiesTable: FunctionComponent< { + 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 === "lastEditedBy" || key === "createdBy") { + const user = value as MinimalUser | undefined; + return user?.displayName ?? ""; + } 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..591960866b6 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx @@ -0,0 +1,148 @@ +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={ + + } + > + Sort: {activeLabel} + + + + {options.map((option) => { + const isActive = option.columnKey === sort.columnKey; + return ( + handleSelect(option.columnKey)} + sx={{ minWidth: 220 }} + > + + {isActive && ( + palette.gray[60] }} + > + + + )} + + ); + })} + + + ); +}; 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..e51a4765c26 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/table-toolbar.tsx @@ -0,0 +1,111 @@ +import { Box, Tooltip } 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"; + +const toolbarHeight = 44; + +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, + }} + > + + + + Export + + + + setShowSearch(!showSearch)}> + + + + + + + + + ); +}; 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..c7ef6dfbfe0 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/add-filters-menu.tsx @@ -0,0 +1,86 @@ +import { Box, chipClasses, ListItemText, Menu } from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; + +import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; + +import { PlusRegularIcon } from "../../../../shared/icons/plus-regular"; +import { MenuItem } from "../../../../shared/ui"; + +import type { FunctionComponent } from "react"; + +type AddFiltersMenuProps = { + includeArchived: boolean; + onAddIncludeArchived: () => void; +}; + +export const AddFiltersMenu: FunctionComponent = ({ + includeArchived, + onAddIncludeArchived, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-add-filters-menu", + }); + + const handleSelectIncludeArchived = () => { + onAddIncludeArchived(); + popupState.close(); + }; + + return ( + + palette.primary.main }} + /> + } + label={ + + Add filters + + + } + sx={{ + height: 24, + border: ({ palette }) => `1px dashed ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], + cursor: "pointer", + [`.${chipClasses.label}`]: { + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + }, + }} + {...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..dd6d18d90cf --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-ribbon.tsx @@ -0,0 +1,63 @@ +import { Box } from "@mui/material"; + +import { AddFiltersMenu } from "./add-filters-menu"; +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; +}; + +export const FilterRibbon: FunctionComponent = ({ + availableTypes, + availableTypesLoading, + filterState, + internalWebIds, + isTypePinned, + setFilterState, +}) => { + const setIncludeArchived = (includeArchived: boolean) => + setFilterState((prev) => ({ ...prev, includeArchived })); + + return ( + + + setFilterState((prev) => ({ ...prev, web: updater(prev.web) })) + } + /> + {isTypePinned ? null : ( + + setFilterState((prev) => ({ ...prev, type: updater(prev.type) })) + } + /> + )} + {filterState.includeArchived ? ( + setIncludeArchived(false)} /> + ) : null} + setIncludeArchived(true)} + /> + + ); +}; 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..864390b2f05 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/include-archived-pill.tsx @@ -0,0 +1,63 @@ +import { Box, chipClasses } from "@mui/material"; + +import { Chip, IconButton, XMarkRegularIcon } from "@hashintel/design-system"; + +import { BoxArchiveIcon } from "../../../../shared/icons/box-archive-icon"; + +import type { FunctionComponent } from "react"; + +type IncludeArchivedPillProps = { + onRemove: () => void; +}; + +export const IncludeArchivedPill: FunctionComponent< + IncludeArchivedPillProps +> = ({ onRemove }) => { + return ( + + palette.primary.main }} + /> + } + label={ + + Include archived + palette.gray[60], + "&:hover": { + color: ({ palette }) => palette.gray[90], + background: "transparent", + }, + }} + > + + + + } + sx={{ + height: 24, + border: ({ palette }) => `1px solid ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], + [`.${chipClasses.label}`]: { + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + }, + }} + /> + + ); +}; 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..7266a50ec2e --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/query-count.tsx @@ -0,0 +1,40 @@ +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, + minWidth: 24, + justifyContent: "flex-end", + }} + > + {loading ? ( + + ) : count != null ? ( + formatNumber(count) + ) : ( + "–" + )} + + ); +}; 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..d94a9588de1 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx @@ -0,0 +1,217 @@ +import { + Box, + Checkbox, + chipClasses, + ListItemText, + Menu, + Typography, +} from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; +import { useCallback, useMemo } from "react"; + +import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; + +import { AsteriskLightIcon } from "../../../../shared/icons/asterisk-light-icon"; +import { MenuItem } from "../../../../shared/ui"; + +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 buildLabel = ({ + availableTypes, + selectedTypeIds, +}: { + availableTypes: AvailableType[]; + selectedTypeIds: Set | null; +}): string => { + if (selectedTypeIds === null) { + return "All types"; + } + + const count = selectedTypeIds.size; + + 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`; +}; + +export const TypeFilterPill: FunctionComponent = ({ + availableTypes, + loading, + typeState, + setTypeState, +}) => { + const popupState = usePopupState({ + variant: "popover", + popupId: "entities-visualizer-type-filter-pill", + }); + + const allAvailableIds = useMemo( + () => availableTypes.map((type) => type.entityTypeId), + [availableTypes], + ); + + 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); + } + return { selectedTypeIds: next }; + }); + }, + [allAvailableIds, setTypeState], + ); + + const label = buildLabel({ + availableTypes, + selectedTypeIds: typeState.selectedTypeIds, + }); + + const unknownSelectedIds = useMemo(() => { + if (typeState.selectedTypeIds === null) { + return []; + } + const availableIdSet = new Set(allAvailableIds); + return [...typeState.selectedTypeIds].filter( + (id) => !availableIdSet.has(id), + ); + }, [typeState.selectedTypeIds, allAvailableIds]); + + return ( + + palette.primary.main }} + /> + } + label={ + + {label} + + + } + sx={{ + height: 24, + border: ({ palette }) => `1px solid ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], + cursor: "pointer", + [`.${chipClasses.label}`]: { + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + }, + }} + {...bindTrigger(popupState)} + /> + + {availableTypes.length === 0 && unknownSelectedIds.length === 0 ? ( + + palette.gray[60], fontSize: 13 }} + > + {loading ? "Loading…" : "No types"} + + + ) : null} + {unknownSelectedIds.map((id) => ( + toggle(id)} sx={{ minWidth: 260 }}> + + palette.gray[60], + }, + }} + /> + + ))} + {availableTypes.map(({ entityTypeId, title, count }) => { + const checked = isChecked(entityTypeId); + return ( + toggle(entityTypeId)} + sx={{ minWidth: 260 }} + > + + + palette.gray[50], + fontSize: 12, + }} + > + {formatNumber(count)} + + + ); + })} + + + ); +}; 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..9da1e1b573a --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/visualizer-header.tsx @@ -0,0 +1,42 @@ +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..2bd69238115 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/web-filter-pill.tsx @@ -0,0 +1,189 @@ +import { + Box, + Checkbox, + chipClasses, + Divider, + ListItemText, + Menu, +} from "@mui/material"; +import { + bindMenu, + bindTrigger, + usePopupState, +} from "material-ui-popup-state/hooks"; +import { useCallback, useMemo } from "react"; + +import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; + +import { useGetOwnerForEntity } from "../../../../components/hooks/use-get-owner-for-entity"; +import { HouseRegularIcon } from "../../../../shared/icons/house-regular-icon"; +import { MenuItem } from "../../../../shared/ui"; + +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 "All webs"; + } + if (selectedCount === 0) { + return "Other webs"; + } + return `Other webs + ${selectedCount} own`; + } + + if (allSelected) { + return totalCount === 1 ? "Your web" : "All 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, + }); + + return ( + + palette.primary.main }} + /> + } + label={ + + {label} + + + } + sx={{ + height: 24, + border: ({ palette }) => `1px solid ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], + cursor: "pointer", + [`.${chipClasses.label}`]: { + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + }, + }} + {...bindTrigger(popupState)} + /> + + {webItems.map(({ webId, label: itemLabel }) => { + const checked = webState.selectedInternalWebIds.has(webId); + return ( + 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, ], ); }; From c907a20c6edf436d0e1c6259dc68660ecb5d648d Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Thu, 28 May 2026 15:49:21 +0300 Subject: [PATCH 02/17] adjust UI/UX --- .../entities-table/sort-control.tsx | 3 +- .../entities-table/table-toolbar.tsx | 13 +- .../header/add-filters-menu.tsx | 34 +- .../header/clear-filters-button.tsx | 48 +++ .../header/filter-ribbon.tsx | 67 +++- .../header/include-archived-pill.tsx | 22 +- .../entities-visualizer/header/pill-styles.ts | 45 +++ .../header/query-count.tsx | 9 +- .../header/type-filter-pill.tsx | 309 +++++++++++++++--- .../header/web-filter-pill.tsx | 54 ++- 10 files changed, 481 insertions(+), 123 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/clear-filters-button.tsx create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/pill-styles.ts 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 index 591960866b6..cf64a170ea5 100644 --- 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 @@ -109,6 +109,7 @@ export const SortControl: FunctionComponent = ({ }} /> } + sx={{ borderRadius: "4px", px: 1.25 }} > Sort: {activeLabel} @@ -134,7 +135,7 @@ export const SortControl: FunctionComponent = ({ display="inline-flex" alignItems="center" ml={1} - sx={{ color: ({ palette }) => palette.gray[60] }} + sx={{ color: ({ palette }) => palette.common.white }} > 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 index e51a4765c26..b9fc0bacb82 100644 --- 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 @@ -92,16 +92,19 @@ export const TableToolbar: FunctionComponent = ({ }} > - - - Export - - setShowSearch(!showSearch)}> + + + Export + + 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 index c7ef6dfbfe0..d387262c76f 100644 --- 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 @@ -1,14 +1,15 @@ -import { Box, chipClasses, ListItemText, Menu } from "@mui/material"; +import { Box, ListItemText, Menu } from "@mui/material"; import { bindMenu, bindTrigger, usePopupState, } from "material-ui-popup-state/hooks"; -import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; +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"; @@ -39,33 +40,8 @@ export const AddFiltersMenu: FunctionComponent = ({ sx={{ fill: ({ palette }) => palette.primary.main }} /> } - label={ - - Add filters - - - } - sx={{ - height: 24, - border: ({ palette }) => `1px dashed ${palette.gray[30]}`, - background: ({ palette }) => palette.gray[5], - cursor: "pointer", - [`.${chipClasses.label}`]: { - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - }, - }} + label="Add filter" + sx={dashedPillSx} {...bindTrigger(popupState)} /> void; +}; + +export const ClearFiltersButton: FunctionComponent = ({ + onClear, +}) => { + return ( + + palette.gray[60], fontSize: 12 }} + /> + } + label="Clear" + sx={{ + height: 26, + borderRadius: "4px", + cursor: "pointer", + border: `1px solid transparent`, + background: "transparent", + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + "& .MuiChip-label": { + color: ({ palette }) => palette.gray[70], + fontSize: 13, + fontWeight: 500, + }, + "&:hover": { + background: ({ palette }) => palette.gray[20], + "& .MuiChip-label": { + color: ({ palette }) => palette.gray[90], + }, + }, + }} + /> + + ); +}; 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 index dd6d18d90cf..350e45274ee 100644 --- 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 @@ -1,6 +1,7 @@ import { Box } from "@mui/material"; 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"; @@ -21,6 +22,48 @@ type FilterRibbonProps = { ) => void; }; +const buildDefaultFilterState = ( + internalWebIds: WebId[], +): EntitiesFilterState => ({ + web: { + selectedInternalWebIds: new Set(internalWebIds), + includeOtherWebs: false, + }, + type: { selectedTypeIds: null }, + includeArchived: false, +}); + +const isWebFilterDefault = ( + web: EntitiesFilterState["web"], + internalWebIds: WebId[], +) => { + if (web.includeOtherWebs) { + return false; + } + if (web.selectedInternalWebIds.size !== internalWebIds.length) { + return false; + } + return internalWebIds.every((id) => web.selectedInternalWebIds.has(id)); +}; + +const isTypeFilterDefault = ( + type: EntitiesFilterState["type"], + availableTypes: AvailableType[], +) => { + if (type.selectedTypeIds === null) { + return true; + } + if (availableTypes.length === 0) { + return type.selectedTypeIds.size === 0; + } + if (type.selectedTypeIds.size !== availableTypes.length) { + return false; + } + return availableTypes.every(({ entityTypeId }) => + type.selectedTypeIds!.has(entityTypeId), + ); +}; + export const FilterRibbon: FunctionComponent = ({ availableTypes, availableTypesLoading, @@ -32,6 +75,19 @@ export const FilterRibbon: FunctionComponent = ({ 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(() => buildDefaultFilterState(internalWebIds)); + }; + + const allExtraFiltersEnabled = filterState.includeArchived; + return ( = ({ {filterState.includeArchived ? ( setIncludeArchived(false)} /> ) : null} - setIncludeArchived(true)} - /> + {!allExtraFiltersEnabled && ( + setIncludeArchived(true)} + /> + )} + {filtersAreDefault ? null : } ); }; 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 index 864390b2f05..b90799c6a93 100644 --- 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 @@ -1,8 +1,9 @@ -import { Box, chipClasses } from "@mui/material"; +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"; @@ -17,9 +18,7 @@ export const IncludeArchivedPill: FunctionComponent< palette.primary.main }} - /> + palette.blue[70] }} /> } label={ palette.gray[60], + color: ({ palette }) => palette.blue[70], "&:hover": { - color: ({ palette }) => palette.gray[90], + color: ({ palette }) => palette.blue[90], background: "transparent", }, }} @@ -47,16 +46,7 @@ export const IncludeArchivedPill: FunctionComponent< } - sx={{ - height: 24, - border: ({ palette }) => `1px solid ${palette.gray[30]}`, - background: ({ palette }) => palette.gray[5], - [`.${chipClasses.label}`]: { - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - }, - }} + 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..d872f3ec176 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/pill-styles.ts @@ -0,0 +1,45 @@ +import { chipClasses } from "@mui/material"; + +import type { SxProps, Theme } from "@mui/material"; + +export const defaultPillSx: SxProps = { + height: 26, + borderRadius: "4px", + cursor: "pointer", + border: ({ palette }: Theme) => `1px solid ${palette.gray[30]}`, + background: ({ palette }: Theme) => palette.gray[5], + [`.${chipClasses.label}`]: { + fontSize: 13, + fontWeight: 500, + color: ({ palette }: Theme) => palette.gray[70], + }, +}; + +export const activePillSx: SxProps = { + height: 26, + borderRadius: "4px", + cursor: "pointer", + border: ({ palette }: Theme) => `1px solid ${palette.blue[40]}`, + background: ({ palette }: Theme) => palette.blue[15], + [`.${chipClasses.label}`]: { + fontSize: 13, + fontWeight: 500, + color: ({ palette }: Theme) => palette.blue[90], + }, + "&:hover": { + background: ({ palette }: Theme) => palette.blue[20], + }, +}; + +export const dashedPillSx: SxProps = { + height: 26, + borderRadius: "4px", + cursor: "pointer", + border: ({ palette }: Theme) => `1px dashed ${palette.gray[30]}`, + background: ({ palette }: Theme) => palette.gray[5], + [`.${chipClasses.label}`]: { + fontSize: 13, + fontWeight: 500, + color: ({ palette }: Theme) => palette.gray[70], + }, +}; 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 index 7266a50ec2e..489c8f3bfa9 100644 --- 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 @@ -20,18 +20,21 @@ export const QueryCount: FunctionComponent = ({ palette.gray[70], fontSize: 13, fontWeight: 500, - minWidth: 24, justifyContent: "flex-end", }} > {loading ? ( - + <> + + Loading + ) : count != null ? ( - formatNumber(count) + `${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 index d94a9588de1..386b8845515 100644 --- 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 @@ -1,7 +1,6 @@ import { Box, Checkbox, - chipClasses, ListItemText, Menu, Typography, @@ -11,14 +10,20 @@ import { bindTrigger, usePopupState, } from "material-ui-popup-state/hooks"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; -import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; +import { + CaretDownSolidIcon, + Chip, + TextField, +} from "@hashintel/design-system"; import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; import { AsteriskLightIcon } from "../../../../shared/icons/asterisk-light-icon"; import { MenuItem } from "../../../../shared/ui"; +import { activePillSx, defaultPillSx } from "./pill-styles"; + import type { EntitiesFilterState } from "../data/types"; import type { AvailableType } from "../data/use-available-types"; import type { VersionedUrl } from "@blockprotocol/type-system"; @@ -33,25 +38,46 @@ type TypeFilterPillProps = { ) => 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 (selectedTypeIds === null) { + if (isAllSelected({ selectedTypeIds, allAvailableIds })) { return "All types"; } - const count = selectedTypeIds.size; + const count = selectedTypeIds?.size ?? 0; if (count === 0) { return "No types"; } if (count === 1) { - const [only] = selectedTypeIds; + const [only] = selectedTypeIds!; const match = availableTypes.find((type) => type.entityTypeId === only); return match?.title ?? "1 type"; } @@ -70,11 +96,18 @@ export const TypeFilterPill: FunctionComponent = ({ 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) { @@ -96,15 +129,35 @@ export const TypeFilterPill: FunctionComponent = ({ } 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(() => { @@ -117,12 +170,27 @@ export const TypeFilterPill: FunctionComponent = ({ ); }, [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; + return ( palette.primary.main }} + sx={{ + fill: ({ palette }) => + isActive ? palette.blue[70] : palette.primary.main, + }} /> } label={ @@ -132,7 +200,28 @@ export const TypeFilterPill: FunctionComponent = ({ alignItems="center" gap={0.6} > - {label} + + isActive ? palette.blue[70] : palette.gray[60], + }} + > + Type is + + + isActive ? palette.blue[90] : palette.gray[80], + }} + > + {label} + = ({ /> } - sx={{ - height: 24, - border: ({ palette }) => `1px solid ${palette.gray[30]}`, - background: ({ palette }) => palette.gray[5], - cursor: "pointer", - [`.${chipClasses.label}`]: { - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - }, - }} + sx={isActive ? activePillSx : defaultPillSx} {...bindTrigger(popupState)} /> { + setSearchQuery(""); + }, + }} > - {availableTypes.length === 0 && unknownSelectedIds.length === 0 ? ( + 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 + + + + {filteredTypes.length === 0 && + unknownSelectedIds.length === 0 && + !loading ? ( palette.gray[60], fontSize: 13 }} > - {loading ? "Loading…" : "No types"} + {availableTypes.length === 0 ? "No types" : "No matches"} ) : null} - {unknownSelectedIds.map((id) => ( - toggle(id)} sx={{ minWidth: 260 }}> - - palette.gray[60], - }, - }} - /> - - ))} - {availableTypes.map(({ entityTypeId, title, count }) => { + {loading && availableTypes.length === 0 ? ( + + palette.gray[60], fontSize: 13 }} + > + Loading… + + + ) : null} + {!searchQuery + ? unknownSelectedIds.map((id) => ( + toggle(id)} + sx={{ minWidth: 260 }} + > + + palette.gray[60], + }, + }} + /> + + )) + : null} + {filteredTypes.map(({ entityTypeId, title, count }) => { const checked = isChecked(entityTypeId); return ( toggle(entityTypeId)} - sx={{ minWidth: 260 }} + sx={{ + minWidth: 260, + "&:hover .type-filter-only-button": { + visibility: "visible", + }, + "&:hover .type-filter-count": { + visibility: "hidden", + }, + }} > - - palette.gray[50], - fontSize: 12, + + - {formatNumber(count)} - + palette.gray[50], + fontSize: 12, + }} + > + {formatNumber(count)} + + { + event.stopPropagation(); + selectOnly(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 + + ); })} 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 index 2bd69238115..bef4379bd12 100644 --- 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 @@ -1,10 +1,10 @@ import { Box, Checkbox, - chipClasses, Divider, ListItemText, Menu, + Typography, } from "@mui/material"; import { bindMenu, @@ -19,6 +19,8 @@ import { useGetOwnerForEntity } from "../../../../components/hooks/use-get-owner import { HouseRegularIcon } from "../../../../shared/icons/house-regular-icon"; import { MenuItem } from "../../../../shared/ui"; +import { activePillSx, defaultPillSx } from "./pill-styles"; + import type { EntitiesFilterState } from "../data/types"; import type { WebId } from "@blockprotocol/type-system"; import type { FunctionComponent } from "react"; @@ -48,7 +50,7 @@ const buildLabel = ({ if (includeOtherWebs) { if (allSelected) { - return "All webs"; + return "Any web"; } if (selectedCount === 0) { return "Other webs"; @@ -57,7 +59,7 @@ const buildLabel = ({ } if (allSelected) { - return totalCount === 1 ? "Your web" : "All your webs"; + return totalCount === 1 ? "Your web" : "Your webs"; } if (selectedCount === 0) { return "No webs"; @@ -117,12 +119,21 @@ export const WebFilterPill: FunctionComponent = ({ includeOtherWebs: webState.includeOtherWebs, }); + const allInternalSelected = + webState.selectedInternalWebIds.size === internalWebIds.length && + internalWebIds.every((id) => webState.selectedInternalWebIds.has(id)); + + const isActive = !allInternalSelected || webState.includeOtherWebs; + return ( palette.primary.main }} + sx={{ + fill: ({ palette }) => + isActive ? palette.blue[70] : palette.primary.main, + }} /> } label={ @@ -132,7 +143,28 @@ export const WebFilterPill: FunctionComponent = ({ alignItems="center" gap={0.6} > - {label} + + isActive ? palette.blue[70] : palette.gray[60], + }} + > + Web is + + + isActive ? palette.blue[90] : palette.gray[80], + }} + > + {label} + = ({ /> } - sx={{ - height: 24, - border: ({ palette }) => `1px solid ${palette.gray[30]}`, - background: ({ palette }) => palette.gray[5], - cursor: "pointer", - [`.${chipClasses.label}`]: { - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - }, - }} + sx={isActive ? activePillSx : defaultPillSx} {...bindTrigger(popupState)} /> Date: Thu, 28 May 2026 15:49:25 +0300 Subject: [PATCH 03/17] fix padding issue --- .../use-entities-table/generate-table-data-from-rows.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 426c3b4bca00a0bfdf326fb11a3edb362458225e Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Thu, 28 May 2026 23:18:40 +0300 Subject: [PATCH 04/17] cleanup --- .../src/pages/shared/entities-visualizer.tsx | 40 ++-- .../shared/entities-visualizer/data/types.ts | 11 ++ .../entities-visualizer/entities-table.tsx | 1 - .../header/add-filters-menu.tsx | 8 +- .../header/filter-ribbon.tsx | 19 +- .../header/type-filter-pill.tsx | 173 ++++++++++-------- .../header/web-filter-pill.tsx | 1 - 7 files changed, 127 insertions(+), 126 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index a9bb6fdbd31..5e0bb53f160 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -16,6 +16,7 @@ import { tableContentSx } from "../../shared/table-content"; import { BulkActionsDropdown } from "../../shared/table-header/bulk-actions-dropdown"; import { useMemoCompare } from "../../shared/use-memo-compare"; 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"; @@ -161,14 +162,9 @@ export const EntitiesVisualizer: FunctionComponent<{ }, ); - const [filterState, _setFilterState] = useState(() => ({ - web: { - selectedInternalWebIds: new Set(internalWebIds), - includeOtherWebs: false, - }, - type: { selectedTypeIds: null }, - includeArchived: false, - })); + const [filterState, _setFilterState] = useState(() => + createDefaultFilterState(internalWebIds), + ); const [cursor, setCursor] = useState(); const [activeConversionsWithoutTitle, _setActiveConversions] = useState<{ @@ -428,7 +424,9 @@ export const EntitiesVisualizer: FunctionComponent<{ 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)})` + : `(${ + HEADER_HEIGHT + TOP_CONTEXT_BAR_HEIGHT + 230 + visualizerHeaderHeight + }px + ${theme.spacing(5)} + ${theme.spacing(5)})` }))`; const isPrimaryEntity = useCallback( @@ -492,17 +490,19 @@ export const EntitiesVisualizer: FunctionComponent<{ /> ); - const selectedEntities = useMemo( - () => - view === "Table" && selectedTableRows.length > 0 && entities - ? entities.filter((entity) => - selectedTableRows.some( - ({ entityId }) => entity.metadata.recordId.entityId === entityId, - ), - ) - : [], - [entities, selectedTableRows, view], - ); + 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(); 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 index 586f5f907fc..ec37fc97456 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts @@ -14,3 +14,14 @@ export type EntitiesFilterState = { }; 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/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table.tsx index 8062add2125..e6cabb899fc 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 @@ -651,7 +651,6 @@ export const EntitiesTable: FunctionComponent< "created", "entityLabel", "entityTypes", - "entityLabel", "lastEdited", ...columns.map((column) => column.id).filter((key) => isBaseUrl(key)), ]; 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 index d387262c76f..bef24719a60 100644 --- 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 @@ -14,12 +14,10 @@ import { dashedPillSx } from "./pill-styles"; import type { FunctionComponent } from "react"; type AddFiltersMenuProps = { - includeArchived: boolean; onAddIncludeArchived: () => void; }; export const AddFiltersMenu: FunctionComponent = ({ - includeArchived, onAddIncludeArchived, }) => { const popupState = usePopupState({ @@ -49,11 +47,7 @@ export const AddFiltersMenu: FunctionComponent = ({ anchorOrigin={{ vertical: 30, horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} > - + 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 index 350e45274ee..272fa9ae411 100644 --- 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 @@ -1,5 +1,6 @@ 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"; @@ -22,17 +23,6 @@ type FilterRibbonProps = { ) => void; }; -const buildDefaultFilterState = ( - internalWebIds: WebId[], -): EntitiesFilterState => ({ - web: { - selectedInternalWebIds: new Set(internalWebIds), - includeOtherWebs: false, - }, - type: { selectedTypeIds: null }, - includeArchived: false, -}); - const isWebFilterDefault = ( web: EntitiesFilterState["web"], internalWebIds: WebId[], @@ -83,7 +73,7 @@ export const FilterRibbon: FunctionComponent = ({ const filtersAreDefault = webIsDefault && typeIsDefault && archivedIsDefault; const handleClear = () => { - setFilterState(() => buildDefaultFilterState(internalWebIds)); + setFilterState(() => createDefaultFilterState(internalWebIds)); }; const allExtraFiltersEnabled = filterState.includeArchived; @@ -111,10 +101,7 @@ export const FilterRibbon: FunctionComponent = ({ setIncludeArchived(false)} /> ) : null} {!allExtraFiltersEnabled && ( - setIncludeArchived(true)} - /> + setIncludeArchived(true)} /> )} {filtersAreDefault ? null : } 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 index 386b8845515..90cd75a0238 100644 --- 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 @@ -1,10 +1,4 @@ -import { - Box, - Checkbox, - ListItemText, - Menu, - Typography, -} from "@mui/material"; +import { Box, Checkbox, ListItemText, Menu, Typography } from "@mui/material"; import { bindMenu, bindTrigger, @@ -12,16 +6,11 @@ import { } from "material-ui-popup-state/hooks"; import { useCallback, useMemo, useState } from "react"; -import { - CaretDownSolidIcon, - Chip, - TextField, -} from "@hashintel/design-system"; +import { CaretDownSolidIcon, Chip, TextField } from "@hashintel/design-system"; import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; import { AsteriskLightIcon } from "../../../../shared/icons/asterisk-light-icon"; import { MenuItem } from "../../../../shared/ui"; - import { activePillSx, defaultPillSx } from "./pill-styles"; import type { EntitiesFilterState } from "../data/types"; @@ -85,6 +74,85 @@ const buildLabel = ({ 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 + + + +); + export const TypeFilterPill: FunctionComponent = ({ availableTypes, loading, @@ -354,74 +422,17 @@ export const TypeFilterPill: FunctionComponent = ({ )) : null} - {filteredTypes.map(({ entityTypeId, title, count }) => { - const checked = isChecked(entityTypeId); - return ( - toggle(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(); - selectOnly(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 - - - - ); - })} + {filteredTypes.map(({ entityTypeId, title, count }) => ( + + ))} ); 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 index bef4379bd12..b60af4aeefa 100644 --- 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 @@ -18,7 +18,6 @@ import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; import { useGetOwnerForEntity } from "../../../../components/hooks/use-get-owner-for-entity"; import { HouseRegularIcon } from "../../../../shared/icons/house-regular-icon"; import { MenuItem } from "../../../../shared/ui"; - import { activePillSx, defaultPillSx } from "./pill-styles"; import type { EntitiesFilterState } from "../data/types"; From 35ead7aafadd3b57a0cdce66194653a363fbf65d Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Thu, 28 May 2026 23:32:59 +0300 Subject: [PATCH 05/17] cleanup --- .../src/pages/shared/entities-visualizer.tsx | 75 +++++++++---------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index 5e0bb53f160..29226bf182e 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -451,24 +451,6 @@ export const EntitiesVisualizer: FunctionComponent<{ setCursor(nextCursor ?? undefined); }, [nextCursor]); - const viewToggle = ( - ({ - icon: visualizerViewIcons[optionValue], - label: `${optionValue} view`, - value: optionValue, - }))} - /> - ); - const isTypePinned = !!entityTypeBaseUrl || !!entityTypeId; const { types: availableTypes, loading: availableTypesLoading } = @@ -479,17 +461,6 @@ export const EntitiesVisualizer: FunctionComponent<{ entityTypeIds: entityTypeId ? [entityTypeId] : undefined, }); - const filterRibbon = ( - setFilterState(updater)} - /> - ); - const selectedEntities = useMemo(() => { if (view !== "Table" || selectedTableRows.length === 0 || !entities) { return []; @@ -509,29 +480,51 @@ export const EntitiesVisualizer: FunctionComponent<{ setSelectedTableRows([]); }, [entitiesData]); - const headerLeft = - selectedEntities.length > 0 ? ( - - ) : ( - filterRibbon - ); + const showLoading = !subgraph || !closedMultiEntityTypesRootMap; return ( 0 ? ( + + ) : ( + setFilterState(updater)} + /> + ) + } right={ <> - {viewToggle} + ({ + icon: visualizerViewIcons[optionValue], + label: `${optionValue} view`, + value: optionValue, + }))} + /> } /> - {!subgraph || !closedMultiEntityTypesRootMap ? ( + {showLoading ? ( Date: Fri, 29 May 2026 00:01:00 +0300 Subject: [PATCH 06/17] Refactor filter pill components: integrate FilterPill for Type and Web filters --- .../header/filter-pill.tsx | 71 +++++++++++ .../header/type-filter-pill.tsx | 85 +++----------- .../header/web-filter-pill.tsx | 111 ++++-------------- 3 files changed, 112 insertions(+), 155 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-pill.tsx 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..ae5be000180 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/filter-pill.tsx @@ -0,0 +1,71 @@ +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, +}) => ( + + active ? palette.blue[70] : 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/type-filter-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/type-filter-pill.tsx index 90cd75a0238..0d45ab23246 100644 --- 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 @@ -1,17 +1,12 @@ -import { Box, Checkbox, ListItemText, Menu, Typography } from "@mui/material"; -import { - bindMenu, - bindTrigger, - usePopupState, -} from "material-ui-popup-state/hooks"; +import { Box, ListItemText, Menu, Typography } from "@mui/material"; +import { bindMenu, usePopupState } from "material-ui-popup-state/hooks"; import { useCallback, useMemo, useState } from "react"; -import { CaretDownSolidIcon, Chip, TextField } from "@hashintel/design-system"; +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 { MenuItem } from "../../../../shared/ui"; -import { activePillSx, defaultPillSx } from "./pill-styles"; +import { FilterPill } from "./filter-pill"; import type { EntitiesFilterState } from "../data/types"; import type { AvailableType } from "../data/use-available-types"; @@ -91,7 +86,8 @@ const TypeFilterMenuItem: FunctionComponent = ({ onToggle, onSelectOnly, }) => ( - onToggle(entityTypeId)} sx={{ minWidth: 260, @@ -103,10 +99,6 @@ const TypeFilterMenuItem: FunctionComponent = ({ }, }} > - = ({ Only - + ); export const TypeFilterPill: FunctionComponent = ({ @@ -252,54 +244,12 @@ export const TypeFilterPill: FunctionComponent = ({ return ( - - isActive ? palette.blue[70] : palette.primary.main, - }} - /> - } - label={ - - - isActive ? palette.blue[70] : palette.gray[60], - }} - > - Type is - - - isActive ? palette.blue[90] : palette.gray[80], - }} - > - {label} - - - - } - sx={isActive ? activePillSx : defaultPillSx} - {...bindTrigger(popupState)} + = ({ ) : null} {!searchQuery ? unknownSelectedIds.map((id) => ( - toggle(id)} sx={{ minWidth: 260 }} > - = ({ }, }} /> - + )) : null} {filteredTypes.map(({ entityTypeId, title, count }) => ( 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 index b60af4aeefa..d6a74974529 100644 --- 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 @@ -1,24 +1,12 @@ -import { - Box, - Checkbox, - Divider, - ListItemText, - Menu, - Typography, -} from "@mui/material"; -import { - bindMenu, - bindTrigger, - usePopupState, -} from "material-ui-popup-state/hooks"; +import { Box, Divider, ListItemText, Menu } from "@mui/material"; +import { bindMenu, usePopupState } from "material-ui-popup-state/hooks"; import { useCallback, useMemo } from "react"; -import { CaretDownSolidIcon, Chip } from "@hashintel/design-system"; +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 { MenuItem } from "../../../../shared/ui"; -import { activePillSx, defaultPillSx } from "./pill-styles"; +import { FilterPill } from "./filter-pill"; import type { EntitiesFilterState } from "../data/types"; import type { WebId } from "@blockprotocol/type-system"; @@ -126,84 +114,35 @@ export const WebFilterPill: FunctionComponent = ({ return ( - - isActive ? palette.blue[70] : palette.primary.main, - }} - /> - } - label={ - - - isActive ? palette.blue[70] : palette.gray[60], - }} - > - Web is - - - isActive ? palette.blue[90] : palette.gray[80], - }} - > - {label} - - - - } - sx={isActive ? activePillSx : defaultPillSx} - {...bindTrigger(popupState)} + - {webItems.map(({ webId, label: itemLabel }) => { - const checked = webState.selectedInternalWebIds.has(webId); - return ( - toggleInternalWeb(webId)} - sx={{ minWidth: 220 }} - > - - - - ); - })} + {webItems.map(({ webId, label: itemLabel }) => ( + toggleInternalWeb(webId)} + sx={{ minWidth: 220 }} + > + + + ))} - - + - + ); From fa58d6e0bdf5f14281b2ff24387d82b658e850e3 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 00:12:26 +0300 Subject: [PATCH 07/17] cleanup --- .../entities-visualizer/data/build-filter.ts | 6 --- .../shared/entities-visualizer/data/types.ts | 4 -- .../data/use-available-types.ts | 9 ---- .../header/clear-filters-button.tsx | 51 +++++-------------- 4 files changed, 13 insertions(+), 57 deletions(-) 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 index 7637599207d..01a3cdf4576 100644 --- 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 @@ -5,12 +5,6 @@ import type { EntitiesFilterState } from "./types"; import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system"; import type { Filter } from "@local/hash-graph-client"; -/** - * A `WebId` value that can never match a real web. Used to emit a clause that - * the Graph will accept but match nothing against, for the "include only my - * selected internal webs" branch when the user has unchecked every one of - * their webs. - */ const MATCH_NOTHING_WEB_ID = "00000000-0000-0000-0000-000000000000" as WebId; const buildArchivedClauses = (includeArchived: boolean): Filter[] => { 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 index ec37fc97456..27f3c4ad08f 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts @@ -6,10 +6,6 @@ export type EntitiesFilterState = { includeOtherWebs: boolean; }; type: { - /** - * `null` means "all types selected" (the default). An explicit `Set` is - * recorded only after the user unchecks something. - */ selectedTypeIds: Set | null; }; includeArchived: boolean; 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 index c8d22a3ce47..5760de9204b 100644 --- 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 @@ -19,15 +19,6 @@ export type AvailableType = { count: number; }; -/** - * Drives the option list for the type-filter pill. Runs a minimal subgraph - * request that applies the current web + archived filters but deliberately - * ignores the user's type filter -- otherwise unchecking a type would remove - * it from the dropdown and the user could never re-check it. - * - * Skipped entirely when the visualizer's type is pinned by route prop, since - * the pill is not rendered in that case. - */ export const useAvailableTypes = ({ filterState, internalWebIds, 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 index 95c53877909..a681d0beeae 100644 --- 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 @@ -1,6 +1,6 @@ -import { Box } from "@mui/material"; +import { XMarkRegularIcon } from "@hashintel/design-system"; -import { Chip, XMarkRegularIcon } from "@hashintel/design-system"; +import { Button } from "../../../../shared/ui"; import type { FunctionComponent } from "react"; @@ -10,39 +10,14 @@ type ClearFiltersButtonProps = { export const ClearFiltersButton: FunctionComponent = ({ onClear, -}) => { - return ( - - palette.gray[60], fontSize: 12 }} - /> - } - label="Clear" - sx={{ - height: 26, - borderRadius: "4px", - cursor: "pointer", - border: `1px solid transparent`, - background: "transparent", - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - "& .MuiChip-label": { - color: ({ palette }) => palette.gray[70], - fontSize: 13, - fontWeight: 500, - }, - "&:hover": { - background: ({ palette }) => palette.gray[20], - "& .MuiChip-label": { - color: ({ palette }) => palette.gray[90], - }, - }, - }} - /> - - ); -}; +}) => ( + +); From 9830e1b28347e9b6f2a61125221af6222f4f9828 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 00:29:48 +0300 Subject: [PATCH 08/17] fix scroll issue --- .../hash-frontend/src/pages/shared/entities-visualizer.tsx | 7 +++++-- .../entities-visualizer/entities-table/table-toolbar.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index 29226bf182e..a769abdb35f 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -20,7 +20,10 @@ 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 } from "./entities-visualizer/entities-table/table-toolbar"; +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 { @@ -574,7 +577,7 @@ export const EntitiesVisualizer: FunctionComponent<{ handleEntityClick={handleEntityClick} loading={dataLoading} isViewingOnlyPages={isViewingOnlyPages} - maxHeight={tableHeight} + maxHeight={`calc(${tableHeight} - ${toolbarHeight}px)`} loadMoreRows={nextCursor ? nextPage : undefined} setActiveConversions={setActiveConversions} setSelectedEntityType={handleEntityTypeClick} 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 index b9fc0bacb82..807c4fae0e1 100644 --- 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 @@ -18,7 +18,7 @@ import type { BaseUrl } from "@blockprotocol/type-system"; import type { SizedGridColumn } from "@glideapps/glide-data-grid"; import type { FunctionComponent, MutableRefObject, RefObject } from "react"; -const toolbarHeight = 44; +export const toolbarHeight = 44; type TableToolbarProps = { csvFileTitle: string; From 8628a27936495e26e2768283d8090a85a8cda244 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 01:19:13 +0300 Subject: [PATCH 09/17] remove now-working displayName usage --- .../entities-visualizer/entities-table/generate-csv-file.ts | 4 ---- 1 file changed, 4 deletions(-) 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 index f95f0c40afe..39e4c55e5c5 100644 --- 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 @@ -1,6 +1,5 @@ import { stringifyPropertyValue } from "@local/hash-isomorphic-utils/stringify-property-value"; -import type { MinimalUser } from "../../../../lib/user-and-org"; import type { EntitiesTableRow } from "../types"; import type { SizedGridColumn } from "@glideapps/glide-data-grid"; @@ -32,9 +31,6 @@ export const generateEntitiesCsvFile = ({ if (typeof value === "string") { return value; - } else if (key === "lastEditedBy" || key === "createdBy") { - const user = value as MinimalUser | undefined; - return user?.displayName ?? ""; } else if (key === "archived") { return row.archived ? "Yes" : "No"; } else if (key === "sourceEntity" || key === "targetEntity") { From bff34cee2764b6a33ca3a5ee43eb76635da34b66 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 08:07:09 +0300 Subject: [PATCH 10/17] fix test --- tests/hash-playwright/tests/features/entities-page.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); }); From 7a2a2e2ea706fadb1fd63e48442ff3c3e5457dbd Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 08:32:11 +0300 Subject: [PATCH 11/17] use sx prop --- .../src/pages/shared/entities-visualizer.tsx | 4 ++-- .../entities-visualizer/entities-table.tsx | 8 ++++---- .../entities-table/sort-control.tsx | 10 ++++++---- .../entities-table/table-toolbar.tsx | 10 +++++----- .../entities-visualizer/header/filter-pill.tsx | 5 ++++- .../header/filter-ribbon.tsx | 2 +- .../header/include-archived-pill.tsx | 4 +--- .../entities-visualizer/header/query-count.tsx | 6 +++--- .../header/type-filter-pill.tsx | 12 +++++++----- .../header/visualizer-header.tsx | 18 +++++++++++++----- 10 files changed, 46 insertions(+), 33 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index a769abdb35f..cfc40db8eea 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -529,10 +529,10 @@ export const EntitiesVisualizer: FunctionComponent<{ {showLoading ? ( + ({ + alignItems: "center", + justifyContent: "center", + mt: 1, 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/sort-control.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx index cf64a170ea5..28e00bd5aba 100644 --- 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 @@ -132,10 +132,12 @@ export const SortControl: FunctionComponent = ({ {isActive && ( palette.common.white }} + sx={{ + display: "inline-flex", + alignItems: "center", + ml: 1, + color: ({ palette }) => palette.common.white, + }} > 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 index 807c4fae0e1..f4fa3e897d4 100644 --- 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 @@ -75,10 +75,10 @@ export const TableToolbar: FunctionComponent = ({ return ( palette.common.white, borderLeftWidth: 1, borderRightWidth: 1, @@ -91,7 +91,7 @@ export const TableToolbar: FunctionComponent = ({ minHeight: toolbarHeight, }} > - + setShowSearch(!showSearch)}> @@ -106,7 +106,7 @@ export const TableToolbar: FunctionComponent = ({ - + 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 index ae5be000180..4493813abe0 100644 --- 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 @@ -34,7 +34,10 @@ export const FilterPill: FunctionComponent = ({ /> } label={ - + = ({ const allExtraFiltersEnabled = filterState.includeArchived; return ( - + Include archived = ({ return ( palette.gray[70], fontSize: 13, fontWeight: 500, 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 index 0d45ab23246..2fcfb158b26 100644 --- 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 @@ -294,11 +294,13 @@ export const TypeFilterPill: FunctionComponent = ({ }} /> palette.gray[60], fontSize: 11 }} 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 index 9da1e1b573a..f55d69325c1 100644 --- 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 @@ -15,10 +15,10 @@ export const VisualizerHeader: FunctionComponent = ({ }) => { return ( palette.gray[20], borderWidth: 1, borderStyle: "solid", @@ -31,10 +31,18 @@ export const VisualizerHeader: FunctionComponent = ({ minHeight: visualizerHeaderHeight, }} > - + {left} - + {right} From 6173ca7f07ce55e8f579c54a1bf5d93ef4ad5952 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 08:37:41 +0300 Subject: [PATCH 12/17] cleanup --- .../shared/entities-visualizer/header/filter-pill.tsx | 9 +-------- .../entities-visualizer/header/filter-ribbon.tsx | 11 ++--------- 2 files changed, 3 insertions(+), 17 deletions(-) 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 index 4493813abe0..cb2d9e24079 100644 --- 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 @@ -25,14 +25,7 @@ export const FilterPill: FunctionComponent = ({ popupState, }) => ( - active ? palette.blue[70] : palette.primary.main, - }} - /> - } + icon={ palette.primary.main }} />} label={ web.selectedInternalWebIds.has(id)); }; @@ -43,12 +41,7 @@ const isTypeFilterDefault = ( if (type.selectedTypeIds === null) { return true; } - if (availableTypes.length === 0) { - return type.selectedTypeIds.size === 0; - } - if (type.selectedTypeIds.size !== availableTypes.length) { - return false; - } + return availableTypes.every(({ entityTypeId }) => type.selectedTypeIds!.has(entityTypeId), ); From 346ac94efdd528f0db7c78759f41d180a63d5327 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 08:42:10 +0300 Subject: [PATCH 13/17] simplify rendering conditions --- .../header/filter-ribbon.tsx | 8 +-- .../header/type-filter-pill.tsx | 63 +++++++++---------- 2 files changed, 35 insertions(+), 36 deletions(-) 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 index 3195e1b9344..d23844d1585 100644 --- 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 @@ -80,7 +80,7 @@ export const FilterRibbon: FunctionComponent = ({ setFilterState((prev) => ({ ...prev, web: updater(prev.web) })) } /> - {isTypePinned ? null : ( + {!isTypePinned && ( = ({ } /> )} - {filterState.includeArchived ? ( + {filterState.includeArchived && ( setIncludeArchived(false)} /> - ) : null} + )} {!allExtraFiltersEnabled && ( setIncludeArchived(true)} /> )} - {filtersAreDefault ? null : } + {!filtersAreDefault && } ); }; 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 index 2fcfb158b26..447ce49b583 100644 --- 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 @@ -332,17 +332,17 @@ export const TypeFilterPill: FunctionComponent = ({ {filteredTypes.length === 0 && - unknownSelectedIds.length === 0 && - !loading ? ( - - palette.gray[60], fontSize: 13 }} - > - {availableTypes.length === 0 ? "No types" : "No matches"} - - - ) : null} - {loading && availableTypes.length === 0 ? ( + unknownSelectedIds.length === 0 && + !loading && ( + + palette.gray[60], fontSize: 13 }} + > + {availableTypes.length === 0 ? "No types" : "No matches"} + + + )} + {loading && availableTypes.length === 0 && ( palette.gray[60], fontSize: 13 }} @@ -350,27 +350,26 @@ export const TypeFilterPill: FunctionComponent = ({ Loading… - ) : null} - {!searchQuery - ? unknownSelectedIds.map((id) => ( - toggle(id)} - sx={{ minWidth: 260 }} - > - palette.gray[60], - }, - }} - /> - - )) - : null} + )} + {!searchQuery && + unknownSelectedIds.map((id) => ( + toggle(id)} + sx={{ minWidth: 260 }} + > + palette.gray[60], + }, + }} + /> + + ))} {filteredTypes.map(({ entityTypeId, title, count }) => ( Date: Fri, 29 May 2026 09:06:28 +0300 Subject: [PATCH 14/17] cleanup --- .../header/type-filter-pill.tsx | 116 ++++++++++-------- 1 file changed, 66 insertions(+), 50 deletions(-) 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 index 447ce49b583..0321a4dc378 100644 --- 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 @@ -145,6 +145,14 @@ const TypeFilterMenuItem: FunctionComponent = ({ ); +const TypeFilterMessage: FunctionComponent<{ text: string }> = ({ text }) => ( + + palette.gray[60], fontSize: 13 }}> + {text} + + +); + export const TypeFilterPill: FunctionComponent = ({ availableTypes, loading, @@ -242,6 +250,62 @@ export const TypeFilterPill: FunctionComponent = ({ 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 ( = ({ - {filteredTypes.length === 0 && - unknownSelectedIds.length === 0 && - !loading && ( - - palette.gray[60], fontSize: 13 }} - > - {availableTypes.length === 0 ? "No types" : "No matches"} - - - )} - {loading && availableTypes.length === 0 && ( - - palette.gray[60], fontSize: 13 }} - > - Loading… - - - )} - {!searchQuery && - unknownSelectedIds.map((id) => ( - toggle(id)} - sx={{ minWidth: 260 }} - > - palette.gray[60], - }, - }} - /> - - ))} - {filteredTypes.map(({ entityTypeId, title, count }) => ( - - ))} + + {renderListContent()} ); From c744e5a17daea45510107b97a59a3e73b2fedd44 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 09:39:44 +0300 Subject: [PATCH 15/17] cleanup styles --- .../entities-visualizer/entities-table.tsx | 1 - .../entities-table/sort-control.tsx | 1 - .../entities-table/table-toolbar.tsx | 12 ++++++-- .../header/filter-pill.tsx | 1 - .../header/include-archived-pill.tsx | 2 +- .../entities-visualizer/header/pill-styles.ts | 30 +++++++------------ 6 files changed, 21 insertions(+), 26 deletions(-) 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 f205f83382e..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 @@ -786,7 +786,6 @@ export const EntitiesTable: FunctionComponent< sx={({ palette }) => ({ alignItems: "center", justifyContent: "center", - mt: 1, 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/sort-control.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/entities-table/sort-control.tsx index 28e00bd5aba..fd80e5c1aaf 100644 --- 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 @@ -136,7 +136,6 @@ export const SortControl: FunctionComponent = ({ display: "inline-flex", alignItems: "center", ml: 1, - color: ({ palette }) => palette.common.white, }} > 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 index f4fa3e897d4..8601dcf4239 100644 --- 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 @@ -1,4 +1,4 @@ -import { Box, Tooltip } from "@mui/material"; +import { Box, Tooltip, type SxProps } from "@mui/material"; import { unparse } from "papaparse"; import { useCallback } from "react"; @@ -20,6 +20,12 @@ 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; @@ -91,7 +97,7 @@ export const TableToolbar: FunctionComponent = ({ minHeight: toolbarHeight, }} > - + setShowSearch(!showSearch)}> @@ -106,7 +112,7 @@ export const TableToolbar: FunctionComponent = ({ - + 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 index cb2d9e24079..1c6d34d07c2 100644 --- 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 @@ -35,7 +35,6 @@ export const FilterPill: FunctionComponent = ({ component="span" sx={{ fontSize: 13, - fontWeight: 400, color: ({ palette }) => active ? palette.blue[70] : palette.gray[60], }} 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 index 49449d6c02a..8d28105a336 100644 --- 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 @@ -40,7 +40,7 @@ export const IncludeArchivedPill: FunctionComponent< }, }} > - + } 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 index d872f3ec176..40be96add8b 100644 --- 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 @@ -2,44 +2,36 @@ import { chipClasses } from "@mui/material"; import type { SxProps, Theme } from "@mui/material"; -export const defaultPillSx: SxProps = { +const basePillSx = { height: 26, borderRadius: "4px", - cursor: "pointer", - border: ({ palette }: Theme) => `1px solid ${palette.gray[30]}`, background: ({ palette }: Theme) => palette.gray[5], [`.${chipClasses.label}`]: { fontSize: 13, - fontWeight: 500, 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", - cursor: "pointer", border: ({ palette }: Theme) => `1px solid ${palette.blue[40]}`, background: ({ palette }: Theme) => palette.blue[15], [`.${chipClasses.label}`]: { fontSize: 13, - fontWeight: 500, color: ({ palette }: Theme) => palette.blue[90], }, "&:hover": { background: ({ palette }: Theme) => palette.blue[20], }, }; - -export const dashedPillSx: SxProps = { - height: 26, - borderRadius: "4px", - cursor: "pointer", - border: ({ palette }: Theme) => `1px dashed ${palette.gray[30]}`, - background: ({ palette }: Theme) => palette.gray[5], - [`.${chipClasses.label}`]: { - fontSize: 13, - fontWeight: 500, - color: ({ palette }: Theme) => palette.gray[70], - }, -}; From 1d821087c5a411908c73fd78592e09b4e4d36e45 Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 18:27:07 +0300 Subject: [PATCH 16/17] wip - UI only --- .../src/pages/shared/entities-visualizer.tsx | 15 ++++ .../header/add-filters-menu.tsx | 13 ++++ .../header/filter-ribbon.tsx | 27 ++++++- .../header/semantic-search-pill.tsx | 72 +++++++++++++++++++ 4 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index cfc40db8eea..d59186cc826 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -170,6 +170,11 @@ export const EntitiesVisualizer: FunctionComponent<{ ); const [cursor, setCursor] = useState(); + + /* Semantic search — local text only, not yet wired to a query. */ + const [semanticSearchText, setSemanticSearchText] = useState(""); + const [semanticSearchAdded, setSemanticSearchAdded] = useState(false); + const [activeConversionsWithoutTitle, _setActiveConversions] = useState<{ [columnBaseUrl: BaseUrl]: VersionedUrl; } | null>(null); @@ -502,6 +507,16 @@ export const EntitiesVisualizer: FunctionComponent<{ internalWebIds={internalWebIds} isTypePinned={isTypePinned} setFilterState={(updater) => setFilterState(updater)} + semanticSearch={{ + added: semanticSearchAdded, + value: semanticSearchText, + onChange: setSemanticSearchText, + onAdd: () => setSemanticSearchAdded(true), + onRemove: () => { + setSemanticSearchAdded(false); + setSemanticSearchText(""); + }, + }} /> ) } 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 index bef24719a60..72eace30562 100644 --- 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 @@ -15,10 +15,13 @@ import type { FunctionComponent } from "react"; type AddFiltersMenuProps = { onAddIncludeArchived: () => void; + /** When set, adds a "Semantic search" filter option to the menu. */ + onAddSemanticSearch?: () => void; }; export const AddFiltersMenu: FunctionComponent = ({ onAddIncludeArchived, + onAddSemanticSearch, }) => { const popupState = usePopupState({ variant: "popover", @@ -30,6 +33,11 @@ export const AddFiltersMenu: FunctionComponent = ({ popupState.close(); }; + const handleSelectSemanticSearch = () => { + onAddSemanticSearch?.(); + popupState.close(); + }; + return ( = ({ + {onAddSemanticSearch && ( + + + + )} ); 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 index d23844d1585..174be25e7b6 100644 --- 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 @@ -4,6 +4,7 @@ import { createDefaultFilterState } from "../data/types"; import { AddFiltersMenu } from "./add-filters-menu"; import { ClearFiltersButton } from "./clear-filters-button"; import { IncludeArchivedPill } from "./include-archived-pill"; +import { SemanticSearchPill } from "./semantic-search-pill"; import { TypeFilterPill } from "./type-filter-pill"; import { WebFilterPill } from "./web-filter-pill"; @@ -21,6 +22,14 @@ type FilterRibbonProps = { setFilterState: ( updater: (prev: EntitiesFilterState) => EntitiesFilterState, ) => void; + /** Semantic search, added as a dismissable filter from the "Add filter" menu. */ + semanticSearch: { + added: boolean; + value: string; + onChange: (value: string) => void; + onAdd: () => void; + onRemove: () => void; + }; }; const isWebFilterDefault = ( @@ -54,6 +63,7 @@ export const FilterRibbon: FunctionComponent = ({ internalWebIds, isTypePinned, setFilterState, + semanticSearch, }) => { const setIncludeArchived = (includeArchived: boolean) => setFilterState((prev) => ({ ...prev, includeArchived })); @@ -69,7 +79,8 @@ export const FilterRibbon: FunctionComponent = ({ setFilterState(() => createDefaultFilterState(internalWebIds)); }; - const allExtraFiltersEnabled = filterState.includeArchived; + const allExtraFiltersEnabled = + filterState.includeArchived || semanticSearch.added; return ( @@ -93,8 +104,20 @@ export const FilterRibbon: FunctionComponent = ({ {filterState.includeArchived && ( setIncludeArchived(false)} /> )} + {semanticSearch.added && ( + + )} {!allExtraFiltersEnabled && ( - setIncludeArchived(true)} /> + setIncludeArchived(true)} + onAddSemanticSearch={ + semanticSearch.added ? undefined : semanticSearch.onAdd + } + /> )} {!filtersAreDefault && } diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx new file mode 100644 index 00000000000..0c4d8524655 --- /dev/null +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx @@ -0,0 +1,72 @@ +import { Box, InputBase } from "@mui/material"; + +import { IconButton, XMarkRegularIcon } from "@hashintel/design-system"; + +import { SearchIcon } from "../../../../shared/icons"; + +import type { FunctionComponent } from "react"; + +/** + * A dismissable semantic-search filter pill, added from the "Add filter" menu + * and rendered among the other filter pills. Styled to match the filter pills, + * with an inline borderless input and an × to remove. + */ +export const SemanticSearchPill: FunctionComponent<{ + value: string; + onChange: (value: string) => void; + onRemove: () => void; +}> = ({ value, onChange, onRemove }) => ( + `1px solid ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], + }} + > + palette.gray[50] }} + /> + onChange(event.target.value)} + sx={{ + width: 190, + "& .MuiInputBase-input": { + p: 0, + height: "auto", + fontSize: 13, + lineHeight: 1, + color: ({ palette }) => palette.gray[70], + "&::placeholder": { + color: ({ palette }) => palette.gray[50], + opacity: 1, + }, + }, + }} + /> + palette.gray[50], + "&:hover": { + color: ({ palette }) => palette.gray[70], + background: "transparent", + }, + }} + > + + + +); From b8a674646708be44e6190f62cdb48722b51e798b Mon Sep 17 00:00:00 2001 From: Yusuf Kinatas Date: Fri, 29 May 2026 19:49:38 +0300 Subject: [PATCH 17/17] implement semantic search functionality with debounced input and filter integration --- .../src/pages/shared/entities-visualizer.tsx | 86 +++++++++-- .../entities-visualizer/data/build-filter.ts | 31 ++++ .../shared/entities-visualizer/data/types.ts | 23 +++ .../entities-visualizer/entities-table.tsx | 4 +- .../header/add-filters-menu.tsx | 16 +- .../header/filter-ribbon.tsx | 29 ++-- .../header/semantic-search-pill.tsx | 141 +++++++++++------- .../use-entities-visualizer-data.tsx | 18 ++- 8 files changed, 261 insertions(+), 87 deletions(-) diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx index d59186cc826..d89417d0283 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer.tsx @@ -8,6 +8,7 @@ import { getClosedMultiEntityTypeFromMap, type HashEntity, } from "@local/hash-graph-sdk/entity"; +import { formatNumber } from "@local/hash-isomorphic-utils/format-number"; import { systemEntityTypes } from "@local/hash-isomorphic-utils/ontology-type-ids"; import { useEntityTypesContextRequired } from "../../shared/entity-types-context/hooks/use-entity-types-context-required"; @@ -16,7 +17,10 @@ import { tableContentSx } from "../../shared/table-content"; import { BulkActionsDropdown } from "../../shared/table-header/bulk-actions-dropdown"; import { useMemoCompare } from "../../shared/use-memo-compare"; import { useAuthenticatedUser } from "./auth-info-context"; -import { createDefaultFilterState } from "./entities-visualizer/data/types"; +import { + createDefaultFilterState, + hasActiveSemanticQuery, +} 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"; @@ -63,6 +67,14 @@ import type { } from "@local/hash-graph-client"; import type { Dispatch, FunctionComponent, SetStateAction } from "react"; +/** + * Rows fetched per request in the Table/Grid views. When a semantic search is + * active this is also the hard cap on results — there is no "load more" because + * the graph layer disallows cursors alongside a distance filter — so the best + * matches by relevance are always the ones shown. + */ +const ENTITIES_TABLE_RESULT_LIMIT = 500; + /** * @todo: avoid having to maintain this list, potentially by * adding an `isFile` boolean to the generated ontology IDs file. @@ -171,10 +183,6 @@ export const EntitiesVisualizer: FunctionComponent<{ const [cursor, setCursor] = useState(); - /* Semantic search — local text only, not yet wired to a query. */ - const [semanticSearchText, setSemanticSearchText] = useState(""); - const [semanticSearchAdded, setSemanticSearchAdded] = useState(false); - const [activeConversionsWithoutTitle, _setActiveConversions] = useState<{ [columnBaseUrl: BaseUrl]: VersionedUrl; } | null>(null); @@ -209,6 +217,28 @@ export const EntitiesVisualizer: FunctionComponent<{ [setCursor], ); + /** + * Writes the debounced semantic query into `filterState` (which resets the + * cursor and refetches via the shared data hook). Guarded so a debounced + * keystroke that lands after the pill was removed can't resurrect a stale + * query, and so an unchanged value doesn't trigger a needless refetch. + */ + const setSemanticQuery = useCallback( + (query: string) => { + setFilterState((prev) => + !prev.semanticSearch.added || prev.semanticSearch.query === query + ? prev + : { + ...prev, + semanticSearch: { ...prev.semanticSearch, query }, + }, + ); + }, + [setFilterState], + ); + + const semanticQueryActive = hasActiveSemanticQuery(filterState); + const [view, _setView] = useState("Table"); const setView = useCallback( @@ -258,7 +288,7 @@ export const EntitiesVisualizer: FunctionComponent<{ filterState, hideColumns, internalWebIds, - limit: view === "Graph" ? undefined : 500, + limit: view === "Graph" ? undefined : ENTITIES_TABLE_RESULT_LIMIT, sort: graphSort, view, }); @@ -508,20 +538,44 @@ export const EntitiesVisualizer: FunctionComponent<{ isTypePinned={isTypePinned} setFilterState={(updater) => setFilterState(updater)} semanticSearch={{ - added: semanticSearchAdded, - value: semanticSearchText, - onChange: setSemanticSearchText, - onAdd: () => setSemanticSearchAdded(true), - onRemove: () => { - setSemanticSearchAdded(false); - setSemanticSearchText(""); - }, + added: filterState.semanticSearch.added, + initialQuery: filterState.semanticSearch.query, + onAdd: () => + setFilterState((prev) => ({ + ...prev, + semanticSearch: { ...prev.semanticSearch, added: true }, + })), + onQueryChange: setSemanticQuery, + onRemove: () => + setFilterState((prev) => ({ + ...prev, + semanticSearch: { added: false, query: "" }, + })), }} /> ) } right={ <> + {semanticQueryActive && !dataLoading && totalResultCount ? ( + palette.gray[50], + }} + > + {/* The 500 cap only applies to the Table/Grid views (Graph + fetches the full match set), so only claim a cap there. */} + {view !== "Graph" && + totalResultCount > ENTITIES_TABLE_RESULT_LIMIT + ? `Top ${formatNumber( + ENTITIES_TABLE_RESULT_LIMIT, + )} by relevance` + : "Ranked by relevance"} + + ) : null} { + if (!hasActiveSemanticQuery(filterState)) { + return null; + } + + return { + cosineDistance: [ + { path: ["embedding"] }, + // The string is embedded server-side before the distance is computed. + { parameter: filterState.semanticSearch.query.trim() }, + { parameter: MAXIMUM_SEMANTIC_DISTANCE }, + ], + }; +}; + export const buildEntitiesFilter = ({ filterState, internalWebIds, @@ -175,5 +201,10 @@ export const buildEntitiesFilter = ({ clauses.push(ignoreNoisySystemTypesFilter); } + const semanticSearchClause = buildSemanticSearchClause(filterState); + if (semanticSearchClause) { + clauses.push(semanticSearchClause); + } + return { all: clauses }; }; 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 index 27f3c4ad08f..3639a8c0175 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/data/types.ts @@ -9,6 +9,16 @@ export type EntitiesFilterState = { selectedTypeIds: Set | null; }; includeArchived: boolean; + /** + * Server-side semantic search, modelled as a filter. `added` tracks whether + * the search pill is present (it can be added with an empty query, which is a + * no-op browse); `query` holds the debounced free-text query that is embedded + * server-side and turned into a `cosineDistance` clause. + */ + semanticSearch: { + added: boolean; + query: string; + }; }; export const createDefaultFilterState = ( @@ -20,4 +30,17 @@ export const createDefaultFilterState = ( }, type: { selectedTypeIds: null }, includeArchived: false, + semanticSearch: { added: false, query: "" }, }); + +/** + * Whether a semantic search is currently driving the query — i.e. the pill is + * present and holds a non-empty query. When false the `cosineDistance` clause is + * omitted and the table behaves as a normal (cursor-paginated, column-sorted) + * browse. + */ +export const hasActiveSemanticQuery = ( + filterState: EntitiesFilterState, +): boolean => + filterState.semanticSearch.added && + filterState.semanticSearch.query.trim().length > 0; 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 80cd11493f3..c06177278b9 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 @@ -748,7 +748,9 @@ export const EntitiesTable: FunctionComponent< }, [rows.length]); const hasMoreRowsAvailable = - totalResultCount && totalResultCount > rows.length; + !!loadMoreRows && + totalResultCount !== null && + totalResultCount > rows.length; const loadMoreRowHeight = 60; return ( 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 index 72eace30562..1599e4abf29 100644 --- 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 @@ -14,7 +14,8 @@ import { dashedPillSx } from "./pill-styles"; import type { FunctionComponent } from "react"; type AddFiltersMenuProps = { - onAddIncludeArchived: () => void; + /** When set, adds an "Include archived" filter option to the menu. */ + onAddIncludeArchived?: () => void; /** When set, adds a "Semantic search" filter option to the menu. */ onAddSemanticSearch?: () => void; }; @@ -29,7 +30,7 @@ export const AddFiltersMenu: FunctionComponent = ({ }); const handleSelectIncludeArchived = () => { - onAddIncludeArchived(); + onAddIncludeArchived?.(); popupState.close(); }; @@ -55,9 +56,14 @@ export const AddFiltersMenu: FunctionComponent = ({ anchorOrigin={{ vertical: 30, horizontal: "left" }} transformOrigin={{ vertical: "top", horizontal: "left" }} > - - - + {onAddIncludeArchived && ( + + + + )} {onAddSemanticSearch && ( 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 index 174be25e7b6..4ae7a3551a4 100644 --- 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 @@ -25,9 +25,9 @@ type FilterRibbonProps = { /** Semantic search, added as a dismissable filter from the "Add filter" menu. */ semanticSearch: { added: boolean; - value: string; - onChange: (value: string) => void; + initialQuery: string; onAdd: () => void; + onQueryChange: (query: string) => void; onRemove: () => void; }; }; @@ -72,15 +72,22 @@ export const FilterRibbon: FunctionComponent = ({ const typeIsDefault = isTypePinned || isTypeFilterDefault(filterState.type, availableTypes); const archivedIsDefault = !filterState.includeArchived; + const semanticIsDefault = !semanticSearch.added; - const filtersAreDefault = webIsDefault && typeIsDefault && archivedIsDefault; + const filtersAreDefault = + webIsDefault && typeIsDefault && archivedIsDefault && semanticIsDefault; const handleClear = () => { setFilterState(() => createDefaultFilterState(internalWebIds)); }; - const allExtraFiltersEnabled = - filterState.includeArchived || semanticSearch.added; + /** + * The "Add filter" menu offers two filters (archived, semantic search). Hide + * it only once *both* are added; otherwise keep it visible and let each menu + * item gate itself, so adding one filter never blocks adding the other. + */ + const allExtraFiltersAdded = + filterState.includeArchived && semanticSearch.added; return ( @@ -106,14 +113,18 @@ export const FilterRibbon: FunctionComponent = ({ )} {semanticSearch.added && ( )} - {!allExtraFiltersEnabled && ( + {!allExtraFiltersAdded && ( setIncludeArchived(true)} + onAddIncludeArchived={ + filterState.includeArchived + ? undefined + : () => setIncludeArchived(true) + } onAddSemanticSearch={ semanticSearch.added ? undefined : semanticSearch.onAdd } diff --git a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx index 0c4d8524655..1192331f835 100644 --- a/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-visualizer/header/semantic-search-pill.tsx @@ -1,4 +1,6 @@ +import { useDebouncedCallback } from "@mantine/hooks"; import { Box, InputBase } from "@mui/material"; +import { useState } from "react"; import { IconButton, XMarkRegularIcon } from "@hashintel/design-system"; @@ -6,67 +8,98 @@ import { SearchIcon } from "../../../../shared/icons"; import type { FunctionComponent } from "react"; +/** + * Matches the global search bar. Each settled query triggers an embedding + * generation + a subgraph fetch, so debouncing protects the backend. + */ +const SEMANTIC_SEARCH_DEBOUNCE_MS = 300; + /** * A dismissable semantic-search filter pill, added from the "Add filter" menu * and rendered among the other filter pills. Styled to match the filter pills, * with an inline borderless input and an × to remove. + * + * The pill owns its instantly-updating input buffer and only reports the + * debounced value up via `onQueryChange`. It is conditionally rendered by its + * parent, so it remounts (and re-seeds from `initialQuery`) whenever the filter + * is added — meaning a "Clear filters" never leaves a stale query behind. */ export const SemanticSearchPill: FunctionComponent<{ - value: string; - onChange: (value: string) => void; + initialQuery: string; + onQueryChange: (query: string) => void; onRemove: () => void; -}> = ({ value, onChange, onRemove }) => ( - `1px solid ${palette.gray[30]}`, - background: ({ palette }) => palette.gray[5], - }} - > - palette.gray[50] }} - /> - onChange(event.target.value)} - sx={{ - width: 190, - "& .MuiInputBase-input": { - p: 0, - height: "auto", - fontSize: 13, - lineHeight: 1, - color: ({ palette }) => palette.gray[70], - "&::placeholder": { - color: ({ palette }) => palette.gray[50], - opacity: 1, - }, - }, - }} - /> - = ({ initialQuery, onQueryChange, onRemove }) => { + const [displayedQuery, setDisplayedQuery] = useState(initialQuery); + + // `flushOnUnmount` so a final keystroke isn't dropped when the pill unmounts + // mid-debounce (e.g. selecting a row swaps the filter ribbon for the bulk + // actions bar). The `setSemanticQuery` guard safely ignores a flushed write + // if the pill was instead removed (which sets `added: false`). + const reportQuery = useDebouncedCallback(onQueryChange, { + delay: SEMANTIC_SEARCH_DEBOUNCE_MS, + flushOnUnmount: true, + }); + + return ( + palette.gray[50], - "&:hover": { - color: ({ palette }) => palette.gray[70], - background: "transparent", - }, + display: "flex", + alignItems: "center", + gap: 0.75, + height: 26, + boxSizing: "border-box", + pl: 1, + pr: 0.5, + borderRadius: "4px", + border: ({ palette }) => `1px solid ${palette.gray[30]}`, + background: ({ palette }) => palette.gray[5], }} > - - - -); + palette.gray[50], + }} + /> + { + setDisplayedQuery(event.target.value); + reportQuery(event.target.value); + }} + sx={{ + width: 190, + "& .MuiInputBase-input": { + p: 0, + height: "auto", + fontSize: 13, + lineHeight: 1, + color: ({ palette }) => palette.gray[70], + "&::placeholder": { + color: ({ palette }) => palette.gray[50], + opacity: 1, + }, + }, + }} + /> + palette.gray[50], + "&:hover": { + color: ({ palette }) => palette.gray[70], + background: "transparent", + }, + }} + > + + + + ); +}; 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 25bd0bb7c6a..477514cce3a 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 @@ -13,6 +13,7 @@ import { queryEntitySubgraphQuery } from "../../../graphql/queries/knowledge/ent import { apolloClient } from "../../../lib/apollo-client"; import { buildEntitiesFilter } from "./data/build-filter"; import { traversalPathsForView } from "./data/traversal-paths"; +import { hasActiveSemanticQuery } from "./data/types"; import { useEntitiesTableData } from "./use-entities-table-data"; import type { @@ -85,11 +86,22 @@ export const useEntitiesVisualizerData = (params: { hideArchivedColumn: !filterState.includeArchived, }); + /** + * The graph layer disallows cursors alongside a `cosineDistance` filter + * ("Cannot use distance function with cursor"). The caller already avoids + * setting a cursor while searching (load-more is disabled and changing the + * query resets the cursor), but force it off here too so the request can + * never 500. + */ + const cursorForRequest = hasActiveSemanticQuery(filterState) + ? undefined + : cursor; + const variables = useMemo( () => ({ request: { conversions, - cursor, + cursor: cursorForRequest, limit, includeCount: true, includeTypeIds: true, @@ -115,7 +127,7 @@ export const useEntitiesVisualizerData = (params: { }), [ conversions, - cursor, + cursorForRequest, entityTypeBaseUrl, entityTypeIds, filterState, @@ -143,7 +155,7 @@ export const useEntitiesVisualizerData = (params: { const newEntities = getRoots(newSubgraph); updateTableData({ - appliedPaginationCursor: cursor ?? null, + appliedPaginationCursor: cursorForRequest ?? null, closedMultiEntityTypesRootMap: completedData.queryEntitySubgraph.closedMultiEntityTypes ?? {}, definitions: completedData.queryEntitySubgraph.definitions,