From 361c4baee16157287a27479136445b91b64077d5 Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Thu, 11 Apr 2024 14:55:08 -0500 Subject: [PATCH] Improve node and edge filter options (#289) --- packages/graph-explorer/.vscode/launch.json | 19 +++ packages/graph-explorer/package.json | 1 + .../IconButton/IconButton.styles.ts | 32 ++--- .../src/components/Sidebar/SidebarButton.tsx | 25 +--- .../src/core/ConfigurationProvider/types.ts | 28 ++-- .../src/core/StateProvider/configuration.ts | 18 +++ .../src/core/StateProvider/filterCount.ts | 18 +++ packages/graph-explorer/src/core/index.ts | 1 - .../graph-explorer/src/hooks/useEntities.ts | 4 +- .../EntitiesFilter/useFiltersConfig.test.ts | 132 ++++++++++++++++++ .../EntitiesFilter/useFiltersConfig.tsx | 77 ++++++---- .../src/modules/GraphViewer/GraphViewer.tsx | 2 - .../modules/GraphViewer/useGraphViewerInit.ts | 27 ---- .../utils/testing/TestableRootProviders.tsx | 13 ++ .../src/utils/testing/randomData.ts | 112 +++++++++++++++ .../src/utils/testing/useTestSchema.ts | 37 +++++ .../GraphExplorer/GraphExplorer.tsx | 5 + pnpm-lock.yaml | 24 +++- 18 files changed, 462 insertions(+), 113 deletions(-) create mode 100644 packages/graph-explorer/.vscode/launch.json create mode 100644 packages/graph-explorer/src/core/StateProvider/filterCount.ts create mode 100644 packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts delete mode 100644 packages/graph-explorer/src/modules/GraphViewer/useGraphViewerInit.ts create mode 100644 packages/graph-explorer/src/utils/testing/TestableRootProviders.tsx create mode 100644 packages/graph-explorer/src/utils/testing/randomData.ts create mode 100644 packages/graph-explorer/src/utils/testing/useTestSchema.ts diff --git a/packages/graph-explorer/.vscode/launch.json b/packages/graph-explorer/.vscode/launch.json new file mode 100644 index 000000000..89be2edd8 --- /dev/null +++ b/packages/graph-explorer/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest current file", + "skipFiles": ["/**"], + "runtimeExecutable": "sh", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["${relativeFile}", "--coverage=false"], + "console": "integratedTerminal", + "internalConsoleOptions": "openOnFirstSessionStart" + } + ] +} diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index ec8c22b09..46b0cb083 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -91,6 +91,7 @@ "@babel/preset-env": "^7.23.2", "@babel/preset-react": "^7.22.15", "@babel/preset-typescript": "^7.22.15", + "@jest/globals": "29.0.0", "@react-aria/overlays": "3.9.1", "@react-stately/radio": "^3.8.2", "@react-types/button": "^3.7.3", diff --git a/packages/graph-explorer/src/components/IconButton/IconButton.styles.ts b/packages/graph-explorer/src/components/IconButton/IconButton.styles.ts index 18d073250..11afe62b4 100644 --- a/packages/graph-explorer/src/components/IconButton/IconButton.styles.ts +++ b/packages/graph-explorer/src/components/IconButton/IconButton.styles.ts @@ -362,42 +362,42 @@ export const defaultBadgeStyles = } &.${pfx}-placement-bottom-right { - bottom: -4px; - right: 0; + bottom: -6px; + right: -6px; &.${pfx}-variant-undetermined { - bottom: 4px; - right: 2px; + bottom: 0; + right: 0; } } &.${pfx}-placement-bottom-left { - bottom: -4px; - left: 0; + bottom: -6px; + left: -2px; &.${pfx}-variant-undetermined { - bottom: 4px; - left: 2px; + bottom: 0; + left: 0; } } &.${pfx}-placement-top-right { - top: -4px; - right: 0; + top: -6px; + right: -6px; &.${pfx}-variant-undetermined { - top: 4px; - right: 2px; + top: 0; + right: 0; } } &.${pfx}-placement-top-left { - top: -4px; - left: 0; + top: -6px; + left: -2px; &.${pfx}-variant-undetermined { - top: 4px; - left: 2px; + top: 0; + left: 0; } } } diff --git a/packages/graph-explorer/src/components/Sidebar/SidebarButton.tsx b/packages/graph-explorer/src/components/Sidebar/SidebarButton.tsx index 9d3d19185..4da141f48 100644 --- a/packages/graph-explorer/src/components/Sidebar/SidebarButton.tsx +++ b/packages/graph-explorer/src/components/Sidebar/SidebarButton.tsx @@ -1,7 +1,7 @@ import { cx } from "@emotion/css"; import type { ReactNode } from "react"; import { withClassNamePrefix } from "../../core"; -import IconButton from "../IconButton"; +import IconButton, { IconButtonProps } from "../IconButton"; export type NavItem = { icon: ReactNode; @@ -10,40 +10,25 @@ export type NavItem = { }; export type SidebarButtonProps = { - tooltipText?: string; - icon: ReactNode; - onPress(): void; - classNamePrefix?: string; - className?: string; active?: boolean; -}; +} & Omit; const SidebarButton = ({ - tooltipText, classNamePrefix = "ft", className, active, - icon, - onPress, + ...props }: SidebarButtonProps) => { const pfx = withClassNamePrefix(classNamePrefix); return ( ); }; diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts index e759b9505..888a28800 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/types.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/types.ts @@ -147,6 +147,20 @@ export type ConnectionConfig = { fetchTimeoutMs?: number; }; +export type Schema = { + totalVertices: number; + vertices: Array; + totalEdges: number; + edges: Array; + lastUpdate?: Date; + triedToSync?: boolean; + lastSyncFail?: boolean; + /** + * List of RDF prefixes (only for SPARQL) + */ + prefixes?: Array; +}; + export type RawConfiguration = { /** * Unique identifier for this config @@ -166,19 +180,7 @@ export type RawConfiguration = { /** * Database schema: types, names, labels, icons, ... */ - schema?: { - totalVertices: number; - vertices: Array; - totalEdges: number; - edges: Array; - lastUpdate?: Date; - triedToSync?: boolean; - lastSyncFail?: boolean; - /** - * List of RDF prefixes (only for SPARQL) - */ - prefixes?: Array; - }; + schema?: Schema; /** * Mark as created from a file */ diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index 199ce03d0..4dc1811f4 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -176,3 +176,21 @@ const mergeEdge = ( attributes, }; }; + +/** Same as `useConfig().vertexTypes */ +export const vertexTypesSelector = selector({ + key: "config-vertex-types", + get: ({ get }) => { + const configuration = get(mergedConfigurationSelector); + return configuration?.schema?.vertices?.map(vt => vt.type) || []; + }, +}); + +/** Same as `useConfig().edgeTypes */ +export const edgeTypesSelector = selector({ + key: "config-edge-types", + get: ({ get }) => { + const configuration = get(mergedConfigurationSelector); + return configuration?.schema?.edges?.map(vt => vt.type) || []; + }, +}); diff --git a/packages/graph-explorer/src/core/StateProvider/filterCount.ts b/packages/graph-explorer/src/core/StateProvider/filterCount.ts new file mode 100644 index 000000000..9591153e5 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/filterCount.ts @@ -0,0 +1,18 @@ +import { selector } from "recoil"; +import { edgesTypesFilteredAtom } from "./edges"; +import { nodesTypesFilteredAtom } from "./nodes"; + +/** + * The count of filtered node and edge types + */ +export const totalFilteredCount = selector({ + key: "nodes-and-edges-filtered-count", + get: ({ get }) => { + // Get all the filtered entities + const filteredNodeTypes = get(nodesTypesFilteredAtom); + const filteredEdgeTypes = get(edgesTypesFilteredAtom); + + // Determine how many entity types are not checked + return filteredNodeTypes.size + filteredEdgeTypes.size; + }, +}); diff --git a/packages/graph-explorer/src/core/index.ts b/packages/graph-explorer/src/core/index.ts index 27e99dd0e..827d6b62e 100644 --- a/packages/graph-explorer/src/core/index.ts +++ b/packages/graph-explorer/src/core/index.ts @@ -1,3 +1,2 @@ export * from "./ConfigurationProvider"; export * from "./ThemeProvider"; -export * from "./ConnectedProvider"; diff --git a/packages/graph-explorer/src/hooks/useEntities.ts b/packages/graph-explorer/src/hooks/useEntities.ts index 14d2abde0..f0aa5033b 100644 --- a/packages/graph-explorer/src/hooks/useEntities.ts +++ b/packages/graph-explorer/src/hooks/useEntities.ts @@ -123,12 +123,12 @@ const useEntities = ({ disableFilters }: { disableFilters?: boolean } = {}): [ let filteredEdges = edges; if (!disableFilters) { filteredNodes = nodes.filter(node => { - return vertexTypes.has(node.data.type); + return vertexTypes.has(node.data.type) === false; }); filteredEdges = edges.filter(edge => { return ( - connectionTypes.has(edge.data.type) && + connectionTypes.has(edge.data.type) === false && filteredNodes.some(node => node.data.id === edge.data.source) && filteredNodes.some(node => node.data.id === edge.data.target) ); diff --git a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts new file mode 100644 index 000000000..8663669f8 --- /dev/null +++ b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.test.ts @@ -0,0 +1,132 @@ +import { expect, jest } from "@jest/globals"; +import { act, renderHook } from "@testing-library/react-hooks"; +import useFiltersConfig from "./useFiltersConfig"; +import { useTestSchema } from "../../utils/testing/useTestSchema"; +import { createRandomSchema } from "../../utils/testing/randomData"; +import { TestableRootProviders } from "../../utils/testing/TestableRootProviders"; +import { sample, sortBy } from "lodash"; +import { Schema } from "../../core"; + +jest.mock("localforage", () => ({ + config: jest.fn(), + getItem: jest.fn(), + setItem: jest.fn(), +})); + +/** Creates a config with the schema and makes it active, then renders the `useFiltersConfig` hook. */ +function renderFilterConfigHook(schema: Schema) { + return renderHook( + () => { + useTestSchema(schema); + return useFiltersConfig(); + }, + { + wrapper: TestableRootProviders, + } + ); +} + +describe("useFiltersConfig", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("should have all entities selected", () => { + const schema = createRandomSchema(); + + const { result } = renderFilterConfigHook(schema); + + expect(result.current.selectedVertexTypes).toEqual( + new Set(schema.vertices.map(v => v.type)) + ); + expect(result.current.selectedConnectionTypes).toEqual( + new Set(schema.edges.map(v => v.type)) + ); + }); + + it("should have all vertices in checkboxes", () => { + const schema = createRandomSchema(); + const expectedCheckboxIds = schema.vertices.map(v => v.type); + + const { result } = renderFilterConfigHook(schema); + + expect(result.current.vertexTypes.map(vt => vt.id)).toEqual( + expect.arrayContaining(expectedCheckboxIds) + ); + }); + + it("should sort vertex checkboxes alphabetically", () => { + const schema = createRandomSchema(); + + const { result } = renderFilterConfigHook(schema); + + expect(result.current.vertexTypes).toEqual( + sortBy(result.current.vertexTypes, vt => vt.text) + ); + }); + + it("should have all edges in checkboxes", () => { + const schema = createRandomSchema(); + const expectedCheckboxIds = schema.edges.map(v => v.type); + + const { result } = renderFilterConfigHook(schema); + + expect(result.current.connectionTypes.map(vt => vt.id)).toEqual( + expect.arrayContaining(expectedCheckboxIds) + ); + }); + + it("should sort edge checkboxes alphabetically", () => { + const schema = createRandomSchema(); + + const { result } = renderFilterConfigHook(schema); + + expect(result.current.connectionTypes).toEqual( + sortBy(result.current.connectionTypes, vt => vt.text) + ); + }); + + it("should unselect vertex when toggled", () => { + const schema = createRandomSchema(); + const changingVertex = sample(schema.vertices)!; + + const { result } = renderFilterConfigHook(schema); + + // Ensure vertex is selected initially + expect(result.current.selectedVertexTypes.has(changingVertex.type)).toEqual( + true + ); + + // Deselect vertex + act(() => { + result.current.onChangeVertexTypes(changingVertex.type, false); + }); + + // Ensure vertex is no longer selected + expect(result.current.selectedVertexTypes.has(changingVertex.type)).toEqual( + false + ); + }); + + it("should unselect edge when toggled", () => { + const schema = createRandomSchema(); + const changingEdge = sample(schema.edges)!; + + const { result } = renderFilterConfigHook(schema); + + // Ensure edge is selected initially + expect( + result.current.selectedConnectionTypes.has(changingEdge.type) + ).toEqual(true); + + // Deselect edge + act(() => { + result.current.onChangeConnectionTypes(changingEdge.type, false); + }); + + // Ensure edge is no longer selected + expect( + result.current.selectedConnectionTypes.has(changingEdge.type) + ).toEqual(false); + }); +}); diff --git a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx index e6a79d48f..3c82943a9 100644 --- a/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx +++ b/packages/graph-explorer/src/modules/EntitiesFilter/useFiltersConfig.tsx @@ -1,22 +1,50 @@ import sortBy from "lodash/sortBy"; import { useCallback, useMemo } from "react"; -import { useRecoilState } from "recoil"; -import { EdgeIcon, VertexIcon } from "../../components"; +import { selector, useRecoilValue, useSetRecoilState } from "recoil"; +import { EdgeIcon } from "../../components/icons"; +import VertexIcon from "../../components/VertexIcon"; import { useConfiguration } from "../../core"; import { edgesTypesFilteredAtom } from "../../core/StateProvider/edges"; import { nodesTypesFilteredAtom } from "../../core/StateProvider/nodes"; import useTextTransform from "../../hooks/useTextTransform"; +import { + edgeTypesSelector, + vertexTypesSelector, +} from "../../core/StateProvider/configuration"; +import { CheckboxListItemProps } from "../../components"; + +const selectedVerticesSelector = selector({ + key: "filters-selected-vertices", + get: ({ get }) => { + const filteredNodeTypes = get(nodesTypesFilteredAtom); + const allNodeTypes = get(vertexTypesSelector); + return new Set( + [...allNodeTypes].filter(n => filteredNodeTypes.has(n) === false) + ); + }, +}); + +const selectedEdgesSelector = selector({ + key: "filters-selected-edges", + get: ({ get }) => { + const filteredEdgeTypes = get(edgesTypesFilteredAtom); + const allEdgeTypes = get(edgeTypesSelector); + return new Set( + [...allEdgeTypes].filter(n => filteredEdgeTypes.has(n) === false) + ); + }, +}); const useFiltersConfig = () => { const config = useConfiguration(); const textTransform = useTextTransform(); - const [nodesTypesFiltered, setNodesTypesFiltered] = useRecoilState( - nodesTypesFilteredAtom - ); - const [edgesTypesFiltered, setEdgesTypesFiltered] = useRecoilState( - edgesTypesFilteredAtom - ); + const vertexTypes = useRecoilValue(vertexTypesSelector); + const edgeTypes = useRecoilValue(edgeTypesSelector); + const setNodesTypesFiltered = useSetRecoilState(nodesTypesFilteredAtom); + const setEdgesTypesFiltered = useSetRecoilState(edgesTypesFilteredAtom); + const selectedVertexTypes = useRecoilValue(selectedVerticesSelector); + const selectedConnectionTypes = useRecoilValue(selectedEdgesSelector); const addVertex = useCallback( (vertex: string) => { @@ -64,37 +92,32 @@ const useFiltersConfig = () => { const onChangeVertexTypes = useCallback( (vertexId: string, isSelected: boolean): void => { - isSelected ? addVertex(vertexId) : deleteVertex(vertexId); + isSelected ? deleteVertex(vertexId) : addVertex(vertexId); }, [addVertex, deleteVertex] ); const onChangeAllVertexTypes = useCallback( (isSelected: boolean): void => { - setNodesTypesFiltered( - isSelected ? new Set(config?.vertexTypes || []) : new Set() - ); + setNodesTypesFiltered(isSelected ? new Set() : new Set(vertexTypes)); }, - [config?.vertexTypes, setNodesTypesFiltered] + [vertexTypes, setNodesTypesFiltered] ); - const { vertexTypes, edgeTypes, getVertexTypeConfig, getEdgeTypeConfig } = - config || {}; + const { getVertexTypeConfig, getEdgeTypeConfig } = config || {}; const onChangeConnectionTypes = useCallback( (connectionId: string, isSelected: boolean): void => { - isSelected ? addConnection(connectionId) : deleteConnection(connectionId); + isSelected ? deleteConnection(connectionId) : addConnection(connectionId); }, [addConnection, deleteConnection] ); const onChangeAllConnectionTypes = useCallback( (isSelected: boolean): void => { - setEdgesTypesFiltered( - isSelected ? new Set(config?.edgeTypes || []) : new Set() - ); + setEdgesTypesFiltered(isSelected ? new Set() : new Set(edgeTypes)); }, - [config?.edgeTypes, setEdgesTypesFiltered] + [edgeTypes, setEdgesTypesFiltered] ); const vertexTypesCheckboxes = useMemo(() => { @@ -117,12 +140,11 @@ const useFiltersConfig = () => { /> ), - isSelected: nodesTypesFiltered.has(vt), - }; + } as CheckboxListItemProps; }), type => type.text ); - }, [vertexTypes, getVertexTypeConfig, textTransform, nodesTypesFiltered]); + }, [vertexTypes, getVertexTypeConfig, textTransform]); const connectionTypesCheckboxes = useMemo(() => { return sortBy( @@ -132,19 +154,18 @@ const useFiltersConfig = () => { id: et, text: edgeConfig?.displayLabel || textTransform(et), endAdornment: , - isSelected: edgesTypesFiltered.has(et), - }; + } as CheckboxListItemProps; }), type => type.text ); - }, [edgeTypes, getEdgeTypeConfig, edgesTypesFiltered, textTransform]); + }, [edgeTypes, getEdgeTypeConfig, textTransform]); return { - selectedVertexTypes: nodesTypesFiltered, + selectedVertexTypes, vertexTypes: vertexTypesCheckboxes, onChangeVertexTypes, onChangeAllVertexTypes, - selectedConnectionTypes: edgesTypesFiltered, + selectedConnectionTypes, connectionTypes: connectionTypesCheckboxes, onChangeConnectionTypes, onChangeAllConnectionTypes, diff --git a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx index 9b31fb2a7..fc96d128f 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx @@ -47,7 +47,6 @@ import ContextMenu from "./internalComponents/ContextMenu"; import useContextMenu from "./useContextMenu"; import useGraphGlobalActions from "./useGraphGlobalActions"; import useGraphStyles from "./useGraphStyles"; -import useGraphViewerInit from "./useGraphViewerInit"; import useNodeBadges from "./useNodeBadges"; import useNodeDrop from "./useNodeDrop"; @@ -142,7 +141,6 @@ const GraphViewer = ({ const styleWithTheme = useWithTheme(); const pfx = withClassNamePrefix("ft"); - useGraphViewerInit(); const graphRef = useRef(null); const [entities] = useEntities(); const { dropAreaRef, isOver, canDrop } = useNodeDrop(); diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphViewerInit.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphViewerInit.ts deleted file mode 100644 index 45277339f..000000000 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphViewerInit.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useEffect } from "react"; -import { useSetRecoilState } from "recoil"; -import useConfiguration from "../../core/ConfigurationProvider/useConfiguration"; -import { edgesTypesFilteredAtom } from "../../core/StateProvider/edges"; -import { nodesTypesFilteredAtom } from "../../core/StateProvider/nodes"; -import equalSet from "../../utils/set/equal"; - -const useGraphViewerInit = () => { - const config = useConfiguration(); - const setNodesTypes = useSetRecoilState(nodesTypesFilteredAtom); - const setEdgesTypes = useSetRecoilState(edgesTypesFilteredAtom); - - useEffect(() => { - setNodesTypes(prev => - equalSet(prev, new Set(config?.vertexTypes ?? [])) - ? prev - : new Set(config?.vertexTypes ?? []) - ); - setEdgesTypes(prev => - equalSet(prev, new Set(config?.edgeTypes ?? [])) - ? prev - : new Set(config?.edgeTypes ?? []) - ); - }, [config?.edgeTypes, config?.vertexTypes, setEdgesTypes, setNodesTypes]); -}; - -export default useGraphViewerInit; diff --git a/packages/graph-explorer/src/utils/testing/TestableRootProviders.tsx b/packages/graph-explorer/src/utils/testing/TestableRootProviders.tsx new file mode 100644 index 000000000..efc77755a --- /dev/null +++ b/packages/graph-explorer/src/utils/testing/TestableRootProviders.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { RecoilRoot } from "recoil"; +import ConfigurationProvider from "../../core/ConfigurationProvider"; + +export function TestableRootProviders({ + children, +}: React.PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/packages/graph-explorer/src/utils/testing/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts new file mode 100644 index 000000000..2e87b9c95 --- /dev/null +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -0,0 +1,112 @@ +import { v4 } from "uuid"; +import { + AttributeConfig, + EdgeTypeConfig, + Schema, + VertexTypeConfig, +} from "../../core"; + +/* + +# Developer Note + +These helper functions are provided to allow for easier test data creation. + +When creating test data you should start with a random object, then set the values +that directly apply to the logic you are testing. + +The randomness of all the other values ensures that the logic under test is not +affected by those values, regardless of what they are. + +*/ + +/** + * Creates a random string with a prefix, if provided. + * @param prefix The prefix to prepend to the string. + * @returns A random string that will resemble "prefix-8d49f0". + */ +export function createRandomName(prefix: string = ""): string { + return `${prefix}${prefix.length > 0 ? "-" : ""}${v4().substring(0, 6)}`; +} + +/** + * Creates a random boolean. + * @returns A random boolean value. + */ +export function createRandomBoolean(): boolean { + return Math.random() < 0.5; +} + +/** + * Randomly returns the provided value or undefined. + * @returns Either the value or undefined. + */ +export function randomlyUndefined(value: T): T | undefined { + return createRandomBoolean() ? value : undefined; +} + +/** + * Creates an array containing values generated from the factory function with the given length. + * @param length The number of items to generate. + * @param factory A function to generate the desired value. + * @returns An array with items generated using the factory function. + */ +export function createArray(length: number, factory: () => T): T[] { + return Array.from({ length }, factory); +} + +/** + * Creates a random AttributeConfig object. + * @returns A random AttributeConfig object. + */ +export function createRandomAttributeConfig(): AttributeConfig { + return { + name: createRandomName("name"), + displayLabel: createRandomName("displayLabel"), + dataType: randomlyUndefined(createRandomName("dataType")), + hidden: randomlyUndefined(createRandomBoolean()), + searchable: randomlyUndefined(createRandomBoolean()), + }; +} + +/** + * Creates a random EdgeTypeConfig object. + * @returns A random EdgeTypeConfig object. + */ +export function createRandomEdgeTypeConfig(): EdgeTypeConfig { + return { + type: createRandomName("type"), + attributes: createArray(6, createRandomAttributeConfig), + displayLabel: randomlyUndefined(createRandomName("displayLabel")), + hidden: randomlyUndefined(createRandomBoolean()), + }; +} + +/** + * Creates a random VertexTypeConfig object. + * @returns A random VertexTypeConfig object. + */ +export function createRandomVertexTypeConfig(): VertexTypeConfig { + return { + type: createRandomName("type"), + attributes: createArray(6, createRandomAttributeConfig), + displayLabel: randomlyUndefined(createRandomName("displayLabel")), + hidden: randomlyUndefined(createRandomBoolean()), + }; +} + +/** + * Creates a random schema object. + * @returns A random Schema object. + */ +export function createRandomSchema(): Schema { + const edges = createArray(6, createRandomEdgeTypeConfig); + const vertices = createArray(6, createRandomVertexTypeConfig); + const schema: Schema = { + edges, + vertices, + totalEdges: edges.length, + totalVertices: vertices.length, + }; + return schema; +} diff --git a/packages/graph-explorer/src/utils/testing/useTestSchema.ts b/packages/graph-explorer/src/utils/testing/useTestSchema.ts new file mode 100644 index 000000000..04b65aed9 --- /dev/null +++ b/packages/graph-explorer/src/utils/testing/useTestSchema.ts @@ -0,0 +1,37 @@ +import { RawConfiguration, Schema } from "../../core"; +import { useSetRecoilState } from "recoil"; +import { + activeConfigurationAtom, + configurationAtom, +} from "../../core/StateProvider/configuration"; +import { useEffect, useMemo } from "react"; + +/** + * Initializes a configuration in Recoil state that can be used for testing. + * @param schema The schema to use in the config + */ +export function useTestSchema(schema: Schema) { + const setConfigMap = useSetRecoilState(configurationAtom); + const setActiveConfigId = useSetRecoilState(activeConfigurationAtom); + + const config: RawConfiguration = useMemo( + () => ({ + id: "test-config-id", + schema, + connection: { url: "https://www.example.com" }, + }), + [schema] + ); + + useEffect(() => { + // Add the config to the config map + setConfigMap(prev => { + const updatedConfig = new Map(prev); + updatedConfig.set(config.id, config); + return updatedConfig; + }); + + // Set the config as active + setActiveConfigId(config.id); + }, [config, setConfigMap, setActiveConfigId]); +} diff --git a/packages/graph-explorer/src/workspaces/GraphExplorer/GraphExplorer.tsx b/packages/graph-explorer/src/workspaces/GraphExplorer/GraphExplorer.tsx index 35bf7d89c..046db3bfa 100644 --- a/packages/graph-explorer/src/workspaces/GraphExplorer/GraphExplorer.tsx +++ b/packages/graph-explorer/src/workspaces/GraphExplorer/GraphExplorer.tsx @@ -28,6 +28,7 @@ import { } from "../../core"; import { edgesSelectedIdsAtom } from "../../core/StateProvider/edges"; import { nodesSelectedIdsAtom } from "../../core/StateProvider/nodes"; +import { totalFilteredCount } from "../../core/StateProvider/filterCount"; import { userLayoutAtom } from "../../core/StateProvider/userPreferences"; import { usePrevious } from "../../hooks"; import useTranslations from "../../hooks/useTranslations"; @@ -70,6 +71,7 @@ const GraphExplorer = ({ classNamePrefix = "ft" }: GraphViewProps) => { const edgesSelectedIds = useRecoilValue(edgesSelectedIdsAtom); const nodeOrEdgeSelected = nodesSelectedIds.size + edgesSelectedIds.size === 1; + const filteredEntitiesCount = useRecoilValue(totalFilteredCount); const closeSidebar = useCallback(() => { setUserLayout(prev => ({ @@ -296,6 +298,9 @@ const GraphExplorer = ({ classNamePrefix = "ft" }: GraphViewProps) => { tooltipText={"Filters"} icon={} onPress={toggleSidebar("filters")} + badge={filteredEntitiesCount} + badgeVariant="undetermined" + badgePlacement="top-right" active={userLayout.activeSidebarItem === "filters"} /> =18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + dev: true + /@jest/globals@29.7.0: resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -4690,7 +4706,7 @@ packages: /@swc/helpers@0.4.14: resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} dependencies: - tslib: 2.6.0 + tslib: 2.6.2 dev: false /@swc/helpers@0.4.36: @@ -5347,7 +5363,7 @@ packages: resolution: {integrity: sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 + vite: '>=4.5.2' dependencies: '@babel/core': 7.23.2 '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.23.2) @@ -6971,7 +6987,7 @@ packages: /esbuild-loader@2.21.0(webpack@5.76.0): resolution: {integrity: sha512-k7ijTkCT43YBSZ6+fBCW1Gin7s46RrJ0VQaM8qA7lq7W+OLsGgtLyFV8470FzYi/4TeDexniTBTPTwZUnXXR5g==} peerDependencies: - webpack: ^4.40.0 || ^5.0.0 + webpack: '>=5.76.0' dependencies: esbuild: 0.16.17 joycon: 3.1.1 @@ -10852,7 +10868,7 @@ packages: '@swc/core': '*' esbuild: '*' uglify-js: '*' - webpack: ^5.1.0 + webpack: '>=5.76.0' peerDependenciesMeta: '@swc/core': optional: true