diff --git a/frontend/packages/dev-console/src/components/topology/D3ForceDirectedRenderer.tsx b/frontend/packages/dev-console/src/components/topology/D3ForceDirectedRenderer.tsx index a636d8d2c96..8d8dbaecb24 100644 --- a/frontend/packages/dev-console/src/components/topology/D3ForceDirectedRenderer.tsx +++ b/frontend/packages/dev-console/src/components/topology/D3ForceDirectedRenderer.tsx @@ -1,5 +1,6 @@ /* eslint-disable react/no-multi-comp */ import * as React from 'react'; +import * as classNames from 'classnames'; import * as d3 from 'd3'; import * as ReactDOM from 'react-dom'; import * as _ from 'lodash'; @@ -76,7 +77,8 @@ export interface D3ForceDirectedRendererProps { contextMenu: ContextMenuProvider; nodeSize: number; selected?: string; - onSelect?(string): void; + selectedType?: string; + onSelect?(type: GraphElementType, id: string): void; onUpdateNodeGroup?(nodeId: string, targetGroup: string): Promise; onCreateConnection?( sourceNodeId: string, @@ -259,7 +261,7 @@ export default class D3ForceDirectedRenderer extends React.Component< if (this.ignoreNextSizeChange && sizeChanged) { this.ignoreNextSizeChange = false; if (this.props.selected) { - this.makeNodeVisible(this.props.selected); + this.makeSelectionVisible(); } } @@ -309,22 +311,47 @@ export default class D3ForceDirectedRenderer extends React.Component< this.setState({ zoomTransform: d3.event.transform }); }; - private makeNodeVisible = (nodeId: string) => { + private makeBoxVisible = (boundingBox: { + left: number; + right: number; + top: number; + bottom: number; + }) => { const { width, height } = this.props; - const { nodesById, zoomTransform = { x: 0, y: 0, k: 1 } } = this.state; - const node: ViewNode = nodesById[nodeId]; - let moveToNode = false; + const { zoomTransform = { x: 0, y: 0, k: 1 } } = this.state; + let move = false; const panOffset = 20; - if (!node) { - return; - } - const center = { x: (width / 2 - zoomTransform.x) / zoomTransform.k, y: (height / 2 - zoomTransform.y) / zoomTransform.k, }; + if (boundingBox.right < 0) { + center.x += (boundingBox.left - panOffset) / zoomTransform.k; + move = true; + } + if (boundingBox.left > width) { + center.x += (boundingBox.right - width + panOffset) / zoomTransform.k; + move = true; + } + if (boundingBox.bottom < 0) { + center.y += (boundingBox.top - panOffset) / zoomTransform.k; + move = true; + } + if (boundingBox.top > height) { + center.y += (boundingBox.bottom - height + panOffset) / zoomTransform.k; + move = true; + } + + if (move) { + this.zoom.translateTo(this.$svg, center.x, center.y); + } + }; + + private getNodeBoundingBox = (node: ViewNode) => { + const { zoomTransform = { x: 0, y: 0, k: 1 } } = this.state; + const nodeMin = { x: zoomTransform.x + (node.x - node.size / 2) * zoomTransform.k, y: zoomTransform.y + (node.y - node.size / 2) * zoomTransform.k, @@ -334,26 +361,60 @@ export default class D3ForceDirectedRenderer extends React.Component< y: zoomTransform.y + (node.y + node.size / 2) * zoomTransform.k, }; - if (nodeMax.x < 0) { - center.x += (nodeMin.x - panOffset) / zoomTransform.k; - moveToNode = true; - } - if (nodeMin.x > width) { - center.x += (nodeMax.x - width + panOffset) / zoomTransform.k; - moveToNode = true; - } - if (nodeMax.y < 0) { - center.y += (nodeMin.y - panOffset) / zoomTransform.k; - moveToNode = true; - } - if (nodeMin.y > height) { - center.y += (nodeMax.y - height + panOffset) / zoomTransform.k; - moveToNode = true; + return { + left: nodeMin.x, + right: nodeMax.x, + top: nodeMin.y, + bottom: nodeMax.y, + }; + }; + + private makeNodeVisible = (nodeId) => { + const { nodesById } = this.state; + const node: ViewNode = nodesById[nodeId]; + + if (!node) { + return; } - if (moveToNode) { - this.zoom.translateTo(this.$svg, center.x, center.y); + this.makeBoxVisible(this.getNodeBoundingBox(node)); + }; + + private makeGroupVisible = (groupId: string) => { + const { groupsById } = this.state; + const group: ViewGroup = groupsById[groupId]; + + const boundingBox = _.reduce( + group.nodes, + (box, node) => { + const nodeBox = this.getNodeBoundingBox(node); + return { + left: Math.min(nodeBox.left, box.left), + right: Math.max(nodeBox.right, box.right), + top: Math.min(nodeBox.top, box.top), + bottom: Math.max(nodeBox.bottom, box.bottom), + }; + }, + { + left: Number.POSITIVE_INFINITY, + right: Number.NEGATIVE_INFINITY, + top: Number.POSITIVE_INFINITY, + bottom: Number.NEGATIVE_INFINITY, + }, + ); + + this.makeBoxVisible(boundingBox); + }; + + private makeSelectionVisible = () => { + const { selectedType, selected } = this.props; + + if (selectedType === GraphElementType.node) { + this.makeNodeVisible(selected); + return; } + + this.makeGroupVisible(selected); }; private lockNodes = () => { @@ -822,10 +883,58 @@ export default class D3ForceDirectedRenderer extends React.Component< private deselect = (e: React.MouseEvent) => { e.stopPropagation(); if (this.props.selected) { - this.props.onSelect(null); + this.props.onSelect(null, null); } }; + private isDragActive = (): boolean => { + const { dragNodeId, createConnection, moveConnection } = this.state; + return !!dragNodeId || !!moveConnection || (!!createConnection && createConnection.dragging); + }; + + private draggedEdges() { + const { dragNodeId, edgesById } = this.state; + if (!dragNodeId) { + return []; + } + const edges = _.filter(edgesById, (viewEdge: ViewEdge) => { + return viewEdge.source.id === dragNodeId || viewEdge.target.id === dragNodeId; + }); + return _.map(edges, 'id'); + } + + private draggedNodes() { + const { dragNodeId, edgesById, nodes } = this.state; + if (!dragNodeId) { + return []; + } + const edges = _.filter(edgesById, (viewEdge: ViewEdge) => { + return viewEdge.source.id === dragNodeId || viewEdge.target.id === dragNodeId; + }); + + return _.filter(nodes, (nodeId: string) => + _.find(edges, (edge: ViewEdge) => edge.source.id === nodeId || edge.target.id === nodeId), + ); + } + + private createGroupLinks(): any[] { + const { groups, groupsById } = this.state; + const groupLinks = []; + // link each node within a group together to form group clusters + groups.forEach((g) => { + const { nodes } = groupsById[g]; + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + groupLinks.push({ + source: nodes[i], + target: nodes[j], + }); + } + } + }); + return groupLinks; + } + public api() { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; @@ -886,51 +995,31 @@ export default class D3ForceDirectedRenderer extends React.Component< return api; } - private createGroupLinks(): any[] { - const { groups, groupsById } = this.state; - const groupLinks = []; - // link each node within a group together to form group clusters - groups.forEach((g) => { - const { nodes } = groupsById[g]; - for (let i = 0; i < nodes.length; i++) { - for (let j = i + 1; j < nodes.length; j++) { - groupLinks.push({ - source: nodes[i], - target: nodes[j], - }); - } - } - }); - return groupLinks; - } - - private draggedEdges() { - const { dragNodeId, edgesById } = this.state; - if (!dragNodeId) { - return []; - } - const edges = _.filter(edgesById, (viewEdge: ViewEdge) => { - return viewEdge.source.id === dragNodeId || viewEdge.target.id === dragNodeId; - }); - return _.map(edges, 'id'); - } - - private draggedNodes() { - const { dragNodeId, edgesById, nodes } = this.state; - if (!dragNodeId) { - return []; - } - const edges = _.filter(edgesById, (viewEdge: ViewEdge) => { - return viewEdge.source.id === dragNodeId || viewEdge.target.id === dragNodeId; - }); + renderGroup(groupId: string) { + const { groupProvider, onSelect, selectedType, selected } = this.props; + const { groupsById, sourceGroup, targetGroup } = this.state; + const viewGroup = groupsById[groupId]; + const Component = groupProvider(viewGroup.type); - return _.filter(nodes, (nodeId: string) => - _.find(edges, (edge: ViewEdge) => edge.source.id === nodeId || edge.target.id === nodeId), + return ( + onSelect(GraphElementType.group, groupId) : null} + selected={selectedType === GraphElementType.group && selected === groupId} + dragActive={this.isDragActive()} + dropSource={groupId === sourceGroup} + dropTarget={groupId === targetGroup} + groupRef={(ref: GroupElementInterface) => this.refGroupSvg(ref, groupId)} + /> ); } renderNode(nodeId: string) { - const { nodeProvider, selected, onSelect, topology } = this.props; + const { nodeProvider, selected, selectedType, onSelect, topology } = this.props; const { nodesById, dragNodeId, @@ -949,8 +1038,9 @@ export default class D3ForceDirectedRenderer extends React.Component< view={viewNode} data={data} key={nodeId} - selected={nodeId === selected} - onSelect={onSelect ? () => onSelect(nodeId) : null} + dragActive={this.isDragActive()} + selected={selectedType === GraphElementType.node && nodeId === selected} + onSelect={onSelect ? () => onSelect(GraphElementType.node, nodeId) : null} onEnter={this.onNodeEnter} onHover={(hovered: boolean) => this.onNodeHover(viewNode, hovered)} isDragging={ @@ -1019,6 +1109,7 @@ export default class D3ForceDirectedRenderer extends React.Component< {...viewEdge} key={edgeId} data={data} + dragActive={this.isDragActive()} isDragging={viewEdge.source.id === dragNodeId || viewEdge.target.id === dragNodeId} onTargetArrowEnter={this.onMoveConnectionEnter} onRemove={() => onRemoveConnection(viewEdge.source.id, viewEdge.target.id)} @@ -1036,16 +1127,13 @@ export default class D3ForceDirectedRenderer extends React.Component< } render() { - const { width, height, groupProvider } = this.props; + const { width, height } = this.props; const { nodes, edges, groups, - groupsById, zoomTransform, dragNodeId, - sourceGroup, - targetGroup, createConnection, moveConnection, } = this.state; @@ -1053,35 +1141,19 @@ export default class D3ForceDirectedRenderer extends React.Component< const draggedNodes = this.draggedNodes(); const dragActive = dragNodeId || !!moveConnection || (createConnection && createConnection.dragging); + + const className = classNames('odc-graph__svg', { 'odc-m-drag-active': dragActive }); return ( - - {groups.map((groupId) => { - const viewGroup = groupsById[groupId]; - const Component = groupProvider(viewGroup.type); - - return ( - this.refGroupSvg(ref, groupId)} - /> - ); - })} - + {groups.map((groupId) => this.renderGroup(groupId))} {edges.map((edgeId) => _.includes(draggedEdges, edgeId) ? null : this.renderEdge(edgeId), diff --git a/frontend/packages/dev-console/src/components/topology/Graph.scss b/frontend/packages/dev-console/src/components/topology/Graph.scss index 27bc3968fc0..872a7e01254 100644 --- a/frontend/packages/dev-console/src/components/topology/Graph.scss +++ b/frontend/packages/dev-console/src/components/topology/Graph.scss @@ -9,4 +9,7 @@ & > svg { display: block; } + &__svg.odc-m-drag-active { + cursor: pointer; + } } diff --git a/frontend/packages/dev-console/src/components/topology/Graph.tsx b/frontend/packages/dev-console/src/components/topology/Graph.tsx index c109cdf18b8..95e18545c06 100644 --- a/frontend/packages/dev-console/src/components/topology/Graph.tsx +++ b/frontend/packages/dev-console/src/components/topology/Graph.tsx @@ -10,6 +10,7 @@ import { GroupProvider, ActionProvider, ContextMenuProvider, + GraphElementType, } from './topology-types'; import './Graph.scss'; import { GraphContextMenu } from './GraphContextMenu'; @@ -29,7 +30,8 @@ export interface GraphProps { graph: GraphModel; topology: TopologyDataMap; selected?: string; - onSelect?(string): void; + selectedType?: string; + onSelect?(type: GraphElementType, id: string): void; onUpdateNodeGroup?(nodeId: string, targetGroup: string): Promise; onCreateConnection?( sourceNodeId: string, @@ -82,6 +84,7 @@ export default class Graph extends React.Component { onCreateConnection, onRemoveConnection, selected, + selectedType, topology, } = this.props; const { dimensions } = this.state; @@ -104,6 +107,7 @@ export default class Graph extends React.Component { onRemoveConnection={onRemoveConnection} selected={selected} contextMenu={this.contextMenuRef} + selectedType={selectedType} /> )} diff --git a/frontend/packages/dev-console/src/components/topology/Topology.tsx b/frontend/packages/dev-console/src/components/topology/Topology.tsx index 17370074b23..b8ccf9b6c3d 100644 --- a/frontend/packages/dev-console/src/components/topology/Topology.tsx +++ b/frontend/packages/dev-console/src/components/topology/Topology.tsx @@ -1,9 +1,15 @@ import * as React from 'react'; +import * as _ from 'lodash'; import { TopologyView } from '@patternfly/react-topology'; import { confirmModal, errorModal } from '@console/internal/components/modals'; -import { nodeProvider, edgeProvider, groupProvider } from './shape-providers'; +import { edgeProvider, groupProvider, nodeProvider } from './shape-providers'; import Graph from './Graph'; -import { GraphApi, TopologyDataModel, TopologyDataObject } from './topology-types'; +import { + GraphApi, + GraphElementType, + TopologyDataModel, + TopologyDataObject, +} from './topology-types'; import { createTopologyResourceConnection, removeTopologyResourceConnection, @@ -12,9 +18,12 @@ import { import TopologyControlBar from './TopologyControlBar'; import TopologySideBar from './TopologySideBar'; import { ActionProviders } from './actions-providers'; +import TopologyApplicationPanel from './TopologyApplicationPanel'; +import TopologyResourcePanel from './TopologyResourcePanel'; type State = { selected?: string; + selectedType?: GraphElementType; graphApi?: GraphApi; }; @@ -22,6 +31,27 @@ export interface TopologyProps { data: TopologyDataModel; } +const getSelectedItem = ( + topologyData: TopologyDataModel, + selectedType: GraphElementType, + selectedId: string, +) => { + let selectedItem; + + switch (selectedType) { + case GraphElementType.node: + selectedItem = topologyData.topology[selectedId]; + break; + case GraphElementType.group: + selectedItem = _.find(topologyData.graph.groups, { id: selectedId }); + break; + default: + selectedItem = null; + } + + return selectedItem; +}; + export default class Topology extends React.Component { constructor(props) { super(props); @@ -29,16 +59,24 @@ export default class Topology extends React.Component { } static getDerivedStateFromProps(nextProps: TopologyProps, prevState: State): State { - const { selected } = prevState; - if (selected && !nextProps.data.topology[selected]) { - return { selected: null }; + const { selected, selectedType } = prevState; + + if (selected && selectedType) { + const selectedItem = getSelectedItem(nextProps.data, selectedType, selected); + if (!selectedItem) { + return { selected: null, selectedType: null }; + } } + return prevState; } - onSelect = (nodeId: string) => { - this.setState(({ selected }) => { - return { selected: !nodeId || selected === nodeId ? null : nodeId }; + onSelect = (type: GraphElementType, id: string) => { + this.setState(({ selected, selectedType }) => { + if (!id || !type || (selected === id && selectedType === type)) { + return { selected: null, type: null }; + } + return { selected: id, selectedType: type }; }); }; @@ -102,18 +140,43 @@ export default class Topology extends React.Component { this.setState({ graphApi: api }); }; + renderSelectedItemDetails() { + const { data } = this.props; + const { selected, selectedType } = this.state; + const selectedItem = getSelectedItem(data, selectedType, selected); + + if (!selectedItem) { + return null; + } + + switch (selectedType) { + case GraphElementType.node: + return ; + case GraphElementType.group: + return ( + data.topology[nodeId]), + }} + /> + ); + default: + return null; + } + } + render() { const { data: { graph, topology }, } = this.props; - const { selected, graphApi } = this.state; + const { selected, selectedType, graphApi } = this.state; const topologySideBar = ( - + + {this.renderSelectedItemDetails()} + ); const actionProvider = new ActionProviders(topology); @@ -132,6 +195,7 @@ export default class Topology extends React.Component { groupProvider={groupProvider} actionProvider={actionProvider.getActions} selected={selected} + selectedType={selectedType} onSelect={this.onSelect} onUpdateNodeGroup={this.onUpdateNodeGroup} onCreateConnection={this.onCreateConnection} diff --git a/frontend/packages/dev-console/src/components/topology/TopologyApplicationPanel.tsx b/frontend/packages/dev-console/src/components/topology/TopologyApplicationPanel.tsx new file mode 100644 index 00000000000..af2a54c3aee --- /dev/null +++ b/frontend/packages/dev-console/src/components/topology/TopologyApplicationPanel.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { ResourceIcon } from '@console/internal/components/utils'; +import { TopologyApplicationObject } from './topology-types'; + +export type TopologyApplicationPanelProps = { + application: TopologyApplicationObject; +}; + +const TopologyApplicationPanel: React.FC = ({ application }) => ( +
+
+

+
+ + {application.name} +
+

+
+
+
+); + +export default TopologyApplicationPanel; diff --git a/frontend/packages/dev-console/src/components/topology/TopologyResourcePanel.tsx b/frontend/packages/dev-console/src/components/topology/TopologyResourcePanel.tsx new file mode 100644 index 00000000000..3618087101d --- /dev/null +++ b/frontend/packages/dev-console/src/components/topology/TopologyResourcePanel.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { ResourceOverviewPage } from '@console/internal/components/overview/resource-overview-page'; +import { + DeploymentConfigModel, + DeploymentModel, + DaemonSetModel, + StatefulSetModel, + RouteModel, + ServiceModel, + BuildConfigModel, +} from '@console/internal/models'; +import { + ConfigurationModel, + RouteModel as ServerlessRouteModel, + RevisionModel, +} from '@console/knative-plugin'; +import { ResourceProps } from '@console/shared'; +import { TopologyDataObject } from './topology-types'; + +export type TopologyResourcePanelProps = { + item: TopologyDataObject; +}; + +const possibleKinds = [ + DeploymentConfigModel.kind, + DeploymentModel.kind, + DaemonSetModel.kind, + StatefulSetModel.kind, +]; + +/** + * REMOVE: once we get labels in place + * This is a temporary check to avoid the `Warning: Each child in an array or iterator should have a unique "key" prop`. + * Its coming when buildConfig/route/service metadata is empty object, + * BuildOverviewList, RouteOverviewList, ServiceOverview list uses the metadata.uid as the value of `key` prop. + * + * Datacontroller get the buildConfigs based on apps.kubernetes.io/instance label which is not applied to apps created using browser catalog + */ + +function metadataUIDCheck(items: ResourceProps[]): ResourceProps[] { + return items.filter((item) => item.metadata && item.metadata.uid); +} + +const TopologyResourcePanel: React.FC = ({ item }) => { + let resourceItemToShowOnSideBar; + if (!item) { + return null; + } + + const dc = item.resources.filter(({ kind }) => possibleKinds.includes(kind)); + const routes = metadataUIDCheck(item.resources.filter(({ kind }) => kind === RouteModel.kind)); + const services = metadataUIDCheck( + item.resources.filter(({ kind }) => kind === ServiceModel.kind), + ); + const buildConfigs = metadataUIDCheck( + item.resources.filter(({ kind }) => kind === BuildConfigModel.kind), + ); + + resourceItemToShowOnSideBar = { + obj: { apiVersion: 'apps.openshift.io/v1', ...dc[0] }, + kind: dc[0].kind, + routes, + services, + buildConfigs, + pods: item.pods, + }; + + const ksroutes = metadataUIDCheck( + item.resources.filter( + (o) => + o.kind === ServerlessRouteModel.kind && + o.apiVersion === `${ServerlessRouteModel.apiGroup}/${ServerlessRouteModel.apiVersion}`, + ), + ); + const configurations = metadataUIDCheck( + item.resources.filter( + (o) => + o.kind === ConfigurationModel.kind && + o.apiVersion === `${ConfigurationModel.apiGroup}/${ConfigurationModel.apiVersion}`, + ), + ); + const revisions = metadataUIDCheck( + item.resources.filter( + (o) => + o.kind === RevisionModel.kind && + o.apiVersion === `${RevisionModel.apiGroup}/${RevisionModel.apiVersion}`, + ), + ); + + if (configurations.length) { + resourceItemToShowOnSideBar = { + ...resourceItemToShowOnSideBar, + ...{ ksroutes, configurations, revisions }, + }; + } + + if (resourceItemToShowOnSideBar) { + return ( + + ); + } + + return null; +}; + +export default TopologyResourcePanel; diff --git a/frontend/packages/dev-console/src/components/topology/TopologySideBar.tsx b/frontend/packages/dev-console/src/components/topology/TopologySideBar.tsx index 55f095a4a50..7696dac2a80 100644 --- a/frontend/packages/dev-console/src/components/topology/TopologySideBar.tsx +++ b/frontend/packages/dev-console/src/components/topology/TopologySideBar.tsx @@ -1,109 +1,19 @@ import * as React from 'react'; import { TopologySideBar as PFTopologySideBar } from '@patternfly/react-topology'; import { CloseButton } from '@console/internal/components/utils'; -import { ResourceOverviewPage } from '@console/internal/components/overview/resource-overview-page'; -import { - DeploymentConfigModel, - DeploymentModel, - DaemonSetModel, - StatefulSetModel, - RouteModel, - ServiceModel, - BuildConfigModel, -} from '@console/internal/models'; -import { - ConfigurationModel, - RouteModel as ServerlessRouteModel, - RevisionModel, -} from '@console/knative-plugin'; -import { ResourceProps } from '@console/shared'; -import { TopologyDataObject } from './topology-types'; export type TopologySideBarProps = { - item: TopologyDataObject; show: boolean; onClose: Function; }; -const possibleKinds = [ - DeploymentConfigModel.kind, - DeploymentModel.kind, - DaemonSetModel.kind, - StatefulSetModel.kind, -]; - -/** - * REMOVE: once we get labels in place - * This is a temporary check to avoid the `Warning: Each child in an array or iterator should have a unique "key" prop`. - * Its coming when buildConfig/route/service metadata is empty object, - * BuildOverviewList, RouteOverviewList, ServiceOverview list uses the metadata.uid as the value of `key` prop. - * - * Datacontroller get the buildConfigs based on apps.kubernetes.io/instance label which is not applied to apps created using browser catalog - */ - -function metadataUIDCheck(items: any): ResourceProps[] { - return items.filter((item) => item.metadata && item.metadata.uid); -} - -const TopologySideBar: React.FC = ({ item, show, onClose }) => { - let itemtoShowOnSideBar; - if (item) { - const dc = item.resources.filter(({ kind }) => possibleKinds.includes(kind)); - const routes = metadataUIDCheck(item.resources.filter(({ kind }) => kind === RouteModel.kind)); - const services = metadataUIDCheck( - item.resources.filter(({ kind }) => kind === ServiceModel.kind), - ); - const buildConfigs = metadataUIDCheck( - item.resources.filter(({ kind }) => kind === BuildConfigModel.kind), - ); - itemtoShowOnSideBar = { - obj: { apiVersion: 'apps.openshift.io/v1', ...dc[0] }, - kind: dc[0].kind, - routes, - services, - buildConfigs, - pods: item.pods, - }; - - const ksroutes = metadataUIDCheck( - item.resources.filter( - (o) => - o.kind === ServerlessRouteModel.kind && - o.apiVersion === `${ServerlessRouteModel.apiGroup}/${ServerlessRouteModel.apiVersion}`, - ), - ); - const configurations = metadataUIDCheck( - item.resources.filter( - (o) => - o.kind === ConfigurationModel.kind && - o.apiVersion === `${ConfigurationModel.apiGroup}/${ConfigurationModel.apiVersion}`, - ), - ); - const revisions = metadataUIDCheck( - item.resources.filter( - (o) => - o.kind === RevisionModel.kind && - o.apiVersion === `${RevisionModel.apiGroup}/${RevisionModel.apiVersion}`, - ), - ); - if (configurations.length) { - itemtoShowOnSideBar = { - ...itemtoShowOnSideBar, - ...{ ksroutes, configurations, revisions }, - }; - } - } - - return ( - -
- -
- {itemtoShowOnSideBar ? ( - - ) : null} -
- ); -}; +const TopologySideBar: React.FC = ({ children, show, onClose }) => ( + +
+ +
+ {children} +
+); export default TopologySideBar; diff --git a/frontend/packages/dev-console/src/components/topology/__tests__/TopologySideBar.spec.tsx b/frontend/packages/dev-console/src/components/topology/__tests__/TopologySideBar.spec.tsx index c8cf46b2a1d..ba08cf5f9f7 100644 --- a/frontend/packages/dev-console/src/components/topology/__tests__/TopologySideBar.spec.tsx +++ b/frontend/packages/dev-console/src/components/topology/__tests__/TopologySideBar.spec.tsx @@ -6,10 +6,6 @@ import SideBar, { TopologySideBarProps } from '../TopologySideBar'; describe('TopologySideBar:', () => { const props: TopologySideBarProps = { show: true, - item: { - resources: [{ kind: 'DeploymentConfig' }, { kind: 'Route' }, { kind: 'Service' }], - data: {}, - } as any, onClose: () => '', }; @@ -20,7 +16,7 @@ describe('TopologySideBar:', () => { it('clicking on close button should call the onClose callback function', () => { const onClose = jest.fn(); - const wrapper = shallow(); + const wrapper = shallow(); wrapper .find(CloseButton) .shallow() diff --git a/frontend/packages/dev-console/src/components/topology/shapes/BaseEdge.scss b/frontend/packages/dev-console/src/components/topology/shapes/BaseEdge.scss index 6cd1ea2e21e..2dcf94f501f 100644 --- a/frontend/packages/dev-console/src/components/topology/shapes/BaseEdge.scss +++ b/frontend/packages/dev-console/src/components/topology/shapes/BaseEdge.scss @@ -11,6 +11,10 @@ .odc-base-edge { opacity: 0.3; + &.is-hover { + stroke: var(--pf-global--BackgroundColor--dark-200); + } + &.is-highlight { opacity: 0.5; } diff --git a/frontend/packages/dev-console/src/components/topology/shapes/BaseNode.tsx b/frontend/packages/dev-console/src/components/topology/shapes/BaseNode.tsx index 5df3bbe2b57..4afaa2b3b60 100644 --- a/frontend/packages/dev-console/src/components/topology/shapes/BaseNode.tsx +++ b/frontend/packages/dev-console/src/components/topology/shapes/BaseNode.tsx @@ -23,6 +23,7 @@ export interface BaseNodeProps { kind?: string; children?: React.ReactNode; attachments?: React.ReactNode; + dragActive?: boolean; isDragging?: boolean; isTarget?: boolean; onHover?(hovered: boolean): void; @@ -87,6 +88,7 @@ export default class BaseNode extends React.Component { onHover, children, attachments, + dragActive, isDragging, isTarget, } = this.props; @@ -121,7 +123,9 @@ export default class BaseNode extends React.Component { cx={0} cy={0} r={outerRadius} - filter={hover ? createSvgIdUrl(FILTER_ID_HOVER) : createSvgIdUrl(FILTER_ID)} + filter={ + hover && !dragActive ? createSvgIdUrl(FILTER_ID_HOVER) : createSvgIdUrl(FILTER_ID) + } /> = ({ source, target, + dragActive, isDragging, targetArrowRef, onRemove, @@ -62,11 +63,13 @@ const ConnectsTo: React.FC = ({ - {hover && ( + {hover && !dragActive && ( { @@ -81,18 +86,43 @@ class DefaultGroup extends React.Component return pointInSvgPath(containerPath, point[0], point[1]); }; + setHover = (isHover: boolean) => { + this.setState({ isHover }); + }; + + handleClick = (e: React.MouseEvent) => { + const { onSelect } = this.props; + e.stopPropagation(); + onSelect(); + }; + render() { - const { name, dropTarget } = this.props; - const { nodes, lowestPoint, containerPath } = this.state; + const { name, dropTarget, selected, onSelect, dragActive } = this.props; + const { nodes, lowestPoint, containerPath, isHover } = this.state; if (nodes.length === 0) { return null; } - const pathClasses = classNames('odc-default-group', { 'is-highlight': dropTarget }); + const pathClasses = classNames('odc-default-group', { + 'is-highlight': dropTarget, + 'is-selected': selected, + 'is-hover': isHover, + }); return ( - + + + this.setHover(true)} + onMouseLeave={() => this.setHover(false)} + filter={ + isHover && !dragActive ? createSvgIdUrl(FILTER_ID_HOVER) : createSvgIdUrl(FILTER_ID) + } + > + + } } -export default DefaultGroup; +export default React.memo(DefaultGroup); diff --git a/frontend/packages/dev-console/src/components/topology/topology-types.ts b/frontend/packages/dev-console/src/components/topology/topology-types.ts index e8bea2f9e97..818be565a82 100644 --- a/frontend/packages/dev-console/src/components/topology/topology-types.ts +++ b/frontend/packages/dev-console/src/components/topology/topology-types.ts @@ -65,6 +65,12 @@ export interface TopologyDataObject { data: D; } +export interface TopologyApplicationObject { + id: string; + name: string; + resources: TopologyDataObject[]; +} + export interface WorkloadData { url?: string; editUrl?: string; @@ -85,6 +91,12 @@ export interface GraphApi { resetLayout(): void; } +export enum GraphElementType { + node = 'node', + edge = 'edge', + group = 'group', +} + export interface Selectable { selected?: boolean; onSelect?(): void; @@ -124,6 +136,7 @@ export type ViewGroup = { export type NodeProps = ViewNode & Selectable & { data?: TopologyDataObject; + dragActive?: boolean; isDragging?: boolean; isTarget?: boolean; onHover?(hovered: boolean): void; @@ -138,16 +151,19 @@ export type DragConnectionProps = NodeProps & { export type EdgeProps = ViewEdge & { data?: TopologyDataObject; + dragActive?: boolean; isDragging?: boolean; targetArrowRef?(ref: SVGPathElement): void; onRemove?: () => void; }; -export type GroupProps = ViewGroup & { - dropSource?: boolean; - dropTarget?: boolean; - groupRef(element: GroupElementInterface): void; -}; +export type GroupProps = ViewGroup & + Selectable & { + dragActive?: boolean; + dropSource?: boolean; + dropTarget?: boolean; + groupRef(element: GroupElementInterface): void; + }; export type NodeProvider = (type: string) => ComponentType; @@ -155,11 +171,6 @@ export type EdgeProvider = (type: string) => ComponentType; export type GroupProvider = (type: string) => ComponentType; -export enum GraphElementType { - node = 'node', - edge = 'edge', - group = 'group', -} export type ActionProvider = (type: GraphElementType, id: string) => KebabOption[]; export type ContextMenuProvider = { diff --git a/frontend/packages/dev-console/src/models/applications.ts b/frontend/packages/dev-console/src/models/applications.ts new file mode 100644 index 00000000000..54f1e4511e8 --- /dev/null +++ b/frontend/packages/dev-console/src/models/applications.ts @@ -0,0 +1,14 @@ +import { K8sKind } from '@console/internal/module/k8s'; + +export const ApplicationModel: K8sKind = { + id: 'application', + kind: 'application', + plural: 'applications', + label: 'Application', + labelPlural: 'Applications', + abbr: 'A', + apiGroup: '', + apiVersion: '', + namespaced: true, + crd: false, +}; diff --git a/frontend/packages/dev-console/src/models/index.ts b/frontend/packages/dev-console/src/models/index.ts index dd4093e028e..835cf5812c9 100644 --- a/frontend/packages/dev-console/src/models/index.ts +++ b/frontend/packages/dev-console/src/models/index.ts @@ -1 +1,2 @@ +export * from './applications'; export * from './pipelines'; diff --git a/frontend/public/components/_resource.scss b/frontend/public/components/_resource.scss index 74857faa0e1..231d54d11bf 100644 --- a/frontend/public/components/_resource.scss +++ b/frontend/public/components/_resource.scss @@ -26,6 +26,10 @@ width: 50px; } +.co-m-resource-application { + background-color: $color-application-dark; +} + .co-m-resource-clusterrole, .co-m-resource-role { background-color: $color-rbac-role-dark; diff --git a/frontend/public/style/_vars.scss b/frontend/public/style/_vars.scss index 1588869dbcb..fdcd74406de 100644 --- a/frontend/public/style/_vars.scss +++ b/frontend/public/style/_vars.scss @@ -61,6 +61,7 @@ $co-side-nav-font-size: 16px; // Side nav section (sub-section set by body) $color-alertmanager-dark: $pf-color-orange-600; $color-co-m-row-hover: #fafafa; +$color-application-dark: $pf-color-green-500; $color-configmap-dark: $pf-color-purple-600; $color-container-dark: $pf-color-blue-300; $color-error: $color-pf-red-100;