Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/features/schema-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Both the active tab and sidebar width are remembered across sessions.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { act } from "react";

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.activeConfig.id)).toBe(
true,
);

act(() => result.current.toggleType(type));
expect(store.get(hiddenSchemaTypesAtom).has(dbState.activeConfig.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.activeConfig.id]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { atom, useAtomValue, useSetAtom } from "jotai";

import type { VertexType } from "../entities";

import { activeConfigurationAtom, hiddenSchemaTypesAtom } from "./storageAtoms";

const EMPTY_HIDDEN_TYPES: ReadonlySet<VertexType> = 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(activeConfigurationAtom);
return (id && get(hiddenSchemaTypesAtom).get(id)) ?? EMPTY_HIDDEN_TYPES;
},
(get, set, update: (prev: ReadonlySet<VertexType>) => Set<VertexType>) => {
const id = get(activeConfigurationAtom);
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;
}),
};
}
1 change: 1 addition & 0 deletions packages/graph-explorer/src/core/StateProvider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const [
defaultNeighborExpansionLimitEnabledAtom,
defaultNeighborExpansionLimitAtom,
diagnosticLoggingAtom,
hiddenSchemaTypesAtom,
] = await Promise.all([
createActiveConfigurationAtom(),
atomWithLocalForage<Map<ConfigurationId, RawConfiguration>>(
Expand Down Expand Up @@ -144,6 +145,12 @@ const [
atomWithLocalForage<number>("defaultNeighborExpansionLimit", 10),
/** Enables verbose diagnostic logging to the browser console. */
atomWithLocalForage<boolean>("diagnosticLogging", false),
/** Vertex types hidden from the Schema view, per connection. */
atomWithLocalForage(
"hidden-schema-types",
new Map<ConfigurationId, Set<VertexType>>(),
{ reconcile: reconcileMapByKey },
),
]);

export {
Expand All @@ -162,4 +169,5 @@ export {
defaultNeighborExpansionLimitEnabledAtom,
defaultNeighborExpansionLimitAtom,
diagnosticLoggingAtom,
hiddenSchemaTypesAtom,
};
27 changes: 27 additions & 0 deletions packages/graph-explorer/src/core/entities/vertex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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",
Expand Down
29 changes: 24 additions & 5 deletions packages/graph-explorer/src/core/entities/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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[];

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ export function SchemaExplorerSidebar({
/>
</SidebarTabsContent>
<SidebarTabsContent value="styles">
<Styles onClose={closeSidebar} tabAtom={schemaViewStylesTabAtom} />
<Styles
onClose={closeSidebar}
tabAtom={schemaViewStylesTabAtom}
showVertexVisibilityToggle
/>
</SidebarTabsContent>
</SidebarTabs>
</CollapsibleSidebar>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { act } from "react";

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);
});
});
Loading