diff --git a/docs/features/schema-view.md b/docs/features/schema-view.md index c3334a4df..00b4a5232 100644 --- a/docs/features/schema-view.md +++ b/docs/features/schema-view.md @@ -17,7 +17,7 @@ You can open the Schema view by clicking "Schema" in the navigation bar. The sidebar has two panels: - **Details** — shows properties and connections for the selected node type or edge connection -- **Styles** — customize colors and icons for node types and edge types, split across a Nodes tab and an Edges tab +- **Styles** — customize colors and icons for node types and edge types, split across a Nodes tab and an Edges tab. On the Nodes tab, the eye icon next to each node type hides it (and its connections) from the Schema view only — the main Graph view and data discovery are unaffected. Hidden types stay listed, dimmed, so they can be shown again; the hidden set persists per connection. Click the active tab icon to collapse the sidebar to just the icon strip. Click any tab icon to reopen it. The active tab, sidebar width, and chosen graph layout are remembered per browser tab, so two tabs can keep different setups side by side. When you reopen Graph Explorer after closing all tabs, it resumes the setup you most recently used. diff --git a/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.test.ts b/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.test.ts new file mode 100644 index 000000000..5d82e0808 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it } from "vitest"; + +import { createVertexType, getAppStore } from "@/core"; +import { DbState, renderHookWithState } from "@/utils/testing"; + +import { useHiddenSchemaTypes } from "./hiddenSchemaTypes"; +import { hiddenSchemaTypesAtom } from "./storageAtoms"; + +describe("useHiddenSchemaTypes", () => { + it("starts with no hidden types", () => { + const dbState = new DbState(); + const { result } = renderHookWithState( + () => useHiddenSchemaTypes(), + dbState, + ); + + expect(result.current.isHidden(createVertexType("Vertex"))).toBe(false); + expect(result.current.hiddenTypes.size).toBe(0); + }); + + it("toggles a type on and off for the active connection", () => { + const dbState = new DbState(); + const type = createVertexType("Vertex"); + const { result } = renderHookWithState( + () => useHiddenSchemaTypes(), + dbState, + ); + + act(() => result.current.toggleType(type)); + expect(result.current.isHidden(type)).toBe(true); + + act(() => result.current.toggleType(type)); + expect(result.current.isHidden(type)).toBe(false); + }); + + it("removes the connection's map entry once its set empties", () => { + const dbState = new DbState(); + const type = createVertexType("Vertex"); + const store = getAppStore(); + const { result } = renderHookWithState( + () => useHiddenSchemaTypes(), + dbState, + ); + + act(() => result.current.toggleType(type)); + expect( + store.get(hiddenSchemaTypesAtom).has(dbState.activeConnection.id), + ).toBe(true); + + act(() => result.current.toggleType(type)); + expect( + store.get(hiddenSchemaTypesAtom).has(dbState.activeConnection.id), + ).toBe(false); + }); + + it("isolates hidden types per connection", () => { + const dbState = new DbState(); + const type = createVertexType("Vertex"); + const store = getAppStore(); + const { result } = renderHookWithState( + () => useHiddenSchemaTypes(), + dbState, + ); + + act(() => result.current.toggleType(type)); + + const map = store.get(hiddenSchemaTypesAtom); + expect(map.size).toBe(1); + expect([...map.keys()]).toStrictEqual([dbState.activeConnection.id]); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.ts b/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.ts new file mode 100644 index 000000000..7b86ba8fc --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/hiddenSchemaTypes.ts @@ -0,0 +1,56 @@ +import { atom, useAtomValue, useSetAtom } from "jotai"; + +import type { VertexType } from "../entities"; + +import { activeConnectionIdAtom, hiddenSchemaTypesAtom } from "./storageAtoms"; + +const EMPTY_HIDDEN_TYPES: ReadonlySet = new Set(); + +/** + * The active connection's hidden Schema-view vertex types. Reads fall back to a + * shared empty set for referential stability; writes delete the connection's + * entry once its set empties, keeping storage clean. + */ +export const activeHiddenSchemaTypesAtom = atom( + get => { + const id = get(activeConnectionIdAtom); + return (id && get(hiddenSchemaTypesAtom).get(id)) ?? EMPTY_HIDDEN_TYPES; + }, + (get, set, update: (prev: ReadonlySet) => Set) => { + const id = get(activeConnectionIdAtom); + if (!id) { + return; + } + set(hiddenSchemaTypesAtom, prev => { + const current = prev.get(id) ?? EMPTY_HIDDEN_TYPES; + const next = update(current); + const map = new Map(prev); + if (next.size === 0) { + map.delete(id); + } else { + map.set(id, next); + } + return map; + }); + }, +); + +/** Read the active connection's hidden vertex types plus hide/show/toggle actions. */ +export function useHiddenSchemaTypes() { + const hiddenTypes = useAtomValue(activeHiddenSchemaTypesAtom); + const setHidden = useSetAtom(activeHiddenSchemaTypesAtom); + return { + hiddenTypes, + isHidden: (type: VertexType) => hiddenTypes.has(type), + toggleType: (type: VertexType) => + setHidden(prev => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }), + }; +} diff --git a/packages/graph-explorer/src/core/StateProvider/index.ts b/packages/graph-explorer/src/core/StateProvider/index.ts index dba94662b..3a34d5a84 100644 --- a/packages/graph-explorer/src/core/StateProvider/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/index.ts @@ -10,6 +10,7 @@ export * from "./neighbors"; export * from "./nodes"; export * from "./renderedEntities"; export * from "./graphStyles"; +export * from "./hiddenSchemaTypes"; export * from "./schema"; export * from "./storageAtoms"; export * from "./graphSession"; diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index c9800d75b..f69d7d698 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -81,6 +81,7 @@ const [ defaultNeighborExpansionLimitEnabledAtom, defaultNeighborExpansionLimitAtom, diagnosticLoggingAtom, + hiddenSchemaTypesAtom, ] = await Promise.all([ createActiveConnectionIdAtom(), atomWithLocalForage>( @@ -135,6 +136,12 @@ const [ atomWithLocalForage("defaultNeighborExpansionLimit", 10), /** Enables verbose diagnostic logging to the browser console. */ atomWithLocalForage("diagnosticLogging", false), + /** Vertex types hidden from the Schema view, per connection. */ + atomWithLocalForage( + "hidden-schema-types", + new Map>(), + { reconcile: reconcileMapByKey }, + ), ]); export { @@ -151,4 +158,5 @@ export { defaultNeighborExpansionLimitEnabledAtom, defaultNeighborExpansionLimitAtom, diagnosticLoggingAtom, + hiddenSchemaTypesAtom, }; diff --git a/packages/graph-explorer/src/core/entities/vertex.test.ts b/packages/graph-explorer/src/core/entities/vertex.test.ts index 2dd0516ef..8e42c753d 100644 --- a/packages/graph-explorer/src/core/entities/vertex.test.ts +++ b/packages/graph-explorer/src/core/entities/vertex.test.ts @@ -45,6 +45,33 @@ describe("createVertex", () => { }); }); + it("should style by the specific label when the generic vertex label is present", () => { + const vertex = createVertex({ + id: "1", + types: ["vertex", "sqsqueue"], + }); + + expect(vertex.type).toBe("sqsqueue"); + expect(vertex.types).toStrictEqual(["vertex", "sqsqueue"]); + }); + + it("should ignore the generic vertex label regardless of case or position", () => { + expect(createVertex({ id: "1", types: ["Vertex", "sqsqueue"] }).type).toBe( + "sqsqueue", + ); + expect(createVertex({ id: "1", types: ["VERTEX", "sqsqueue"] }).type).toBe( + "sqsqueue", + ); + expect( + createVertex({ id: "1", types: ["vertex", "sqsqueue", "resource"] }).type, + ).toBe("sqsqueue"); + }); + + it("should keep the generic vertex label when it is the only label", () => { + expect(createVertex({ id: "1", types: ["vertex"] }).type).toBe("vertex"); + expect(createVertex({ id: "1", types: ["Vertex"] }).type).toBe("Vertex"); + }); + it("should create a vertex with missing types", () => { const vertex = createVertex({ id: "1", diff --git a/packages/graph-explorer/src/core/entities/vertex.ts b/packages/graph-explorer/src/core/entities/vertex.ts index fd3f592f3..1801da091 100644 --- a/packages/graph-explorer/src/core/entities/vertex.ts +++ b/packages/graph-explorer/src/core/entities/vertex.ts @@ -30,9 +30,12 @@ export type Vertex = { id: VertexId; /** - * Single vertex type. + * The primary vertex type, used to drive styling and naming. * - For PG, the node label * - For RDF, the resource class + * + * When a node carries several labels, this is the most specific one: the + * generic `vertex` base label is skipped unless it is the only label. */ type: VertexType; @@ -63,9 +66,10 @@ export function createVertex(options: { id: EntityRawId; /** - * The primary type (used for styling) will be the first type in the array. If - * no types are provided, then types will be an empty array and the primary - * type will be empty string. + * The primary type (used for styling and naming) is the first specific label + * in the array, skipping the generic `vertex` base label unless it is the + * only label. If no types are provided, the vertex falls back to a single + * "missing type" label. */ types?: string[]; @@ -83,13 +87,28 @@ export function createVertex(options: { ) as VertexType[]; return { id: createVertexId(options.id), - type: types[0], + type: selectPrimaryType(types), types, attributes: options.attributes != null ? options.attributes : {}, isBlankNode: options.isBlankNode ?? false, }; } +/** The default label Gremlin/TinkerPop assigns to a vertex created without one. */ +const GENERIC_VERTEX_LABEL = "vertex"; + +/** + * Picks the type that drives a vertex's styling and naming. The generic + * `vertex` base label (matched case-insensitively) is skipped when a more + * specific label exists, so a node labeled `["vertex", "sqsqueue"]` is styled + * and named as `sqsqueue`. A vertex whose only label is `vertex` keeps it. + */ +function selectPrimaryType(types: VertexType[]): VertexType { + return ( + types.find(type => type.toLowerCase() !== GENERIC_VERTEX_LABEL) ?? types[0] + ); +} + /** Creates a VertexType from a string */ export function createVertexType(type: string): VertexType { return type as VertexType; diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx index 04bf99d3c..abbad4006 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx @@ -118,6 +118,26 @@ describe("SchemaExplorerSidebar", () => { expect(screen.getByText("locatedIn")).toBeInTheDocument(); }); + test("toggles a vertex type's visibility from the styles tab", async () => { + const user = userEvent.setup(); + const state = stateWithDetailsTab(); + const vertex = createTestableVertex().with({ types: ["Airport"] }); + state.addTestableVertexToGraph(vertex); + + renderSidebar(state); + + await user.click(screen.getByRole("tab", { name: "Styles" })); + + const toggle = screen.getByRole("button", { + name: "Hide Airport from schema view", + }); + await user.click(toggle); + + expect( + screen.getByRole("button", { name: "Show Airport in schema view" }), + ).toBeInTheDocument(); + }); + test("collapses sidebar when clicking the active tab", async () => { const user = userEvent.setup(); renderSidebar(stateWithDetailsTab()); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx index f42d380b0..70d04f44e 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.tsx @@ -65,7 +65,11 @@ export function SchemaExplorerSidebar({ /> - + diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.ts b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.ts new file mode 100644 index 000000000..a757dcede --- /dev/null +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.test.ts @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { describe, expect, it } from "vitest"; + +import { createEdgeType, createVertexType, useHiddenSchemaTypes } from "@/core"; +import { + createTestableVertex, + DbState, + renderHookWithState, +} from "@/utils/testing"; + +import { useSchemaGraphData } from "./useSchemaGraphData"; + +describe("useSchemaGraphData", () => { + it("excludes hidden vertex types from the nodes", () => { + const state = new DbState(); + state.activeSchema.vertices = []; + state.addTestableVertexToGraph( + createTestableVertex().with({ types: ["Airport"] }), + ); + state.addTestableVertexToGraph( + createTestableVertex().with({ types: ["City"] }), + ); + + const { result } = renderHookWithState( + () => ({ + data: useSchemaGraphData(), + hidden: useHiddenSchemaTypes(), + }), + state, + ); + + expect( + result.current.data.nodes.map(n => n.data.type).toSorted(), + ).toStrictEqual([createVertexType("Airport"), createVertexType("City")]); + + act(() => result.current.hidden.toggleType(createVertexType("Airport"))); + + expect(result.current.data.nodes.map(n => n.data.type)).toStrictEqual([ + createVertexType("City"), + ]); + }); + + it("drops edges whose endpoint vertex type is hidden", () => { + const state = new DbState(); + state.addTestableVertexToGraph( + createTestableVertex().with({ types: ["Airport"] }), + ); + state.addTestableVertexToGraph( + createTestableVertex().with({ types: ["City"] }), + ); + state.activeSchema.edgeConnections = [ + { + edgeType: createEdgeType("locatedIn"), + sourceVertexType: createVertexType("Airport"), + targetVertexType: createVertexType("City"), + }, + ]; + + const { result } = renderHookWithState( + () => ({ + data: useSchemaGraphData(), + hidden: useHiddenSchemaTypes(), + }), + state, + ); + + expect(result.current.data.edges).toHaveLength(1); + + act(() => result.current.hidden.toggleType(createVertexType("City"))); + + expect(result.current.data.edges).toHaveLength(0); + }); +}); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts index 782fd5882..5b18ce081 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts +++ b/packages/graph-explorer/src/modules/SchemaGraph/useSchemaGraphData.ts @@ -7,6 +7,7 @@ import { useActiveSchema, useDisplayEdgeTypeConfigs, useDisplayVertexTypeConfigs, + useHiddenSchemaTypes, type VertexType, } from "@/core"; @@ -44,10 +45,12 @@ export function useSchemaGraphData() { /** Transforms vertex type configs into schema graph nodes. */ function useSchemaGraphNodes(): SchemaGraphNode[] { const vtConfigs = useDisplayVertexTypeConfigs(); + const { isHidden } = useHiddenSchemaTypes(); const nodes: SchemaGraphNode[] = []; for (const config of vtConfigs.values()) { + if (isHidden(config.type)) continue; nodes.push({ data: { id: config.type, diff --git a/packages/graph-explorer/src/modules/Styles/Styles.test.tsx b/packages/graph-explorer/src/modules/Styles/Styles.test.tsx index b1f3fb531..cb3134d31 100644 --- a/packages/graph-explorer/src/modules/Styles/Styles.test.tsx +++ b/packages/graph-explorer/src/modules/Styles/Styles.test.tsx @@ -84,4 +84,16 @@ describe("Styles", () => { expect(screen.getByText("locatedIn")).toBeInTheDocument(); }); + + test("does not show a vertex visibility toggle for the main graph view", () => { + const state = new DbState(); + const vertex = createTestableVertex().with({ types: ["Airport"] }); + state.addTestableVertexToGraph(vertex); + + renderStyles(state); + + expect( + screen.queryByRole("button", { name: /schema view/ }), + ).not.toBeInTheDocument(); + }); }); diff --git a/packages/graph-explorer/src/modules/Styles/Styles.tsx b/packages/graph-explorer/src/modules/Styles/Styles.tsx index ec83d3275..26b1d6586 100644 --- a/packages/graph-explorer/src/modules/Styles/Styles.tsx +++ b/packages/graph-explorer/src/modules/Styles/Styles.tsx @@ -38,13 +38,19 @@ export const schemaViewStylesTabAtom = atom("nodes"); export type StylesProps = { onClose: () => void; tabAtom: PrimitiveAtom; + /** Shows a per-row eye toggle to hide vertex types from the Schema view. */ + showVertexVisibilityToggle?: boolean; }; /** * Combined node and edge styling sidebar panel. The two style lists live behind * an internal Nodes/Edges tab switch so both share one styling entry point. */ -export function Styles({ onClose, tabAtom }: StylesProps) { +export function Styles({ + onClose, + tabAtom, + showVertexVisibilityToggle, +}: StylesProps) { const t = useTranslations(); const [selectedTab, setSelectedTab] = useAtom(tabAtom); @@ -78,7 +84,7 @@ export function Styles({ onClose, tabAtom }: StylesProps) { {t("edges")} - + diff --git a/packages/graph-explorer/src/modules/Styles/VertexStyles.tsx b/packages/graph-explorer/src/modules/Styles/VertexStyles.tsx index 8af9fa6d8..e41505bc8 100644 --- a/packages/graph-explorer/src/modules/Styles/VertexStyles.tsx +++ b/packages/graph-explorer/src/modules/Styles/VertexStyles.tsx @@ -1,13 +1,24 @@ +import { EyeIcon, EyeOffIcon } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { Fragment } from "react/jsx-runtime"; -import { Divider, NoNodeTypesEmptyState } from "@/components"; -import { useDisplayVertexTypeConfigs } from "@/core"; +import { Button, Divider, NoNodeTypesEmptyState } from "@/components"; +import { + useDisplayVertexTypeConfigs, + useHiddenSchemaTypes, + type VertexType, +} from "@/core"; +import { cn } from "@/utils"; import { VertexStyleRow } from "./VertexStyleRow"; +export type VertexStylesProps = { + /** Shows a per-row eye toggle to hide vertex types from the Schema view. */ + showVisibilityToggle?: boolean; +}; + /** Styling list for every vertex type, shown on the Nodes tab. */ -export function VertexStyles() { +export function VertexStyles({ showVisibilityToggle }: VertexStylesProps) { const vtConfigs = Array.from(useDisplayVertexTypeConfigs().values()); if (vtConfigs.length === 0) { @@ -21,12 +32,59 @@ export function VertexStyles() { itemContent={(index, vtConfig) => ( {index !== 0 ? : null} - + {showVisibilityToggle ? ( + + ) : ( + + )} )} /> ); } + +/** + * Wraps VertexStyleRow with an eye toggle that hides the vertex type from + * the Schema view, without changing the shared row also rendered by the + * main Graph view's Styles panel. + */ +function SchemaVertexStyleRow({ + vertexType, + className, +}: { + vertexType: VertexType; + className?: string; +}) { + const { isHidden, toggleType } = useHiddenSchemaTypes(); + const hidden = isHidden(vertexType); + + return ( + + ); +}