diff --git a/src/lib/topology/createTopologyHashMaps.test.ts b/src/lib/topology/createTopologyHashMaps.test.ts new file mode 100644 index 000000000..7af2b630a --- /dev/null +++ b/src/lib/topology/createTopologyHashMaps.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest' +import { createTopologyHashMaps } from './createTopologyHashMaps' + +describe('createTopologyHashMap', () => { + it('should create a hash map from a valid topology', () => { + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ] + const result = createTopologyHashMaps(topology) + expect(result).toEqual({ + childIdToParentNodeMap: new Map(), + idToNodeMap: new Map( + Object.entries({ + '1': { id: '1', name: 'Node 1' }, + '2': { id: '2', name: 'Node 2' }, + '3': { id: '3', name: 'Node 3' }, + }), + ), + }) + }) + + it('should return an empty maps for an empty topology', () => { + const topology: any[] = [] + const result = createTopologyHashMaps(topology) + expect(result).toEqual({ + childIdToParentNodeMap: new Map(), + idToNodeMap: new Map(), + }) + }) + + it('should handle topology with duplicate ids', () => { + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '1', name: 'Node 1 Duplicate' }, + { id: '3', name: 'Node 3' }, + ] + const result = createTopologyHashMaps(topology) + expect(result).toEqual({ + childIdToParentNodeMap: new Map(), + idToNodeMap: new Map( + Object.entries({ + '1': { id: '1', name: 'Node 1 Duplicate' }, + '3': { id: '3', name: 'Node 3' }, + }), + ), + }) + }) + + it('should handle a nested topology', () => { + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2', topologyNodes: [{ id: '3', name: 'Node 3' }] }, + { id: '4', name: 'Node 4' }, + ] + const result = createTopologyHashMaps(topology) + expect(result).toEqual({ + childIdToParentNodeMap: new Map( + Object.entries({ + '3': { + id: '2', + name: 'Node 2', + topologyNodes: [{ id: '3', name: 'Node 3' }], + }, + }), + ), + idToNodeMap: new Map( + Object.entries({ + '1': { id: '1', name: 'Node 1' }, + '2': { + id: '2', + name: 'Node 2', + topologyNodes: [{ id: '3', name: 'Node 3' }], + }, + '3': { id: '3', name: 'Node 3' }, + '4': { id: '4', name: 'Node 4' }, + }), + ), + }) + }) + + it('should handle a deeply nested topology', () => { + const topology = [ + { id: '1', name: 'Node 1' }, + { + id: '2', + name: 'Node 2', + topologyNodes: [ + { + id: '3', + name: 'Node 3', + topologyNodes: [{ id: '4', name: 'Node 4' }], + }, + ], + }, + { id: '5', name: 'Node 5' }, + ] + const result = createTopologyHashMaps(topology) + expect(result).toEqual({ + childIdToParentNodeMap: new Map( + Object.entries({ + '3': { + id: '2', + name: 'Node 2', + topologyNodes: [ + { + id: '3', + name: 'Node 3', + topologyNodes: [{ id: '4', name: 'Node 4' }], + }, + ], + }, + '4': { + id: '3', + name: 'Node 3', + topologyNodes: [{ id: '4', name: 'Node 4' }], + }, + }), + ), + idToNodeMap: new Map( + Object.entries({ + '1': { id: '1', name: 'Node 1' }, + '2': { + id: '2', + name: 'Node 2', + topologyNodes: [ + { + id: '3', + name: 'Node 3', + topologyNodes: [{ id: '4', name: 'Node 4' }], + }, + ], + }, + '3': { + id: '3', + name: 'Node 3', + topologyNodes: [{ id: '4', name: 'Node 4' }], + }, + '4': { id: '4', name: 'Node 4' }, + '5': { id: '5', name: 'Node 5' }, + }), + ), + }) + }) +}) diff --git a/src/lib/topology/createTopologyHashMaps.ts b/src/lib/topology/createTopologyHashMaps.ts new file mode 100644 index 000000000..af6e5a8b2 --- /dev/null +++ b/src/lib/topology/createTopologyHashMaps.ts @@ -0,0 +1,41 @@ +import type { TopologyNode } from '@deltares/fews-pi-requests' + +/** + * Creates hash maps for topology nodes. + * + * This function generates two hash maps: + * 1. idToNodeMap: Maps node IDs to their corresponding TopologyNode objects. + * 2. childIdToParentNodeMap: Maps child node IDs to their parent node. + * + * @param {TopologyNode[] | undefined} nodes - An array of TopologyNode objects or undefined. + * @returns {{ idToNodeMap: Map, childIdToParentNodeMap: Map }} - An object containing the two hash maps. + */ +export function createTopologyHashMaps(nodes: TopologyNode[] | undefined): { + idToNodeMap: Map + childIdToParentNodeMap: Map +} { + const idToNodeMap = new Map() + const childIdToParentNodeMap = new Map() + + function recursivelyFillMaps(nodes: TopologyNode[]) { + for (const node of nodes) { + idToNodeMap.set(node.id, node) + + if (node.topologyNodes) { + node.topologyNodes.forEach((childNode) => { + childIdToParentNodeMap.set(childNode.id, node) + }) + recursivelyFillMaps(node.topologyNodes) + } + } + } + + if (nodes) { + recursivelyFillMaps(nodes) + } + + return { + idToNodeMap, + childIdToParentNodeMap, + } +} diff --git a/src/lib/topology/getTopologyNodes.ts b/src/lib/topology/getTopologyNodes.ts new file mode 100644 index 000000000..7ce237e58 --- /dev/null +++ b/src/lib/topology/getTopologyNodes.ts @@ -0,0 +1,28 @@ +import { configManager } from '@/services/application-config/index.ts' +import { + PiWebserviceProvider, + type TopologyNode, +} from '@deltares/fews-pi-requests' +import { createTransformRequestFn } from '@/lib/requests/transformRequest' + +/** + * Fetch the topology nodes from the FEWS web services. + * + * @returns {Promise} - An array of TopologyNode objects. + */ +export async function getTopologyNodes(): Promise { + const baseUrl = configManager.get('VITE_FEWS_WEBSERVICES_URL') + const piProvider = new PiWebserviceProvider(baseUrl, { + transformRequestFn: createTransformRequestFn(), + }) + + let nodes: TopologyNode[] = [] + try { + const response = await piProvider.getTopologyNodes() + nodes = response.topologyNodes + } catch (error) { + error = 'error-loading' + } + + return nodes +} diff --git a/src/lib/topology/index.ts b/src/lib/topology/index.ts deleted file mode 100644 index cfb39e52d..000000000 --- a/src/lib/topology/index.ts +++ /dev/null @@ -1,53 +0,0 @@ -export * from './locations' -import { configManager } from '../../services/application-config/index.ts' -import { - PiWebserviceProvider, - type TopologyNode, -} from '@deltares/fews-pi-requests' -import { createTransformRequestFn } from '@/lib/requests/transformRequest' - -/** - * Recursively updates a topology map where each node is stored by its id. - * - * @param {TopologyNode[] | undefined} nodes - An array of TopologyNode objects or undefined. - * @param {Map} topologyMap - A Map used to store the topology nodes. - */ -export function createTopologyMap(nodes: TopologyNode[] | undefined) { - const topologyMap = new Map() - - function recursiveCreateTopologyMap( - nodes: TopologyNode[] | undefined, - topologyMap: Map, - ) { - if (nodes === undefined) return undefined - for (const node of nodes) { - topologyMap.set(node.id, node) - recursiveCreateTopologyMap(node.topologyNodes, topologyMap) - } - } - - recursiveCreateTopologyMap(nodes, topologyMap) - return topologyMap -} - -/** - * Fetch the topology nodes from the FEWS web services. - * - * @returns {Promise} - An array of TopologyNode objects. - */ -export async function getTopologyNodes(): Promise { - const baseUrl = configManager.get('VITE_FEWS_WEBSERVICES_URL') - const piProvider = new PiWebserviceProvider(baseUrl, { - transformRequestFn: createTransformRequestFn(), - }) - - let nodes: TopologyNode[] = [] - try { - const response = await piProvider.getTopologyNodes() - nodes = response.topologyNodes - } catch (error) { - error = 'error-loading' - } - - return nodes -} diff --git a/src/services/application-config/ApplicationConfigManager.ts b/src/services/application-config/ApplicationConfigManager.ts index 43a56fa02..8d2318e7c 100644 --- a/src/services/application-config/ApplicationConfigManager.ts +++ b/src/services/application-config/ApplicationConfigManager.ts @@ -1,6 +1,6 @@ import { UserManagerSettings } from 'oidc-client-ts' import { ApplicationConfig } from './ApplicationConfig' -import oidcSettings from '../authentication/oidcSettings.ts' +import { getOidcSettings } from '@/services/authentication/oidcSettings' export class ApplicationConfigManager { _config!: ApplicationConfig @@ -30,7 +30,7 @@ export class ApplicationConfigManager { getUserManagerSettings(): UserManagerSettings { return { - ...oidcSettings, + ...getOidcSettings(), ...{ authority: this.get('VITE_AUTH_AUTHORITY'), client_id: this.get('VITE_AUTH_ID'), diff --git a/src/services/authentication/oidcSettings.ts b/src/services/authentication/oidcSettings.ts index 2984659bf..00c688ad8 100644 --- a/src/services/authentication/oidcSettings.ts +++ b/src/services/authentication/oidcSettings.ts @@ -1,17 +1,18 @@ import { UserManagerSettings } from 'oidc-client-ts' -const baseUrl = window.location.origin + import.meta.env.BASE_URL -const oidcSettings: UserManagerSettings = { - authority: `${import.meta.env.VITE_AUTH_AUTHORITY}`, - metadataUrl: `${import.meta.env.VITE_AUTH_METADATA_URL}`, - client_id: `${import.meta.env.VITE_AUTH_ID}`, - redirect_uri: `${baseUrl}auth/callback`, - silent_redirect_uri: `${baseUrl}auth/silent`, - response_type: `code`, - scope: `${import.meta.env.VITE_AUTH_SCOPE}`, - post_logout_redirect_uri: `${baseUrl}auth/logout`, - monitorSession: false, - automaticSilentRenew: true, +export function getOidcSettings() { + const baseUrl = window.location.origin + import.meta.env.BASE_URL + const oidcSettings: UserManagerSettings = { + authority: `${import.meta.env.VITE_AUTH_AUTHORITY}`, + metadataUrl: `${import.meta.env.VITE_AUTH_METADATA_URL}`, + client_id: `${import.meta.env.VITE_AUTH_ID}`, + redirect_uri: `${baseUrl}auth/callback`, + silent_redirect_uri: `${baseUrl}auth/silent`, + response_type: `code`, + scope: `${import.meta.env.VITE_AUTH_SCOPE}`, + post_logout_redirect_uri: `${baseUrl}auth/logout`, + monitorSession: false, + automaticSilentRenew: true, + } + return oidcSettings } - -export default oidcSettings diff --git a/src/services/useFilterLocations/index.ts b/src/services/useFilterLocations/index.ts index 54eaa8d31..6b57675bd 100644 --- a/src/services/useFilterLocations/index.ts +++ b/src/services/useFilterLocations/index.ts @@ -2,11 +2,10 @@ import type { FeatureCollection, Geometry } from 'geojson' import type { Location } from '@deltares/fews-pi-requests' import type { Ref, MaybeRefOrGetter, ShallowRef } from 'vue' import { ref, watchEffect, shallowRef, toValue } from 'vue' - import { convertGeoJsonToFewsPiLocation, fetchLocationsAsGeoJson, -} from '@/lib/topology' +} from '@/lib/topology/locations' export interface UseFilterLocationsReturn { error: Ref diff --git a/src/stores/topologyNodes.test.ts b/src/stores/topologyNodes.test.ts new file mode 100644 index 000000000..24b38c594 --- /dev/null +++ b/src/stores/topologyNodes.test.ts @@ -0,0 +1,143 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it } from 'vitest' +import { useTopologyNodesStore } from './topologyNodes' +import { nextTick } from 'vue' + +describe('Toplogy Nodes Store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + }) + + it('should get nodes by id', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getNodeById('1')).toEqual({ + id: '1', + name: 'Node 1', + }) + }) + + it('should get nodes by id with duplicate ids', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '1', name: 'Node 1 Duplicate' }, + { id: '3', name: 'Node 3' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getNodeById('1')).toEqual({ + id: '1', + name: 'Node 1 Duplicate', + }) + }) + + it('should get nodes by id with no nodes', async () => { + const topologyNodesStore = useTopologyNodesStore() + topologyNodesStore.nodes = [] + + await nextTick() + + expect(topologyNodesStore.getNodeById('1')).toBeUndefined() + }) + + it('should get nodes by id before fetch', async () => { + const topologyNodesStore = useTopologyNodesStore() + expect(topologyNodesStore.getNodeById('1')).toBeUndefined() + }) + + it('should get parent node by id', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2', topologyNodes: [{ id: '3', name: 'Node 3' }] }, + { id: '4', name: 'Node 4' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getParentNodeById('3')).toEqual({ + id: '2', + name: 'Node 2', + topologyNodes: [{ id: '3', name: 'Node 3' }], + }) + }) + + it('should get parent node by id with no parent', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getParentNodeById('3')).toBeUndefined() + }) + + it('should get parent node by id with no nodes', async () => { + const topologyNodesStore = useTopologyNodesStore() + topologyNodesStore.nodes = [] + + await nextTick() + + expect(topologyNodesStore.getParentNodeById('3')).toBeUndefined() + }) + + it('should get sub nodes for ids', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getSubNodesForIds(['1', '2'])).toEqual([ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + ]) + }) + + it('should get sub nodes for ids with no ids', async () => { + const topologyNodesStore = useTopologyNodesStore() + const topology = [ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ] + topologyNodesStore.nodes = topology + + await nextTick() + + expect(topologyNodesStore.getSubNodesForIds()).toEqual([ + { id: '1', name: 'Node 1' }, + { id: '2', name: 'Node 2' }, + { id: '3', name: 'Node 3' }, + ]) + }) + + it('should get sub nodes for ids with no nodes', async () => { + const topologyNodesStore = useTopologyNodesStore() + topologyNodesStore.nodes = [] + + await nextTick() + + expect(topologyNodesStore.getSubNodesForIds()).toEqual([]) + }) +}) diff --git a/src/stores/topologyNodes.ts b/src/stores/topologyNodes.ts new file mode 100644 index 000000000..fa2ea95f8 --- /dev/null +++ b/src/stores/topologyNodes.ts @@ -0,0 +1,52 @@ +import { createTopologyHashMaps } from '@/lib/topology/createTopologyHashMaps' +import { getTopologyNodes } from '@/lib/topology/getTopologyNodes' +import { TopologyNode } from '@deltares/fews-pi-requests' +import { defineStore } from 'pinia' +import { ref, watchEffect } from 'vue' + +export const useTopologyNodesStore = defineStore('topologyNodes', () => { + const nodes = ref([]) + const _idToNodeMap = ref>(new Map()) + const _childIdToParentNodeMap = ref>(new Map()) + const subNodes = ref([]) + + watchEffect(() => { + const { idToNodeMap, childIdToParentNodeMap } = createTopologyHashMaps( + nodes.value, + ) + _idToNodeMap.value = idToNodeMap + _childIdToParentNodeMap.value = childIdToParentNodeMap + }) + + function getSubNodesForIds(nodeIds?: string[]) { + if (!nodeIds) return nodes.value + + return nodeIds.flatMap((nodeId) => { + const node = _idToNodeMap.value.get(nodeId) + return node ? [node] : [] + }) + } + + function getNodeById(nodeId: string) { + return _idToNodeMap.value.get(nodeId) + } + + function getParentNodeById(childNodeId: string) { + return _childIdToParentNodeMap.value.get(childNodeId) + } + + async function fetch() { + nodes.value = await getTopologyNodes() + } + + return { + nodes, + fetch, + subNodes, + _idToNodeMap, + _childIdToParentNodeMap, + getSubNodesForIds, + getNodeById, + getParentNodeById, + } +}) diff --git a/src/views/TimeSeriesDisplayView.vue b/src/views/TimeSeriesDisplayView.vue index 0a0c361e8..b485fc24d 100644 --- a/src/views/TimeSeriesDisplayView.vue +++ b/src/views/TimeSeriesDisplayView.vue @@ -12,7 +12,7 @@ import { ref, watch } from 'vue' import type { ColumnItem } from '../components/general/ColumnItem' import type { TopologyNode } from '@deltares/fews-pi-requests' import TimeSeriesDisplay from '../components/timeseries/TimeSeriesDisplay.vue' -import { getTopologyNodes } from '@/lib/topology' +import { getTopologyNodes } from '@/lib/topology/getTopologyNodes' const TIME_SERIES_DIALOG_PANEL: string = 'time series dialog' diff --git a/src/views/TopologyDisplayView.vue b/src/views/TopologyDisplayView.vue index 240add29a..9608f81b0 100644 --- a/src/views/TopologyDisplayView.vue +++ b/src/views/TopologyDisplayView.vue @@ -90,7 +90,6 @@ import WorkflowsControl from '@/components/workflows/WorkflowsControl.vue' import LeafNodeButtons from '@/components/general/LeafNodeButtons.vue' import type { ColumnItem } from '@/components/general/ColumnItem' -import { createTopologyMap, getTopologyNodes } from '@/lib/topology' import { useConfigStore } from '@/stores/config' import { useUserSettingsStore } from '@/stores/userSettings' import { useWorkflowsStore } from '@/stores/workflows' @@ -115,6 +114,7 @@ import { displayTabsForNode, type DisplayTab, } from '@/lib/topology/displayTabs.js' +import { useTopologyNodesStore } from '@/stores/topologyNodes' interface Props { topologyId?: string @@ -149,7 +149,7 @@ watch(active, () => { const activeNode = computed(() => { if (!active.value) return - const node = topologyMap.value.get(active.value) + const node = topologyNodesStore.getNodeById(active.value) if (node?.topologyNodes) { const leafNode = node.topologyNodes.find( (n) => n.id === nodesStore.activeNodeId, @@ -240,23 +240,17 @@ const showActiveThresholdCrossingsForFilters = computed(() => { ) }) -const nodes = ref() -const topologyMap = ref(new Map()) -const subNodes = computed( - () => - topologyDisplayNodes.value?.flatMap((nodeId) => { - const node = topologyMap.value.get(nodeId) - return node ? [node] : [] - }) ?? nodes.value, -) +const topologyNodesStore = useTopologyNodesStore() +topologyNodesStore + .fetch() + .catch(() => console.error('Failed to fetch topology nodes')) -getTopologyNodes().then((response) => { - nodes.value = response - topologyMap.value = createTopologyMap(nodes.value) +const subNodes = computed(() => + topologyNodesStore.getSubNodesForIds(topologyDisplayNodes.value), +) +watch(topologyNodesStore.nodes, () => { const to = reroute(route) - if (to) { - router.push(to) - } + if (to) router.push(to) }) function updateItems(): void { @@ -289,7 +283,7 @@ watchEffect(() => { : undefined // Check if the active node is a leaf. - const node = topologyMap.value.get(activeNodeId) + const node = topologyNodesStore.getNodeById(activeNodeId) if (node === undefined) { filterIds.value = [] return @@ -299,7 +293,8 @@ watchEffect(() => { if (showLeafsAsButton.value && Array.isArray(props.nodeId)) { const menuNodeId = props.nodeId[0] - const menuNode = topologyMap.value.get(menuNodeId) as any + const menuNode = topologyNodesStore.getNodeById(menuNodeId) + if (!menuNode) return nodesStore.setNodeButtons( nodeButtonItems( menuNode, @@ -320,10 +315,9 @@ onBeforeRouteUpdate(reroute) function reroute(to: RouteLocationNormalized) { if (!to.params.nodeId) { - if (topologyMap.value.size === 0) return - const topologyEntry = topologyMap.value.entries().next() - if (topologyEntry.value) { - to.params.nodeId = topologyEntry.value[0] + const firstSubNodeId = subNodes.value[0].id + if (firstSubNodeId) { + to.params.nodeId = firstSubNodeId return to } return @@ -336,13 +330,11 @@ function reroute(to: RouteLocationNormalized) { const parentNodeId = Array.isArray(to.params.nodeId) ? to.params.nodeId[0] : to.params.nodeId - const menuNode = topologyMap.value.get(parentNodeId) as TopologyNode + const menuNode = topologyNodesStore.getNodeById(parentNodeId) if (menuNode === undefined) return if (menuNode.topologyNodes === undefined) { const leafNodeId = parentNodeId - const parentNode = [...topologyMap.value?.values()].find((p) => { - return p.topologyNodes?.map((c) => c.id).includes(leafNodeId) - }) + const parentNode = topologyNodesStore.getParentNodeById(leafNodeId) if (parentNode?.id === undefined) return const to = { name: 'TopologyDisplay', @@ -372,7 +364,9 @@ function reroute(to: RouteLocationNormalized) { Array.isArray(to.params.nodeId) && to.params.nodeId.length > 1 ? to.params.nodeId[0] : undefined - const menuNode = topologyMap.value.get(leafNodeId) as TopologyNode + const menuNode = topologyNodesStore.getNodeById(leafNodeId) + if (!menuNode) return + const topologyId = to.params.topologyId as string const tabs = displayTabsForNode(menuNode, parentNodeId, topologyId) const tab = tabs.find((t) => t.type === activeTab.value) ?? tabs[0]