diff --git a/ui/src/features/project/pipelines/images.tsx b/ui/src/features/project/pipelines/images.tsx index aa16a7697..7123c1221 100644 --- a/ui/src/features/project/pipelines/images.tsx +++ b/ui/src/features/project/pipelines/images.tsx @@ -138,7 +138,7 @@ export const Images = memo( : []; return ( -
+

IMAGES @@ -152,7 +152,7 @@ export const Images = memo(

-
+
{curImage ? ( <>
diff --git a/ui/src/features/project/pipelines/pipelines.tsx b/ui/src/features/project/pipelines/pipelines.tsx index 060eda32f..5c3ff94e3 100644 --- a/ui/src/features/project/pipelines/pipelines.tsx +++ b/ui/src/features/project/pipelines/pipelines.tsx @@ -9,6 +9,7 @@ import { faMagnifyingGlassMinus, faMagnifyingGlassPlus, faMasksTheater, + faMouse, faPalette, faRefresh, faWandSparkles, @@ -17,7 +18,8 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useQueryClient } from '@tanstack/react-query'; import { Button, Dropdown, Spin, Tooltip, message } from 'antd'; -import React, { Suspense, lazy, useCallback, useEffect, useMemo } from 'react'; +import classNames from 'classnames'; +import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef } from 'react'; import { generatePath, useNavigate, useParams } from 'react-router-dom'; import { paths } from '@ui/config/paths'; @@ -58,6 +60,10 @@ import { CollapseMode, FreightTimelineAction, NodeType } from './types'; import { LINE_THICKNESS } from './utils/graph'; import { isPromoting, usePipelineState } from './utils/state'; import { usePipelineGraph } from './utils/use-pipeline-graph'; +import { + usePipelinesInfiniteCanvas, + usePipelineViewPrefHook +} from './utils/use-pipelines-infinite-canvas'; import { onError } from './utils/util'; import { Watcher } from './utils/watcher'; @@ -119,8 +125,6 @@ export const Pipelines = ({ } }); - const [zoom, setZoom] = React.useState(100); - const [highlightedStages, setHighlightedStages] = React.useState<{ [key: string]: boolean }>({}); const [hideSubscriptions, setHideSubscriptions] = useLocalStorage( `${name}-hideSubscriptions`, @@ -266,6 +270,27 @@ export const Pipelines = ({ } }, [stagesPerFreight, fullFreightById]); + const canvasNodeRef = useRef(null); + const movingObjectsRef = useRef(null); + const zoomRef = useRef(null); + const pipelinesConfigRef = useRef(null); + + // @ts-expect-error project name is must + const [pipelineViewPref, setPipelineViewPref] = usePipelineViewPrefHook(name); + + const infinitePipelineCanvas = usePipelinesInfiniteCanvas({ + refs: { + movingObjectsRef, + zoomRef, + pipelinesConfigRef + }, + onCanvas(node) { + canvasNodeRef.current = node; + }, + onMove: setPipelineViewPref, + pipelineViewPref + }); + if (isLoading || isLoadingFreight || isLoadingImages) return ; const stage = stageName && (data?.stages || []).find((item) => item.metadata?.name === stageName); @@ -352,24 +377,38 @@ export const Pipelines = ({
-
-
-
-
- {zoom !== 100 && ( -
-
-
- {nodes?.map((node, index) => ( -
- {node.type === NodeType.STAGE ? ( - <> - fullFreightById[f.name || ''] - )} - hasNoSubscribers={ - Array.from(subscribersByStage[node?.data?.metadata?.name || ''] || []) - .length <= 1 + +
+ +
+
+
+
+ {nodes?.map((node, index) => ( +
+ {node.type === NodeType.STAGE ? ( + <> + fullFreightById[f.name || ''] + )} + hasNoSubscribers={ + Array.from(subscribersByStage[node?.data?.metadata?.name || ''] || []) + .length <= 1 + } + onPromoteClick={(type: FreightTimelineAction) => { + const currentFreight = getCurrentFreight(node.data); + const isWarehouseKind = currentFreight.reduce( + (acc, cur) => acc || cur?.origin?.kind === 'Warehouse', + false + ); + let currentWarehouse = ''; + if (isWarehouseKind) { + currentWarehouse = + currentFreight[0]?.origin?.name || + node.data?.spec?.requestedFreight[0]?.origin?.name || + ''; } - onPromoteClick={(type: FreightTimelineAction) => { - const currentFreight = getCurrentFreight(node.data); - const isWarehouseKind = currentFreight.reduce( - (acc, cur) => acc || cur?.origin?.kind === 'Warehouse', - false - ); - let currentWarehouse = ''; - if (isWarehouseKind) { - currentWarehouse = - currentFreight[0]?.origin?.name || - node.data?.spec?.requestedFreight[0]?.origin?.name || - ''; - } - setSelectedWarehouse(currentWarehouse); - if (state.stage === node.data?.metadata?.name) { - // deselect - state.clear(); - setSelectedWarehouse(''); - } else { - const stageName = node.data?.metadata?.name || ''; - state.select(type, stageName, undefined); - } - }} - action={state.action} - onClick={ - state.action === FreightTimelineAction.ManualApproval + setSelectedWarehouse(currentWarehouse); + if (state.stage === node.data?.metadata?.name) { + // deselect + state.clear(); + setSelectedWarehouse(''); + } else { + const stageName = node.data?.metadata?.name || ''; + state.select(type, stageName, undefined); + } + }} + action={state.action} + onClick={ + state.action === FreightTimelineAction.ManualApproval + ? () => { + manualApproveAction({ + stage: node.data?.metadata?.name, + project: name, + name: state.freight + }); + } + : state.action === FreightTimelineAction.PromoteFreight ? () => { - manualApproveAction({ - stage: node.data?.metadata?.name, + state.setStage(node.data?.metadata?.name || ''); + promoteAction({ + stage: node.data?.metadata?.name || '', project: name, - name: state.freight + freight: state.freight }); } - : state.action === FreightTimelineAction.PromoteFreight - ? () => { - state.setStage(node.data?.metadata?.name || ''); - promoteAction({ - stage: node.data?.metadata?.name || '', - project: name, - freight: state.freight - }); - } - : undefined - } - onHover={(h) => onHover(h, node.data?.metadata?.name || '', true)} - highlighted={highlightedStages[node.data?.metadata?.name || '']} - autoPromotion={autoPromotionMap[node.data?.metadata?.name || '']} - /> - - ) : ( - - )} -
- ))} - {connectors?.map((connector) => - connector.map((line, i) => - hideSubscriptions[line.to] && line.from?.startsWith('subscription-') ? null : ( -
- ) + /> + )} + +
+ )} + {node.type === NodeType.WAREHOUSE && ( + + setHideSubscriptions({ + ...hideSubscriptions, + [node.warehouseName]: !hideSubscriptions[node.warehouseName] + }) + } + icon={hideSubscriptions[node.warehouseName] ? faEye : faEyeSlash} + begin={true} + /> + )} + + )} +
+ ))} + {connectors?.map((connector) => + connector.map((line, i) => + hideSubscriptions[line.to] && line.from?.startsWith('subscription-') ? null : ( +
) - )} -
+ ) + )}
- - {!hideImages && ( -
- -
- )}
{stage && } diff --git a/ui/src/features/project/pipelines/project-details.module.less b/ui/src/features/project/pipelines/project-details.module.less index 9350c42fd..cc13bc2de 100644 --- a/ui/src/features/project/pipelines/project-details.module.less +++ b/ui/src/features/project/pipelines/project-details.module.less @@ -12,5 +12,81 @@ linear-gradient(var(--dot-bg) calc(var(--dot-space) - var(--dot-size)), transparent 1%) center / var(--dot-space) var(--dot-space), var(--dot-color); + + @apply h-0; + + @apply cursor-pointer; + + @apply active:cursor-move; +} + +.staticView { + @apply fixed; + @apply right-5; + @apply z-50; + @apply space-y-10; + @apply mt-5; +} + +.pipelinesViewConfig { + @apply flex; + div { + @apply ml-auto; + } +} + +.pipelinesView { + @apply p-10; + + @apply relative; + + user-select: none; + + transform: translate(0px, 0px); + + --start-transform: translate(0px, 0px); + --end-transform: translate(0px, 0px); + + animation: movePipeline 0.1s ease forwards; +} + +@keyframes movePipeline { + from { + transform: var(--start-transform); + } + + to { + transform: var(--end-transform); + } +} + +.imagesMatrix { + width: 450px; +} + +.toolbar { + @apply flex; + @apply gap-2; + @apply bg-white; + @apply shadow-md; + @apply px-5; + @apply py-2; } +.pipelinesViewNavigationHelper { + @apply text-xs; + @apply bg-white; + @apply shadow-lg; + @apply w-fit; + @apply absolute; + @apply px-5; + @apply py-2; + @apply text-gray-400; + @apply rounded-lg; + @apply absolute; + @apply bottom-5; + @apply left-5; + @apply flex; + @apply items-center; + @apply gap-3; +} \ No newline at end of file diff --git a/ui/src/features/project/pipelines/utils/use-pipelines-infinite-canvas.ts b/ui/src/features/project/pipelines/utils/use-pipelines-infinite-canvas.ts new file mode 100644 index 000000000..443234fe6 --- /dev/null +++ b/ui/src/features/project/pipelines/utils/use-pipelines-infinite-canvas.ts @@ -0,0 +1,320 @@ +import { Dispatch, RefObject, SetStateAction, useCallback, useEffect, useRef } from 'react'; + +import { useLocalStorage } from '@ui/utils/use-local-storage'; + +type PipelineViewPref = { + zoom?: number; + // coordinates - [x, y] + position?: [number, number]; +}; + +export const usePipelineViewPrefHook = (project: string, opts?: { onSet?(): void }) => { + const key = `${project}-pipeline-view-pref`; + + const [state] = useLocalStorage(key) as [ + PipelineViewPref, + Dispatch> + ]; + + const setState = (nextPref: PipelineViewPref) => { + // IMPORTANT: for performance reasons we don't want react to recalculate the whole pipeline view if preference is changed + // this is only required on first render + window.localStorage.setItem(key, JSON.stringify(nextPref)); + opts?.onSet?.(); + }; + + return [state, setState] as const; +}; + +type pipelineInfiniteCanvasHook = { + refs: { + movingObjectsRef: RefObject; + zoomRef: RefObject; + pipelinesConfigRef: RefObject; + }; + moveSpeed?: number; // px - default 2.5 + zoomSpeed?: number; // % - default 5 + onCanvas?(node: HTMLDivElement): void; + onMove?(newPref: PipelineViewPref): void; + pipelineViewPref?: PipelineViewPref; +}; + +export const usePipelinesInfiniteCanvas = (conf: pipelineInfiniteCanvasHook) => { + const cleanupFunction = useRef<() => void>(); + + const moveSpeed = conf?.moveSpeed || 2.5; + const zoomSpeed = conf?.zoomSpeed || 5; + + useEffect(() => { + return cleanupFunction.current; + }, []); + + const getCurrentZoom = useCallback(() => { + if (!conf.refs.zoomRef.current) { + return 100; + } + + return ( + ( + conf.refs.zoomRef.current.computedStyleMap().get('transform') as CSSTransformValue + ).toMatrix().a * 100 + ); + }, []); + + const zoom = useCallback((percentage: number) => { + if (!conf.refs.zoomRef.current) { + return; + } + + conf.refs.zoomRef.current.style.transform = `scale(${percentage}%)`; + }, []); + + const zoomOut = useCallback(() => { + const currentZoom = getCurrentZoom(); + + zoom(currentZoom + zoomSpeed); + }, []); + + const zoomIn = useCallback(() => { + const currentZoom = getCurrentZoom(); + + zoom(currentZoom - zoomSpeed); + }, []); + + const fitToView = useCallback((canvasNode: HTMLDivElement) => { + if ( + !conf.refs.pipelinesConfigRef.current || + !conf.refs.zoomRef.current || + !conf.refs.movingObjectsRef.current + ) { + return; + } + + // reset previously scaled properties - must + conf.refs.zoomRef.current.style.transform = ''; + updatePos(0, 0); + + // canvas hides the overflow of pipeline so we want accurate view by screen + const { x, y, left, top } = canvasNode.getBoundingClientRect(); + const canvasHeight = document.body.offsetHeight - y; + const canvasWidth = document.body.offsetWidth - x; + + const pipelineConfigWidth = + document.body.offsetWidth - conf.refs.pipelinesConfigRef.current.getBoundingClientRect().x; + + const padding = 50; + + const W2 = canvasWidth - pipelineConfigWidth - padding; + const H2 = canvasHeight - padding; + + const pipelineRect = conf.refs.zoomRef.current.getBoundingClientRect(); + + const W1 = pipelineRect.width; + const H1 = pipelineRect.height; + + const nextZoom = Math.min(W2 / W1, H2 / H1); + + if (nextZoom === 1) { + return; + } + + conf.refs.zoomRef.current.style.transform = `scale(${nextZoom})`; + + // now move the pipeline to fit the screen + const x2 = left + W2 / 2; + const y2 = top + H2 / 2; + + const newPipelineRect /* because we did zoom */ = + conf.refs.zoomRef.current.getBoundingClientRect(); + + const x1 = newPipelineRect.left + newPipelineRect.width / 2 - padding / 2; + const y1 = newPipelineRect.top + newPipelineRect.height / 2 - padding / 2; + + const deltaX = x2 - x1; + const deltaY = y2 - y1; + + updatePos(deltaX, deltaY); + + conf?.onMove?.(getPipelineView()); + }, []); + + const getPos = useCallback(() => { + if (conf.refs.movingObjectsRef.current) { + const transform = conf.refs.movingObjectsRef.current + .computedStyleMap() + .get('transform') as CSSTransformValue; + + if (!(transform instanceof CSSTransformValue)) { + throw new Error( + 'Canvas moving mechanism seems to be changed and unsupported! Please report this bug.' + ); + } + + const { e, f } = transform.toMatrix().translate(); + + return [e, f] as const; + } + + return [0, 0] as const; + }, []); + + const updatePos = useCallback((x: number, y: number) => { + const currentPos = getPos(); + + const newPos = [x, y]; + + if (!conf.refs.movingObjectsRef.current) { + return; + } + + const startTransform = `translate(${currentPos[0]}px, ${currentPos[1]}px)`; + const endTransform = `translate(${newPos[0]}px, ${newPos[1]}px)`; + + conf.refs.movingObjectsRef.current.style.animation = 'none'; + conf.refs.movingObjectsRef.current.style.setProperty('--end-transform', endTransform); + conf.refs.movingObjectsRef.current.style.setProperty('--start-transform', startTransform); + conf.refs.movingObjectsRef.current.style.animation = ''; + }, []); + + const getPipelineView = useCallback(() => { + const [x = 0, y = 0] = getPos(); + return { + zoom: getCurrentZoom(), + position: [x, y] + } satisfies PipelineViewPref; + }, []); + + const registerCanvas = useCallback((canvasNode: HTMLDivElement | null) => { + if (!canvasNode) { + return; + } + + conf.onCanvas?.(canvasNode); + + const { pipelineViewPref } = conf; + + if (pipelineViewPref) { + if (typeof pipelineViewPref?.zoom === 'number') { + zoom(pipelineViewPref.zoom); + } + + if (pipelineViewPref?.position?.length === 2) { + updatePos(...pipelineViewPref.position); + } + } else { + fitToView(canvasNode); + } + + const startMovingObjects = (init: MouseEvent) => { + let prev = init; + return (e: MouseEvent) => { + if (!conf.refs.movingObjectsRef.current) { + return; + } + + const deltaX = e.clientX - prev.clientX; + const deltaY = e.clientY - prev.clientY; + + let [newRight, newTop] = getPos(); + + if (deltaX > 0) { + newRight += moveSpeed; + } else if (deltaX < 0) { + newRight -= moveSpeed; + } + + if (deltaY > 0) { + newTop += moveSpeed; + } else if (deltaY < 0) { + newTop -= moveSpeed; + } + + updatePos(newRight, newTop); + + prev = e; + }; + }; + + let registeredEventListener = false; + + let onWindowMouseMove: (e: MouseEvent) => void = () => {}; + + const onCanvasMouseDown = (e: MouseEvent) => { + if (registeredEventListener) { + onCanvasMouseUp(); + return; + } + registeredEventListener = true; + + if (conf.refs.zoomRef.current) { + // block any pointer events in pipeline + // this makes only window mousemove event happen + // other events like hover on node will conflict and causes glitches while moving + conf.refs.zoomRef.current.style.pointerEvents = 'none'; + conf.refs.zoomRef.current.style.cursor = 'cursor-move'; + } + + onWindowMouseMove = startMovingObjects(e); + + window.addEventListener('mousemove', onWindowMouseMove); + }; + + const onCanvasMouseUp = () => { + registeredEventListener = false; + conf?.onMove?.(getPipelineView()); + + if (conf.refs.zoomRef.current) { + conf.refs.zoomRef.current.style.pointerEvents = ''; + conf.refs.zoomRef.current.style.cursor = ''; + } + + window.removeEventListener('mousemove', onWindowMouseMove); + }; + + const onWheel = (e: WheelEvent) => { + if (!conf.refs.zoomRef.current) { + return; + } + + if (conf.refs.pipelinesConfigRef.current) { + const { top, height, left, width } = + conf.refs.pipelinesConfigRef.current.getBoundingClientRect(); + + const { x, y } = e; + + const overlapOnXAxis = x >= left && x <= left + width; + const overlapOnYAxis = y >= top && y <= top + height; + + if (overlapOnXAxis && overlapOnYAxis) { + return; + } + } + + if (e.deltaY > 0) { + zoomIn(); + } else if (e.deltaY < 0) { + zoomOut(); + } + + conf?.onMove?.(getPipelineView()); + }; + + canvasNode.addEventListener('mousedown', onCanvasMouseDown); + canvasNode.addEventListener('mouseup', onCanvasMouseUp); + canvasNode.addEventListener('wheel', onWheel); + + cleanupFunction.current = () => { + canvasNode.removeEventListener('mousedown', onCanvasMouseDown); + canvasNode.removeEventListener('mouseup', onCanvasMouseUp); + canvasNode.removeEventListener('wheel', onWheel); + }; + }, []); + + return { + registerCanvas, + fitToView, + zoomIn, + zoomOut, + getPipelineView + }; +}; diff --git a/ui/src/utils/use-local-storage.ts b/ui/src/utils/use-local-storage.ts index 3770334a1..e7580b7a4 100644 --- a/ui/src/utils/use-local-storage.ts +++ b/ui/src/utils/use-local-storage.ts @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const useLocalStorage = (key: string, initialValue?: any) => { - const [storedValue, setStoredValue] = useState(() => { + const [storedValue, _setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item ? JSON.parse(item) : initialValue; @@ -11,8 +11,15 @@ export const useLocalStorage = (key: string, initialValue?: any) => { } }); - useEffect(() => { + const setStoredValue: typeof _setStoredValue = (storedValue) => { + _setStoredValue(storedValue); + + if (!storedValue) { + window.localStorage.removeItem(key); + return; + } window.localStorage.setItem(key, JSON.stringify(storedValue)); - }, [storedValue]); + }; + return [storedValue, setStoredValue]; };