From a428b375283aac34175cfc86a9028da22393f832 Mon Sep 17 00:00:00 2001 From: Skylar Date: Wed, 25 Sep 2024 13:03:53 -0600 Subject: [PATCH] feat: Add ability to toggle flow visibility in topology graph (#44) --- .../src/components/CytoscapeGraph/Legend.tsx | 31 ++++--- .../src/components/CytoscapeGraph/index.tsx | 10 ++- .../components/CytoscapeGraph/stylesheet.ts | 1 + kontrol-frontend/src/contexts/ApiContext.tsx | 26 +++--- .../src/contexts/FlowsContext.tsx | 90 +++++++++++++++++++ kontrol-frontend/src/main.tsx | 5 +- .../src/pages/TrafficConfiguration.tsx | 20 ++++- 7 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 kontrol-frontend/src/contexts/FlowsContext.tsx diff --git a/kontrol-frontend/src/components/CytoscapeGraph/Legend.tsx b/kontrol-frontend/src/components/CytoscapeGraph/Legend.tsx index 989924c..d72bdb5 100644 --- a/kontrol-frontend/src/components/CytoscapeGraph/Legend.tsx +++ b/kontrol-frontend/src/components/CytoscapeGraph/Legend.tsx @@ -1,4 +1,4 @@ -import { Flex } from "@chakra-ui/react"; +import { Flex, IconButton } from "@chakra-ui/react"; import { useApi } from "@/contexts/ApiContext"; import { Table, @@ -9,14 +9,16 @@ import { Td, TableContainer, } from "@/components/Table"; -import { FiExternalLink } from "react-icons/fi"; +import { FiExternalLink, FiEye, FiEyeOff } from "react-icons/fi"; import { useEffect, useState } from "react"; import { ClusterTopology, NodeVersion, Node } from "@/types"; import { Link } from "@chakra-ui/react"; +import { useFlowsContext } from "@/contexts/FlowsContext"; const Legend = () => { - const { flows, getFlows, getTopology } = useApi(); + const { getTopology } = useApi(); const [topology, setTopology] = useState(null); + const { flows, flowVisibility, setFlowVisibility } = useFlowsContext(); const servicesForFlowId = (flowId: string, isBaseline: boolean): Node[] => { if (topology == null) { @@ -34,11 +36,14 @@ const Legend = () => { }; useEffect(() => { - getFlows(); getTopology().then(setTopology); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + const sortedFlows = flows.sort((a, b) => { + return a["flow-id"].localeCompare(b["flow-id"]); + }); + return ( { Flow ID Baseline URL - {/* Show/Hide */} + Show/Hide - {flows.map((flow) => { + {sortedFlows.map((flow) => { // TODO: Update these when this PR is merged: // https://github.com/kurtosis-tech/kardinal/pull/234 const flowId = flow["flow-id"]; @@ -84,10 +89,8 @@ const Legend = () => { - {/* TODO: Uncomment when this feature is fully implemented - * - {highlightedFlowId === flowId ? ( + {flowVisibility[flowId] === true ? ( { w={"24px"} minW={"24px"} color={isBaseline ? "gray.300" : "gray.600"} - icon={} + icon={} disabled={isBaseline} onClick={() => { - // setHighlightedFlowId(null); + if (isBaseline) return; // cant hide baseline flow + setFlowVisibility(flowId, false); }} /> ) : ( @@ -108,17 +112,16 @@ const Legend = () => { minH={"24px"} w={"24px"} minW={"24px"} - icon={} disabled={isBaseline} + icon={} color={isBaseline ? "gray.300" : "gray.600"} cursor={isBaseline ? "not-allowed" : "pointer"} onClick={() => { - // setHighlightedFlowId(flowId); + setFlowVisibility(flowId, true); }} /> )} - **/} ); })} diff --git a/kontrol-frontend/src/components/CytoscapeGraph/index.tsx b/kontrol-frontend/src/components/CytoscapeGraph/index.tsx index 8c6c885..a9de3e8 100644 --- a/kontrol-frontend/src/components/CytoscapeGraph/index.tsx +++ b/kontrol-frontend/src/components/CytoscapeGraph/index.tsx @@ -60,10 +60,12 @@ const CytoscapeGraph = ({ if (tooltip.current != null) { tooltip.current.destroy(); } - const tooltipInstance = createTooltip(e.target); - if (tooltipInstance == null) return; - tooltip.current = tooltipInstance.instance; - setTooltipPortalElem(tooltipInstance.element); + const newTooltip = createTooltip(e.target); + if (newTooltip == null) return; + const { instance, element } = newTooltip; + if (instance == null || element == null) return; + tooltip.current = instance; + setTooltipPortalElem(element); setHoveredNode(e.target); }); cy.current.on("mouseout", function () { diff --git a/kontrol-frontend/src/components/CytoscapeGraph/stylesheet.ts b/kontrol-frontend/src/components/CytoscapeGraph/stylesheet.ts index bc2bfc5..5516658 100644 --- a/kontrol-frontend/src/components/CytoscapeGraph/stylesheet.ts +++ b/kontrol-frontend/src/components/CytoscapeGraph/stylesheet.ts @@ -142,6 +142,7 @@ const stylesheet = [ { selector: ".external", css: { + ghost: "no", "line-style": "dashed", "border-style": "dotted", "background-color": "#f2ebf8", diff --git a/kontrol-frontend/src/contexts/ApiContext.tsx b/kontrol-frontend/src/contexts/ApiContext.tsx index 387bfda..f9a4c03 100644 --- a/kontrol-frontend/src/contexts/ApiContext.tsx +++ b/kontrol-frontend/src/contexts/ApiContext.tsx @@ -25,17 +25,16 @@ export type RequestBody< : never; export interface ApiContextType { - deleteFlow: (flowId: string) => Promise; + deleteFlow: (flowId: string) => Promise; deleteTemplate: (templateName: string) => Promise; error: string | null; - flows: Flow[]; - getFlows: () => Promise; + getFlows: () => Promise; getTemplates: () => Promise; getTopology: () => Promise; loading: boolean; postFlowCreate: ( b: RequestBody<"/tenant/{uuid}/flow/create", "post">, - ) => Promise; + ) => Promise; postTemplateCreate: ( b: RequestBody<"/tenant/{uuid}/templates/create", "post">, ) => Promise; @@ -43,17 +42,20 @@ export interface ApiContextType { } const defaultContextValue: ApiContextType = { - deleteFlow: async () => {}, + deleteFlow: async () => [], deleteTemplate: async () => {}, error: null, - flows: [], - getFlows: async () => {}, + getFlows: async () => [], getTemplates: async () => {}, getTopology: async () => { return { nodes: [], edges: [] }; }, loading: false, - postFlowCreate: async () => {}, + postFlowCreate: async () => ({ + "flow-id": "", + "flow-urls": [], + isBaseline: false, + }), postTemplateCreate: async () => {}, templates: [], }; @@ -72,7 +74,6 @@ export const ApiContextProvider = ({ children }: PropsWithChildren) => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [templates, setTemplates] = useState([]); - const [flows, setFlows] = useState([]); // boilerplate loading state, error handling for any API call const handleApiCall = useCallback(async function ( @@ -104,7 +105,7 @@ export const ApiContextProvider = ({ children }: PropsWithChildren) => { body, }), ); - setFlows((state) => [...state, flow]); + return flow; }, [uuid, handleApiCall], ); @@ -117,7 +118,7 @@ export const ApiContextProvider = ({ children }: PropsWithChildren) => { params: { path: { uuid } }, }), ); - setFlows(flows); + return flows; }, [uuid, handleApiCall]); // DELETE "/tenant/{uuid}/flow/{flow-id}" @@ -129,7 +130,7 @@ export const ApiContextProvider = ({ children }: PropsWithChildren) => { params: { path: { uuid, "flow-id": flowId } }, }), ); - setFlows(flows); + return flows; }, [uuid, handleApiCall], ); @@ -193,7 +194,6 @@ export const ApiContextProvider = ({ children }: PropsWithChildren) => { deleteFlow, deleteTemplate, error, - flows, getFlows, getTemplates, getTopology, diff --git a/kontrol-frontend/src/contexts/FlowsContext.tsx b/kontrol-frontend/src/contexts/FlowsContext.tsx new file mode 100644 index 0000000..f76f338 --- /dev/null +++ b/kontrol-frontend/src/contexts/FlowsContext.tsx @@ -0,0 +1,90 @@ +import { + createContext, + useContext, + useState, + ReactNode, + useEffect, +} from "react"; +import { useApi } from "@/contexts/ApiContext"; +import { Flow } from "@/types"; + +interface FlowsContextType { + flows: Flow[]; + refetchFlows: () => Promise; + flowVisibility: Record; + setFlowVisibility: (flowId: string, visible: boolean) => void; +} + +// Create the context with a default value +const FlowsContext = createContext({ + flows: [], + refetchFlows: async () => [], + flowVisibility: {}, + setFlowVisibility: () => {}, +}); + +// Create a provider component +interface FlowsContextProviderProps { + children: ReactNode; +} + +export const FlowsContextProvider = ({ + children, +}: FlowsContextProviderProps) => { + const { getFlows } = useApi(); + const [flows, setFlows] = useState([]); + + const [flowVisibility, setFlowVisibility] = useState>( + {}, + ); + + useEffect(() => { + setFlowVisibility((prevState) => + flows.reduce( + (acc, flow) => ({ + ...acc, + [flow["flow-id"]]: prevState[flow["flow-id"]] ?? true, + }), + {}, + ), + ); + }, [flows]); + + useEffect(() => { + getFlows().then(setFlows); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const refetchFlows = async () => { + const newFlows = await getFlows(); + setFlows(newFlows); + return newFlows; + }; + + return ( + { + setFlowVisibility({ ...flowVisibility, [flowId]: visible }); + }, + }} + > + {children} + + ); +}; + +// Create a custom hook to use the context +// eslint-disable-next-line react-refresh/only-export-components +export const useFlowsContext = (): FlowsContextType => { + const context = useContext(FlowsContext); + if (context === undefined) { + throw new Error( + "useFlowsContext must be used within a FlowsContextProvider", + ); + } + return context; +}; diff --git a/kontrol-frontend/src/main.tsx b/kontrol-frontend/src/main.tsx index 1dc2aed..6c7eac2 100644 --- a/kontrol-frontend/src/main.tsx +++ b/kontrol-frontend/src/main.tsx @@ -16,6 +16,7 @@ import NotFound from "@/pages/NotFound"; import { ErrorBoundary } from "react-error-boundary"; import { NavigationContextProvider } from "@/contexts/NavigationContext"; import { ApiContextProvider } from "@/contexts/ApiContext"; +import { FlowsContextProvider } from "@/contexts/FlowsContext"; const router = createBrowserRouter([ { @@ -72,7 +73,9 @@ ReactDOM.createRoot(document.getElementById("root")!).render( - + + + diff --git a/kontrol-frontend/src/pages/TrafficConfiguration.tsx b/kontrol-frontend/src/pages/TrafficConfiguration.tsx index 61bc4bd..2305a4a 100644 --- a/kontrol-frontend/src/pages/TrafficConfiguration.tsx +++ b/kontrol-frontend/src/pages/TrafficConfiguration.tsx @@ -3,6 +3,7 @@ import { Grid } from "@chakra-ui/react"; import CytoscapeGraph, { utils } from "@/components/CytoscapeGraph"; import { ElementDefinition } from "cytoscape"; import { useApi } from "@/contexts/ApiContext"; +import { useFlowsContext } from "@/contexts/FlowsContext"; const pollingIntervalSeconds = 1; @@ -10,11 +11,23 @@ const Page = () => { const [elems, setElems] = useState([]); const prevResponse = useRef(); const { getTopology } = useApi(); + const { refetchFlows, flowVisibility } = useFlowsContext(); useEffect(() => { const fetchElems = async () => { const response = await getTopology(); - const newElems = utils.normalizeData(response); + const filtered = { + ...response, + nodes: response.nodes.map((node) => { + return { + ...node, + versions: node.versions?.filter((version) => { + return flowVisibility[version.flowId] === true; + }), + }; + }), + }; + const newElems = utils.normalizeData(filtered); // dont update react state if the API response is identical to the previous one // This avoids unnecessary re-renders @@ -23,13 +36,16 @@ const Page = () => { } prevResponse.current = JSON.stringify(newElems); setElems(newElems); + // re-fetch flows if topology changes + refetchFlows(); }; // Continuously fetch elements const intervalId = setInterval(fetchElems, pollingIntervalSeconds * 1000); fetchElems(); return () => clearInterval(intervalId); - }, [getTopology]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [flowVisibility]); return (