Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions calm-hub-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ import Hub from './hub/Hub.js';
import Visualizer from './visualizer/Visualizer.js';

function App() {
//TODO: The artifacts route will eventually need to be changed/replaced once we create a unique identifier for resources that can be used across CalmHubs.
//When this happens the logic to handle params in TreeNavigation will also have to be updated.
//Currently the format of the route allows deeplinks to only be used within a single CalmHub.
return (
<Router>
<Routes>
<Route path="/" element={<Hub />} />
<Route path="/artifacts/:namespace?/:type?/:id?/:version?" element={<Hub />} />
<Route path="/visualizer" element={<Visualizer />} />
</Routes>
</Router>
Expand Down
4 changes: 2 additions & 2 deletions calm-hub-ui/src/hub/Hub.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { TreeNavigation } from './components/tree-navigation/TreeNavigation.js';
import { Data, Adr } from '../model/calm.js';
import { Navbar } from '../components/navbar/Navbar.js';
Expand Down Expand Up @@ -27,7 +27,7 @@ export default function Hub() {
<div className="flex flex-row flex-1 overflow-hidden bg-base-300">
<div className="w-1/4 p-4 pr-2">
<div className="h-full bg-base-100 rounded-2xl overflow-hidden shadow-xl">
<TreeNavigation onDataLoad={handleDataLoad} onAdrLoad={handleAdrLoad} />
<TreeNavigation onDataLoad={useMemo(() => handleDataLoad, [])} onAdrLoad={useMemo(() => handleAdrLoad, [])} />
</div>
</div>
<div className="flex-1 overflow-auto">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { render, screen } from '@testing-library/react';
import { TreeNavigation } from './TreeNavigation.js';
import { MemoryRouter, useParams } from 'react-router-dom';
import { fetchArchitectureIDs, fetchArchitectureVersions, fetchFlow, fetchFlowIDs, fetchFlowVersions, fetchNamespaces, fetchPatternIDs } from '../../../service/calm-service.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';

// Mock react-router-dom
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useParams: vi.fn().mockReturnValue({}),
useNavigate: vi.fn(),
};
});

// Mock the service functions
vi.mock('../../../service/calm-service.js', () => ({
Expand Down Expand Up @@ -34,15 +47,19 @@ describe('TreeNavigation', () => {
});

it('renders the tree navigation component', () => {
render(<TreeNavigation {...mockProps} />);
render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

expect(screen.getByText('Namespaces')).toBeInTheDocument();
expect(screen.getByText('test-namespace')).toBeInTheDocument();
expect(screen.getByText('another-namespace')).toBeInTheDocument();
});

it('shows resource types only when namespace is selected', () => {
render(<TreeNavigation {...mockProps} />);
render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

// Initially, resource types should not be visible since no namespace is selected
expect(screen.queryByText('Architectures')).not.toBeInTheDocument();
Expand All @@ -52,10 +69,91 @@ describe('TreeNavigation', () => {
});

it('handles initial state correctly', () => {
render(<TreeNavigation {...mockProps} />);
render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

expect(screen.getByText('Namespaces')).toBeInTheDocument();
expect(screen.getByText('test-namespace')).toBeInTheDocument();
expect(screen.getByText('another-namespace')).toBeInTheDocument();
});

it('loads data based on deeplink route - type', () => {
vi.mocked(useParams).mockReturnValue({
namespace: 'test-namespace',
type: 'Patterns',
});

// Mock fetchPatternIDs to return some data for UI checks
vi.mocked(fetchPatternIDs).mockImplementation((ns, callback) => Promise.resolve(callback(['pattern1', 'pattern2'])));

render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

expect(fetchNamespaces).toHaveBeenCalledWith(expect.any(Function));
expect(fetchPatternIDs).toHaveBeenCalledWith('test-namespace', expect.any(Function));

// Resource IDs for selected type should be visible
expect(screen.getByText('pattern1')).toBeInTheDocument();
expect(screen.getByText('pattern2')).toBeInTheDocument();
});

it('loads data based on deeplink route - resource ID', () => {
vi.mocked(useParams).mockReturnValue({
namespace: 'test-namespace',
type: 'Architectures',
id: '201',
});

// Mock fetchArchitectureIDs and fetchArchitectureVersions to return data
vi.mocked(fetchArchitectureIDs).mockImplementation((ns, callback) => Promise.resolve(callback(['201', '202'])));
vi.mocked(fetchArchitectureVersions).mockImplementation((ns, id, callback) => Promise.resolve(callback(['v1.0', 'v2.0'])));

render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

expect(fetchNamespaces).toHaveBeenCalledWith(expect.any(Function));
expect(fetchArchitectureIDs).toHaveBeenCalledWith('test-namespace', expect.any(Function));
expect(fetchArchitectureVersions).toHaveBeenCalledWith('test-namespace', '201', expect.any(Function));

// Architecture IDs should be visible
expect(screen.getByText('201')).toBeInTheDocument();
expect(screen.getByText('202')).toBeInTheDocument();

// Versions should be visible
expect(screen.getByText('v1.0')).toBeInTheDocument();
expect(screen.getByText('v2.0')).toBeInTheDocument();
});

it('loads data based on deeplink route - version', () => {
vi.mocked(useParams).mockReturnValue({
namespace: 'test-namespace',
type: 'Flows',
id: '201',
version: 'v2.0'
});

// Mock fetchFlowIDs, fetchFlowVersions, and fetchFlow to return data
vi.mocked(fetchFlowIDs).mockImplementation((ns, callback) => Promise.resolve(callback(['201', '202'])));
vi.mocked(fetchFlowVersions).mockImplementation((ns, id, callback) => Promise.resolve(callback(['v1.0', 'v2.0'])));

render(<MemoryRouter initialEntries={["/"]}>
<TreeNavigation {...mockProps} />
</MemoryRouter>);

expect(fetchNamespaces).toHaveBeenCalledWith(expect.any(Function));
expect(fetchFlowIDs).toHaveBeenCalledWith('test-namespace', expect.any(Function));
expect(fetchFlowVersions).toHaveBeenCalledWith('test-namespace', '201', expect.any(Function));
expect(fetchFlow).toHaveBeenCalledWith('test-namespace', '201', 'v2.0', expect.any(Function));

// Flow IDs should be visible
expect(screen.getByText('201')).toBeInTheDocument();
expect(screen.getByText('202')).toBeInTheDocument();

// Versions should be visible
expect(screen.getByText('v1.0')).toBeInTheDocument();
expect(screen.getByText('v2.0')).toBeInTheDocument();
});
});
157 changes: 97 additions & 60 deletions calm-hub-ui/src/hub/components/tree-navigation/TreeNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IoCompassOutline } from 'react-icons/io5';
import {
fetchNamespaces,
Expand All @@ -14,6 +14,16 @@ import {
} from '../../../service/calm-service.js';
import { AdrService } from '../../../service/adr-service/adr-service.js';
import { Data, Adr } from '../../../model/calm.js';
import { useNavigate, useParams } from 'react-router-dom';

type HubParams = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is the best place to define these properties?

namespace?: string;
type?: 'Architectures' | 'Patterns' | 'Flows' | 'ADRs';
id?: string;
version?: string;
};

const basePath = '/artifacts';

interface TreeNavigationProps {
onDataLoad: (data: Data) => void;
Expand Down Expand Up @@ -200,7 +210,54 @@ function NamespaceItem({
);
}

function isNotNullOrEmptyString(value: string | null | undefined): value is string {
return value !== null && value !== undefined && value !== '';
}

function loadResourceIds(type: string, namespace: string, setArchitectureIDs: (ids: string[]) => void, setPatternIDs: (ids: string[]) => void, setFlowIDs: (ids: string[]) => void, adrService: AdrService, setAdrIDs: (ids: string[]) => void) {
if (type === 'Architectures') {
fetchArchitectureIDs(namespace, setArchitectureIDs);
} else if (type === 'Patterns') {
fetchPatternIDs(namespace, setPatternIDs);
} else if (type === 'Flows') {
fetchFlowIDs(namespace, setFlowIDs);
} else if (type === 'ADRs') {
adrService
.fetchAdrIDs(namespace)
.then((ids) => setAdrIDs(ids.map((id) => id.toString())));
}
}

function loadVersions(resourceID: string, type: string, namespace: string, setArchitectureVersions: (versions: string[]) => void, setPatternVersions: (versions: string[]) => void, setFlowVersions: (versions: string[]) => void, adrService: AdrService, setAdrRevisions: (revisions: string[]) => void) {
if (type === 'Architectures') {
fetchArchitectureVersions(namespace, resourceID, setArchitectureVersions);
} else if (type === 'Patterns') {
fetchPatternVersions(namespace, resourceID, setPatternVersions);
} else if (type === 'Flows') {
fetchFlowVersions(namespace, resourceID, setFlowVersions);
} else if (type === 'ADRs') {
adrService
.fetchAdrRevisions(namespace, resourceID)
.then((revisions) => setAdrRevisions(revisions.map((rev) => rev.toString())));
}
}

function loadResource(version: string, type: string, namespace: string, resourceID: string, onDataLoad: (data: Data) => void, onAdrLoad: (adr: Adr) => void, adrService: AdrService) {
if (type === 'Architectures') {
fetchArchitecture(namespace, resourceID, version, onDataLoad);
} else if (type === 'Patterns') {
fetchPattern(namespace, resourceID, version, onDataLoad);
} else if (type === 'Flows') {
fetchFlow(namespace, resourceID, version, onDataLoad);
} else if (type === 'ADRs') {
adrService.fetchAdr(namespace, resourceID, version).then(onAdrLoad);
}
}

export function TreeNavigation({ onDataLoad, onAdrLoad }: TreeNavigationProps) {
const navigate = useNavigate();
const params = useParams<HubParams>();

const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNamespace, setSelectedNamespace] = useState<string>('');
const [selectedType, setSelectedType] = useState<string>('');
Expand All @@ -217,74 +274,54 @@ export function TreeNavigation({ onDataLoad, onAdrLoad }: TreeNavigationProps) {
const [flowVersions, setFlowVersions] = useState<string[]>([]);
const [adrRevisions, setAdrRevisions] = useState<string[]>([]);

const adrService = new AdrService();
const adrService = useMemo(() => new AdrService(), []);

useEffect(() => {
fetchNamespaces(setNamespaces);
}, []);

const handleNamespaceClick = (namespace: string) => {
if (selectedNamespace === namespace) {
setSelectedNamespace('');
} else {
setSelectedNamespace(namespace);
}
setSelectedType('');
setSelectedResourceID('');
setSelectedVersion('');
};

const handleTypeClick = (type: string) => {
if (selectedType === type) {
setSelectedType('');
} else {
setSelectedType(type);
if (type === 'Architectures') {
fetchArchitectureIDs(selectedNamespace, setArchitectureIDs);
} else if (type === 'Patterns') {
fetchPatternIDs(selectedNamespace, setPatternIDs);
} else if (type === 'Flows') {
fetchFlowIDs(selectedNamespace, setFlowIDs);
} else if (type === 'ADRs') {
adrService
.fetchAdrIDs(selectedNamespace)
.then((ids) => setAdrIDs(ids.map((id) => id.toString())));
// Check if namespace exists
if (isNotNullOrEmptyString(params.namespace)) {
//Set selected namespace based on params
setSelectedNamespace(params.namespace);
// Check if resource type exists
if (isNotNullOrEmptyString(params.type)) {
//Set selected type based on params
setSelectedType(params.type);
//Load resource IDs for the selected type and namespace
loadResourceIds(params.type, params.namespace, setArchitectureIDs, setPatternIDs, setFlowIDs, adrService, setAdrIDs);
// Check if resource ID exists
if (isNotNullOrEmptyString(params.id)) {
//Set selected resource ID based on params
setSelectedResourceID(params.id);
//Load versions for the selected resource ID, type, and namespace
loadVersions(params.id, params.type, params.namespace, setArchitectureVersions, setPatternVersions, setFlowVersions, adrService, setAdrRevisions);
// Check if version exists
if (isNotNullOrEmptyString(params.version)) {
//Set selected version based on params
setSelectedVersion(params.version);
//Load the resource data or ADR based on all params
loadResource(params.version, params.type, params.namespace, params.id, onDataLoad, onAdrLoad, adrService);
}
}
}
}
setSelectedResourceID('');
setSelectedVersion('');
};

}, [params, adrService, onDataLoad, onAdrLoad]);

const handleResourceClick = (resourceID: string, type: string) => {
setSelectedResourceID(resourceID);
setSelectedVersion('');
const handleNamespaceClick = useCallback((namespace: string) => {
navigate(`${basePath}/${namespace}`);
}, [navigate]);

if (type === 'Architectures') {
fetchArchitectureVersions(selectedNamespace, resourceID, setArchitectureVersions);
} else if (type === 'Patterns') {
fetchPatternVersions(selectedNamespace, resourceID, setPatternVersions);
} else if (type === 'Flows') {
fetchFlowVersions(selectedNamespace, resourceID, setFlowVersions);
} else if (type === 'ADRs') {
adrService
.fetchAdrRevisions(selectedNamespace, resourceID)
.then((revisions) => setAdrRevisions(revisions.map((rev) => rev.toString())));
}
};
const handleTypeClick = useCallback((type: string) => {
navigate(`${basePath}/${selectedNamespace}/${type}`);
}, [navigate, selectedNamespace]);

const handleVersionClick = (version: string, type: string) => {
setSelectedVersion(version);
const handleResourceClick = useCallback((resourceID: string, type: string) => {
navigate(`${basePath}/${selectedNamespace}/${type}/${resourceID}`);
}, [navigate, selectedNamespace]);

if (type === 'Architectures') {
fetchArchitecture(selectedNamespace, selectedResourceID, version, onDataLoad);
} else if (type === 'Patterns') {
fetchPattern(selectedNamespace, selectedResourceID, version, onDataLoad);
} else if (type === 'Flows') {
fetchFlow(selectedNamespace, selectedResourceID, version, onDataLoad);
} else if (type === 'ADRs') {
adrService.fetchAdr(selectedNamespace, selectedResourceID, version).then(onAdrLoad);
}
};
const handleVersionClick = useCallback((version: string, type: string) => {
navigate(`${basePath}/${selectedNamespace}/${type}/${selectedResourceID}/${version}`);
}, [navigate, selectedNamespace, selectedResourceID]);

const getResourceIDs = (type: string): string[] => {
switch (type) {
Expand Down
2 changes: 1 addition & 1 deletion calm-hub-ui/src/visualizer/contracts/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,4 @@ export type NodeLayoutViolations = {
export type IdAndPosition = {
nodeId: string;
position: cytoscape.Position;
};
};
Loading