diff --git a/frontend/package.json b/frontend/package.json index 7d291014..1bd2fd1a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@types/react-dom": "^17.0.3", "clsx": "^1.1.1", "cypress": "^7.3.0", + "fast-equals": "^2.0.3", "inversify": "^5.1.1", "lru_map": "^0.4.1", "notistack": "^1.0.9", diff --git a/frontend/src/configureServices.ts b/frontend/src/configureServices.ts index f868caee..6d1ec04b 100644 --- a/frontend/src/configureServices.ts +++ b/frontend/src/configureServices.ts @@ -24,6 +24,7 @@ import { EntityDetailsStateStore } from './stores/details/EntityDetailsStateStor import { EntityDetailsStore } from './stores/details/EntityDetailsStore'; import { RoutingStateStore } from './stores/routing/RoutingStateStore'; import ChordDetailsStateStore from './stores/details/ChordDetailsStateStore'; +import { GraphStateStore } from './stores/graph/GraphStateStore'; /** * Configures all services in the frontend app. @@ -85,4 +86,5 @@ export default function configureServices(container: Container): void { container.bind(EntityDetailsStore).toSelf().inSingletonScope(); container.bind(RoutingStateStore).toSelf().inSingletonScope(); container.bind(ChordDetailsStateStore).toSelf().inSingletonScope(); + container.bind(GraphStateStore).toSelf().inSingletonScope(); } diff --git a/frontend/src/stores/details/EntityDetailsStateStore.ts b/frontend/src/stores/details/EntityDetailsStateStore.ts index c41e0ca1..29db254c 100644 --- a/frontend/src/stores/details/EntityDetailsStateStore.ts +++ b/frontend/src/stores/details/EntityDetailsStateStore.ts @@ -5,11 +5,13 @@ import SimpleStore from '../SimpleStore'; import { EntityDetailsState } from './EntityDetailsState'; import { RoutingStateStore } from '../routing/RoutingStateStore'; import SearchSelectionStore from '../SearchSelectionStore'; +import QueryResultStore from '../QueryResultStore'; @injectable() export class EntityDetailsStateStore extends SimpleStore { private routingStateStoreSubscription?: Subscription; private searchSelectionStoreSubscription?: Subscription; + private queryResultStoreSubscription?: Subscription; protected getInitialValue(): EntityDetailsState { return { node: null, edge: null }; @@ -21,6 +23,9 @@ export class EntityDetailsStateStore extends SimpleStore { @inject(SearchSelectionStore) private readonly searchSelectionStore!: SearchSelectionStore; + @inject(QueryResultStore) + private readonly queryResultStore!: QueryResultStore; + public clear(): void { this.setState(this.getInitialValue()); } @@ -42,6 +47,10 @@ export class EntityDetailsStateStore extends SimpleStore { this.searchSelectionStoreSubscription = this.subscribeToSearchSelectionStore(); } + + if (this.queryResultStoreSubscription == null) { + this.queryResultStoreSubscription = this.subscribeQueryResultStore(); + } } private subscribeToRoutingStateStore(): Subscription { @@ -67,6 +76,27 @@ export class EntityDetailsStateStore extends SimpleStore { }, }); } + + private subscribeQueryResultStore(): Subscription { + return this.queryResultStore.getState().subscribe({ + next: (queryResult) => { + const state = this.getValue(); + // If the current selection is a node + if (state.node !== null) { + // If the node is not part of the query-result + if (!queryResult.nodes.some((node) => node.id === state.node)) { + this.clear(); + } + // If the current selection is an edge + } else if (state.edge !== null) { + // If the edge is not part of the query-result + if (!queryResult.edges.some((edge) => edge.id === state.edge)) { + this.clear(); + } + } + }, + }); + } } export default EntityDetailsStateStore; diff --git a/frontend/src/stores/graph/GraphStateStore.ts b/frontend/src/stores/graph/GraphStateStore.ts new file mode 100644 index 00000000..56ca4071 --- /dev/null +++ b/frontend/src/stores/graph/GraphStateStore.ts @@ -0,0 +1,98 @@ +import 'reflect-metadata'; +import { inject, injectable } from 'inversify'; +import { Subscription } from 'rxjs'; +import { GraphData } from 'react-graph-vis'; +import { uuid } from 'uuidv4'; +import { deepEqual } from 'fast-equals'; +import SimpleStore from '../SimpleStore'; +import QueryResultStore, { QueryResultMeta } from '../QueryResultStore'; +import EntityStyleStore from '../colors/EntityStyleStore'; +import convertQueryResult from '../../visualization/shared-ops/convertQueryResult'; +import { EntityStyleProvider } from '../colors'; +import { QueryResult } from '../../shared/queries'; + +export type UUID = string; + +export interface GraphState { + graph: GraphData; + key: UUID; + /** + * If undefined: no shortest path queried. + * If false: shortest path queried but is not in result + * If true: shortest path queried and is in result. + */ + containsShortestPath?: boolean; +} + +@injectable() +export class GraphStateStore extends SimpleStore { + protected getInitialValue(): GraphState { + return { + graph: { + nodes: [], + edges: [], + }, + key: uuid(), + containsShortestPath: false, + }; + } + + private queryResultStoreSubscription?: Subscription; + private entityStyleStoreSubscription?: Subscription; + + @inject(QueryResultStore) + private readonly queryResultStore!: QueryResultStore; + + @inject(EntityStyleStore) + private readonly entityStyleStore!: EntityStyleStore; + + protected ensureInit(): void { + if (this.queryResultStoreSubscription == null) { + this.queryResultStoreSubscription = this.subscribeQueryResultStore(); + } + + if (this.entityStyleStoreSubscription == null) { + this.entityStyleStoreSubscription = this.subscribeEntityStyleStore(); + } + } + + subscribeQueryResultStore(): Subscription { + return this.queryResultStore.getState().subscribe({ + next: (queryResult) => + this.update(queryResult, this.entityStyleStore.getValue()), + }); + } + + subscribeEntityStyleStore(): Subscription { + return this.entityStyleStore.getState().subscribe({ + next: (styleProvider) => + this.update(this.queryResultStore.getValue(), styleProvider), + }); + } + + update( + queryResult: QueryResult & QueryResultMeta, + styleProvider: EntityStyleProvider + ): void { + const currentState = this.getValue(); + const graph = convertQueryResult(queryResult, styleProvider); + const { containsShortestPath } = queryResult; + const updatedState = { + graph, + containsShortestPath, + key: uuid(), + }; + + // TODO: Do we need to update the render-token if containsShortestPath changed? + if (deepEqual(graph, currentState.graph)) { + // Make sure that the object are the same instance such that + // the graph-vis library does not update the graph component. + updatedState.graph = currentState.graph; + updatedState.key = currentState.key; + } + + this.setState(updatedState); + } +} + +export default GraphStateStore; diff --git a/frontend/src/visualization/pages/Graph.tsx b/frontend/src/visualization/pages/Graph.tsx index 23d4f6a5..398dd44a 100644 --- a/frontend/src/visualization/pages/Graph.tsx +++ b/frontend/src/visualization/pages/Graph.tsx @@ -1,7 +1,8 @@ +/* istanbul ignore file */ import React, { useRef, useEffect } from 'react'; import VisGraph, { EventParameters, GraphEvents } from 'react-graph-vis'; import { uuid } from 'uuidv4'; -import { map, tap } from 'rxjs/operators'; +import { tap } from 'rxjs/operators'; import { combineLatest } from 'rxjs'; import { useSnackbar } from 'notistack'; import useStylesVisualization from './useStylesVisualization'; @@ -13,11 +14,10 @@ import QueryResultStore from '../../stores/QueryResultStore'; import SearchSelectionStore from '../../stores/SearchSelectionStore'; import useObservable from '../../utils/useObservable'; import { isEntitySelected } from '../../stores/colors/EntityStyleProviderImpl'; -import convertQueryResult from '../shared-ops/convertQueryResult'; -import EntityStyleStore from '../../stores/colors/EntityStyleStore'; import GraphDetails from './GraphDetails'; import { EntityDetailsStateStore } from '../../stores/details/EntityDetailsStateStore'; import { EntityDetailsStore } from '../../stores/details/EntityDetailsStore'; +import GraphStateStore from '../../stores/graph/GraphStateStore'; /** * Keys for the snackbar notifications. @@ -42,17 +42,19 @@ function Graph(props: GraphProps): JSX.Element { const detailsStateStore = useService(EntityDetailsStateStore); const queryResultStore = useService(QueryResultStore); - const entityColorStore = useService(EntityStyleStore); const searchSelectionStore = useService(SearchSelectionStore); - const graphData = useObservable( + const graphStateStore = useService(GraphStateStore); + const graphState = useObservable( + graphStateStore.getState(), + graphStateStore.getValue() + ); + // eslint-disable-next-line spaced-comment + useObservable( // When one emits, the whole observable emits with the last emitted value from the other inputs // Example: New query result comes in => emits it with the most recent values from entityColorStore - combineLatest([ - queryResultStore.getState(), - entityColorStore.getState(), - ]).pipe( - tap(([queryResult]) => { + queryResultStore.getState().pipe( + tap((queryResult) => { closeSnackbar(SNACKBAR_KEYS.SHORTEST_PATH_NOT_FOUND); if (queryResult.containsShortestPath === false) { // assign new random id to avoid strange ui glitches @@ -65,12 +67,8 @@ function Graph(props: GraphProps): JSX.Element { } ); } - }), - map(([queryResult, styleProvider]) => - convertQueryResult(queryResult, styleProvider) - ) - ), - { edges: [], nodes: [] } + }) + ) ); const detailsStore = useService(EntityDetailsStore); @@ -170,23 +168,25 @@ function Graph(props: GraphProps): JSX.Element {
{ network.unselectAll(); if (details !== null) { if (details.entityType === 'node') { - if (graphData.nodes.some((node) => node.id === details.id)) { + if ( + graphState.graph.nodes.some((node) => node.id === details.id) + ) { network.selectNodes([details.id], true); } } else if ( - graphData.edges.some((edge) => edge.id === details.id) + graphState.graph.edges.some((edge) => edge.id === details.id) ) { network.selectEdges([details.id]); } diff --git a/frontend/src/visualization/pages/Schema.tsx b/frontend/src/visualization/pages/Schema.tsx index 038e5918..3731c27f 100644 --- a/frontend/src/visualization/pages/Schema.tsx +++ b/frontend/src/visualization/pages/Schema.tsx @@ -1,3 +1,4 @@ +/* istanbul ignore file */ import React, { useEffect } from 'react'; import { map, tap } from 'rxjs/operators'; import { combineLatest } from 'rxjs'; diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 1e591121..10b2927e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -7221,6 +7221,11 @@ fast-diff@^1.1.1, fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== +fast-equals@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.3.tgz#7039b0a039909f345a2ce53f6202a14e5f392efc" + integrity sha512-0EMw4TTUxsMDpDkCg0rXor2gsg+npVrMIHbEhvD0HZyIhUX6AktC/yasm+qKwfyswd06Qy95ZKk8p2crTo0iPA== + fast-glob@^3.1.1, fast-glob@^3.2.5: version "3.2.5" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.5.tgz#7939af2a656de79a4f1901903ee8adcaa7cb9661"