From c6773ca5b4711c5c5f3fb1a51a1f98983f8ea566 Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Fri, 25 Jul 2025 13:56:14 +0530 Subject: [PATCH 01/40] fix: update on clicks and common functions --- .../Details/TriggerView/EnvTriggerView.tsx | 2 + .../app/details/triggerView/Constants.ts | 1 + .../app/details/triggerView/TriggerView.tsx | 220 +++++------------- .../details/triggerView/TriggerView.utils.tsx | 35 ++- .../app/details/triggerView/cdMaterial.tsx | 4 +- .../app/details/triggerView/config.ts | 5 +- .../app/details/triggerView/types.ts | 8 +- .../details/triggerView/workflow/Workflow.tsx | 3 +- 8 files changed, 101 insertions(+), 177 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index 0712452b7d..30def362e1 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -1503,6 +1503,8 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT onClickCDMaterial, onClickRollbackMaterial, reloadTriggerView, + // TODO: Update below function + onClickApprovalNode: () => {} }} > {renderWorkflow()} diff --git a/src/components/app/details/triggerView/Constants.ts b/src/components/app/details/triggerView/Constants.ts index 0ae30a4c44..eb739d12e4 100644 --- a/src/components/app/details/triggerView/Constants.ts +++ b/src/components/app/details/triggerView/Constants.ts @@ -113,4 +113,5 @@ export const TRIGGER_VIEW_PARAMS = { APPROVAL_NODE: 'approval-node', CD_NODE: 'cd-node', NODE_TYPE: 'node-type', + ROLLBACK_NODE: 'rollback-node', } diff --git a/src/components/app/details/triggerView/TriggerView.tsx b/src/components/app/details/triggerView/TriggerView.tsx index 6bf8ae5bc2..014de063b3 100644 --- a/src/components/app/details/triggerView/TriggerView.tsx +++ b/src/components/app/details/triggerView/TriggerView.tsx @@ -15,6 +15,8 @@ */ import React, { Component } from 'react' +import { withRouter, Route, Switch } from 'react-router-dom' + import { ServerErrors, showError, @@ -24,15 +26,13 @@ import { VisibleModal, DeploymentNodeType, CommonNodeAttr, - ToastManager, - ToastVariantType, getEnvironmentListMinPublic, DocLink, DEFAULT_ENV, + handleAnalyticsEvent, WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' -import ReactGA from 'react-ga4' -import { withRouter, Route, Switch } from 'react-router-dom' + import { getWorkflowStatus } from '../../service' import { getCDPipelineURL, @@ -56,7 +56,7 @@ import { processWorkflowStatuses } from '../../../ApplicationGroup/AppGroup.util import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManager.service' import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' import { getExternalCIConfig } from '@Components/ciPipeline/Webhook/webhook.service' -import { shouldRenderWebhookAddImageModal } from './TriggerView.utils' +import { getSelectedNodeFromWorkflows, shouldRenderWebhookAddImageModal } from './TriggerView.utils' import { BuildImageModal } from './BuildImageModal' const ApprovalMaterialModal = importComponentFromFELibrary('ApprovalMaterialModal') @@ -78,10 +78,7 @@ class TriggerView extends Component { code: 0, view: ViewType.LOADING, workflows: [], - cdNodeId: 0, workflowId: 0, - nodeType: null, - materialType: '', isLoading: false, hostURLConfig: undefined, filteredCIPipelines: [], @@ -96,7 +93,6 @@ class TriggerView extends Component { selectedWebhookNodeId: null, isEnvListLoading: false, } - this.onClickCDMaterial = this.onClickCDMaterial.bind(this) this.abortController = new AbortController() this.abortCIBuild = new AbortController() } @@ -108,7 +104,7 @@ class TriggerView extends Component { componentDidMount() { this.getHostURLConfig() - this.getWorkflows(true) + this.getWorkflows() this.getEnvironments() } @@ -156,7 +152,7 @@ class TriggerView extends Component { }) } - getWorkflows = async (isFromOnMount?: boolean): Promise => { + getWorkflows = async (): Promise => { try { const result = await getTriggerWorkflows( this.props.match.params.appId, @@ -169,59 +165,6 @@ class TriggerView extends Component { const workflows = result.workflows || [] this.setState({ workflows, view: ViewType.FORM, filteredCIPipelines: _filteredCIPipelines }, () => { this.getWorkflowStatus() - if (isFromOnMount) { - if (ApprovalMaterialModal) { - if (this.props.location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { - const searchParams = new URLSearchParams(this.props.location.search) - const nodeId = searchParams.get(TRIGGER_VIEW_PARAMS.APPROVAL_NODE) - this.onClickCDMaterial(nodeId, DeploymentNodeType.CD, true) - } - } - - if (this.props.location.search.includes('rollback-node')) { - const searchParams = new URLSearchParams(this.props.location.search) - const nodeId = Number(searchParams.get('rollback-node')) - if (!isNaN(nodeId)) { - this.onClickRollbackMaterial(nodeId) - } else { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - this.props.history.push({ - search: '', - }) - } - } else if (this.props.location.search.includes('cd-node')) { - const searchParams = new URLSearchParams(this.props.location.search) - const nodeId = Number(searchParams.get('cd-node')) - const nodeType = searchParams.get('node-type') ?? DeploymentNodeType.CD - - if ( - nodeType !== DeploymentNodeType.CD && - nodeType !== DeploymentNodeType.PRECD && - nodeType !== DeploymentNodeType.POSTCD - ) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node type', - }) - this.props.history.push({ - search: '', - }) - } else if (!isNaN(nodeId)) { - this.onClickCDMaterial(nodeId, nodeType as DeploymentNodeType) - } else { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - this.props.history.push({ - search: '', - }) - } - } - } this.timerRef && clearInterval(this.timerRef) this.timerRef = setInterval(() => { this.getWorkflowStatus() @@ -241,7 +184,7 @@ class TriggerView extends Component { .then((response) => { this.setState({ hostURLConfig: response.result }) }) - .catch((error) => {}) + .catch(() => {}) } componentDidUpdate(prevProps) { @@ -281,85 +224,33 @@ class TriggerView extends Component { this.props.history.push(`${this.props.match.url}${URLS.BUILD}/${ciNodeId}`) } - // TODO: Can also combine rollback and onClickCDMaterial - // Till then make sure that they are consistent - onClickCDMaterial(cdNodeId, nodeType: DeploymentNodeType, isApprovalNode: boolean = false) { - ReactGA.event(isApprovalNode ? TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked : TRIGGER_VIEW_GA_EVENTS.ImageClicked) - - const workflows = [...this.state.workflows].map((workflow) => { - const nodes = workflow.nodes.map((node) => { - if (cdNodeId == node.id && node.type === nodeType) { - if (node.type === 'CD') { - // TODO: Potential bug since removed, data was from api which is now in cdmaterials data.userApprovalConfig ?? workflow.approvalConfiguredIdsMap[cdNodeId] - node.approvalConfigData = workflow.approvalConfiguredIdsMap[cdNodeId] - } - } - return node - }) + onClickApprovalNode = (cdNodeId: number) => { + handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked) - workflow.nodes = nodes - return workflow - }) - this.setState({ - workflows, - materialType: 'inputMaterialList', - cdNodeId, - nodeType, - }) + const newParams = new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], + ]) + this.props.history.push({ search: newParams.toString() }) + } - const newParams = new URLSearchParams(this.props.location.search) - newParams.set( - isApprovalNode ? TRIGGER_VIEW_PARAMS.APPROVAL_NODE : TRIGGER_VIEW_PARAMS.CD_NODE, - cdNodeId.toString(), - ) - if (!isApprovalNode) { - newParams.set('node-type', nodeType) - } else { - const currentApprovalState = newParams.get(TRIGGER_VIEW_PARAMS.APPROVAL_STATE) - const approvalState = - currentApprovalState === TRIGGER_VIEW_PARAMS.PENDING - ? TRIGGER_VIEW_PARAMS.PENDING - : TRIGGER_VIEW_PARAMS.APPROVAL - - newParams.set(TRIGGER_VIEW_PARAMS.APPROVAL_STATE, approvalState) - newParams.delete(TRIGGER_VIEW_PARAMS.CD_NODE) - newParams.delete(TRIGGER_VIEW_PARAMS.NODE_TYPE) - } + onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { + handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ImageClicked) + + const newParams = new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], + ]) this.props.history.push({ search: newParams.toString(), }) } // Assuming that rollback has only CD as nodeType - onClickRollbackMaterial = (cdNodeId: number, offset?: number, size?: number) => { - if (!offset && !size) { - ReactGA.event(TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - } + onClickRollbackMaterial = (cdNodeId: number) => { + handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - const workflows = [...this.state.workflows].map((workflow) => { - const nodes = workflow.nodes.map((node) => { - if (node.type === 'CD' && +node.id == cdNodeId) { - node.approvalConfigData = workflow.approvalConfiguredIdsMap[cdNodeId] - } - return node - }) - workflow.nodes = nodes - return workflow - }) - this.setState( - { - workflows, - materialType: 'rollbackMaterialList', - cdNodeId, - nodeType: 'CD', - }, - () => { - this.getWorkflowStatus() - }, - ) - - const newParams = new URLSearchParams(this.props.location.search) - newParams.set('rollback-node', cdNodeId.toString()) + const newParams = new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]) this.props.history.push({ search: newParams.toString(), }) @@ -393,25 +284,7 @@ class TriggerView extends Component { this.setState({ selectedWebhookNodeId: null }) } - getCDNode = (): CommonNodeAttr => { - let node: CommonNodeAttr - if (this.state.cdNodeId) { - for (const _workflow of this.state.workflows) { - node = _workflow.nodes.find((el) => { - // NOTE: cdNodeId is not a number here - return +el.id == this.state.cdNodeId && el.type == this.state.nodeType - }) - - if (node) { - break - } - } - } - - return node ?? ({} as CommonNodeAttr) - } - - renderCDMaterialContent = (cdNode: CommonNodeAttr) => { + renderCDMaterialContent = (cdNode: CommonNodeAttr, materialType: string) => { const selectedWorkflow = this.state.workflows.find((wf) => wf.nodes.some((node) => node.id === cdNode.id)) const selectedCINode = selectedWorkflow?.nodes.find((node) => node.type === 'CI' || node.type === 'WEBHOOK') const doesWorkflowContainsWebhook = selectedCINode?.type === 'WEBHOOK' @@ -426,10 +299,10 @@ class TriggerView extends Component { return ( { } renderCDMaterial() { - if (this.props.location.search.includes('cd-node') || this.props.location.search.includes('rollback-node')) { - const cdNode: CommonNodeAttr = this.getCDNode() - if (!cdNode.id) { + if ( + this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) || + this.props.location.search.includes(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) + ) { + const cdNode: CommonNodeAttr = getSelectedNodeFromWorkflows( + this.state.workflows, + this.props.location.search, + ) + if (!cdNode) { return null } - const material = cdNode[this.state.materialType] || [] + const materialType = this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) + ? 'inputMaterialList' + : 'rollbackMaterialList' + const material = cdNode[materialType] || [] return ( @@ -476,7 +358,7 @@ class TriggerView extends Component { ) : ( - this.renderCDMaterialContent(cdNode) + this.renderCDMaterialContent(cdNode, materialType) )} @@ -488,16 +370,21 @@ class TriggerView extends Component { renderApprovalMaterial() { if (ApprovalMaterialModal && this.props.location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { - const node: CommonNodeAttr = this.getCDNode() + const node = getSelectedNodeFromWorkflows(this.state.workflows, this.props.location.search) + + if (!node) { + return null + } + return ( { value={{ onClickCDMaterial: this.onClickCDMaterial, onClickRollbackMaterial: this.onClickRollbackMaterial, + onClickApprovalNode: this.onClickApprovalNode, reloadTriggerView: this.reloadTriggerView, }} > diff --git a/src/components/app/details/triggerView/TriggerView.utils.tsx b/src/components/app/details/triggerView/TriggerView.utils.tsx index 5621dd9d06..f0113e17f4 100644 --- a/src/components/app/details/triggerView/TriggerView.utils.tsx +++ b/src/components/app/details/triggerView/TriggerView.utils.tsx @@ -16,7 +16,14 @@ import { useLocation } from 'react-router-dom' -import { DeploymentHistoryDetail, DeploymentWithConfigType } from '@devtron-labs/devtron-fe-common-lib' +import { + CommonNodeAttr, + DeploymentHistoryDetail, + DeploymentNodeType, + DeploymentWithConfigType, + showError, + WorkflowType, +} from '@devtron-labs/devtron-fe-common-lib' import { URLS } from '@Config/routes' @@ -190,3 +197,29 @@ export const shouldRenderWebhookAddImageModal = (location: ReturnType { + const searchParams = new URLSearchParams(search) + const cdNodeId = + searchParams.get(TRIGGER_VIEW_PARAMS.CD_NODE) || + searchParams.get(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) || + searchParams.get(TRIGGER_VIEW_PARAMS.APPROVAL_NODE) + + if (!cdNodeId) { + showError('Could not find node') + return {} as CommonNodeAttr + } + const nodeType = searchParams.get(TRIGGER_VIEW_PARAMS.NODE_TYPE) ?? DeploymentNodeType.CD + + // Use flatMap to flatten all nodes, then find the matching node + const allNodes = workflows.flatMap((workflow) => workflow.nodes) + + const foundNode = allNodes.find((n) => cdNodeId === n.id && n.type === nodeType) + + if (foundNode) { + return foundNode + } + + showError('Could not find node') + return {} as CommonNodeAttr +} diff --git a/src/components/app/details/triggerView/cdMaterial.tsx b/src/components/app/details/triggerView/cdMaterial.tsx index 02026a54b8..8931be4245 100644 --- a/src/components/app/details/triggerView/cdMaterial.tsx +++ b/src/components/app/details/triggerView/cdMaterial.tsx @@ -310,7 +310,7 @@ const CDMaterial = ({ const materialsResult: CDMaterialResponseType = responseList?.[0] const deploymentWindowMetadata = responseList?.[1] ?? {} - const { onClickCDMaterial } = useContext(TriggerViewContext) + const { onClickApprovalNode } = useContext(TriggerViewContext) const [noMoreImages, setNoMoreImages] = useState(false) const [tagsEditable, setTagsEditable] = useState(false) const [appReleaseTagNames, setAppReleaseTagNames] = useState([]) @@ -662,7 +662,7 @@ const CDMaterial = ({ }) } else { closeCDModal(e) - onClickCDMaterial(pipelineId, DeploymentNodeType.CD, true) + onClickApprovalNode(pipelineId) } } diff --git a/src/components/app/details/triggerView/config.ts b/src/components/app/details/triggerView/config.ts index ec6ac5ffbe..12cbcbb29a 100644 --- a/src/components/app/details/triggerView/config.ts +++ b/src/components/app/details/triggerView/config.ts @@ -22,9 +22,10 @@ import { importComponentFromFELibrary } from '@Components/common' const WebhookAddImageButton = importComponentFromFELibrary('WebhookAddImageButton', null, 'function') export const TriggerViewContext = createContext({ - onClickCDMaterial: (cdNodeId, nodeType: DeploymentNodeType, isApprovalNode?: boolean, imageTag?: string) => {}, - onClickRollbackMaterial: (cdNodeId: number, offset?: number, size?: number) => {}, + onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => {}, + onClickRollbackMaterial: (cdNodeId: number) => {}, reloadTriggerView: () => {}, + onClickApprovalNode: (cdNodeId: number) => {}, }) export enum WorkflowDimensionType { diff --git a/src/components/app/details/triggerView/types.ts b/src/components/app/details/triggerView/types.ts index 4e10970bd6..87d9306c73 100644 --- a/src/components/app/details/triggerView/types.ts +++ b/src/components/app/details/triggerView/types.ts @@ -336,8 +336,9 @@ export interface WorkflowProps } export interface TriggerViewContextType { - onClickCDMaterial: (cdNodeId, nodeType: DeploymentNodeType, isApprovalNode?: boolean) => void - onClickRollbackMaterial: (cdNodeId: number, offset?: number, size?: number) => void + onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => void + onClickApprovalNode: (cdNodeId: number) => void + onClickRollbackMaterial: (cdNodeId: number) => void reloadTriggerView: () => void } @@ -381,9 +382,6 @@ export interface TriggerViewState { code: number view: string workflows: WorkflowType[] - nodeType: null | 'CI' | 'CD' | 'PRECD' | 'POSTCD' | 'APPROVAL' - cdNodeId: number - materialType: '' | 'inputMaterialList' | 'rollbackMaterialList' isLoading: boolean hostURLConfig: HostURLConfig workflowId: number diff --git a/src/components/app/details/triggerView/workflow/Workflow.tsx b/src/components/app/details/triggerView/workflow/Workflow.tsx index 3bd348c3b8..5138c3f0b6 100644 --- a/src/components/app/details/triggerView/workflow/Workflow.tsx +++ b/src/components/app/details/triggerView/workflow/Workflow.tsx @@ -35,6 +35,7 @@ import { WorkflowProps, TriggerViewContextType } from '../types' import { WebhookNode } from '../../../../workflowEditor/nodes/WebhookNode' import { GIT_BRANCH_NOT_CONFIGURED } from '../../../../../config' import { TriggerViewContext } from '../config' +import { TRIGGER_VIEW_PARAMS } from '../Constants' const ApprovalNodeEdge = importComponentFromFELibrary('ApprovalNodeEdge') const LinkedCDNode = importComponentFromFELibrary('LinkedCDNode') @@ -322,7 +323,7 @@ export class Workflow extends Component { onClickNodeEdge = (nodeId: number) => { this.context.onClickCDMaterial(nodeId, DeploymentNodeType.CD, true) this.props.history.push({ - search: `approval-node=${nodeId}`, + search: `${TRIGGER_VIEW_PARAMS.APPROVAL_NODE}=${nodeId}`, }) } From e4c171bd2d00bef6a92f04948b50899f80a33cc7 Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Fri, 25 Jul 2025 16:26:31 +0530 Subject: [PATCH 02/40] feat: extract on click rollback cd and approval common logic --- .../ApplicationGroup/AppGroup.types.ts | 5 - .../Details/TriggerView/EnvTriggerView.tsx | 255 ++++-------------- .../Details/TriggerView/utils.ts | 59 +++- .../app/details/triggerView/TriggerView.tsx | 14 +- .../details/triggerView/TriggerView.utils.tsx | 16 +- 5 files changed, 122 insertions(+), 227 deletions(-) diff --git a/src/components/ApplicationGroup/AppGroup.types.ts b/src/components/ApplicationGroup/AppGroup.types.ts index e9b4b2b0b2..74a566b626 100644 --- a/src/components/ApplicationGroup/AppGroup.types.ts +++ b/src/components/ApplicationGroup/AppGroup.types.ts @@ -216,11 +216,6 @@ export interface TriggerModalRowType { isVirtualEnv?: boolean } -export interface WorkflowNodeSelectionType { - id: number - name: string - type: WorkflowNodeType -} export interface WorkflowAppSelectionType { id: number name: string diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index 30def362e1..5820ef19c5 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -35,6 +35,7 @@ import { DeploymentStrategyTypeWithDefault, ErrorScreenManager, getStageTitle, + handleAnalyticsEvent, PipelineIdsVsDeploymentStrategyMap, PopupMenu, Progressing, @@ -82,7 +83,6 @@ import { ResponseRowType, TriggerVirtualEnvResponseRowType, WorkflowAppSelectionType, - WorkflowNodeSelectionType, } from '../../AppGroup.types' import { processWorkflowStatuses } from '../../AppGroup.utils' import { @@ -98,7 +98,7 @@ import { import BulkCDTrigger from './BulkCDTrigger' import BulkSourceChange from './BulkSourceChange' import { RenderCDMaterialContentProps } from './types' -import { getSelectedCDNode } from './utils' +import { getSelectedCDNode, getSelectedNodeAndAppId, getSelectedNodeAndMeta } from './utils' import './EnvTriggerView.scss' @@ -126,8 +126,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const match = useRouteMatch() const { url } = useRouteMatch() - // ref to make sure that on initial mount after we fetch workflows we handle modal based on url - const handledLocation = useRef(false) const abortControllerRef = useRef(new AbortController()) const [pageViewType, setPageViewType] = useState(ViewType.LOADING) @@ -143,7 +141,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const [selectedAppList, setSelectedAppList] = useState([]) const [workflows, setWorkflows] = useState([]) const [filteredWorkflows, setFilteredWorkflows] = useState([]) - const [selectedCDNode, setSelectedCDNode] = useState(null) const [filteredCIPipelines, setFilteredCIPipelines] = useState(null) const [bulkTriggerType, setBulkTriggerType] = useState(null) const [materialType, setMaterialType] = useState(MATERIAL_TYPE.inputMaterialList) @@ -160,13 +157,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const enableRoutePrompt = isBranchChangeLoading || isBulkTriggerLoading usePrompt({ shouldPrompt: enableRoutePrompt }) - useEffect( - () => () => { - handledLocation.current = false - }, - [], - ) - useEffect(() => { if (envId) { setPageViewType(ViewType.LOADING) @@ -184,72 +174,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT getWorkflowsData() } - useEffect(() => { - if (!handledLocation.current && filteredWorkflows?.length) { - handledLocation.current = true - // Would have been better if filteredWorkflows had default value to null since we are using it as a flag - // URL Encoding for Bulk is not planned as of now - setShowBulkCDModal(false) - if (location.search.includes('approval-node')) { - const searchParams = new URLSearchParams(location.search) - const nodeId = Number(searchParams.get('approval-node')) - if (!isNaN(nodeId)) { - onClickCDMaterial(nodeId, DeploymentNodeType.CD, true) - } else { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - history.push({ - search: '', - }) - } - } else if (location.search.includes('rollback-node')) { - const searchParams = new URLSearchParams(location.search) - const nodeId = Number(searchParams.get('rollback-node')) - if (!isNaN(nodeId)) { - onClickRollbackMaterial(nodeId) - } else { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - history.push({ - search: '', - }) - } - } else if (location.search.includes('cd-node')) { - const searchParams = new URLSearchParams(location.search) - const nodeId = Number(searchParams.get('cd-node')) - const nodeType = searchParams.get('node-type') ?? DeploymentNodeType.CD - - if ( - nodeType !== DeploymentNodeType.CD && - nodeType !== DeploymentNodeType.PRECD && - nodeType !== DeploymentNodeType.POSTCD - ) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node type', - }) - history.push({ - search: '', - }) - } else if (!isNaN(nodeId)) { - onClickCDMaterial(nodeId, nodeType as DeploymentNodeType) - } else { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - history.push({ - search: '', - }) - } - } - } - }, [filteredWorkflows]) - const preserveSelection = (_workflows: WorkflowType[]) => { if (!workflows || !_workflows) { return @@ -450,104 +374,33 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT ) } - const onClickCDMaterial = (cdNodeId, nodeType: DeploymentNodeType, isApprovalNode: boolean = false): void => { - ReactGA.event( - isApprovalNode ? ENV_TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked : ENV_TRIGGER_VIEW_GA_EVENTS.ImageClicked, - ) - - let _workflowId - let _appID - let _selectedNode - - // FIXME: This needs to be replicated in rollback, env group since we need cipipelineid as 0 in external case - const _workflows = [...filteredWorkflows].map((workflow) => { - const nodes = workflow.nodes.map((node) => { - if (cdNodeId == node.id && node.type === nodeType) { - // TODO: Ig not using this, can remove it - if (node.type === WorkflowNodeType.CD) { - node.approvalConfigData = workflow.approvalConfiguredIdsMap[cdNodeId] - } - _selectedNode = node - _workflowId = workflow.id - _appID = workflow.appId - } - return node - }) - workflow.nodes = nodes - return workflow - }) + const onClickApprovalNode = (cdNodeId: number) => { + handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked) - if (!_selectedNode) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - history.push({ - search: '', - }) - return - } + const newParams = new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], + ]) + history.push({ search: newParams.toString() }) + } - setFilteredWorkflows(_workflows) - setSelectedCDNode({ id: +cdNodeId, name: _selectedNode.name, type: _selectedNode.type }) - setMaterialType(MATERIAL_TYPE.inputMaterialList) + const onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { + handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.ImageClicked) - const newParams = new URLSearchParams(location.search) - newParams.set(isApprovalNode ? 'approval-node' : 'cd-node', cdNodeId.toString()) - if (!isApprovalNode) { - newParams.set('node-type', nodeType) - } else { - const currentApprovalState = newParams.get(TRIGGER_VIEW_PARAMS.APPROVAL_STATE) - // If the current state is pending, then we should change the state to pending - const approvalState = - currentApprovalState === TRIGGER_VIEW_PARAMS.PENDING - ? TRIGGER_VIEW_PARAMS.PENDING - : TRIGGER_VIEW_PARAMS.APPROVAL - - newParams.set(TRIGGER_VIEW_PARAMS.APPROVAL_STATE, approvalState) - newParams.delete(TRIGGER_VIEW_PARAMS.CD_NODE) - newParams.delete(TRIGGER_VIEW_PARAMS.NODE_TYPE) - } + const newParams = new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], + ]) history.push({ search: newParams.toString(), }) } + // Assuming that rollback has only CD as nodeType const onClickRollbackMaterial = (cdNodeId: number) => { - ReactGA.event(ENV_TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - - let _selectedNode - - const _workflows = [...filteredWorkflows].map((workflow) => { - const nodes = workflow.nodes.map((node) => { - if (node.type === 'CD' && +node.id == cdNodeId) { - node.approvalConfigData = workflow.approvalConfiguredIdsMap[cdNodeId] - _selectedNode = node - } - return node - }) - workflow.nodes = nodes - return workflow - }) - - if (!_selectedNode) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Invalid node id', - }) - history.push({ - search: '', - }) - return - } - - setFilteredWorkflows(_workflows) - setSelectedCDNode({ id: +cdNodeId, name: _selectedNode.name, type: _selectedNode.type }) - setMaterialType(MATERIAL_TYPE.rollbackMaterialList) - getWorkflowStatusData(_workflows) + handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - const newParams = new URLSearchParams(location.search) - newParams.set('rollback-node', cdNodeId.toString()) + const newParams = new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]) history.push({ search: newParams.toString(), }) @@ -1186,8 +1039,8 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT materialType={materialType} appId={appId} envId={node?.environmentId} - pipelineId={selectedCDNode?.id} - stageType={DeploymentNodeType[selectedCDNode?.type]} + pipelineId={+node.id} + stageType={node.type as DeploymentNodeType} envName={node?.environmentName} closeCDModal={closeCDModal} triggerType={node?.triggerType} @@ -1207,32 +1060,22 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT } const renderCDMaterial = (): JSX.Element | null => { - if (!selectedCDNode?.id) { - return null - } + if ( + location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) || + location.search.includes(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) + ) { - if (location.search.includes('cd-node') || location.search.includes('rollback-node')) { - let node: CommonNodeAttr - let _appID - let selectedAppName: string - let workflowId: string - let selectedCINode: CommonNodeAttr - - if (selectedCDNode?.id) { - for (const _wf of filteredWorkflows) { - node = _wf.nodes.find((el) => +el.id == selectedCDNode.id && el.type == selectedCDNode.type) - if (node) { - selectedCINode = _wf.nodes.find( - (node) => node.type === WorkflowNodeType.CI || node.type === WorkflowNodeType.WEBHOOK, - ) - workflowId = _wf.id - _appID = _wf.appId - selectedAppName = _wf.name - break - } - } + const { node, appId, workflowId, appName, selectedCINode } = getSelectedNodeAndMeta(filteredWorkflows, location.search) + + if (!node?.id) { + return null } - const material = node?.[materialType] || [] + + const cdMaterialType = location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) + ? MATERIAL_TYPE.inputMaterialList + : MATERIAL_TYPE.rollbackMaterialList + + const material = node[cdMaterialType] || [] return ( @@ -1256,8 +1099,8 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT ) : ( renderCDMaterialContent({ node, - appId: _appID, - selectedAppName, + appId, + selectedAppName: appName, workflowId, doesWorkflowContainsWebhook: selectedCINode?.type === WorkflowNodeType.WEBHOOK, ciNodeId: selectedCINode?.id, @@ -1273,16 +1116,11 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const renderApprovalMaterial = () => { if (ApprovalMaterialModal && location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { - let node: CommonNodeAttr - let _appID - if (selectedCDNode?.id) { - for (const _wf of filteredWorkflows) { - node = _wf.nodes.find((el) => +el.id == selectedCDNode.id && el.type == selectedCDNode.type) - if (node) { - _appID = _wf.appId - break - } - } + const { node, appId } = getSelectedNodeAndAppId(filteredWorkflows, location.search) + + if (!node?.id || !appId) { + showError('Invalid node id') + return null } return ( @@ -1290,10 +1128,10 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT isLoading={isCDLoading} node={node ?? ({} as CommonNodeAttr)} materialType={materialType} - stageType={DeploymentNodeType[selectedCDNode?.type]} + stageType={DeploymentNodeType.CD} closeApprovalModal={closeApprovalModal} - appId={_appID} - pipelineId={selectedCDNode?.id} + appId={appId} + pipelineId={node.id} getModuleInfo={getModuleInfo} ciPipelineId={node?.connectingCiPipelineId} history={history} @@ -1503,8 +1341,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT onClickCDMaterial, onClickRollbackMaterial, reloadTriggerView, - // TODO: Update below function - onClickApprovalNode: () => {} + onClickApprovalNode, }} > {renderWorkflow()} diff --git a/src/components/ApplicationGroup/Details/TriggerView/utils.ts b/src/components/ApplicationGroup/Details/TriggerView/utils.ts index 79efb052b6..d88cdf7789 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/utils.ts +++ b/src/components/ApplicationGroup/Details/TriggerView/utils.ts @@ -14,9 +14,10 @@ * limitations under the License. */ -import { CommonNodeAttr, DeploymentNodeType } from '@devtron-labs/devtron-fe-common-lib' +import { CommonNodeAttr, DeploymentNodeType, WorkflowNodeType, WorkflowType } from '@devtron-labs/devtron-fe-common-lib' import { getIsMaterialApproved } from '@Components/app/details/triggerView/cdMaterials.utils' +import { getNodeIdAndTypeFromSearch } from '@Components/app/details/triggerView/TriggerView.utils' import { BulkCDDetailType } from '../../AppGroup.types' @@ -62,3 +63,59 @@ export const getSelectedAppListForBulkStrategy = (appList: BulkCDDetailType[], f appList .map((app) => ({ pipelineId: +app.cdPipelineId, appName: app.name })) .filter(({ pipelineId }) => feasiblePipelineIds.has(pipelineId)) + +export const getSelectedNodeAndMeta = ( + workflows: WorkflowType[], + search: string, +): { node: CommonNodeAttr; workflowId: string; appId: number; appName: string; selectedCINode: CommonNodeAttr } => { + const { cdNodeId, nodeType } = getNodeIdAndTypeFromSearch(search) + + const result = workflows.reduce( + (acc, workflow) => { + if (acc.node) return acc + const foundNode = workflow.nodes.find((node) => cdNodeId === node.id && node.type === nodeType) + + if (foundNode) { + const selectedCINode = workflow.nodes.find( + (node) => node.type === WorkflowNodeType.CI || node.type === WorkflowNodeType.WEBHOOK, + ) + return { + node: foundNode, + workflowId: workflow.id, + appId: workflow.appId, + appName: workflow.name, + selectedCINode, + } + } + return acc + }, + { node: undefined, workflowId: undefined, appId: undefined, appName: undefined, selectedCINode: undefined }, + ) + + return ( + result || { + node: undefined, + workflowId: undefined, + appId: undefined, + appName: undefined, + selectedCINode: undefined, + } + ) +} + +export const getSelectedNodeAndAppId = ( + workflows: WorkflowType[], + search: string, +): { node: CommonNodeAttr; appId: number } => { + const { cdNodeId, nodeType } = getNodeIdAndTypeFromSearch(search) + + const result = workflows.reduce( + (acc, workflow) => { + if (acc.node) return acc + const node = workflow.nodes.find((n) => n.id === cdNodeId && n.type === nodeType) + return node ? { node, appId: workflow.appId } : acc + }, + { node: undefined, appId: undefined }, + ) + return result +} diff --git a/src/components/app/details/triggerView/TriggerView.tsx b/src/components/app/details/triggerView/TriggerView.tsx index 014de063b3..73c746537d 100644 --- a/src/components/app/details/triggerView/TriggerView.tsx +++ b/src/components/app/details/triggerView/TriggerView.tsx @@ -43,7 +43,7 @@ import { } from '../../../common' import { getTriggerWorkflows } from './workflow.service' import { Workflow } from './workflow/Workflow' -import { TriggerViewProps, TriggerViewState } from './types' +import { MATERIAL_TYPE, TriggerViewProps, TriggerViewState } from './types' import CDMaterial from './cdMaterial' import { URLS, ViewType } from '../../../../config' import { AppNotConfigured } from '../appDetails/AppDetails' @@ -246,7 +246,6 @@ class TriggerView extends Component { }) } - // Assuming that rollback has only CD as nodeType onClickRollbackMaterial = (cdNodeId: number) => { handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.RollbackClicked) @@ -330,12 +329,13 @@ class TriggerView extends Component { this.state.workflows, this.props.location.search, ) - if (!cdNode) { + if (!cdNode.id) { return null } const materialType = this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) - ? 'inputMaterialList' - : 'rollbackMaterialList' + ? MATERIAL_TYPE.inputMaterialList + : MATERIAL_TYPE.rollbackMaterialList + const material = cdNode[materialType] || [] return ( @@ -372,7 +372,7 @@ class TriggerView extends Component { if (ApprovalMaterialModal && this.props.location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { const node = getSelectedNodeFromWorkflows(this.state.workflows, this.props.location.search) - if (!node) { + if (!node.id) { return null } @@ -380,7 +380,7 @@ class TriggerView extends Component { { +export const getNodeIdAndTypeFromSearch = (search: string) => { const searchParams = new URLSearchParams(search) const cdNodeId = searchParams.get(TRIGGER_VIEW_PARAMS.CD_NODE) || searchParams.get(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) || searchParams.get(TRIGGER_VIEW_PARAMS.APPROVAL_NODE) + const nodeType = searchParams.get(TRIGGER_VIEW_PARAMS.NODE_TYPE) ?? DeploymentNodeType.CD + + return { cdNodeId, nodeType } +} + +export const getSelectedNodeFromWorkflows = (workflows: WorkflowType[], search: string): CommonNodeAttr => { + const { cdNodeId, nodeType } = getNodeIdAndTypeFromSearch(search) + if (!cdNodeId) { - showError('Could not find node') + showError('Invalid node id') return {} as CommonNodeAttr } - const nodeType = searchParams.get(TRIGGER_VIEW_PARAMS.NODE_TYPE) ?? DeploymentNodeType.CD // Use flatMap to flatten all nodes, then find the matching node const allNodes = workflows.flatMap((workflow) => workflow.nodes) - const foundNode = allNodes.find((n) => cdNodeId === n.id && n.type === nodeType) if (foundNode) { return foundNode } - showError('Could not find node') + showError('Invalid node id') return {} as CommonNodeAttr } From 32d520b12b931c7d949db83bb1df28b1f804a144 Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Fri, 25 Jul 2025 17:09:39 +0530 Subject: [PATCH 03/40] fix: use approval on click --- src/components/app/details/triggerView/workflow/Workflow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/app/details/triggerView/workflow/Workflow.tsx b/src/components/app/details/triggerView/workflow/Workflow.tsx index 5138c3f0b6..d212ea731e 100644 --- a/src/components/app/details/triggerView/workflow/Workflow.tsx +++ b/src/components/app/details/triggerView/workflow/Workflow.tsx @@ -321,7 +321,7 @@ export class Workflow extends Component { } onClickNodeEdge = (nodeId: number) => { - this.context.onClickCDMaterial(nodeId, DeploymentNodeType.CD, true) + this.context.onClickApprovalModal(nodeId) this.props.history.push({ search: `${TRIGGER_VIEW_PARAMS.APPROVAL_NODE}=${nodeId}`, }) From 15d0336c1bf1a2cf2bbef9a9d2edbdd46814b8b1 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 28 Jul 2025 15:04:55 +0530 Subject: [PATCH 04/40] feat: add base structure for DeployImageModal --- .../DeployImageModal/DeployImageHeader.tsx | 42 ++ .../DeployImageModal/DeployImageModal.tsx | 681 ++++++++++++++++++ .../DeployImageModal/MaterialListSkeleton.tsx | 12 + .../DeployImageModal/RuntimeParamsSidebar.tsx | 40 + .../triggerView/DeployImageModal/service.ts | 60 ++ .../triggerView/DeployImageModal/types.ts | 69 ++ .../triggerView/DeployImageModal/utils.tsx | 206 ++++++ .../details/triggerView/cdMaterials.utils.ts | 12 +- .../app/details/triggerView/types.ts | 2 +- 9 files changed, 1116 insertions(+), 8 deletions(-) create mode 100644 src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/RuntimeParamsSidebar.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/service.ts create mode 100644 src/components/app/details/triggerView/DeployImageModal/types.ts create mode 100644 src/components/app/details/triggerView/DeployImageModal/utils.tsx diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx new file mode 100644 index 0000000000..cce593afd5 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx @@ -0,0 +1,42 @@ +import { + Button, + ButtonStyleType, + ButtonVariantType, + ComponentSizeType, + Icon, +} from '@devtron-labs/devtron-fe-common-lib' + +import { DeployImageHeaderProps } from './types' +import { getCDModalHeaderText } from './utils' + +const DeployImageHeader = ({ + envName, + handleClose, + stageType, + isRollbackTrigger, + isVirtualEnvironment, +}: DeployImageHeaderProps) => ( +
+

+ {getCDModalHeaderText({ + isRollbackTrigger, + stageType, + envName, + isVirtualEnvironment, + })} +

+ +
+) + +export default DeployImageHeader diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx new file mode 100644 index 0000000000..49c6aec317 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -0,0 +1,681 @@ +import { SyntheticEvent, useMemo, useState } from 'react' +import { Prompt, useHistory, useLocation } from 'react-router-dom' + +import { + ACTION_STATE, + AnimatedDeployButton, + API_STATUS_CODES, + ArtifactInfo, + Button, + ButtonStyleType, + ButtonVariantType, + CDMaterialSidebarType, + CommonNodeAttr, + ConditionalWrap, + DEFAULT_ROUTE_PROMPT_MESSAGE, + DEPLOYMENT_CONFIG_DIFF_SORT_KEY, + DeploymentAppTypes, + DeploymentNodeType, + DeploymentStrategyType, + DeploymentWithConfigType, + Drawer, + EnvResourceType, + ErrorScreenManager, + FilterStates, + getIsApprovalPolicyConfigured, + handleAnalyticsEvent, + Icon, + MODAL_TYPE, + PipelineDeploymentStrategy, + ServerErrors, + SortingOrder, + stopPropagation, + ToastManager, + ToastVariantType, + Tooltip, + triggerCDNode, + useAsync, + useDownload, + usePrompt, + useSearchString, +} from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' +import { URL_PARAM_MODE_TYPE } from '@Components/common/helpers/types' +import { URLS } from '@Config/routes' + +import { getCanDeployWithoutApproval, getCanImageApproverDeploy, getWfrId } from '../cdMaterials.utils' +import { CDButtonLabelMap } from '../config' +import { TRIGGER_VIEW_GA_EVENTS } from '../Constants' +import { PipelineConfigDiff, usePipelineDeploymentConfig } from '../PipelineConfigDiff' +import { PipelineConfigDiffStatusTile } from '../PipelineConfigDiff/PipelineConfigDiffStatusTile' +import { MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' +import DeployImageHeader from './DeployImageHeader' +import MaterialListSkeleton from './MaterialListSkeleton' +import RuntimeParamsSidebar from './RuntimeParamsSidebar' +import { getMaterialResponseList } from './service' +import { DeployImageModalProps, RuntimeParamsSidebarProps } from './types' +import { + getCDArtifactId, + getCDModalHeaderText, + getConfigToDeployValue, + getDeployButtonIcon, + getInitialSelectedConfigToDeploy, + getIsImageApprover, + getTriggerArtifactInfoProps, + handleTriggerErrorMessageForHelmManifestPush, + showErrorIfNotAborted, +} from './utils' + +const ApprovalInfoTippy = importComponentFromFELibrary('ApprovalInfoTippy') +const getDeploymentStrategies: (pipelineIds: number[]) => Promise = + importComponentFromFELibrary('getDeploymentStrategies', null, 'function') +const AllowedWithWarningTippy = importComponentFromFELibrary('AllowedWithWarningTippy') +const SelectDeploymentStrategy = importComponentFromFELibrary('SelectDeploymentStrategy', null, 'function') +const DeploymentWindowConfirmationDialog = importComponentFromFELibrary('DeploymentWindowConfirmationDialog') +const validateRuntimeParameters = importComponentFromFELibrary( + 'validateRuntimeParameters', + () => ({ isValid: true, cellError: {} }), + 'function', +) +const getRuntimeParamsPayload = importComponentFromFELibrary('getRuntimeParamsPayload', null, 'function') +const downloadManifestForVirtualEnvironment = importComponentFromFELibrary( + 'downloadManifestForVirtualEnvironment', + null, + 'function', +) + +const DeployImageModal = ({ + appId, + envId, + appName, + stageType, + pipelineId, + materialType, + handleClose, + handleSuccess, + deploymentAppType, + isVirtualEnvironment, + envName, + showPluginWarningBeforeTrigger: _showPluginWarningBeforeTrigger = false, + consequence, + configurePluginURL, +}: DeployImageModalProps) => { + const history = useHistory() + const { pathname } = useLocation() + const { searchParams } = useSearchString() + const { handleDownload } = useDownload() + const [isInitialDataLoading, initialDataResponse, initialDataError, reloadInitialData] = useAsync(() => + getMaterialResponseList({ + stageType, + pipelineId, + appId, + envId, + materialType, + initialSearch: searchParams.search || '', + }), + ) + + const [pipelineStrategiesLoading, pipelineStrategies, pipelineStrategiesError, reloadStrategies] = useAsync( + () => getDeploymentStrategies([pipelineId]), + [pipelineId], + !!getDeploymentStrategies && !!pipelineId, + ) + + const [currentSidebarTab, setCurrentSidebarTab] = useState(CDMaterialSidebarType.PARAMETERS) + const [runtimeParamsErrorState, setRuntimeParamsErrorState] = useState({ + isValid: true, + cellError: {}, + }) + const [isDeploymentLoading, setIsDeploymentLoading] = useState(false) + const [deploymentStrategy, setDeploymentStrategy] = useState(null) + const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) + const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) + + const isCDNode = stageType === DeploymentNodeType.CD + const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD + + const materialResponse = initialDataResponse?.[0] || null + const deploymentWindowMetadata = initialDataResponse?.[1] ?? ({} as (typeof initialDataResponse)[1]) + const materialList = materialResponse?.materials || [] + const selectedMaterial = materialList.find((material) => material.isSelected) + const isRollbackTrigger = materialType === MATERIAL_TYPE.rollbackMaterialList + const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false + const isApprovalConfigured = getIsApprovalPolicyConfigured( + materialResponse?.deploymentApprovalInfo?.approvalConfigData, + ) + const canApproverDeploy = materialResponse?.canApproverDeploy ?? false + const showConfigDiffView = searchParams.mode === URL_PARAM_MODE_TYPE.REVIEW_CONFIG && searchParams.deploy + const isSelectImageTrigger = materialType === MATERIAL_TYPE.inputMaterialList + const areMaterialsPassingFilters = + materialList.filter((materialDetails) => materialDetails.filterState === FilterStates.ALLOWED).length > 0 + const selectedConfigToDeploy = getInitialSelectedConfigToDeploy(materialType, searchParams) + const showPluginWarningBeforeTrigger = _showPluginWarningBeforeTrigger && isPreOrPostCD + // This check assumes we have isPreOrPostCD as true + const allowWarningWithTippyNodeTypeProp: CommonNodeAttr['type'] = + stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' + const runtimeParamsList = materialResponse?.runtimeParams || [] + const requestedUserId = materialResponse?.requestedUserId + + usePrompt({ shouldPrompt: isDeploymentLoading }) + + const pipelineStrategyOptions = useMemo( + () => + (pipelineStrategies ?? []).flatMap(({ error, strategies }) => { + if (error) { + return [] + } + return strategies + }), + [pipelineStrategies], + ) + + const wfrId = getWfrId(selectedMaterial, materialList) + + const { + pipelineDeploymentConfigLoading, + pipelineDeploymentConfig, + radioSelectConfig, + diffFound, + noLastDeploymentConfig, + noSpecificDeploymentConfig, + canDeployWithConfig, + canReviewConfig, + scopeVariablesConfig, + urlFilters, + lastDeploymentWfrId, + errorConfig, + } = usePipelineDeploymentConfig({ + appId, + envId, + appName, + envName, + deploymentStrategy, + setDeploymentStrategy, + pipelineStrategyOptions, + isRollbackTriggerSelected: isRollbackTrigger, + pipelineId, + wfrId, + }) + + const handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] = (e) => { + setCurrentSidebarTab(e.target.value as CDMaterialSidebarType) + } + + const handleClosePluginWarningOverlay = () => { + setShowPluginWarningOverlay(false) + } + + const handleConfirmationClose = (e: SyntheticEvent) => { + e.stopPropagation() + handleClosePluginWarningOverlay() + setShowDeploymentWindowConfirmation(false) + } + + const onClickSetInitialParams = (modeParamValue: URL_PARAM_MODE_TYPE) => { + const newParams = new URLSearchParams({ + ...searchParams, + sortBy: DEPLOYMENT_CONFIG_DIFF_SORT_KEY, + sortOrder: SortingOrder.ASC, + mode: modeParamValue, + deploy: getConfigToDeployValue(materialType, searchParams), + }) + + if (modeParamValue === URL_PARAM_MODE_TYPE.LIST) { + newParams.delete('sortOrder') + newParams.delete('sortBy') + } + + history.push({ + pathname: + modeParamValue === URL_PARAM_MODE_TYPE.REVIEW_CONFIG + ? // Replace consecutive trailing single slashes + `${pathname.replace(/\/+$/g, '')}/${URLS.APP_DIFF_VIEW}/${EnvResourceType.DeploymentTemplate}` + : `${pathname.split(`/${URLS.APP_DIFF_VIEW}`)[0]}`, + search: newParams.toString(), + }) + } + + const handleReviewConfigParams = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.REVIEW_CONFIG) + + const handleNavigateToListView = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) + + const onRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { + setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) + } + + const isDeployButtonDisabled = () => { + const selectedImage = materialList.find((artifact) => artifact.isSelected) + + return ( + !selectedImage || + !areMaterialsPassingFilters || + (isRollbackTrigger && (pipelineDeploymentConfigLoading || !canDeployWithConfig())) || + (selectedConfigToDeploy.value === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG && noLastDeploymentConfig) + ) + } + + const renderDeployCTATippyContent = () => { + if (!areMaterialsPassingFilters) { + return ( + <> +

No eligible images found!

+

+ Please select an image that passes the configured filters to deploy +

+ + ) + } + + return ( + <> +

Selected Config not available!

+

+ {selectedConfigToDeploy.value === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG && + noSpecificDeploymentConfig + ? 'Please select a different image or configuration to deploy' + : 'Please select a different configuration to deploy'} +

+ + ) + } + + const getDeployCTATippyWrapper = (children) => ( + + {children} + + ) + + const redirectToDeploymentStepsPage = () => { + history.push(`/app/${appId}/cd-details/${envId}/${pipelineId}`) + } + + const handleDeployment = ( + nodeType: DeploymentNodeType, + _appId: number, + ciArtifactId: number, + deploymentWithConfig?: string, + computedWfrId?: number, + ) => { + const updatedRuntimeParamsErrorState = validateRuntimeParameters(runtimeParamsList) + onRuntimeParamsError(updatedRuntimeParamsErrorState) + if (!updatedRuntimeParamsErrorState.isValid) { + ToastManager.showToast({ + variant: ToastVariantType.error, + description: 'Please resolve all the errors before deploying', + }) + return + } + + handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.CDTriggered(nodeType)) + setIsDeploymentLoading(true) + + if (_appId && pipelineId && ciArtifactId) { + triggerCDNode({ + pipelineId: Number(pipelineId), + ciArtifactId: Number(ciArtifactId), + appId: Number(_appId), + stageType: nodeType, + deploymentWithConfig, + wfrId: computedWfrId, + abortControllerRef: null, + isRollbackTrigger, + ...(getRuntimeParamsPayload + ? { runtimeParamsPayload: getRuntimeParamsPayload(runtimeParamsList ?? []) } + : {}), + skipIfHibernated: false, + ...(SelectDeploymentStrategy && deploymentStrategy ? { strategy: deploymentStrategy } : {}), + }) + .then((response) => { + if (response.result) { + if (isVirtualEnvironment && deploymentAppType === DeploymentAppTypes.MANIFEST_DOWNLOAD) { + const { helmPackageName } = response.result + downloadManifestForVirtualEnvironment?.({ + appId: _appId, + envId, + helmPackageName, + cdWorkflowType: nodeType, + handleDownload, + }) + } + + const msg = + materialType === MATERIAL_TYPE.rollbackMaterialList + ? 'Rollback Initiated' + : 'Deployment Initiated' + + ToastManager.showToast({ + variant: ToastVariantType.success, + description: msg, + }) + setIsDeploymentLoading(false) + handleSuccess?.() + handleClose() + } + }) + .catch((errors: ServerErrors) => { + if (isVirtualEnvironment && deploymentAppType === DeploymentAppTypes.MANIFEST_PUSH) { + handleTriggerErrorMessageForHelmManifestPush({ + serverError: errors, + searchParams, + redirectToDeploymentStepsPage, + }) + } else { + showErrorIfNotAborted(errors) + } + + setIsDeploymentLoading(false) + }) + } else { + let message = _appId ? '' : 'app id missing ' + message += pipelineId ? '' : 'pipeline id missing ' + message += ciArtifactId ? '' : 'Artifact id missing ' + ToastManager.showToast({ + variant: ToastVariantType.error, + description: message, + }) + setIsDeploymentLoading(false) + } + } + + const deployTrigger = (e: SyntheticEvent) => { + e.stopPropagation() + handleConfirmationClose(e) + // Blocking the deploy action if already deploying or config is not available + if (isDeployButtonDisabled()) { + return + } + + const artifactId = +getCDArtifactId(selectedMaterial, materialList) + + if (isRollbackTrigger || isSelectImageTrigger) { + const computedWfrId = isRollbackTrigger ? wfrId : lastDeploymentWfrId + handleDeployment(stageType, appId, artifactId, selectedConfigToDeploy.value, computedWfrId) + return + } + + handleDeployment(stageType, appId, artifactId) + } + + const onClickDeploy = (e, disableDeployButton: boolean) => { + e.stopPropagation() + if (!disableDeployButton) { + if (!showPluginWarningOverlay && showPluginWarningBeforeTrigger) { + setShowPluginWarningOverlay(true) + return + } + + if ( + deploymentWindowMetadata.userActionState && + deploymentWindowMetadata.userActionState !== ACTION_STATE.ALLOWED + ) { + setShowDeploymentWindowConfirmation(true) + return + } + + deployTrigger(e) + } + } + + const getDeployButtonStyle = ( + userActionState: string, + canDeployWithoutApproval: boolean, + canImageApproverDeploy: boolean, + ): ButtonStyleType => { + if (userActionState === ACTION_STATE.BLOCKED) { + return ButtonStyleType.negative + } + if (userActionState === ACTION_STATE.PARTIAL || canDeployWithoutApproval || canImageApproverDeploy) { + return ButtonStyleType.warning + } + return ButtonStyleType.default + } + + const renderTriggerDeployButton = (disableDeployButton: boolean) => { + const { userActionState } = deploymentWindowMetadata + const canDeployWithoutApproval = getCanDeployWithoutApproval(selectedMaterial, isExceptionUser) + const canImageApproverDeploy = getCanImageApproverDeploy(selectedMaterial, canApproverDeploy, isExceptionUser) + + return ( + onClickDeploy(e, disableDeployButton)} + startIcon={getDeployButtonIcon(deploymentWindowMetadata, stageType)} + text={ + canDeployWithoutApproval + ? 'Deploy without approval' + : `${ + userActionState === ACTION_STATE.BLOCKED + ? 'Deployment is blocked' + : CDButtonLabelMap[stageType] + }${isVirtualEnvironment ? ' to isolated env' : ''}` + } + endIcon={userActionState === ACTION_STATE.BLOCKED ? : null} + style={getDeployButtonStyle(userActionState, canDeployWithoutApproval, canImageApproverDeploy)} + disabled={disableDeployButton} + tooltipContent={ + canDeployWithoutApproval || canImageApproverDeploy + ? 'You are authorized to deploy as an exception user' + : '' + } + animateStartIcon={ + isCDNode && !disableDeployButton && (!userActionState || userActionState === ACTION_STATE.ALLOWED) + } + /> + ) + } + + const renderFooter = () => { + const disableDeployButton = + isDeployButtonDisabled() || + (!isExceptionUser && + materialList.length > 0 && + !canApproverDeploy && + getIsImageApprover(selectedMaterial?.userApprovalMetadata)) + + const hideConfigDiffSelector = isApprovalConfigured && disableDeployButton + + return ( +
+ {!hideConfigDiffSelector && + (isRollbackTrigger || isSelectImageTrigger) && + !showConfigDiffView && + isCDNode ? ( + + ) : ( + // NOTE: needed so that the button is pushed to the right since justify-content is set to space-between +
+ )} +
+ {SelectDeploymentStrategy && + isCDNode && + +(pipelineStrategies?.[0]?.error?.code || 0) !== API_STATUS_CODES.NOT_FOUND && ( + + )} + + {AllowedWithWarningTippy && showPluginWarningBeforeTrigger ? ( + onClickDeploy(e, disableDeployButton)} + nodeType={allowWarningWithTippyNodeTypeProp} + visible={showPluginWarningOverlay} + onClose={handleClosePluginWarningOverlay} + > + {renderTriggerDeployButton(disableDeployButton)} + + ) : ( + renderTriggerDeployButton(disableDeployButton) + )} + +
+
+ ) + } + + const renderContent = () => { + if (isInitialDataLoading) { + return ( +
+ + + +
+ ) + } + + if (initialDataError) { + return ( + + ) + } + + if (showConfigDiffView && canReviewConfig()) { + return ( + + ) + } + + return
+ } + + const renderPipelineConfigDiffHeader = () => ( +
+
+ ) + + return ( + <> + +
+
+
+ {showConfigDiffView ? ( + renderPipelineConfigDiffHeader() + ) : ( + + )} +
{renderContent()}
+
+ + {initialDataError || isInitialDataLoading ? null : ( +
+ {renderFooter()} +
+ )} +
+
+
+ + {DeploymentWindowConfirmationDialog && showDeploymentWindowConfirmation && ( + + )} + + + + ) +} + +export default DeployImageModal diff --git a/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx b/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx new file mode 100644 index 0000000000..d673c8519f --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx @@ -0,0 +1,12 @@ +const MaterialListSkeleton = () => ( +
+
+
+
+ +
+
+
+) + +export default MaterialListSkeleton diff --git a/src/components/app/details/triggerView/DeployImageModal/RuntimeParamsSidebar.tsx b/src/components/app/details/triggerView/DeployImageModal/RuntimeParamsSidebar.tsx new file mode 100644 index 0000000000..702c233f9e --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/RuntimeParamsSidebar.tsx @@ -0,0 +1,40 @@ +import { CD_MATERIAL_SIDEBAR_TABS, CDMaterialSidebarType, noop } from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' + +import { RuntimeParamsSidebarProps } from './types' + +const RuntimeParamTabs = importComponentFromFELibrary('RuntimeParamTabs', null, 'function') + +const RuntimeParamsSidebar = ({ + areTabsDisabled, + currentSidebarTab, + handleSidebarTabChange, + runtimeParamsErrorState, + appName, +}: RuntimeParamsSidebarProps) => ( +
+ {RuntimeParamTabs && ( +
+ +
+ )} + +
+ Application +
+ +
+ {appName} +
+
+) + +export default RuntimeParamsSidebar diff --git a/src/components/app/details/triggerView/DeployImageModal/service.ts b/src/components/app/details/triggerView/DeployImageModal/service.ts new file mode 100644 index 0000000000..01ca9be058 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/service.ts @@ -0,0 +1,60 @@ +import { + CDMaterialResponseType, + CDMaterialServiceEnum, + DeploymentNodeType, + DeploymentWindowProfileMetaData, + genericCDMaterialsService, + GetPolicyConsequencesProps, + PolicyConsequencesDTO, +} from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' + +import { MATERIAL_TYPE } from '../types' +import { GetMaterialResponseListProps } from './types' +import { getIsCDTriggerBlockedThroughConsequences } from './utils' + +const getPolicyConsequences: ({ appId, envId }: GetPolicyConsequencesProps) => Promise = + importComponentFromFELibrary('getPolicyConsequences', null, 'function') + +const getDeploymentWindowProfileMetaData = importComponentFromFELibrary( + 'getDeploymentWindowProfileMetaData', + null, + 'function', +) + +export const getMaterialResponseList = async ({ + stageType = DeploymentNodeType.CD, + pipelineId, + initialSearch, + appId, + envId, + materialType, +}: GetMaterialResponseListProps): Promise< + [CDMaterialResponseType, DeploymentWindowProfileMetaData, PolicyConsequencesDTO] +> => { + const response = await Promise.all([ + genericCDMaterialsService( + materialType === MATERIAL_TYPE.rollbackMaterialList + ? CDMaterialServiceEnum.ROLLBACK + : CDMaterialServiceEnum.CD_MATERIALS, + pipelineId, + // Don't think need to set stageType to approval in case of approval node + stageType, + null, + // It is meant to fetch the first 20 materials + { + offset: 0, + size: 20, + search: initialSearch, + }, + ), + getDeploymentWindowProfileMetaData ? getDeploymentWindowProfileMetaData(appId, envId) : null, + getPolicyConsequences ? getPolicyConsequences({ appId, envId }) : null, + ]) + + if (getPolicyConsequences && getIsCDTriggerBlockedThroughConsequences(response[2].cd, stageType)) { + return [null, null, response[2]] + } + return response +} diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts new file mode 100644 index 0000000000..a29d600aa8 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -0,0 +1,69 @@ +import { + CDMaterialResponseType, + CDMaterialSidebarType, + CDMaterialType, + ConsequenceType, + DeploymentAppTypes, + DeploymentNodeType, + ServerErrors, + useSearchString, +} from '@devtron-labs/devtron-fe-common-lib' + +import { MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' + +export interface DeployImageModalProps { + appId: number + envId: number + appName: string + pipelineId: number + stageType?: DeploymentNodeType + materialType: (typeof MATERIAL_TYPE)[keyof typeof MATERIAL_TYPE] + handleClose: () => void + envName: string + showPluginWarningBeforeTrigger: boolean + consequence: ConsequenceType + configurePluginURL: string + /** + * In case of appDetails trigger re-fetch of app details + */ + handleSuccess?: () => void + deploymentAppType: DeploymentAppTypes + isVirtualEnvironment: boolean +} + +export type DeployImageHeaderProps = Pick< + DeployImageModalProps, + 'handleClose' | 'stageType' | 'isVirtualEnvironment' +> & { + envName: string + isRollbackTrigger: boolean +} + +export interface RuntimeParamsSidebarProps { + areTabsDisabled: boolean + currentSidebarTab: CDMaterialSidebarType + handleSidebarTabChange: (e: React.ChangeEvent) => void + runtimeParamsErrorState: RuntimeParamsErrorState + appName: string +} + +export interface GetMaterialResponseListProps + extends Pick { + initialSearch: string +} + +export interface HandleTriggerErrorMessageForHelmManifestPushProps { + serverError: ServerErrors + searchParams: ReturnType['searchParams'] + redirectToDeploymentStepsPage: () => void +} + +export interface GetTriggerArtifactInfoPropsType + extends Pick, + Pick { + material: CDMaterialType + showApprovalInfoTippy: boolean + isRollbackTrigger: boolean + isExceptionUser: boolean + reloadMaterials: () => void +} diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx new file mode 100644 index 0000000000..7b8bff3c4f --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -0,0 +1,206 @@ +import { + ACTION_STATE, + ArtifactInfoProps, + CDMaterialType, + DeploymentNodeType, + DeploymentWindowProfileMetaData, + DeploymentWithConfigType, + ExcludedImageNode, + FilterStates, + getIsRequestAborted, + Icon, + PipelineStageBlockInfo, + ServerErrors, + showError, + STAGE_TYPE, + ToastManager, + ToastVariantType, + UserApprovalMetadataType, + useSearchString, +} from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' +import { TOAST_BUTTON_TEXT_VIEW_DETAILS } from '@Config/constantMessaging' + +import { + LAST_SAVED_CONFIG_OPTION, + LATEST_TRIGGER_CONFIG_OPTION, + SPECIFIC_TRIGGER_CONFIG_OPTION, +} from '../TriggerView.utils' +import { MATERIAL_TYPE } from '../types' +import { + DeployImageHeaderProps, + DeployImageModalProps, + GetTriggerArtifactInfoPropsType, + HandleTriggerErrorMessageForHelmManifestPushProps, +} from './types' + +const ApprovalInfoTippy = importComponentFromFELibrary('ApprovalInfoTippy') + +export const getIsImageApprover = (userApprovalMetadata?: UserApprovalMetadataType): boolean => + userApprovalMetadata?.hasCurrentUserApproved + +export const getInitialSelectedConfigToDeploy = ( + materialType: DeployImageModalProps['materialType'], + searchParams: ReturnType['searchParams'], +) => { + if ( + (materialType === MATERIAL_TYPE.rollbackMaterialList && !searchParams.deploy) || + searchParams.deploy === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG + ) { + return SPECIFIC_TRIGGER_CONFIG_OPTION + } + if (searchParams.deploy === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG) { + return LATEST_TRIGGER_CONFIG_OPTION + } + return LAST_SAVED_CONFIG_OPTION +} + +export const getConfigToDeployValue = ( + materialType: DeployImageModalProps['materialType'], + searchParams: ReturnType['searchParams'], +) => { + if (searchParams.deploy) { + return searchParams.deploy + } + if (materialType === MATERIAL_TYPE.rollbackMaterialList) { + return DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG + } + return DeploymentWithConfigType.LAST_SAVED_CONFIG +} + +export const getIsCDTriggerBlockedThroughConsequences = ( + cdPolicyConsequences: PipelineStageBlockInfo, + stageType: DeployImageModalProps['stageType'], +) => { + switch (stageType) { + case DeploymentNodeType.PRECD: + return cdPolicyConsequences.pre.isBlocked + case DeploymentNodeType.POSTCD: + return cdPolicyConsequences.post.isBlocked + case DeploymentNodeType.CD: + return cdPolicyConsequences.node.isBlocked + default: + return false + } +} + +export const getCDArtifactId = (selectedMaterial: CDMaterialType, materialList: CDMaterialType[]) => + selectedMaterial ? selectedMaterial.id : materialList?.find((_mat) => _mat.isSelected)?.id + +export const showErrorIfNotAborted = (errors: ServerErrors) => { + if (!getIsRequestAborted(errors)) { + showError(errors) + } +} + +export const handleTriggerErrorMessageForHelmManifestPush = ({ + serverError, + searchParams, + redirectToDeploymentStepsPage, +}: HandleTriggerErrorMessageForHelmManifestPushProps) => { + if ( + serverError instanceof ServerErrors && + Array.isArray(serverError.errors) && + serverError.code !== 403 && + serverError.code !== 408 && + !getIsRequestAborted(searchParams) + ) { + serverError.errors.forEach(({ userMessage, internalMessage }) => { + ToastManager.showToast( + { + variant: ToastVariantType.error, + description: userMessage ?? internalMessage, + buttonProps: { + text: TOAST_BUTTON_TEXT_VIEW_DETAILS, + dataTestId: 'cd-material-view-details-btns', + onClick: redirectToDeploymentStepsPage, + }, + }, + { + autoClose: false, + }, + ) + }) + } else { + showError(serverError) + } +} + +export const getDeployButtonIcon = ( + deploymentWindowMetadata: DeploymentWindowProfileMetaData, + stageType: DeploymentNodeType, +) => { + if (deploymentWindowMetadata.userActionState === ACTION_STATE.BLOCKED) { + return null + } + if (stageType !== STAGE_TYPE.CD) { + return + } + return +} + +export const getCDModalHeaderText = ({ + isRollbackTrigger, + stageType, + envName, + isVirtualEnvironment, +}: Pick): + | JSX.Element + | string => { + const _stageType = isRollbackTrigger ? STAGE_TYPE.ROLLBACK : stageType + switch (_stageType) { + case STAGE_TYPE.PRECD: + return 'Pre Deployment' + case STAGE_TYPE.CD: + return ( + <> + Deploy to   + {`${envName}${isVirtualEnvironment ? ' (Isolated)' : ''}`} + + ) + case STAGE_TYPE.POSTCD: + return 'Post Deployment' + case STAGE_TYPE.ROLLBACK: + return ( + <> + Rollback for {envName} + + ) + default: + return '' + } +} + +// Not sending approvalChecksNode as it is not required in this case +export const getTriggerArtifactInfoProps = ({ + material, + isRollbackTrigger, + showApprovalInfoTippy, + appId, + pipelineId, + isExceptionUser, + reloadMaterials, + requestedUserId, +}: GetTriggerArtifactInfoPropsType): ArtifactInfoProps => ({ + imagePath: material.imagePath, + registryName: material.registryName, + registryType: material.registryType, + image: material.image, + deployedTime: material.deployedTime, + deployedBy: material.deployedBy, + isRollbackTrigger, + excludedImagePathNode: + material.filterState === FilterStates.ALLOWED ? null : , + approvalInfoTippy: showApprovalInfoTippy ? ( + + ) : null, +}) diff --git a/src/components/app/details/triggerView/cdMaterials.utils.ts b/src/components/app/details/triggerView/cdMaterials.utils.ts index ac7012343d..def14ffefc 100644 --- a/src/components/app/details/triggerView/cdMaterials.utils.ts +++ b/src/components/app/details/triggerView/cdMaterials.utils.ts @@ -17,7 +17,7 @@ import { ApprovalRuntimeStateType, CDMaterialType, FilterStates } from '@devtron-labs/devtron-fe-common-lib' import { LAST_SAVED_CONFIG_OPTION, SPECIFIC_TRIGGER_CONFIG_OPTION } from './TriggerView.utils' -import { CDMaterialState, FilterConditionViews, MATERIAL_TYPE, RegexValueType } from './types' +import { FilterConditionViews, MATERIAL_TYPE, RegexValueType } from './types' export const getInitialState = (materialType: string, material: CDMaterialType[], searchImageTag: string) => () => ({ isSecurityModuleInstalled: false, @@ -65,15 +65,13 @@ export const getIsMaterialApproved = (userApprovalMetadata: CDMaterialType['user return approvalRuntimeState === ApprovalRuntimeStateType.approved } -export const getCanDeployWithoutApproval = (state: CDMaterialState, isExceptionUser: boolean) => { - const isMaterialApproved = - state.selectedMaterial && getIsMaterialApproved(state.selectedMaterial.userApprovalMetadata) - +export const getCanDeployWithoutApproval = (selectedMaterial: CDMaterialType, isExceptionUser: boolean) => { + const isMaterialApproved = selectedMaterial && getIsMaterialApproved(selectedMaterial.userApprovalMetadata) return isExceptionUser && !isMaterialApproved } export const getCanImageApproverDeploy = ( - state: CDMaterialState, + selectedMaterial: CDMaterialType, canApproverDeploy: boolean, isExceptionUser: boolean, -) => isExceptionUser && !canApproverDeploy && state.selectedMaterial?.userApprovalMetadata?.hasCurrentUserApproved +) => isExceptionUser && !canApproverDeploy && selectedMaterial?.userApprovalMetadata?.hasCurrentUserApproved diff --git a/src/components/app/details/triggerView/types.ts b/src/components/app/details/triggerView/types.ts index 87d9306c73..7700818ca2 100644 --- a/src/components/app/details/triggerView/types.ts +++ b/src/components/app/details/triggerView/types.ts @@ -609,7 +609,7 @@ export const MATERIAL_TYPE = { rollbackMaterialList: 'rollbackMaterialList', inputMaterialList: 'inputMaterialList', none: 'none', -} +} as const export interface EmptyStateCIMaterialProps { isRepoError: boolean From 67903c953b48364fc17867da301ba433dc70c6b3 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 30 Jul 2025 12:09:52 +0530 Subject: [PATCH 05/40] feat: Enhance DeployImageModal with new image selection and empty state components - Added ImageSelectionCTA component for handling image selection logic and UI. - Introduced MaterialListEmptyState component to manage and display empty states for material lists. - Updated DeployImageModal to integrate new components and improve state management. - Refactored utility functions to support new features and enhance code readability. - Improved type definitions for better TypeScript support and maintainability. --- .../DeployImageModal/DeployImageContent.tsx | 506 ++++++++++++++++++ .../DeployImageModal/DeployImageModal.tsx | 358 +++++++++++-- .../DeployImageModal/ImageSelectionCTA.tsx | 127 +++++ .../MaterialListEmptyState.tsx | 157 ++++++ .../triggerView/DeployImageModal/index.ts | 1 + .../triggerView/DeployImageModal/types.ts | 129 ++++- .../triggerView/DeployImageModal/utils.tsx | 126 ++++- 7 files changed, 1360 insertions(+), 44 deletions(-) create mode 100644 src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/index.ts diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx new file mode 100644 index 0000000000..a48ad52170 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -0,0 +1,506 @@ +import { useContext, useState } from 'react' +import { useHistory } from 'react-router-dom' + +import { + Button, + ButtonStyleType, + ButtonVariantType, + CDMaterialSidebarType, + CDMaterialType, + ConditionalWrap, + DEPLOYMENT_WINDOW_TYPE, + DeploymentNodeType, + getGitCommitInfo, + getIsApprovalPolicyConfigured, + getIsMaterialInfoAvailable, + GitCommitInfoGeneric, + handleUTCTime, + Icon, + ImageCard, + ImageCardAccordion, + ImageTaggingContainerType, + InfoBlock, + isNullOrUndefined, + MaterialInfo, + Progressing, + SearchBar, + useMainContext, +} from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' + +import { TriggerViewContext } from '../config' +import { TRIGGER_VIEW_PARAMS } from '../Constants' +import { TriggerViewContextType } from '../types' +import ImageSelectionCTA from './ImageSelectionCTA' +import MaterialListEmptyState from './MaterialListEmptyState' +import RuntimeParamsSidebar from './RuntimeParamsSidebar' +import { DeployImageContentProps } from './types' +import { + getApprovedImageClass, + getConsumedAndAvailableMaterialList, + getFilterActionBarTabs, + getIsImageApprover, + getSequentialCDCardTitleProps, + getTriggerArtifactInfoProps, +} from './utils' + +const ApprovalInfoTippy = importComponentFromFELibrary('ApprovalInfoTippy') +const ApprovedImagesMessage = importComponentFromFELibrary('ApprovedImagesMessage') +const MaintenanceWindowInfoBar = importComponentFromFELibrary('MaintenanceWindowInfoBar') +const FilterActionBar = importComponentFromFELibrary('FilterActionBar') +const RuntimeParameters = importComponentFromFELibrary('RuntimeParameters', null, 'function') +const SecurityModalSidebar = importComponentFromFELibrary('SecurityModalSidebar', null, 'function') +const CDMaterialInfo = importComponentFromFELibrary('CDMaterialInfo') +const ConfiguredFilters = importComponentFromFELibrary('ConfiguredFilters') + +const renderMaterialListBodyWrapper = (children: JSX.Element) => ( +
{children}
+) + +const DeployImageContent = ({ + appId, + envId, + materialResponse, + isRollbackTrigger, + isTriggerBlockedDueToPlugin, + configurePluginURL, + isBulkTrigger, + deploymentWindowMetadata, + pipelineId, + handleClose, + isRedirectedFromAppDetails, + isSearchApplied, + searchText, + onSearchApply, + onSearchTextChange, + filterView, + showConfiguredFilters, + stageType, + currentSidebarTab, + handleRuntimeParamsChange, + runtimeParamsErrorState, + handleRuntimeParamsError, + uploadRuntimeParamsFile, + handleSidebarTabChange, + appName, + materialInEditModeMap, + isSecurityModuleInstalled, + envName, + handleShowAppliedFilters, + reloadMaterials, + parentEnvironmentName, + isVirtualEnvironment, + handleImageSelection, + setAppReleaseTagNames, + toggleCardMode, + setTagsEditable, + updateCurrentAppMaterial, + handleEnableFiltersView, + handleFilterTabsChange, + loadOlderImages, + isLoadingOlderImages, + policyConsequences, + handleAllImagesView, + showAppliedFilters, + handleDisableFiltersView, + handleDisableAppliedFiltersView, + triggerType, + appliedFilterList, +}: DeployImageContentProps) => { + const history = useHistory() + const { isSuperAdmin } = useMainContext() + + const { onClickApprovalNode } = useContext(TriggerViewContext) + + const [showSearchBar, setShowSearchBar] = useState(false) + + const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false + const requestedUserId = materialResponse?.requestedUserId + const isApprovalConfigured = getIsApprovalPolicyConfigured( + materialResponse?.deploymentApprovalInfo?.approvalConfigData, + ) + const materials = materialResponse?.materials || [] + const canApproverDeploy = materialResponse?.canApproverDeploy ?? false + const resourceFilters = materialResponse?.resourceFilters ?? [] + const hideImageTaggingHardDelete = materialResponse?.hideImageTaggingHardDelete ?? false + const isConsumedImageAvailable = + materials.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false + const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD + const runtimeParamsList = materialResponse?.runtimeParams || [] + const isCDNode = stageType === DeploymentNodeType.CD + + const { consumedImage, materialList, eligibleImagesCount } = getConsumedAndAvailableMaterialList({ + isApprovalConfigured, + isExceptionUser, + materials, + isSearchApplied, + filterView, + resourceFilters, + }) + const selectImageTitle = isRollbackTrigger ? 'Select from previously deployed images' : 'Select Image' + const titleText = isApprovalConfigured && !isExceptionUser ? 'Approved images' : selectImageTitle + const showActionBar = FilterActionBar && !isSearchApplied && !!resourceFilters?.length && !showConfiguredFilters + const areNoMoreImagesPresent = materials.length >= materialResponse?.totalCount + + const viewAllImages = () => { + if (isRedirectedFromAppDetails) { + history.push({ + search: `${TRIGGER_VIEW_PARAMS.APPROVAL_NODE}=${pipelineId}&${TRIGGER_VIEW_PARAMS.APPROVAL_STATE}=${TRIGGER_VIEW_PARAMS.APPROVAL}`, + }) + } else { + handleClose() + onClickApprovalNode(pipelineId) + } + } + + const handleShowSearchBar = () => { + setShowSearchBar(true) + } + + const renderSearch = () => ( + + ) + + const getImageTagContainerProps = (mat: CDMaterialType): ImageTaggingContainerType => ({ + ciPipelineId: null, + artifactId: +mat.id, + imageComment: mat.imageComment, + imageReleaseTags: mat.imageReleaseTags, + appReleaseTagNames: materialResponse?.appReleaseTagNames, + setAppReleaseTagNames, + tagsEditable: materialResponse?.tagsEditable, + toggleCardMode, + setTagsEditable, + updateCurrentAppMaterial, + forceReInit: true, + hideHardDelete: hideImageTaggingHardDelete, + isSuperAdmin, + }) + + const renderSidebar = () => { + if (isBulkTrigger) { + // TODO: Implement bulk trigger sidebar + return null + } + + if (isPreOrPostCD) { + return ( + + ) + } + + return null + } + + const renderGitMaterialInfo = (materialData: CDMaterialType) => ( + <> + {materialData.materialInfo.map((mat: MaterialInfo, index) => { + const _gitCommit = getGitCommitInfo(mat) + + if ( + (materialData.appliedFilters?.length > 0 || + materialData.deploymentBlockedState?.isBlocked || + materialData.deploymentWindowArtifactMetadata?.type) && + CDMaterialInfo + ) { + return ( + handleShowAppliedFilters(materialData)} + filterState={materialData.appliedFiltersState} + dataSource={materialData.dataSource} + deploymentWindowArtifactMetadata={materialData.deploymentWindowArtifactMetadata} + isFilterApplied={materialData.appliedFilters?.length > 0} + triggerBlockedInfo={materialData.deploymentBlockedState} + > + {(_gitCommit.WebhookData?.Data || + _gitCommit.Author || + _gitCommit.Message || + _gitCommit.Date || + _gitCommit.Commit) && ( + + )} + + ) + } + + return ( + (_gitCommit.WebhookData?.Data || + _gitCommit.Author || + _gitCommit.Message || + _gitCommit.Date || + _gitCommit.Commit) && ( + // eslint-disable-next-line react/no-array-index-key +
+ +
+ ) + ) + })} + + ) + + const renderMaterialList = (materialsToRender: typeof materialList, disableSelection: boolean) => + materialsToRender.map((mat) => { + const isMaterialInfoAvailable = getIsMaterialInfoAvailable(mat.materialInfo) + const approvedImageClass = getApprovedImageClass(disableSelection, isApprovalConfigured) + const isImageApprover = getIsImageApprover(mat.userApprovalMetadata) + + const hideSourceInfo = !materialInEditModeMap.get(+mat.id) + + const showApprovalInfoTippy = + !disableSelection && + (isCDNode || isRollbackTrigger) && + isApprovalConfigured && + ApprovalInfoTippy && + !isNullOrUndefined(mat.userApprovalMetadata.approvalRuntimeState) + + const imageCardRootClassName = + mat.isSelected && !disableSelection && !isImageApprover ? 'material-history-selected' : '' + + return ( + + } + sequentialCDCardTitleProps={getSequentialCDCardTitleProps({ + material: mat, + envName, + parentEnvironmentName, + stageType, + isVirtualEnvironment, + isRollbackTrigger, + isSearchApplied, + })} + artifactInfoProps={getTriggerArtifactInfoProps({ + material: mat, + showApprovalInfoTippy, + isRollbackTrigger, + appId, + pipelineId, + isExceptionUser, + reloadMaterials, + requestedUserId, + })} + imageTagContainerProps={getImageTagContainerProps(mat)} + rootClassName={imageCardRootClassName} + materialInfoRootClassName={approvedImageClass} + key={`material-history-${mat.index}`} + > + {mat.materialInfo.length > 0 && + (isMaterialInfoAvailable || mat.appliedFilters?.length) && + hideSourceInfo && ( + + )} + + ) + }) + + if (ConfiguredFilters && (showConfiguredFilters || showAppliedFilters)) { + return ( + + ) + } + + return ( + <> + {isApprovalConfigured && + !isExceptionUser && + ApprovedImagesMessage && + (isRollbackTrigger || materials.length - Number(isConsumedImageAvailable) > 0) && ( + } + /> + )} + {!isBulkTrigger && + MaintenanceWindowInfoBar && + deploymentWindowMetadata.type === DEPLOYMENT_WINDOW_TYPE.MAINTENANCE && + deploymentWindowMetadata.isActive && ( + + )} + +
+ {renderSidebar()} + + + {currentSidebarTab === CDMaterialSidebarType.IMAGE || !RuntimeParameters ? ( + <> + {isApprovalConfigured && renderMaterialList(consumedImage, true)} + +
+ {showActionBar ? ( + + ) : ( + {titleText} + )} + + + {showSearchBar ? ( + renderSearch() + ) : ( +
+ + {materialList.length === 0 ? ( + 0} + envName={envName} + materialResponse={materialResponse} + // TODO: Move to util and remove prop + isExceptionUser={isExceptionUser} + isLoadingMore={isLoadingOlderImages} + viewAllImages={viewAllImages} + triggerType={triggerType} + loadOlderImages={loadOlderImages} + onSearchApply={onSearchApply} + eligibleImagesCount={eligibleImagesCount} + handleEnableFiltersView={handleEnableFiltersView} + handleAllImagesView={handleAllImagesView} + /> + ) : ( + renderMaterialList(materialList, false) + )} + + {!areNoMoreImagesPresent && !!materialList?.length && ( + + )} + + ) : ( +
+ +
+ )} +
+
+ + ) +} + +export default DeployImageContent diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 49c6aec317..4dff1af075 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -1,4 +1,4 @@ -import { SyntheticEvent, useMemo, useState } from 'react' +import { Dispatch, SetStateAction, SyntheticEvent, useMemo, useState } from 'react' import { Prompt, useHistory, useLocation } from 'react-router-dom' import { @@ -9,8 +9,8 @@ import { Button, ButtonStyleType, ButtonVariantType, + CDMaterialServiceEnum, CDMaterialSidebarType, - CommonNodeAttr, ConditionalWrap, DEFAULT_ROUTE_PROMPT_MESSAGE, DEPLOYMENT_CONFIG_DIFF_SORT_KEY, @@ -22,18 +22,23 @@ import { EnvResourceType, ErrorScreenManager, FilterStates, + genericCDMaterialsService, getIsApprovalPolicyConfigured, handleAnalyticsEvent, Icon, MODAL_TYPE, + ModuleNameMap, + ModuleStatus, PipelineDeploymentStrategy, ServerErrors, + showError, SortingOrder, stopPropagation, ToastManager, ToastVariantType, Tooltip, triggerCDNode, + uploadCDPipelineFile, useAsync, useDownload, usePrompt, @@ -42,20 +47,23 @@ import { import { importComponentFromFELibrary } from '@Components/common' import { URL_PARAM_MODE_TYPE } from '@Components/common/helpers/types' +import { getModuleInfo } from '@Components/v2/devtronStackManager/DevtronStackManager.service' import { URLS } from '@Config/routes' import { getCanDeployWithoutApproval, getCanImageApproverDeploy, getWfrId } from '../cdMaterials.utils' import { CDButtonLabelMap } from '../config' -import { TRIGGER_VIEW_GA_EVENTS } from '../Constants' +import { CD_MATERIAL_GA_EVENT, TRIGGER_VIEW_GA_EVENTS } from '../Constants' import { PipelineConfigDiff, usePipelineDeploymentConfig } from '../PipelineConfigDiff' import { PipelineConfigDiffStatusTile } from '../PipelineConfigDiff/PipelineConfigDiffStatusTile' -import { MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' +import { FilterConditionViews, HandleRuntimeParamChange, MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' +import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import MaterialListSkeleton from './MaterialListSkeleton' import RuntimeParamsSidebar from './RuntimeParamsSidebar' import { getMaterialResponseList } from './service' -import { DeployImageModalProps, RuntimeParamsSidebarProps } from './types' +import { DeployImageContentProps, DeployImageModalProps, RuntimeParamsSidebarProps } from './types' import { + getAllowWarningWithTippyNodeTypeProp, getCDArtifactId, getCDModalHeaderText, getConfigToDeployValue, @@ -100,21 +108,36 @@ const DeployImageModal = ({ showPluginWarningBeforeTrigger: _showPluginWarningBeforeTrigger = false, consequence, configurePluginURL, + triggerType, + isRedirectedFromAppDetails, + isTriggerBlockedDueToPlugin, + parentEnvironmentName, }: DeployImageModalProps) => { const history = useHistory() const { pathname } = useLocation() const { searchParams } = useSearchString() const { handleDownload } = useDownload() - const [isInitialDataLoading, initialDataResponse, initialDataError, reloadInitialData] = useAsync(() => - getMaterialResponseList({ - stageType, - pipelineId, - appId, - envId, - materialType, - initialSearch: searchParams.search || '', - }), - ) + const searchImageTag = searchParams.search || '' + + const [isInitialDataLoading, initialDataResponse, initialDataError, reloadInitialData, unTypedSetInitialData] = + useAsync( + () => + getMaterialResponseList({ + stageType, + pipelineId, + appId, + envId, + materialType, + initialSearch: searchImageTag, + }), + [searchImageTag], + ) + + const [, moduleInfoRes] = useAsync(() => getModuleInfo(ModuleNameMap.SECURITY)) + + const isSecurityModuleInstalled = moduleInfoRes && moduleInfoRes?.result?.status === ModuleStatus.INSTALLED + + const setInitialData: Dispatch> = unTypedSetInitialData const [pipelineStrategiesLoading, pipelineStrategies, pipelineStrategiesError, reloadStrategies] = useAsync( () => getDeploymentStrategies([pipelineId]), @@ -131,12 +154,22 @@ const DeployImageModal = ({ const [deploymentStrategy, setDeploymentStrategy] = useState(null) const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) + const [searchText, setSearchText] = useState(searchImageTag) + const [filterView, setFilterView] = useState(FilterConditionViews.ALL) + const [showConfiguredFilters, setShowConfiguredFilters] = useState(false) + const [showAppliedFilters, setShowAppliedFilters] = useState(false) + const [appliedFilterList, setAppliedFilterList] = useState([]) + const [isLoadingOlderImages, setIsLoadingOlderImages] = useState(false) + const [materialInEditModeMap, setMaterialInEditModeMap] = useState< + DeployImageContentProps['materialInEditModeMap'] + >(new Map()) const isCDNode = stageType === DeploymentNodeType.CD const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD const materialResponse = initialDataResponse?.[0] || null const deploymentWindowMetadata = initialDataResponse?.[1] ?? ({} as (typeof initialDataResponse)[1]) + const policyConsequences = initialDataResponse?.[2] ?? ({} as (typeof initialDataResponse)[2]) const materialList = materialResponse?.materials || [] const selectedMaterial = materialList.find((material) => material.isSelected) const isRollbackTrigger = materialType === MATERIAL_TYPE.rollbackMaterialList @@ -151,9 +184,7 @@ const DeployImageModal = ({ materialList.filter((materialDetails) => materialDetails.filterState === FilterStates.ALLOWED).length > 0 const selectedConfigToDeploy = getInitialSelectedConfigToDeploy(materialType, searchParams) const showPluginWarningBeforeTrigger = _showPluginWarningBeforeTrigger && isPreOrPostCD - // This check assumes we have isPreOrPostCD as true - const allowWarningWithTippyNodeTypeProp: CommonNodeAttr['type'] = - stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' + const allowWarningWithTippyNodeTypeProp = getAllowWarningWithTippyNodeTypeProp(stageType) const runtimeParamsList = materialResponse?.runtimeParams || [] const requestedUserId = materialResponse?.requestedUserId @@ -236,6 +267,115 @@ const DeployImageModal = ({ }) } + const handleEnableFiltersView = () => { + setShowConfiguredFilters(true) + } + + const handleDisableFiltersView = () => { + setShowConfiguredFilters(false) + } + + const handleFilterTabsChange: DeployImageContentProps['handleFilterTabsChange'] = (selectedSegment) => { + const { value } = selectedSegment + setFilterView(value as FilterConditionViews) + } + + const handleAllImagesView = () => { + setFilterView(FilterConditionViews.ALL) + } + + const loadOlderImages = async () => { + // TODO: Move to util + handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) + if (!isLoadingOlderImages) { + // TODO: Move to util + const isConsumedImageAvailable = + materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false + + setIsLoadingOlderImages(true) + + try { + const newMaterialsResponse = await genericCDMaterialsService( + isRollbackTrigger ? CDMaterialServiceEnum.ROLLBACK : CDMaterialServiceEnum.CD_MATERIALS, + pipelineId, + stageType, + null, + { + offset: materialList.length - Number(isConsumedImageAvailable), + size: 20, + search: searchImageTag, + }, + ) + + // NOTE: Looping through _newResponse and removing elements that are already deployed and latest + // NOTE: This is done to avoid duplicate images + const filteredNewMaterialResponse = [...newMaterialsResponse.materials].filter( + (materialItem) => !(materialItem.deployed && materialItem.latest), + ) + + // updating the index of materials to maintain consistency + const _newMaterialsResponse = filteredNewMaterialResponse.map((materialItem, index) => ({ + ...materialItem, + index: materialList.length + index, + })) + + const newMaterials = structuredClone(materialList).concat(_newMaterialsResponse) + // Made a change not updating whole response rather updating only materials + setInitialData((prevData) => { + const updatedMaterialResponse = structuredClone(prevData[0]) + updatedMaterialResponse.materials = newMaterials + return [updatedMaterialResponse, prevData[1], prevData[2]] + }) + + const baseSuccessMessage = `Fetched ${_newMaterialsResponse.length} images.` + if (materialResponse?.resourceFilters?.length && !searchImageTag) { + const eligibleImages = _newMaterialsResponse.filter( + (mat) => mat.filterState === FilterStates.ALLOWED, + ).length + + const infoMessage = + eligibleImages === 0 + ? 'No new eligible images found.' + : `${eligibleImages} new eligible images found.` + + if (filterView === FilterConditionViews.ELIGIBLE) { + ToastManager.showToast({ + variant: ToastVariantType.info, + description: `${baseSuccessMessage} ${infoMessage}`, + }) + } else { + ToastManager.showToast({ + variant: ToastVariantType.success, + description: `${baseSuccessMessage} ${infoMessage}`, + }) + } + } else { + ToastManager.showToast({ + variant: ToastVariantType.success, + description: baseSuccessMessage, + }) + } + } catch (error) { + showError(error) + } finally { + setIsLoadingOlderImages(false) + } + } + } + + const handleImageSelection: DeployImageContentProps['handleImageSelection'] = (materialIndex) => { + const updatedMaterialList = materialList.map((material, index) => ({ + ...material, + isSelected: index === materialIndex, + })) + + setInitialData((prevData) => { + const updatedMaterialResponse = structuredClone(prevData[0]) + updatedMaterialResponse.materials = updatedMaterialList + return [updatedMaterialResponse, prevData[1], prevData[2]] + }) + } + const handleReviewConfigParams = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.REVIEW_CONFIG) const handleNavigateToListView = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) @@ -417,6 +557,16 @@ const DeployImageModal = ({ } } + const handleShowAppliedFilters: DeployImageContentProps['handleShowAppliedFilters'] = (materialData) => { + setAppliedFilterList(materialData?.appliedFilters ?? []) + setShowAppliedFilters(true) + } + + const handleDisableAppliedFiltersView = () => { + setAppliedFilterList([]) + setShowAppliedFilters(false) + } + const getDeployButtonStyle = ( userActionState: string, canDeployWithoutApproval: boolean, @@ -431,6 +581,84 @@ const DeployImageModal = ({ return ButtonStyleType.default } + const setTagsEditable: DeployImageContentProps['setTagsEditable'] = (tagsEditable) => { + const newMaterialResponse = structuredClone(materialResponse) + newMaterialResponse.tagsEditable = tagsEditable + setInitialData((prevData) => [newMaterialResponse, prevData[1], prevData[2]]) + } + + const setAppReleaseTagNames: DeployImageContentProps['setAppReleaseTagNames'] = (appReleaseTagNames) => { + const newMaterialResponse = structuredClone(materialResponse) + newMaterialResponse.appReleaseTagNames = appReleaseTagNames + setInitialData((prevData) => [newMaterialResponse, prevData[1], prevData[2]]) + } + + // TODO: This state can be in DeployImageContent ig + const toggleCardMode: DeployImageContentProps['toggleCardMode'] = (index: number) => { + setMaterialInEditModeMap((prevMap) => { + const newMap = new Map(prevMap) + newMap.set(index, !newMap.get(index)) + return newMap + }) + } + + const updateCurrentAppMaterial: DeployImageContentProps['updateCurrentAppMaterial'] = ( + matId, + imageReleaseTags, + imageComment, + ) => { + const updatedMaterialList = materialList.map((material) => { + if (+material.id === +matId) { + return { + ...material, + imageReleaseTags, + imageComment, + } + } + return material + }) + setInitialData((prevData) => { + const updatedMaterialResponse = structuredClone(prevData[0]) + updatedMaterialResponse.materials = updatedMaterialList + return [updatedMaterialResponse, prevData[1], prevData[2]] + }) + } + + const onSearchApply = (newSearchText: string) => { + setSearchText(newSearchText) + const newParams = new URLSearchParams({ + ...searchParams, + search: newSearchText, + }) + + history.push({ + pathname, + search: newParams.toString(), + }) + } + + const onSearchTextChange = (newSearchText: string) => { + setSearchText(newSearchText) + } + + const handleRuntimeParamsChange: HandleRuntimeParamChange = (updatedRuntimeParamsList) => { + setInitialData((prevData) => { + const updatedMaterialResponse = structuredClone(prevData[0]) + updatedMaterialResponse.runtimeParams = updatedRuntimeParamsList + return [updatedMaterialResponse, prevData[1], prevData[2]] + }) + } + + const handleRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { + setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) + } + + const uploadRuntimeParamsFile: DeployImageContentProps['uploadRuntimeParamsFile'] = ({ + file, + allowedExtensions, + maxUploadSize, + }) => uploadCDPipelineFile({ file, allowedExtensions, maxUploadSize, appId, envId }) + const renderTriggerDeployButton = (disableDeployButton: boolean) => { const { userActionState } = deploymentWindowMetadata const canDeployWithoutApproval = getCanDeployWithoutApproval(selectedMaterial, isExceptionUser) @@ -586,7 +814,58 @@ const DeployImageModal = ({ ) } - return
+ return ( + + ) } const renderPipelineConfigDiffHeader = () => ( @@ -634,31 +913,26 @@ const DeployImageModal = ({ className="flexbox-col dc__content-space h-100 bg__modal--primary shadow__modal dc__overflow-auto" onClick={stopPropagation} > -
-
- {showConfigDiffView ? ( - renderPipelineConfigDiffHeader() - ) : ( - - )} -
{renderContent()}
-
- - {initialDataError || isInitialDataLoading ? null : ( -
- {renderFooter()} -
+
+ {showConfigDiffView ? ( + renderPipelineConfigDiffHeader() + ) : ( + )} +
{renderContent()}
+ + {initialDataError || isInitialDataLoading || materialList.length === 0 ? null : ( +
+ {renderFooter()} +
+ )}
diff --git a/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx b/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx new file mode 100644 index 0000000000..8bcd92b57d --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx @@ -0,0 +1,127 @@ +import { + Button, + ButtonVariantType, + ComponentSizeType, + EXCLUDED_IMAGE_TOOLTIP, + FilterStates, + Icon, + Tooltip, +} from '@devtron-labs/devtron-fe-common-lib' + +import { importComponentFromFELibrary } from '@Components/common' + +import { getIsMaterialApproved } from '../cdMaterials.utils' +import { ImageSelectionCTAProps } from './types' +import { getIsImageApprover } from './utils' + +const ExpireApproval = importComponentFromFELibrary('ExpireApproval') + +const ImageSelectionCTA = ({ + material, + disableSelection, + requestedUserId, + reloadMaterials, + appId, + pipelineId, + canApproverDeploy, + isExceptionUser, + handleImageSelection, +}: ImageSelectionCTAProps) => { + const isApprovalRequester = + material.userApprovalMetadata?.requestedUserData && + material.userApprovalMetadata.requestedUserData.userId === requestedUserId + const isImageApprover = getIsImageApprover(material.userApprovalMetadata) + const isMaterialApproved = getIsMaterialApproved(material.userApprovalMetadata) + + const shouldRenderExpireApproval = + isApprovalRequester && + !isImageApprover && + !disableSelection && + isMaterialApproved && + material.userApprovalMetadata?.canCurrentUserApprove + + const renderMaterialCTA = () => { + if (material.filterState !== FilterStates.ALLOWED) { + return ( + + Excluded + + ) + } + + if (material.vulnerable) { + return ( + + Security Issues Found + + ) + } + if (disableSelection || (!isExceptionUser && !canApproverDeploy && isImageApprover)) { + return ( + + + SELECT + + + ) + } + if (material.isSelected) { + return + } + + return ( + + ) + + const renderFilterEmptyStateSubtitle = (): JSX.Element => ( +

+ + is applied on + +

+ ) + + const renderLoadMoreButton = () => ( + -
-
- -
- - ) : ( - renderCDMaterialContent({ - node, - appId, - selectedAppName: appName, - workflowId, - doesWorkflowContainsWebhook: selectedCINode?.type === WorkflowNodeType.WEBHOOK, - ciNodeId: selectedCINode?.id, - }) - )} -
- - ) + return renderCDMaterialContent({ + node, + appId, + selectedAppName: appName, + workflowId, + doesWorkflowContainsWebhook: selectedCINode?.type === WorkflowNodeType.WEBHOOK, + ciNodeId: selectedCINode?.id, + }) } return null diff --git a/src/components/app/details/appDetails/AppDetails.tsx b/src/components/app/details/appDetails/AppDetails.tsx index 52b0015bb4..22ef100b1a 100644 --- a/src/components/app/details/appDetails/AppDetails.tsx +++ b/src/components/app/details/appDetails/AppDetails.tsx @@ -78,7 +78,7 @@ import RotatePodsModal from '../../../v2/appDetails/sourceInfo/rotatePods/Rotate import SyncErrorComponent from '../../../v2/appDetails/SyncError.component' import { TriggerUrlModal } from '../../list/TriggerUrl' import { fetchAppDetailsInTime, fetchResourceTreeInTime } from '../../service' -import { AggregatedNodes } from '../../types' +import { AggregatedNodes, AppDetailsCDModalType } from '../../types' import { renderCIListHeader } from '../cdDetails/utils' import { MATERIAL_TYPE } from '../triggerView/types' import AppEnvSelector from './AppDetails.components' @@ -235,7 +235,7 @@ const Details: React.FC = ({ const [rotateModal, setRotateModal] = useState(false) const [hibernating, setHibernating] = useState(false) const [showIssuesModal, toggleIssuesModal] = useState(false) - const [CDModalMaterialType, setCDModalMaterialType] = useState(null) + const [CDModalMaterialType, setCDModalMaterialType] = useState(null) const [appDetailsError, setAppDetailsError] = useState(undefined) const [hibernationPatchChartName, setHibernationPatchChartName] = useState('') diff --git a/src/components/app/details/appDetails/AppDetailsCDModal.tsx b/src/components/app/details/appDetails/AppDetailsCDModal.tsx index ffd198b866..48b6e15962 100644 --- a/src/components/app/details/appDetails/AppDetailsCDModal.tsx +++ b/src/components/app/details/appDetails/AppDetailsCDModal.tsx @@ -16,14 +16,14 @@ import { useHistory, useLocation } from 'react-router-dom' -import { DeploymentNodeType, stopPropagation, VisibleModal } from '@devtron-labs/devtron-fe-common-lib' +import { DeploymentNodeType } from '@devtron-labs/devtron-fe-common-lib' import { importComponentFromFELibrary } from '../../../common' import { URL_PARAM_MODE_TYPE } from '../../../common/helpers/types' import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManager.service' import { AppDetailsCDModalType } from '../../types' -import CDMaterial from '../triggerView/cdMaterial' import { TRIGGER_VIEW_PARAMS } from '../triggerView/Constants' +import { DeployImageModal } from '../triggerView/DeployImageModal' const ApprovalMaterialModal = importComponentFromFELibrary('ApprovalMaterialModal') @@ -70,28 +70,27 @@ const AppDetailsCDModal = ({ const renderCDModal = () => (mode === URL_PARAM_MODE_TYPE.LIST || mode === URL_PARAM_MODE_TYPE.REVIEW_CONFIG) && ( - -
- -
-
+ ) return ( diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 01ab0ad2c4..c36015c44b 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -24,7 +24,9 @@ import { RuntimeParamsErrorState, } from '../types' -export interface DeployImageModalProps { +export interface DeployViewStateType {} + +export type DeployImageModalProps = { appId: number envId: number appName: string diff --git a/src/components/app/details/triggerView/TriggerView.tsx b/src/components/app/details/triggerView/TriggerView.tsx index 73c746537d..922bb7a130 100644 --- a/src/components/app/details/triggerView/TriggerView.tsx +++ b/src/components/app/details/triggerView/TriggerView.tsx @@ -44,11 +44,9 @@ import { import { getTriggerWorkflows } from './workflow.service' import { Workflow } from './workflow/Workflow' import { MATERIAL_TYPE, TriggerViewProps, TriggerViewState } from './types' -import CDMaterial from './cdMaterial' import { URLS, ViewType } from '../../../../config' import { AppNotConfigured } from '../appDetails/AppDetails' import { getHostURLConfiguration } from '../../../../services/service' -import { ReactComponent as CloseIcon } from '../../../../assets/icons/ic-close.svg' import { TriggerViewContext } from './config' import { TRIGGER_VIEW_PARAMS, TRIGGER_VIEW_GA_EVENTS } from './Constants' import { APP_DETAILS } from '../../../../config/constantMessaging' @@ -58,6 +56,7 @@ import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' import { getExternalCIConfig } from '@Components/ciPipeline/Webhook/webhook.service' import { getSelectedNodeFromWorkflows, shouldRenderWebhookAddImageModal } from './TriggerView.utils' import { BuildImageModal } from './BuildImageModal' +import { DeployImageModal } from './DeployImageModal' const ApprovalMaterialModal = importComponentFromFELibrary('ApprovalMaterialModal') const WorkflowActionRouter = importComponentFromFELibrary('WorkflowActionRouter', null, 'function') @@ -283,43 +282,6 @@ class TriggerView extends Component { this.setState({ selectedWebhookNodeId: null }) } - renderCDMaterialContent = (cdNode: CommonNodeAttr, materialType: string) => { - const selectedWorkflow = this.state.workflows.find((wf) => wf.nodes.some((node) => node.id === cdNode.id)) - const selectedCINode = selectedWorkflow?.nodes.find((node) => node.type === 'CI' || node.type === 'WEBHOOK') - const doesWorkflowContainsWebhook = selectedCINode?.type === 'WEBHOOK' - const configurePluginURL = getCDPipelineURL( - this.props.match.params.appId, - selectedWorkflow.id, - doesWorkflowContainsWebhook ? '0' : selectedCINode?.id, - doesWorkflowContainsWebhook, - cdNode.id, - true, - ) - - return ( - - ) - } - renderCDMaterial() { if ( this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) || @@ -336,32 +298,39 @@ class TriggerView extends Component { ? MATERIAL_TYPE.inputMaterialList : MATERIAL_TYPE.rollbackMaterialList - const material = cdNode[materialType] || [] + const selectedWorkflow = this.state.workflows.find((wf) => wf.nodes.some((node) => node.id === cdNode.id)) + const selectedCINode = selectedWorkflow?.nodes.find((node) => node.type === 'CI' || node.type === 'WEBHOOK') + const doesWorkflowContainsWebhook = selectedCINode?.type === 'WEBHOOK' + const configurePluginURL = getCDPipelineURL( + this.props.match.params.appId, + selectedWorkflow.id, + doesWorkflowContainsWebhook ? '0' : selectedCINode?.id, + doesWorkflowContainsWebhook, + cdNode.id, + true, + ) return ( - -
0 ? '' : 'no-material' - }`} - onClick={stopPropagation} - > - {this.state.isLoading ? ( - <> -
- -
-
- -
- - ) : ( - this.renderCDMaterialContent(cdNode, materialType) - )} -
-
+ ) } diff --git a/src/components/app/details/triggerView/assets/cdMaterialFooter.png b/src/components/app/details/triggerView/assets/cdMaterialFooter.png deleted file mode 100644 index efeba342c3a530e5cf201faa8608c6c7860dfb70..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35544 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 z 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

bffurDgKP0C07fk9l{^xhLT97SsVqBdrwVx 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

LPGd~hL9S)8X0+8HLrD}DwjRWB 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�m7oM*Tveg@brE?%ZlMZ@x8A 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 U7PxUQx&QzG07*qoM6N<$f@}@SvH$=8 diff --git a/src/components/app/details/triggerView/assets/configHeader.png b/src/components/app/details/triggerView/assets/configHeader.png deleted file mode 100644 index 44a4abdc84c14c506c23e8fe8614e099a7c3765f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34102 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

npu_`$NAohK484Zh%gW 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 D3Ktd_ diff --git a/src/components/app/details/triggerView/cdMaterial.readme.md b/src/components/app/details/triggerView/cdMaterial.readme.md deleted file mode 100644 index 3b164bb4c7..0000000000 --- a/src/components/app/details/triggerView/cdMaterial.readme.md +++ /dev/null @@ -1,51 +0,0 @@ -# CDMaterials Documentation - -- Step - 1 : Computes Render -- Step - 2: Computes **isApprovalConfigured** -- Step - 3: If materials.length>0 then **Flow - 1** else **Flow -2** - -## Flow-1 - -Renders two different view: - -1. If coming from bulkCD render **triggerBody** -2. Otherwise render **CDModal** - -### renderTriggerBody: - -This function is the essence of this whole component, it contains three logical parts: - -- TriggerViewConfigDiff -- renderMaterialList -- Fetch more images in rollback. - -Coming to each components: - -- **TriggerViewConfigDiff**: This component is responsible for rendering the following view - ![Screenshot of Config View](./assets/configDiff.png) - -- **renderMaterialList:** This is a function returning JSX.Element containing list of materials, This function internally calls another function called **getConsumedAndAvailableMaterialList** which would contain the list of materials after filtering all the materials according to constraints. After that we compute the text to be shown on header and calls the renderMaterial function to render the material items with their detailing like Excluded state, add action button like expire approval, etc. -- **Load more:** This section aims to fetch more images from the server. This will be beneficial in case user wants more than N latest images, we will trigger load more function from which we will be fetching images from server, minding the fact that user can’t get images more than the configured set of number set in config map of devtron image. For that we will also be putting a check on the current length of materials and the limit we get from the API. - -### renderCDModal: - -1. Renders the header, if showing config view, it will render : - ![Screenshot of Config Header](./assets/configHeader.png) - - Otherwise it will render the basic header using renderCDModalHeader - -2. It renders trigger body using renderTriggerBody. -3. It renders renderTriggerModalCTA, This function is going to render the footer - - ![Screenshot of cdMaterialFooter](./assets/cdMaterialFooter.png) - -## Flow-2 - -Showing Empty states for two cases: BulkCD, CD - -Inside the empty states as well there are other cases: - -- searchImageTag: Based on this we show that on given search input we have no material. -- Based on length of eligible images passing filters we will be showing the empty state. -- Based on approvals there is going to be a state. -- Default empty state. diff --git a/src/components/app/details/triggerView/cdMaterial.tsx b/src/components/app/details/triggerView/cdMaterial.tsx deleted file mode 100644 index 8931be4245..0000000000 --- a/src/components/app/details/triggerView/cdMaterial.tsx +++ /dev/null @@ -1,2054 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useContext, useEffect, useMemo, useRef, useState } from 'react' -import ReactGA from 'react-ga4' -import { Prompt, useHistory, useLocation } from 'react-router-dom' -import Tippy from '@tippyjs/react' - -import { - abortPreviousRequests, - ACTION_STATE, - AnimatedDeployButton, - API_STATUS_CODES, - ApprovalRuntimeStateType, - ArtifactInfo, - ArtifactInfoProps, - Button, - ButtonStyleType, - ButtonVariantType, - CD_MATERIAL_SIDEBAR_TABS, - CDMaterialResponseType, - CDMaterialServiceEnum, - CDMaterialSidebarType, - CDMaterialType, - CommonNodeAttr, - ComponentSizeType, - ConditionalWrap, - DEFAULT_ROUTE_PROMPT_MESSAGE, - DEPLOYMENT_CONFIG_DIFF_SORT_KEY, - DEPLOYMENT_WINDOW_TYPE, - DeploymentAppTypes, - DeploymentNodeType, - DeploymentStrategyType, - DeploymentWithConfigType, - EnvResourceType, - ErrorScreenManager, - EXCLUDED_IMAGE_TOOLTIP, - ExcludedImageNode, - FilterConditionsListType, - FilterStates, - genericCDMaterialsService, - GenericEmptyState, - getGitCommitInfo, - getIsApprovalPolicyConfigured, - getIsMaterialInfoAvailable, - getIsRequestAborted, - GetPolicyConsequencesProps, - GitCommitInfoGeneric, - handleUTCTime, - Icon, - ImageCard, - ImageCardAccordion, - ImageTaggingContainerType, - InfoBlock, - isNullOrUndefined, - MaterialInfo, - MODAL_TYPE, - ModuleNameMap, - ModuleStatus, - noop, - PipelineDeploymentStrategy, - PipelineStageBlockInfo, - PolicyConsequencesDTO, - Progressing, - RuntimePluginVariables, - SearchBar, - SegmentedControlProps, - SequentialCDCardTitleProps, - ServerErrors, - showError, - SortingOrder, - STAGE_TYPE, - stopPropagation, - ToastManager, - ToastVariantType, - triggerCDNode, - uploadCDPipelineFile, - useAsync, - useDownload, - useGetUserRoles, - usePrompt, - UserApprovalMetadataType, - useSearchString, -} from '@devtron-labs/devtron-fe-common-lib' - -import { ReactComponent as BackIcon } from '@Icons/ic-arrow-backward.svg' -import { ReactComponent as RefreshIcon } from '@Icons/ic-arrows_clockwise.svg' -import close from '@Icons/ic-close.svg' -import { ReactComponent as SearchIcon } from '@Icons/ic-search.svg' -import noArtifact from '../../../../assets/img/no-artifact.webp' -import { URLS } from '../../../../config' -import { EMPTY_STATE_STATUS, TOAST_BUTTON_TEXT_VIEW_DETAILS } from '../../../../config/constantMessaging' -import { importComponentFromFELibrary, useAppContext } from '../../../common' -import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManager.service' -import { PipelineConfigDiffStatusTile } from './PipelineConfigDiff/PipelineConfigDiffStatusTile' -import { usePipelineDeploymentConfig } from './PipelineConfigDiff/usePipelineDeploymentConfig' -import { - getCanDeployWithoutApproval, - getCanImageApproverDeploy, - getInitialState, - getIsMaterialApproved, - getWfrId, -} from './cdMaterials.utils' -import { CDButtonLabelMap, TriggerViewContext } from './config' -import { CD_MATERIAL_GA_EVENT, TRIGGER_VIEW_GA_EVENTS, TRIGGER_VIEW_PARAMS } from './Constants' -import { PipelineConfigDiff } from './PipelineConfigDiff' -import { - LAST_SAVED_CONFIG_OPTION, - LATEST_TRIGGER_CONFIG_OPTION, - SPECIFIC_TRIGGER_CONFIG_OPTION, -} from './TriggerView.utils' -import { - BulkSelectionEvents, - CDMaterialProps, - CDMaterialState, - FilterConditionViews, - MATERIAL_TYPE, - RenderCTAType, - RuntimeParamsErrorState, - TriggerViewContextType, -} from './types' -import { URL_PARAM_MODE_TYPE } from '@Components/common/helpers/types' - -const ApprovalInfoTippy = importComponentFromFELibrary('ApprovalInfoTippy') -const ExpireApproval = importComponentFromFELibrary('ExpireApproval') -const ApprovedImagesMessage = importComponentFromFELibrary('ApprovedImagesMessage') -const ApprovalEmptyState = importComponentFromFELibrary('ApprovalEmptyState') -const FilterActionBar = importComponentFromFELibrary('FilterActionBar') -const ConfiguredFilters = importComponentFromFELibrary('ConfiguredFilters') -const CDMaterialInfo = importComponentFromFELibrary('CDMaterialInfo') -const downloadManifestForVirtualEnvironment = importComponentFromFELibrary( - 'downloadManifestForVirtualEnvironment', - null, - 'function', -) -const ImagePromotionInfoChip = importComponentFromFELibrary('ImagePromotionInfoChip', null, 'function') -const getDeploymentWindowProfileMetaData = importComponentFromFELibrary( - 'getDeploymentWindowProfileMetaData', - null, - 'function', -) -const MaintenanceWindowInfoBar = importComponentFromFELibrary('MaintenanceWindowInfoBar') -const DeploymentWindowConfirmationDialog = importComponentFromFELibrary('DeploymentWindowConfirmationDialog') -const RuntimeParamTabs = importComponentFromFELibrary('RuntimeParamTabs', null, 'function') -const RuntimeParameters = importComponentFromFELibrary('RuntimeParameters', null, 'function') -const SecurityModalSidebar = importComponentFromFELibrary('SecurityModalSidebar', null, 'function') -const AllowedWithWarningTippy = importComponentFromFELibrary('AllowedWithWarningTippy') -const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlockState', null, 'function') -const TriggerBlockEmptyState = importComponentFromFELibrary('TriggerBlockEmptyState', null, 'function') -const getPolicyConsequences: ({ appId, envId }: GetPolicyConsequencesProps) => Promise = - importComponentFromFELibrary('getPolicyConsequences', null, 'function') -const getRuntimeParamsPayload = importComponentFromFELibrary('getRuntimeParamsPayload', null, 'function') -const validateRuntimeParameters = importComponentFromFELibrary( - 'validateRuntimeParameters', - () => ({ isValid: true, cellError: {} }), - 'function', -) -const SelectDeploymentStrategy = importComponentFromFELibrary('SelectDeploymentStrategy', null, 'function') -const getDeploymentStrategies: (pipelineIds: number[]) => Promise = - importComponentFromFELibrary('getDeploymentStrategies', null, 'function') - -const CDMaterial = ({ - materialType, - appId, - envId, - pipelineId, - stageType, - isFromBulkCD, - envName, - closeCDModal, - triggerType, - isVirtualEnvironment, - parentEnvironmentName, - isLoading, - // Handle the case of external pipeline, it might be undefined or zero in that case - ciPipelineId, - updateCurrentAppMaterial, - hideInfoTabsContainer, - isSaveLoading, - // WARNING: Be mindful that we need to send materials instead of material since its expecting response - updateBulkCDMaterialsItem, - // Have'nt sent this from Bulk since not required - deploymentAppType, - selectedImageFromBulk, - isRedirectedFromAppDetails, - selectedAppName, - bulkRuntimeParams, - handleBulkRuntimeParamChange, - bulkRuntimeParamErrorState, - handleBulkRuntimeParamError, - bulkSidebarTab, - showPluginWarningBeforeTrigger: _showPluginWarningBeforeTrigger = false, - consequence, - configurePluginURL, - isTriggerBlockedDueToPlugin, - bulkUploadFile, - handleSuccess, -}: Readonly) => { - // stageType should handle approval node, compute CDMaterialServiceEnum, create queryParams state - // FIXME: the query params returned by useSearchString seems faulty - const history = useHistory() - const { pathname } = useLocation() - const { searchParams } = useSearchString() - const { handleDownload } = useDownload() - // Add dep here - const { isSuperAdmin } = useGetUserRoles() - // NOTE: Won't be available in app group will use data from props for that - // DO Not consume directly, use appName variable instead - const { currentAppName } = useAppContext() - - const appName = selectedAppName || currentAppName - - const searchImageTag = searchParams.search - - const [material, setMaterial] = useState([]) - const [state, setState] = useState(getInitialState(materialType, material, searchImageTag)) - const [deploymentStrategy, setDeploymentStrategy] = useState(null) - - // It is derived from materialResult and can be fixed as a constant fix this - const [isConsumedImageAvailable, setIsConsumedImageAvailable] = useState(false) - const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) - // Should be able to abort request using useAsync - const abortControllerRef = useRef(new AbortController()) - const abortDeployRef = useRef(null) - - const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD - const isCDNode = stageType === DeploymentNodeType.CD - const showPluginWarningBeforeTrigger = _showPluginWarningBeforeTrigger && isPreOrPostCD - // This check assumes we have isPreOrPostCD as true - const allowWarningWithTippyNodeTypeProp: CommonNodeAttr['type'] = - stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' - - // this function can be used for other trigger block reasons once api supports it - const getIsTriggerBlocked = (cdPolicyConsequences: PipelineStageBlockInfo) => { - switch (stageType) { - case DeploymentNodeType.PRECD: - return cdPolicyConsequences.pre.isBlocked - case DeploymentNodeType.POSTCD: - return cdPolicyConsequences.post.isBlocked - case DeploymentNodeType.CD: - return cdPolicyConsequences.node.isBlocked - default: - return false - } - } - - const getMaterialResponseList = async (): Promise<[CDMaterialResponseType, any, PolicyConsequencesDTO]> => { - const response = await Promise.all([ - genericCDMaterialsService( - materialType === MATERIAL_TYPE.rollbackMaterialList - ? CDMaterialServiceEnum.ROLLBACK - : CDMaterialServiceEnum.CD_MATERIALS, - pipelineId, - // Don't think need to set stageType to approval in case of approval node - stageType ?? DeploymentNodeType.CD, - abortControllerRef.current.signal, - // It is meant to fetch the first 20 materials - { - offset: 0, - size: 20, - search: searchImageTag, - // Since by default we are setting filterView to eligible and in case of no filters everything is eligible - // So there should'nt be any additional api call - // NOTE: Uncomment this when backend supports the filtering, there will be some minor handling like number of images in segmented control - // filter: - // state.filterView === FilterConditionViews.ELIGIBLE && !state.searchApplied - // ? CDMaterialFilterQuery.RESOURCE - // : null, - }, - ), - getDeploymentWindowProfileMetaData && !isFromBulkCD - ? getDeploymentWindowProfileMetaData(appId, envId) - : null, - getPolicyConsequences ? getPolicyConsequences({ appId, envId }) : null, - ]) - - if (getPolicyConsequences && getIsTriggerBlocked(response[2].cd)) { - return [null, null, response[2]] - } - return response - } - - // TODO: Ask if pipelineId always changes on change of app else add appId as dependency - const [loadingMaterials, responseList, materialsError, reloadMaterials] = useAsync( - () => abortPreviousRequests(() => getMaterialResponseList(), abortControllerRef), - // NOTE: Add state.filterView if want to add filtering support from backend - [pipelineId, stageType, materialType, searchImageTag], - !!pipelineId && !isTriggerBlockedDueToPlugin, - ) - - const [pipelineStrategiesLoading, pipelineStrategies, pipelineStrategiesError, reloadStrategies] = useAsync( - () => getDeploymentStrategies([pipelineId]), - [pipelineId], - !!getDeploymentStrategies && !!pipelineId, - ) - - const materialsResult: CDMaterialResponseType = responseList?.[0] - const deploymentWindowMetadata = responseList?.[1] ?? {} - - const { onClickApprovalNode } = useContext(TriggerViewContext) - const [noMoreImages, setNoMoreImages] = useState(false) - const [tagsEditable, setTagsEditable] = useState(false) - const [appReleaseTagNames, setAppReleaseTagNames] = useState([]) - const [showAppliedFilters, setShowAppliedFilters] = useState(false) - const [deploymentLoading, setDeploymentLoading] = useState(false) - const [appliedFilterList, setAppliedFilterList] = useState([]) - // ----- RUNTIME PARAMS States (To be overridden by parent props in case of bulk) ------- - const [currentSidebarTab, setCurrentSidebarTab] = useState(CDMaterialSidebarType.IMAGE) - const [runtimeParamsList, setRuntimeParamsList] = useState([]) - const [runtimeParamsErrorState, setRuntimeParamsErrorState] = useState({ - isValid: true, - cellError: {}, - }) - const [value, setValue] = useState() - const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) - - const resourceFilters = materialsResult?.resourceFilters ?? [] - const hideImageTaggingHardDelete = materialsResult?.hideImageTaggingHardDelete ?? false - const requestedUserId = materialsResult?.requestedUserId ?? '' - const isApprovalConfigured = getIsApprovalPolicyConfigured( - materialsResult?.deploymentApprovalInfo?.approvalConfigData, - ) - const canApproverDeploy = materialsResult?.canApproverDeploy ?? false - const showConfigDiffView = searchParams.mode === URL_PARAM_MODE_TYPE.REVIEW_CONFIG && searchParams.deploy - const isExceptionUser = materialsResult?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false - - const pipelineStrategyOptions = useMemo( - () => - (pipelineStrategies ?? []).flatMap(({ error, strategies }) => { - if (error) { - return [] - } - return strategies - }), - [pipelineStrategies], - ) - - const { - pipelineDeploymentConfigLoading, - pipelineDeploymentConfig, - radioSelectConfig, - diffFound, - noLastDeploymentConfig, - noSpecificDeploymentConfig, - canDeployWithConfig, - canReviewConfig, - scopeVariablesConfig, - urlFilters, - lastDeploymentWfrId, - errorConfig, - } = usePipelineDeploymentConfig({ - appId, - envId, - appName, - envName, - deploymentStrategy, - setDeploymentStrategy, - pipelineStrategyOptions, - isRollbackTriggerSelected: state.isRollbackTrigger, - pipelineId, - wfrId: getWfrId(state.selectedMaterial, material), - }) - - usePrompt({ shouldPrompt: deploymentLoading }) - - /* ------------ Utils required in useEffect ------------*/ - const getSecurityModuleStatus = async () => { - try { - const { result } = await getModuleInfo(ModuleNameMap.SECURITY) - if (result?.status === ModuleStatus.INSTALLED) { - setState((prevState) => ({ ...prevState, isSecurityModuleInstalled: true })) - } - } catch (error) { - setState((prevState) => ({ ...prevState, isSecurityModuleInstalled: false })) - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Issue while fetching security module status', - }) - } - } - - // Ask whether this id is true or not - const getCDArtifactId = () => - state.selectedMaterial ? state.selectedMaterial.id : material?.find((_mat) => _mat.isSelected)?.id - - const setSearchValue = (searchValue: string) => { - const newParams: any = { - ...searchParams, - search: searchValue, - } - - if (!searchValue) { - delete newParams.search - } - - history.push({ - search: new URLSearchParams(newParams).toString(), - }) - } - - /* ------------ UseEffects ------------*/ - useEffect(() => { - abortDeployRef.current = new AbortController() - return () => { - abortDeployRef.current.abort() - if (history.location.pathname.includes(URLS.APP_DIFF_VIEW)) { - history.replace(history.location.pathname.split(URLS.APP_DIFF_VIEW)[0]) - } - } - }, []) - - useEffect(() => { - if (materialsError) { - showError(materialsError) - return - } - - if (!loadingMaterials && materialsResult) { - if (selectedImageFromBulk) { - const selectedImageIndex = materialsResult.materials.findIndex( - (materialItem) => materialItem.image === selectedImageFromBulk, - ) - if (selectedImageIndex === -1 && selectedImageFromBulk !== BulkSelectionEvents.SELECT_NONE) { - setSearchValue(selectedImageFromBulk) - } else { - const _newMaterials = [...materialsResult.materials] - if (selectedImageIndex !== -1) { - _newMaterials[selectedImageIndex].isSelected = true - _newMaterials.forEach((mat, index) => { - if (index !== selectedImageIndex) { - mat.isSelected = false - } - }) - } else { - _newMaterials.forEach((mat) => { - mat.isSelected = false - }) - } - - setTagsEditable(materialsResult.tagsEditable) - setAppReleaseTagNames(materialsResult.appReleaseTagNames) - setNoMoreImages(materialsResult.materials.length >= materialsResult.totalCount) - setRuntimeParamsList(materialsResult.runtimeParams) - - setMaterial(_newMaterials) - const _isConsumedImageAvailable = - _newMaterials.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false - - setIsConsumedImageAvailable(_isConsumedImageAvailable) - - getSecurityModuleStatus() - - const _newBulkResponse = { - ...materialsResult, - materials: _newMaterials, - } - updateBulkCDMaterialsItem?.(_newBulkResponse) - } - } else { - setTagsEditable(materialsResult.tagsEditable) - setAppReleaseTagNames(materialsResult.appReleaseTagNames) - setNoMoreImages(materialsResult.materials.length >= materialsResult.totalCount) - setRuntimeParamsList(materialsResult.runtimeParams) - - setMaterial(materialsResult.materials) - const _isConsumedImageAvailable = - materialsResult.materials?.some((materialItem) => materialItem.deployed && materialItem.latest) ?? - false - - setIsConsumedImageAvailable(_isConsumedImageAvailable) - - getSecurityModuleStatus() - - updateBulkCDMaterialsItem?.(materialsResult) - } - } - }, [materialsResult, loadingMaterials]) - - useEffect(() => { - // selectedImage is going to be updated since on selection of image we are updating the state - if (selectedImageFromBulk && material?.length) { - const selectedImageIndex = material.findIndex( - (materialItem) => materialItem.image === selectedImageFromBulk, - ) - - if (selectedImageFromBulk === BulkSelectionEvents.SELECT_NONE) { - const _newMaterials = [...material] - _newMaterials.forEach((mat) => { - mat.isSelected = false - }) - setMaterial([..._newMaterials]) - updateBulkCDMaterialsItem?.({ - ...materialsResult, - materials: _newMaterials, - }) - } else if (selectedImageIndex === -1) { - setSearchValue(selectedImageFromBulk) - } else if (!material[selectedImageIndex].isSelected) { - const _newMaterials = [...material] - _newMaterials[selectedImageIndex].isSelected = true - _newMaterials.forEach((mat, index) => { - if (index !== selectedImageIndex) { - mat.isSelected = false - } - }) - setMaterial([..._newMaterials]) - updateBulkCDMaterialsItem?.({ - ...materialsResult, - materials: _newMaterials, - }) - } - } - }, [material, selectedImageFromBulk]) - - useEffect(() => { - if (searchImageTag) { - setState((prevState) => ({ - ...prevState, - searchApplied: true, - showSearch: true, - searchText: searchImageTag, - })) - } else { - setState((prevState) => ({ - ...prevState, - searchApplied: false, - showSearch: false, - searchText: '', - })) - } - }, [searchImageTag]) - - useEffect(() => { - setState((prevState) => ({ - ...prevState, - selectedMaterial: material.find((_mat) => _mat.isSelected), - areMaterialsPassingFilters: - material.filter((materialDetails) => materialDetails.filterState === FilterStates.ALLOWED).length > 0, - })) - // The above states are derived from material so no need to make a state for them and shift the config diff here - }, [material]) - - const getInitialSelectedConfigToDeploy = () => { - if ( - (materialType === MATERIAL_TYPE.rollbackMaterialList && !searchParams.deploy) || - searchParams.deploy === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG - ) { - return SPECIFIC_TRIGGER_CONFIG_OPTION - } - if (searchParams.deploy === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG) { - return LATEST_TRIGGER_CONFIG_OPTION - } - return LAST_SAVED_CONFIG_OPTION - } - useEffect(() => { - setState((prevState) => ({ - ...prevState, - isRollbackTrigger: materialType === MATERIAL_TYPE.rollbackMaterialList, - isSelectImageTrigger: materialType === MATERIAL_TYPE.inputMaterialList, - selectedConfigToDeploy: getInitialSelectedConfigToDeploy(), - })) - }, [materialType]) - - useEffect(() => { - if (searchParams.deploy) { - setState((prevState) => ({ - ...prevState, - selectedConfigToDeploy: getInitialSelectedConfigToDeploy(), - })) - } - }, [searchParams.deploy]) - - useEffect(() => { - setState((prevState) => ({ - ...prevState, - filterView: FilterConditionViews.ELIGIBLE, - showConfiguredFilters: false, - })) - setShowAppliedFilters(false) - }, [appId]) - - /* ------------ Helping utilities ------------*/ - const handleImageSelection = (index: number, selectedMaterial: CDMaterialType) => { - const _updatedMaterial = [...material] - _updatedMaterial[index].isSelected = true - - _updatedMaterial.forEach((mat, _index) => { - if (_index !== index) { - mat.isSelected = false - } - }) - - setMaterial(_updatedMaterial) - if ( - (materialType === 'none' || state.isSelectImageTrigger) && - state.selectedMaterial?.image !== selectedMaterial.image - ) { - setState((prevState) => ({ - ...prevState, - selectedMaterial, - })) - } - - // We have to update the parent state in case of bulkCD - updateBulkCDMaterialsItem?.({ - ...materialsResult, - materials: _updatedMaterial, - }) - } - - const handleDisableFiltersView = (e: React.MouseEvent) => { - e.stopPropagation() - setState((prevState) => ({ - ...prevState, - showConfiguredFilters: false, - })) - } - - const handleRuntimeParamChange: typeof handleBulkRuntimeParamChange = (updatedRuntimeParams) => { - setRuntimeParamsList(updatedRuntimeParams) - } - - const onRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { - setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) - } - - const handleUploadFile: typeof bulkUploadFile = ({ file, allowedExtensions, maxUploadSize }) => - uploadCDPipelineFile({ file, allowedExtensions, maxUploadSize, appId, envId }) - - // RUNTIME PARAMETERS PROPS - const parameters = bulkRuntimeParams || runtimeParamsList - const errorState = bulkRuntimeParamErrorState || runtimeParamsErrorState - const handleRuntimeParamsChange = handleBulkRuntimeParamChange || handleRuntimeParamChange - const handleRuntimeParamsError = handleBulkRuntimeParamError || onRuntimeParamsError - const uploadRuntimeParamsFile = bulkUploadFile || handleUploadFile - - const clearSearch = (e: React.MouseEvent): void => { - stopPropagation(e) - if (state.searchText) { - setSearchValue('') - } - } - - const viewAllImages = (e: React.MouseEvent) => { - e.stopPropagation() - if (isRedirectedFromAppDetails) { - history.push({ - search: `${TRIGGER_VIEW_PARAMS.APPROVAL_NODE}=${pipelineId}&${TRIGGER_VIEW_PARAMS.APPROVAL_STATE}=${TRIGGER_VIEW_PARAMS.APPROVAL}`, - }) - } else { - closeCDModal(e) - onClickApprovalNode(pipelineId) - } - } - - const getIsApprovalRequester = (userApprovalMetadata?: UserApprovalMetadataType) => - userApprovalMetadata?.requestedUserData && userApprovalMetadata.requestedUserData.userId === requestedUserId - - const getIsImageApprover = (userApprovalMetadata?: UserApprovalMetadataType): boolean => - userApprovalMetadata?.hasCurrentUserApproved - - // NOTE: Pure - const getApprovedImageClass = (disableSelection: boolean, isApprovalConfigured: boolean) => { - const disabledClassPostfix = disableSelection ? '-disabled' : '' - return isApprovalConfigured ? `material-history__approved-image${disabledClassPostfix}` : '' - } - - const toggleCardMode = (index) => { - setState((prevState) => { - const _isEditModeList = new Map(prevState.materialInEditModeMap) - _isEditModeList.set(index, !_isEditModeList.get(index)) - return { - ...prevState, - materialInEditModeMap: _isEditModeList, - } - }) - } - - const processConsumedAndApprovedImages = () => { - const consumedImage = [] - const approvedImages = [] - material.forEach((mat) => { - if ( - !mat.userApprovalMetadata || - mat.userApprovalMetadata.approvalRuntimeState !== ApprovalRuntimeStateType.approved - ) { - mat.isSelected = false - consumedImage.push(mat) - } else { - approvedImages.push(mat) - } - }) - return { consumedImage, approvedImages } - } - - const getConsumedAndAvailableMaterialList = (isApprovalConfigured: boolean) => { - if (isExceptionUser) { - return { - consumedImage: [], - materialList: material, - eligibleImagesCount: material.filter((mat) => mat.filterState === FilterStates.ALLOWED).length, - } - } - - let _consumedImage = [] - let materialList: CDMaterialType[] = [] - - if (isApprovalConfigured) { - const { consumedImage, approvedImages } = processConsumedAndApprovedImages() - _consumedImage = consumedImage - materialList = approvedImages - } else { - materialList = material - } - - const eligibleImagesCount = materialList.filter((mat) => mat.filterState === FilterStates.ALLOWED).length - - if (!state.searchApplied && resourceFilters?.length && state.filterView === FilterConditionViews.ELIGIBLE) { - materialList = materialList.filter((mat) => mat.filterState === FilterStates.ALLOWED) - } - - return { - consumedImage: _consumedImage, - materialList, - eligibleImagesCount, - } - } - - const handleRefresh = (e) => { - stopPropagation(e) - - if (state.searchApplied) { - reloadMaterials() - } else { - reloadMaterials() - - setState((prevState) => ({ - ...prevState, - showSearch: false, - })) - } - } - - const handleSearchClick = (e) => { - stopPropagation(e) - setState((prevState) => ({ - ...prevState, - showSearch: true, - })) - } - - const handleFilterKeyPress = (_searchText: string): void => { - setState({ - ...state, - searchText: _searchText, - }) - if (_searchText !== searchImageTag) { - setSearchValue(_searchText) - } - } - - const handleEnableFiltersView = (e: React.MouseEvent) => { - e.stopPropagation() - setState((prevState) => ({ - ...prevState, - showConfiguredFilters: true, - })) - } - - const handleSidebarTabChange = (e: React.ChangeEvent) => { - setCurrentSidebarTab(e.target.value as CDMaterialSidebarType) - } - - const handleFilterTabsChange: SegmentedControlProps['onChange'] = (selectedSegment) => { - const { value } = selectedSegment - setState((prevState) => ({ - ...prevState, - filterView: value as FilterConditionViews, - })) - } - - const handleAllImagesView = (e: React.MouseEvent) => { - e.stopPropagation() - setState((prevState) => ({ - ...prevState, - filterView: FilterConditionViews.ALL, - })) - } - - const getFilterActionBarTabs = (filteredImagesCount: number, consumedImageCount: number) => [ - { - label: `Eligible images ${filteredImagesCount}/${material.length - consumedImageCount}`, - value: FilterConditionViews.ELIGIBLE, - }, - { - label: `Latest ${material.length - consumedImageCount} images`, - value: FilterConditionViews.ALL, - }, - ] - - const getConfigToDeployValue = () => { - if (searchParams.deploy) { - return searchParams.deploy - } - if (materialType === MATERIAL_TYPE.rollbackMaterialList) { - return DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG - } - return DeploymentWithConfigType.LAST_SAVED_CONFIG - } - - const onClickSetInitialParams = (modeParamValue: URL_PARAM_MODE_TYPE) => { - const newParams = new URLSearchParams({ - ...searchParams, - sortBy: DEPLOYMENT_CONFIG_DIFF_SORT_KEY, - sortOrder: SortingOrder.ASC, - mode: modeParamValue, - deploy: getConfigToDeployValue(), - }) - - if (modeParamValue === URL_PARAM_MODE_TYPE.LIST) { - newParams.delete('sortOrder') - newParams.delete('sortBy') - } - - history.push({ - pathname: - modeParamValue === URL_PARAM_MODE_TYPE.REVIEW_CONFIG - ? // Replace consecutive trailing single slashes - `${pathname.replace(/\/+$/g, '')}/${URLS.APP_DIFF_VIEW}/${EnvResourceType.DeploymentTemplate}` - : `${pathname.split(`/${URLS.APP_DIFF_VIEW}`)[0]}`, - search: newParams.toString(), - }) - } - - const isDeployButtonDisabled = () => { - const selectedImage = material.find((artifact) => artifact.isSelected) - - return ( - !selectedImage || - !state.areMaterialsPassingFilters || - (state.isRollbackTrigger && (pipelineDeploymentConfigLoading || !canDeployWithConfig())) || - (state.selectedConfigToDeploy.value === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG && - noLastDeploymentConfig) - ) - } - - // NOTE: In the three functions below we already have data from props so can be handled in a better way - const redirectToDeploymentStepsPage = (cdPipelineId: number, environmentId: number) => { - history.push(`/app/${appId}/cd-details/${environmentId}/${cdPipelineId}`) - } - - const handleTriggerErrorMessageForHelmManifestPush = ( - serverError: any, - cdPipelineId: number, - environmentId: number, - ) => { - if ( - serverError instanceof ServerErrors && - Array.isArray(serverError.errors) && - serverError.code !== 403 && - serverError.code !== 408 && - !getIsRequestAborted(searchParams) - ) { - serverError.errors.map(({ userMessage, internalMessage }) => { - ToastManager.showToast( - { - variant: ToastVariantType.error, - description: userMessage ?? internalMessage, - buttonProps: { - text: TOAST_BUTTON_TEXT_VIEW_DETAILS, - dataTestId: 'cd-material-view-details-btns', - onClick: () => redirectToDeploymentStepsPage(cdPipelineId, environmentId), - }, - }, - { - autoClose: false, - }, - ) - }) - } else { - showError(serverError) - } - } - - const showErrorIfNotAborted = (errors: ServerErrors) => { - if (!getIsRequestAborted(errors)) { - showError(errors) - } - } - - const handleDeployment = ( - nodeType: DeploymentNodeType, - _appId: number, - ciArtifactId: number, - e: React.MouseEvent, - deploymentWithConfig?: string, - wfrId?: number, - ) => { - const updatedRuntimeParamsErrorState = validateRuntimeParameters(parameters) - handleRuntimeParamsError(updatedRuntimeParamsErrorState) - if (!updatedRuntimeParamsErrorState.isValid) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Please resolve all the errors before deploying', - }) - return - } - - ReactGA.event(TRIGGER_VIEW_GA_EVENTS.CDTriggered(nodeType)) - setDeploymentLoading(true) - - if (_appId && pipelineId && ciArtifactId) { - triggerCDNode({ - pipelineId: Number(pipelineId), - ciArtifactId: Number(ciArtifactId), - appId: Number(_appId), - stageType: nodeType, - deploymentWithConfig, - wfrId, - abortControllerRef: abortDeployRef, - isRollbackTrigger: state.isRollbackTrigger, - ...(getRuntimeParamsPayload - ? { runtimeParamsPayload: getRuntimeParamsPayload(runtimeParamsList ?? []) } - : {}), - skipIfHibernated: false, - ...(SelectDeploymentStrategy && deploymentStrategy ? { strategy: deploymentStrategy } : {}), - }) - .then((response: any) => { - if (response.result) { - if (isVirtualEnvironment && deploymentAppType == DeploymentAppTypes.MANIFEST_DOWNLOAD) { - const { helmPackageName } = response.result - downloadManifestForVirtualEnvironment?.({ - appId: _appId, - envId, - helmPackageName, - cdWorkflowType: nodeType, - handleDownload, - }) - } - - const msg = - materialType == MATERIAL_TYPE.rollbackMaterialList - ? 'Rollback Initiated' - : 'Deployment Initiated' - - ToastManager.showToast({ - variant: ToastVariantType.success, - description: msg, - }) - setDeploymentLoading(false) - handleSuccess?.() - closeCDModal(e) - } - }) - .catch((errors: ServerErrors) => { - // TODO: Ask why this was only there in TriggerView - isVirtualEnvironment && deploymentAppType == DeploymentAppTypes.MANIFEST_PUSH - ? handleTriggerErrorMessageForHelmManifestPush(errors, pipelineId, envId) - : showErrorIfNotAborted(errors) - setDeploymentLoading(false) - }) - } else { - let message = _appId ? '' : 'app id missing ' - message += pipelineId ? '' : 'pipeline id missing ' - message += ciArtifactId ? '' : 'Artifact id missing ' - ToastManager.showToast({ - variant: ToastVariantType.error, - description: message, - }) - setDeploymentLoading(false) - } - } - - const deployTrigger = (e: React.MouseEvent) => { - e.stopPropagation() - handleConfirmationClose(e) - // Blocking the deploy action if already deploying or config is not available - if (isLoading || isDeployButtonDisabled()) { - return - } - - if (state.isRollbackTrigger || state.isSelectImageTrigger) { - const wfrId = state.isRollbackTrigger ? getWfrId(state.selectedMaterial, material) : lastDeploymentWfrId - handleDeployment(stageType, appId, Number(getCDArtifactId()), e, state.selectedConfigToDeploy.value, wfrId) - return - } - - handleDeployment(stageType, appId, Number(getCDArtifactId()), e) - } - - const loadOlderImages = () => { - ReactGA.event(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) - if (!state.loadingMore) { - setState((prevState) => ({ - ...prevState, - loadingMore: true, - })) - - abortControllerRef.current.abort() - abortControllerRef.current = new AbortController() - - genericCDMaterialsService( - materialType === MATERIAL_TYPE.rollbackMaterialList - ? CDMaterialServiceEnum.ROLLBACK - : CDMaterialServiceEnum.CD_MATERIALS, - pipelineId, - stageType, - abortControllerRef.current.signal, - { - offset: material.length - Number(isConsumedImageAvailable), - size: 20, - search: searchImageTag, - }, - ) - .then((materialsResponse) => { - if (materialsResponse) { - // NOTE: Looping through _newResponse and removing elements that are already deployed and latest - // NOTE: This is done to avoid duplicate images - const filteredNewMaterialResponse = [...materialsResponse.materials].filter( - (materialItem) => !(materialItem.deployed && materialItem.latest), - ) - - // updating the index of materials to maintain consistency - const _newMaterialsResponse = filteredNewMaterialResponse.map((materialItem, index) => ({ - ...materialItem, - index: material.length + index, - })) - - const _newMaterials = material.concat(_newMaterialsResponse) - setMaterial(_newMaterials) - - const _updatedMaterialResponse = { - ...materialsResponse, - materials: _newMaterials, - } - updateBulkCDMaterialsItem?.(_updatedMaterialResponse) - setNoMoreImages(_newMaterials.length >= materialsResponse.totalCount) - - const baseSuccessMessage = `Fetched ${_newMaterialsResponse.length} images.` - if (resourceFilters?.length && !state.searchApplied) { - const eligibleImages = _newMaterialsResponse.filter( - (mat) => mat.filterState === FilterStates.ALLOWED, - ).length - - const infoMessage = - eligibleImages === 0 - ? 'No new eligible images found.' - : `${eligibleImages} new eligible images found.` - - if (state.filterView === FilterConditionViews.ELIGIBLE) { - ToastManager.showToast({ - variant: ToastVariantType.info, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: baseSuccessMessage, - }) - } - } - }) - .catch((error) => { - showError(error) - }) - .finally(() => { - setState((prevState) => ({ - ...prevState, - loadingMore: false, - })) - }) - } - } - - /* ------------ Render Utilities ------------*/ - const renderGenerateButton = () => ( - - ) - - const renderLoadMoreButton = () => ( - - is applied on - -

- ) - - const renderEmptyState = ( - isApprovalConfigured: boolean, - consumedImagePresent?: boolean, - noEligibleImages?: boolean, - ) => { - if (isTriggerBlockedDueToPlugin && MissingPluginBlockState) { - return ( - - ) - } - - if (TriggerBlockEmptyState && getIsTriggerBlocked(responseList?.[2]?.cd)) { - return - } - - if ( - resourceFilters?.length && - noEligibleImages && - !state.searchApplied && - material.length - Number(consumedImagePresent) > 0 - ) { - return ( - - ) - } - - if (searchImageTag) { - return ( - - ) - } - - if (ApprovalEmptyState && isApprovalConfigured && !isExceptionUser) { - return ( - - ) - } - - return ( - - ) - } - - const handleShowAppliedFilters = (e: React.MouseEvent, materialData: CDMaterialType) => { - e.stopPropagation() - setAppliedFilterList(materialData?.appliedFilters ?? []) - setShowAppliedFilters(true) - } - - const handleDisableAppliedFiltersView = (e: React.MouseEvent) => { - e.stopPropagation() - setAppliedFilterList([]) - setShowAppliedFilters(false) - } - - const reloadMaterialsPropagation = (e: React.MouseEvent) => { - e.stopPropagation() - reloadMaterials() - } - - const renderGitMaterialInfo = (materialData: CDMaterialType) => ( - <> - {materialData.materialInfo.map((mat: MaterialInfo, index) => { - const _gitCommit = getGitCommitInfo(mat) - - if ( - (materialData.appliedFilters?.length > 0 || - materialData.deploymentBlockedState?.isBlocked || - materialData.deploymentWindowArtifactMetadata?.type) && - CDMaterialInfo - ) { - return ( - handleShowAppliedFilters(e, materialData)} - filterState={materialData.appliedFiltersState} - dataSource={materialData.dataSource} - deploymentWindowArtifactMetadata={materialData.deploymentWindowArtifactMetadata} - isFilterApplied={materialData.appliedFilters?.length > 0} - triggerBlockedInfo={materialData.deploymentBlockedState} - > - {(_gitCommit.WebhookData?.Data || - _gitCommit.Author || - _gitCommit.Message || - _gitCommit.Date || - _gitCommit.Commit) && ( - - )} - - ) - } - - // FIXME: Key seems to be missing here, look into this issue later - return ( - (_gitCommit.WebhookData?.Data || - _gitCommit.Author || - _gitCommit.Message || - _gitCommit.Date || - _gitCommit.Commit) && ( -
- -
- ) - ) - })} - - ) - - const renderMaterialCTA = ( - mat: CDMaterialType, - isImageApprover: boolean = false, - disableSelection: boolean = false, - shouldRenderExpireApproval: boolean = false, - ) => { - if (mat.filterState !== FilterStates.ALLOWED) { - return ( - - Excluded - - ) - } - - if (mat.vulnerable) { - return ( - - Security Issues Found - - ) - } - if (disableSelection || (!isExceptionUser && !canApproverDeploy && isImageApprover)) { - return ( - - - SELECT - - - ) - } - if (mat.isSelected) { - return - } - const cursorClass = mat.isSelected ? 'cursor-default' : 'cursor' - const selectClassName = mat.vulnerable ? 'cursor-not-allowed' : cursorClass - - return ( - { - event.stopPropagation() - handleImageSelection(mat.index, mat) - }} - data-testid={`cd-artifact-select-${mat.index}`} - > - SELECT - - ) - } - - const renderCTA = ({ mat, disableSelection }: RenderCTAType) => { - const isApprovalRequester = getIsApprovalRequester(mat.userApprovalMetadata) - const isImageApprover = getIsImageApprover(mat.userApprovalMetadata) - const isMaterialApproved = getIsMaterialApproved(mat.userApprovalMetadata) - - const shouldRenderExpireApproval = - materialType !== MATERIAL_TYPE.none && - isApprovalRequester && - !isImageApprover && - !disableSelection && - isMaterialApproved && - mat.userApprovalMetadata?.canCurrentUserApprove - - return ( - <> - {shouldRenderExpireApproval && ExpireApproval && ( - <> - - - {mat.filterState !== FilterStates.ALLOWED && ( -
-
-
- )} - - )} - {renderMaterialCTA(mat, isImageApprover, disableSelection, shouldRenderExpireApproval)} - - ) - } - - // Not sending approvalChecksNode as it is not required in this case - const getArtifactInfoProps = (mat: CDMaterialType, showApprovalInfoTippy: boolean): ArtifactInfoProps => ({ - imagePath: mat.imagePath, - registryName: mat.registryName, - registryType: mat.registryType, - image: mat.image, - deployedTime: mat.deployedTime, - deployedBy: mat.deployedBy, - isRollbackTrigger: state.isRollbackTrigger, - excludedImagePathNode: - mat.filterState === FilterStates.ALLOWED ? null : , - approvalInfoTippy: showApprovalInfoTippy ? ( - - ) : null, - }) - - const getImageTagContainerProps = (mat: CDMaterialType): ImageTaggingContainerType => ({ - ciPipelineId, - artifactId: +mat.id, - imageComment: mat.imageComment, - imageReleaseTags: mat.imageReleaseTags, - appReleaseTagNames, - setAppReleaseTagNames, - tagsEditable, - toggleCardMode, - setTagsEditable, - forceReInit: true, - hideHardDelete: hideImageTaggingHardDelete, - updateCurrentAppMaterial, - isSuperAdmin, - }) - - const getSequentialCDCardTitleProps = (mat: CDMaterialType): SequentialCDCardTitleProps => { - const { promotionApprovalMetadata } = mat - const promotionApprovedBy = promotionApprovalMetadata?.approvedUsersData?.map((users) => users.userEmail) - - return { - isLatest: mat.latest, - isRunningOnParentCD: mat.runningOnParentCd, - artifactStatus: mat.artifactStatus, - environmentName: envName, - parentEnvironmentName, - stageType, - showLatestTag: +mat.index === 0 && materialType !== MATERIAL_TYPE.rollbackMaterialList && !searchImageTag, - isVirtualEnvironment, - targetPlatforms: mat.targetPlatforms, - additionalInfo: - ImagePromotionInfoChip && promotionApprovalMetadata?.promotedFromType ? ( - - ) : null, - } - } - - const renderMaterial = (materialList: CDMaterialType[], disableSelection: boolean, isApprovalConfigured: boolean) => - materialList.map((mat) => { - const isMaterialInfoAvailable = getIsMaterialInfoAvailable(mat.materialInfo) - const approvedImageClass = getApprovedImageClass(disableSelection, isApprovalConfigured) - const isImageApprover = getIsImageApprover(mat.userApprovalMetadata) - const hideSourceInfo = !state.materialInEditModeMap.get(+mat.id) - const showApprovalInfoTippy = - !disableSelection && - (isCDNode || state.isRollbackTrigger) && - isApprovalConfigured && - ApprovalInfoTippy && - !isNullOrUndefined(mat.userApprovalMetadata.approvalRuntimeState) - const imageCardRootClassName = - mat.isSelected && !disableSelection && !isImageApprover ? 'material-history-selected' : '' - - return ( - - {mat.materialInfo.length > 0 && - !hideInfoTabsContainer && - (isMaterialInfoAvailable || mat.appliedFilters?.length) && - hideSourceInfo && ( - - )} - - ) - }) - - const renderSearch = (): JSX.Element => ( - - ) - - const renderMaterialListBodyWrapper = (children: JSX.Element) => ( -
{children}
- ) - - const renderRuntimeParamsSidebar = (areTabsDisabled: boolean = false) => { - const showSidebar = isPreOrPostCD && !isFromBulkCD - if (!showSidebar) { - return null - } - - return ( -
- {RuntimeParamTabs && ( -
- -
- )} - -
- Application -
- -
- {appName} -
-
- ) - } - - const renderMaterialList = (isApprovalConfigured: boolean) => { - const { consumedImage, materialList, eligibleImagesCount } = - getConsumedAndAvailableMaterialList(isApprovalConfigured) - const selectImageTitle = state.isRollbackTrigger ? 'Select from previously deployed images' : 'Select Image' - const titleText = isApprovalConfigured && !isExceptionUser ? 'Approved images' : selectImageTitle - const showActionBar = - FilterActionBar && !state.searchApplied && !!resourceFilters?.length && !state.showConfiguredFilters - - return ( -
- {renderRuntimeParamsSidebar()} - - - {(bulkSidebarTab - ? bulkSidebarTab === CDMaterialSidebarType.IMAGE - : currentSidebarTab === CDMaterialSidebarType.IMAGE) || !RuntimeParameters ? ( - <> - {isApprovalConfigured && renderMaterial(consumedImage, true, isApprovalConfigured)} -
- {showActionBar ? ( - - ) : ( - {titleText} - )} - - - {state.showSearch ? ( - renderSearch() - ) : ( - - )} - - -
- - {materialList.length <= 0 - ? renderEmptyState(isApprovalConfigured, consumedImage.length > 0, !eligibleImagesCount) - : renderMaterial(materialList, false, isApprovalConfigured)} - - {!noMoreImages && !!materialList?.length && ( - - )} - - ) : ( -
- -
- )} -
-
- ) - } - - const renderCDModalHeader = (): JSX.Element | string => { - const _stageType = state.isRollbackTrigger ? STAGE_TYPE.ROLLBACK : stageType - switch (_stageType) { - case STAGE_TYPE.PRECD: - return 'Pre Deployment' - case STAGE_TYPE.CD: - return ( - <> - Deploy to   - {`${envName}${isVirtualEnvironment ? ' (Isolated)' : ''}`} - - ) - case STAGE_TYPE.POSTCD: - return 'Post Deployment' - case STAGE_TYPE.ROLLBACK: - return ( - <> - Rollback for {envName} - - ) - default: - return '' - } - } - - const renderTippyContent = () => { - if (!state.areMaterialsPassingFilters) { - return ( - <> -

No eligible images found!

-

- Please select an image that passes the configured filters to deploy -

- - ) - } - - return ( - <> -

Selected Config not available!

-

- {state.selectedConfigToDeploy.value === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG && - noSpecificDeploymentConfig - ? 'Please select a different image or configuration to deploy' - : 'Please select a different configuration to deploy'} -

- - ) - } - - const getDeployButtonIcon = () => { - if (deploymentWindowMetadata.userActionState === ACTION_STATE.BLOCKED) { - return null - } - if (stageType !== STAGE_TYPE.CD) { - return - } - return - } - - const getDeployButtonStyle = ( - userActionState: string, - canDeployWithoutApproval: boolean, - canImageApproverDeploy: boolean, - ): ButtonStyleType => { - if (userActionState === ACTION_STATE.BLOCKED) { - return ButtonStyleType.negative - } - if (userActionState === ACTION_STATE.PARTIAL || canDeployWithoutApproval || canImageApproverDeploy) { - return ButtonStyleType.warning - } - return ButtonStyleType.default - } - - const onClickDeploy = (e, disableDeployButton: boolean) => { - e.stopPropagation() - if (!disableDeployButton) { - if (!showPluginWarningOverlay && showPluginWarningBeforeTrigger) { - setShowPluginWarningOverlay(true) - return - } - - if ( - deploymentWindowMetadata.userActionState && - deploymentWindowMetadata.userActionState !== ACTION_STATE.ALLOWED - ) { - setShowDeploymentWindowConfirmation(true) - return - } - - deployTrigger(e) - } - } - - const renderTriggerDeployButton = (disableDeployButton: boolean) => { - const { userActionState } = deploymentWindowMetadata - const canDeployWithoutApproval = getCanDeployWithoutApproval(state, isExceptionUser) - const canImageApproverDeploy = getCanImageApproverDeploy(state, canApproverDeploy, isExceptionUser) - - return ( - onClickDeploy(e, disableDeployButton)} - startIcon={getDeployButtonIcon()} - text={ - canDeployWithoutApproval - ? 'Deploy without approval' - : `${ - userActionState === ACTION_STATE.BLOCKED - ? 'Deployment is blocked' - : CDButtonLabelMap[stageType] - }${isVirtualEnvironment ? ' to isolated env' : ''}` - } - endIcon={userActionState === ACTION_STATE.BLOCKED ? : null} - style={getDeployButtonStyle(userActionState, canDeployWithoutApproval, canImageApproverDeploy)} - disabled={disableDeployButton} - tooltipContent={ - canDeployWithoutApproval || canImageApproverDeploy - ? 'You are authorized to deploy as an exception user' - : '' - } - animateStartIcon={ - isCDNode && !disableDeployButton && (!userActionState || userActionState === ACTION_STATE.ALLOWED) - } - /> - ) - } - - const renderTriggerModalCTA = (isApprovalConfigured: boolean) => { - const disableDeployButton = - isDeployButtonDisabled() || - (!isExceptionUser && - material.length > 0 && - !canApproverDeploy && - getIsImageApprover(state.selectedMaterial?.userApprovalMetadata)) - const hideConfigDiffSelector = isApprovalConfigured && disableDeployButton - - return ( -
- {!hideConfigDiffSelector && - (state.isRollbackTrigger || state.isSelectImageTrigger) && - !showConfigDiffView && - isCDNode ? ( - onClickSetInitialParams(URL_PARAM_MODE_TYPE.REVIEW_CONFIG)} - noLastDeploymentConfig={noLastDeploymentConfig} - canReviewConfig={canReviewConfig()} - renderConfigNotAvailableTooltip={renderTippyContent} - /> - ) : ( - // NOTE: needed so that the button is pushed to the right since justify-content is set to space-between -
- )} -
- {/* == as we are expecting number but receiving string, 404 means custom charts */} - {SelectDeploymentStrategy && - isCDNode && - pipelineStrategies?.[0]?.error?.code != API_STATUS_CODES.NOT_FOUND && ( - - )} - ( - - {children} - - )} - > - {AllowedWithWarningTippy && showPluginWarningBeforeTrigger ? ( - onClickDeploy(e, disableDeployButton)} - nodeType={allowWarningWithTippyNodeTypeProp} - visible={showPluginWarningOverlay} - onClose={handleClosePluginWarningOverlay} - > - {renderTriggerDeployButton(disableDeployButton)} - - ) : ( - renderTriggerDeployButton(disableDeployButton) - )} - -
-
- ) - } - - const renderTriggerViewConfigDiff = () => ( - - ) - - const renderTriggerBody = (isApprovalConfigured: boolean) => ( -
- {showConfigDiffView && canReviewConfig() - ? renderTriggerViewConfigDiff() - : renderMaterialList(isApprovalConfigured)} -
- ) - - const handleClosePluginWarningOverlay = () => { - setShowPluginWarningOverlay(false) - } - - const handleConfirmationClose = (e) => { - e.stopPropagation() - handleClosePluginWarningOverlay() - setShowDeploymentWindowConfirmation(false) - } - - const renderCDModal = (isApprovalConfigured: boolean) => ( - <> -
- {showConfigDiffView ? ( -
- -

{renderCDModalHeader()}

- {state.selectedMaterial && ( - - )} -
- ) : ( -

{renderCDModalHeader()}

- )} -
- - {/* FIXME: This material.length>1 needs to be optimised */} - {isApprovalConfigured && - !isExceptionUser && - ApprovedImagesMessage && - (state.isRollbackTrigger || material.length - Number(isConsumedImageAvailable) > 0) && ( - } - /> - )} - {!isFromBulkCD && - MaintenanceWindowInfoBar && - deploymentWindowMetadata.type === DEPLOYMENT_WINDOW_TYPE.MAINTENANCE && - deploymentWindowMetadata.isActive && ( - - )} - {renderTriggerBody(isApprovalConfigured)} - {renderTriggerModalCTA(isApprovalConfigured)} - {DeploymentWindowConfirmationDialog && showDeploymentWindowConfirmation && ( - - )} - - - ) - - /* ------------Main Rendering logic ------------*/ - if (state.showConfiguredFilters && ConfiguredFilters) { - return ( - - ) - } - - if (showAppliedFilters) { - return ( - - ) - } - - // NOTE: Can make a skeleton component for loader - // TODO: Fix this condition for aborting the request - if (loadingMaterials || materialsError?.code === 0) { - return ( - <> - {!isFromBulkCD && ( -
-

{renderCDModalHeader()}

- -
- )} - -
- {renderRuntimeParamsSidebar(true)} - -
-
-
-
- -
-
-
-
- - ) - } - - if (materialsError) { - return ( - <> - {!isFromBulkCD && ( -
-

{renderCDModalHeader()}

- -
- )} - - - - ) - } - - if (material.length > 0) { - return isFromBulkCD ? renderTriggerBody(isApprovalConfigured) : renderCDModal(isApprovalConfigured) - } - - if (isFromBulkCD) { - return renderEmptyState(isApprovalConfigured) - } - - return ( - <> -
-

{renderCDModalHeader()}

- -
- - {renderEmptyState(isApprovalConfigured)} - - ) -} - -export default CDMaterial diff --git a/src/components/app/types.ts b/src/components/app/types.ts index 80729570e4..84f2c699c8 100644 --- a/src/components/app/types.ts +++ b/src/components/app/types.ts @@ -34,6 +34,7 @@ import { CreateAppFormStateType } from '@Pages/App/CreateAppModal/types' import { GroupFilterType } from '../ApplicationGroup/AppGroup.types' import { DetailsType, ErrorItem, HibernationModalTypes } from './details/appDetails/appDetails.type' +import { DeployImageModalProps } from './details/triggerView/DeployImageModal/types' import { CDMaterialProps } from './details/triggerView/types' interface CDModalProps { @@ -541,7 +542,7 @@ export interface AppDetailsCDModalType cdModal: CDModalProps appName?: string handleSuccess?: CDMaterialProps['handleSuccess'] - materialType: string + materialType: DeployImageModalProps['materialType'] closeCDModal: () => void } From 6aef199343a57a87aced1229bebeb3657ea28f28 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 30 Jul 2025 14:24:25 +0530 Subject: [PATCH 07/40] feat: Refactor DeployImageModal and related components for improved navigation and layout --- .../Details/TriggerView/BulkCDTrigger.tsx | 7 +- .../DeployImageModal/DeployImageContent.tsx | 7 +- .../DeployImageModal/DeployImageHeader.tsx | 35 ++++-- .../DeployImageModal/DeployImageModal.tsx | 103 +++++++----------- .../triggerView/DeployImageModal/types.ts | 2 + .../triggerView/DeployImageModal/utils.tsx | 4 +- 6 files changed, 80 insertions(+), 78 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx b/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx index f24733bb8e..91f4ba2c93 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx @@ -64,7 +64,6 @@ import { getIsMaterialApproved } from '@Components/app/details/triggerView/cdMat import emptyPreDeploy from '../../../../assets/img/empty-pre-deploy.webp' import { ReactComponent as MechanicalOperation } from '../../../../assets/img/ic-mechanical-operation.svg' import notAuthorized from '../../../../assets/img/ic-not-authorized.svg' -import CDMaterial from '../../../app/details/triggerView/cdMaterial' import { BulkSelectionEvents, MATERIAL_TYPE, RuntimeParamsErrorState } from '../../../app/details/triggerView/types' import { importComponentFromFELibrary } from '../../../common' import { BulkCDDetailType, BulkCDTriggerType } from '../../AppGroup.types' @@ -856,7 +855,8 @@ const BulkCDTrigger = ({ warningMessage={appDeploymentWindowMap[selectedApp.appId].warningMessage} /> )} - + /> */} +
)}
diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index a48ad52170..f2cf963794 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -7,6 +7,7 @@ import { ButtonVariantType, CDMaterialSidebarType, CDMaterialType, + ComponentSizeType, ConditionalWrap, DEPLOYMENT_WINDOW_TYPE, DeploymentNodeType, @@ -417,8 +418,8 @@ const DeployImageContent = ({ {titleText} )} - - {showSearchBar ? ( + + {showSearchBar || isSearchApplied ? ( renderSearch() ) : (
diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx index cce593afd5..df0673c611 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx @@ -15,16 +15,35 @@ const DeployImageHeader = ({ stageType, isRollbackTrigger, isVirtualEnvironment, + handleNavigateToMaterialListView, + children, }: DeployImageHeaderProps) => (
-

- {getCDModalHeaderText({ - isRollbackTrigger, - stageType, - envName, - isVirtualEnvironment, - })} -

+
+ {handleNavigateToMaterialListView && ( +
- ) - return ( <> @@ -914,18 +875,34 @@ const DeployImageModal = ({ onClick={stopPropagation} >
- {showConfigDiffView ? ( - renderPipelineConfigDiffHeader() - ) : ( - - )} -
{renderContent()}
+ + {showConfigDiffView && selectedMaterial && ( + + )} + + +
{renderContent()}
{initialDataError || isInitialDataLoading || materialList.length === 0 ? null : ( diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index c36015c44b..bdbfef4f37 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -56,6 +56,8 @@ export type DeployImageHeaderProps = Pick< > & { envName: string isRollbackTrigger: boolean + handleNavigateToMaterialListView?: () => void + children?: React.ReactNode } export interface RuntimeParamsSidebarProps { diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index 7ffe5dc1f1..0148a98fc9 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -161,7 +161,7 @@ export const getCDModalHeaderText = ({ case STAGE_TYPE.CD: return ( <> - Deploy to   + Deploy to  {`${envName}${isVirtualEnvironment ? ' (Isolated)' : ''}`} ) @@ -170,7 +170,7 @@ export const getCDModalHeaderText = ({ case STAGE_TYPE.ROLLBACK: return ( <> - Rollback for {envName} + Rollback for {envName} ) default: From 88b7197412c8222b96f0327f5078c43521461f46 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 30 Jul 2025 18:05:56 +0530 Subject: [PATCH 08/40] feat: Refactor DeployImageModal and related components to streamline state management and improve code readability --- .../details/triggerView/BranchRegexModal.tsx | 2 +- .../BuildImageModal/BuildImageModal.tsx | 8 - .../BuildImageModal/TriggerBuildSidebar.tsx | 25 +- .../triggerView/BuildImageModal/utils.ts | 2 +- .../DeployImageModal/DeployImageContent.tsx | 209 +++++++++++++---- .../DeployImageModal/DeployImageModal.tsx | 213 +++++------------- .../triggerView/DeployImageModal/types.ts | 81 ++++--- 7 files changed, 282 insertions(+), 258 deletions(-) diff --git a/src/components/app/details/triggerView/BranchRegexModal.tsx b/src/components/app/details/triggerView/BranchRegexModal.tsx index 1bcbeeaa13..4270f19616 100644 --- a/src/components/app/details/triggerView/BranchRegexModal.tsx +++ b/src/components/app/details/triggerView/BranchRegexModal.tsx @@ -72,7 +72,7 @@ const BranchRegexModal = ({ const isRegexValueInvalid = (_cm): void => { const regExp = new RegExp(_cm.source.regex) const regVal = structuredClone(regexValue[_cm.gitMaterialId]) - if (!regExp.test(regVal.value)) { + if (!regExp.test(regVal.value) || !regVal.value) { const _regexVal = { ...regexValue, [_cm.gitMaterialId]: { value: regVal.value, isInvalid: true }, diff --git a/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx b/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx index 3c6a55979b..d61b5133d3 100644 --- a/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx @@ -1,11 +1,3 @@ -/** - * Service layer requirements: - * - We need to have filteredCIPipelines - * - Will find the selected pipeline from filteredCIPipelines from which we will get envId [in case of job view] - * - isLoading as prop for when workflows are loading - * - If !isLoading and filteredCIPipelines does not contain the selected pipeline, we will show error - */ - import { useEffect, useRef, useState } from 'react' import { Prompt, useHistory, useParams } from 'react-router-dom' diff --git a/src/components/app/details/triggerView/BuildImageModal/TriggerBuildSidebar.tsx b/src/components/app/details/triggerView/BuildImageModal/TriggerBuildSidebar.tsx index 3a8962ad96..31740372b0 100644 --- a/src/components/app/details/triggerView/BuildImageModal/TriggerBuildSidebar.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/TriggerBuildSidebar.tsx @@ -71,7 +71,7 @@ const TriggerBuildSidebar = ({ } } - const getErrorMessageFromAppDetails = (appDetails: (typeof appList)[number]): string | null => { + const getErrorMessageFromAppDetails = (appDetails: (typeof appList)[number]): JSX.Element | null => { const materialListError = appDetails.materialInitialError ? appDetails.materialInitialError.errors?.[0].userMessage || 'Error fetching material list' : null @@ -84,7 +84,19 @@ const TriggerBuildSidebar = ({ ? 'Invalid runtime parameters' : null - return appDetails.errorMessage || materialListError || runtimeParamsInitialError || runtimeParamsDataError + const errorMessage = + appDetails.errorMessage || materialListError || runtimeParamsInitialError || runtimeParamsDataError + + if (!errorMessage) { + return null + } + + return ( + + + {errorMessage} + + ) } const renderAppName = (appDetails: (typeof appList)[number]): JSX.Element | null => ( @@ -101,14 +113,7 @@ const TriggerBuildSidebar = ({ {appDetails.warningMessage} )} - {appDetails.appId !== appId && !!getErrorMessageFromAppDetails(appDetails) && ( - - - - {getErrorMessageFromAppDetails(appDetails)} - - - )} + {!!getErrorMessageFromAppDetails(appDetails)} {appDetails.node?.pluginBlockState && appDetails.node.pluginBlockState.action !== ConsequenceAction.ALLOW_FOREVER && PolicyEnforcementMessage && ( diff --git a/src/components/app/details/triggerView/BuildImageModal/utils.ts b/src/components/app/details/triggerView/BuildImageModal/utils.ts index 7d73b9ec82..014119c9f7 100644 --- a/src/components/app/details/triggerView/BuildImageModal/utils.ts +++ b/src/components/app/details/triggerView/BuildImageModal/utils.ts @@ -63,7 +63,7 @@ export const getTriggerBuildPayload = ({ const history = material.history.filter((historyItem) => historyItem.isSelected) if (!history.length) { - history.push(material.history[0]) + return } history.forEach((element) => { diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index f2cf963794..1245d45e53 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react' +import { useContext } from 'react' import { useHistory } from 'react-router-dom' import { @@ -25,6 +25,7 @@ import { MaterialInfo, Progressing, SearchBar, + SegmentedControlProps, useMainContext, } from '@devtron-labs/devtron-fe-common-lib' @@ -32,11 +33,11 @@ import { importComponentFromFELibrary } from '@Components/common' import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' -import { TriggerViewContextType } from '../types' +import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' import ImageSelectionCTA from './ImageSelectionCTA' import MaterialListEmptyState from './MaterialListEmptyState' import RuntimeParamsSidebar from './RuntimeParamsSidebar' -import { DeployImageContentProps } from './types' +import { DeployImageContentProps, ImageSelectionCTAProps, RuntimeParamsSidebarProps } from './types' import { getApprovedImageClass, getConsumedAndAvailableMaterialList, @@ -71,51 +72,27 @@ const DeployImageContent = ({ pipelineId, handleClose, isRedirectedFromAppDetails, - isSearchApplied, - searchText, onSearchApply, - onSearchTextChange, - filterView, - showConfiguredFilters, stageType, - currentSidebarTab, - handleRuntimeParamsChange, - runtimeParamsErrorState, - handleRuntimeParamsError, uploadRuntimeParamsFile, - handleSidebarTabChange, appName, - materialInEditModeMap, isSecurityModuleInstalled, envName, - handleShowAppliedFilters, reloadMaterials, parentEnvironmentName, isVirtualEnvironment, - handleImageSelection, - setAppReleaseTagNames, - toggleCardMode, - setTagsEditable, - updateCurrentAppMaterial, - handleEnableFiltersView, - handleFilterTabsChange, loadOlderImages, - isLoadingOlderImages, policyConsequences, - handleAllImagesView, - showAppliedFilters, - handleDisableFiltersView, - handleDisableAppliedFiltersView, triggerType, - appliedFilterList, + deployViewState, + setDeployViewState, + setMaterialResponse, }: DeployImageContentProps) => { const history = useHistory() const { isSuperAdmin } = useMainContext() const { onClickApprovalNode } = useContext(TriggerViewContext) - const [showSearchBar, setShowSearchBar] = useState(false) - const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false const requestedUserId = materialResponse?.requestedUserId const isApprovalConfigured = getIsApprovalPolicyConfigured( @@ -131,6 +108,22 @@ const DeployImageContent = ({ const runtimeParamsList = materialResponse?.runtimeParams || [] const isCDNode = stageType === DeploymentNodeType.CD + const { + searchText, + appliedSearchText, + filterView, + showConfiguredFilters, + currentSidebarTab, + runtimeParamsErrorState, + materialInEditModeMap, + showAppliedFilters, + appliedFilterList, + isLoadingOlderImages, + showSearchBar, + } = deployViewState + + const isSearchApplied = !!appliedSearchText + const { consumedImage, materialList, eligibleImagesCount } = getConsumedAndAvailableMaterialList({ isApprovalConfigured, isExceptionUser, @@ -144,6 +137,93 @@ const DeployImageContent = ({ const showActionBar = FilterActionBar && !isSearchApplied && !!resourceFilters?.length && !showConfiguredFilters const areNoMoreImagesPresent = materials.length >= materialResponse?.totalCount + const handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] = (e) => { + setDeployViewState((prevState) => ({ + ...prevState, + currentSidebarTab: e.target.value as CDMaterialSidebarType, + })) + } + + const onSearchTextChange = (newSearchText: string) => { + setDeployViewState((prevState) => ({ + ...prevState, + searchText: newSearchText, + })) + } + + const handleAllImagesView = () => { + setDeployViewState((prevState) => ({ + ...prevState, + filterView: FilterConditionViews.ALL, + })) + } + + const handleFilterTabsChange: SegmentedControlProps['onChange'] = (selectedSegment) => { + const { value } = selectedSegment + setDeployViewState((prevState) => ({ + ...prevState, + filterView: value as FilterConditionViews, + })) + } + + const handleShowConfiguredFilters = () => { + setDeployViewState((prevState) => ({ + ...prevState, + showConfiguredFilters: true, + })) + } + + const handleExitFiltersView = () => { + setDeployViewState((prevState) => ({ + ...prevState, + showConfiguredFilters: false, + })) + } + + const handleDisableAppliedFiltersView = () => { + setDeployViewState((prevState) => ({ + ...prevState, + appliedFilterList: [], + showAppliedFilters: false, + })) + } + + const getHandleShowAppliedFilters = (materialData: CDMaterialType) => () => { + setDeployViewState((prevState) => ({ + ...prevState, + appliedFilterList: materialData?.appliedFilters ?? [], + showAppliedFilters: true, + })) + } + + const handleShowSearchBar = () => { + setDeployViewState((prevState) => ({ + ...prevState, + showSearchBar: true, + })) + } + + const handleImageSelection: ImageSelectionCTAProps['handleImageSelection'] = (materialIndex) => { + const updatedMaterialList = materialList.map((material, index) => ({ + ...material, + isSelected: index === materialIndex, + })) + + setMaterialResponse((prevData) => { + const updatedMaterialResponse = structuredClone(prevData) + updatedMaterialResponse.materials = updatedMaterialList + return updatedMaterialResponse + }) + } + + const setAppReleaseTagNames: ImageTaggingContainerType['setAppReleaseTagNames'] = (appReleaseTagNames) => { + setMaterialResponse((prevData) => { + const updatedMaterialResponse = structuredClone(prevData) + updatedMaterialResponse.appReleaseTagNames = appReleaseTagNames + return updatedMaterialResponse + }) + } + const viewAllImages = () => { if (isRedirectedFromAppDetails) { history.push({ @@ -155,10 +235,6 @@ const DeployImageContent = ({ } } - const handleShowSearchBar = () => { - setShowSearchBar(true) - } - const renderSearch = () => ( ) + const updateCurrentAppMaterial: ImageTaggingContainerType['updateCurrentAppMaterial'] = ( + matId, + imageReleaseTags, + imageComment, + ) => { + const updatedMaterialList = materialList.map((material) => { + if (+material.id === +matId) { + return { + ...material, + imageReleaseTags, + imageComment, + } + } + return material + }) + + setMaterialResponse((prevData) => { + const updatedMaterialResponse = structuredClone(prevData) + updatedMaterialResponse.materials = updatedMaterialList + return updatedMaterialResponse + }) + } + + const setTagsEditable: ImageTaggingContainerType['setTagsEditable'] = (tagsEditable) => { + setMaterialResponse((prevData) => { + const updatedMaterialResponse = structuredClone(prevData) + updatedMaterialResponse.tagsEditable = tagsEditable + return updatedMaterialResponse + }) + } + + const handleRuntimeParamsChange: HandleRuntimeParamChange = (updatedRuntimeParamsList) => { + setMaterialResponse((prevData) => { + const updatedMaterialResponse = structuredClone(prevData) + updatedMaterialResponse.runtimeParams = updatedRuntimeParamsList + return updatedMaterialResponse + }) + } + + const handleRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { + setDeployViewState((prevState) => ({ + ...prevState, + runtimeParamsErrorState: updatedRuntimeParamsErrorState, + })) + } + + const toggleCardMode: ImageTaggingContainerType['toggleCardMode'] = (index: number) => { + setDeployViewState((prevState) => { + const newMaterialInEditModeMap = new Map(prevState.materialInEditModeMap) + newMaterialInEditModeMap.set(index, !newMaterialInEditModeMap.get(index)) + return { + ...prevState, + materialInEditModeMap: newMaterialInEditModeMap, + } + }) + } + const getImageTagContainerProps = (mat: CDMaterialType): ImageTaggingContainerType => ({ ciPipelineId: null, artifactId: +mat.id, @@ -229,7 +362,7 @@ const DeployImageContent = ({ appliedFiltersTimestamp={handleUTCTime(materialData.appliedFiltersTimestamp)} envName={envName} // Should not use Arrow function here but seems like no choice - showConfiguredFilters={() => handleShowAppliedFilters(materialData)} + showConfiguredFilters={getHandleShowAppliedFilters(materialData)} filterState={materialData.appliedFiltersState} dataSource={materialData.dataSource} deploymentWindowArtifactMetadata={materialData.deploymentWindowArtifactMetadata} @@ -361,7 +494,7 @@ const DeployImageContent = ({ isFromBulkCD={isBulkTrigger} resourceFilters={showConfiguredFilters ? resourceFilters : appliedFilterList} handleDisableFiltersView={ - showConfiguredFilters ? handleDisableFiltersView : handleDisableAppliedFiltersView + showConfiguredFilters ? handleExitFiltersView : handleDisableAppliedFiltersView } envName={envName} closeModal={handleClose} @@ -411,7 +544,7 @@ const DeployImageContent = ({ consumedImage.length, )} onChange={handleFilterTabsChange} - handleEnableFiltersView={handleEnableFiltersView} + handleEnableFiltersView={handleShowConfiguredFilters} initialTab={filterView} /> ) : ( @@ -466,7 +599,7 @@ const DeployImageContent = ({ loadOlderImages={loadOlderImages} onSearchApply={onSearchApply} eligibleImagesCount={eligibleImagesCount} - handleEnableFiltersView={handleEnableFiltersView} + handleEnableFiltersView={handleShowConfiguredFilters} handleAllImagesView={handleAllImagesView} /> ) : ( diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index b3d7ed136e..868ed1593b 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -27,6 +27,7 @@ import { MODAL_TYPE, ModuleNameMap, ModuleStatus, + noop, PipelineDeploymentStrategy, ServerErrors, showError, @@ -53,13 +54,13 @@ import { CDButtonLabelMap } from '../config' import { CD_MATERIAL_GA_EVENT, TRIGGER_VIEW_GA_EVENTS } from '../Constants' import { PipelineConfigDiff, usePipelineDeploymentConfig } from '../PipelineConfigDiff' import { PipelineConfigDiffStatusTile } from '../PipelineConfigDiff/PipelineConfigDiffStatusTile' -import { FilterConditionViews, HandleRuntimeParamChange, MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' +import { FilterConditionViews, MATERIAL_TYPE } from '../types' import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import MaterialListSkeleton from './MaterialListSkeleton' import RuntimeParamsSidebar from './RuntimeParamsSidebar' import { getMaterialResponseList } from './service' -import { DeployImageContentProps, DeployImageModalProps, RuntimeParamsSidebarProps } from './types' +import { DeployImageContentProps, DeployImageModalProps, DeployViewStateType } from './types' import { getAllowWarningWithTippyNodeTypeProp, getCDArtifactId, @@ -142,24 +143,26 @@ const DeployImageModal = ({ !!getDeploymentStrategies && !!pipelineId, ) - const [currentSidebarTab, setCurrentSidebarTab] = useState(CDMaterialSidebarType.IMAGE) - const [runtimeParamsErrorState, setRuntimeParamsErrorState] = useState({ - isValid: true, - cellError: {}, - }) const [isDeploymentLoading, setIsDeploymentLoading] = useState(false) const [deploymentStrategy, setDeploymentStrategy] = useState(null) const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) - const [searchText, setSearchText] = useState(searchImageTag) - const [filterView, setFilterView] = useState(FilterConditionViews.ALL) - const [showConfiguredFilters, setShowConfiguredFilters] = useState(false) - const [showAppliedFilters, setShowAppliedFilters] = useState(false) - const [appliedFilterList, setAppliedFilterList] = useState([]) - const [isLoadingOlderImages, setIsLoadingOlderImages] = useState(false) - const [materialInEditModeMap, setMaterialInEditModeMap] = useState< - DeployImageContentProps['materialInEditModeMap'] - >(new Map()) + + const [deployViewState, setDeployViewState] = useState>({ + searchText: searchImageTag, + filterView: FilterConditionViews.ALL, + showConfiguredFilters: false, + currentSidebarTab: CDMaterialSidebarType.IMAGE, + runtimeParamsErrorState: { + isValid: true, + cellError: {}, + }, + materialInEditModeMap: new Map(), + showAppliedFilters: false, + appliedFilterList: [], + isLoadingOlderImages: false, + showSearchBar: false, + }) const isCDNode = stageType === DeploymentNodeType.CD const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD @@ -226,10 +229,6 @@ const DeployImageModal = ({ wfrId, }) - const handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] = (e) => { - setCurrentSidebarTab(e.target.value as CDMaterialSidebarType) - } - const handleClosePluginWarningOverlay = () => { setShowPluginWarningOverlay(false) } @@ -264,32 +263,18 @@ const DeployImageModal = ({ }) } - const handleEnableFiltersView = () => { - setShowConfiguredFilters(true) - } - - const handleDisableFiltersView = () => { - setShowConfiguredFilters(false) - } - - const handleFilterTabsChange: DeployImageContentProps['handleFilterTabsChange'] = (selectedSegment) => { - const { value } = selectedSegment - setFilterView(value as FilterConditionViews) - } - - const handleAllImagesView = () => { - setFilterView(FilterConditionViews.ALL) - } - const loadOlderImages = async () => { // TODO: Move to util handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) - if (!isLoadingOlderImages) { + if (!deployViewState.isLoadingOlderImages) { // TODO: Move to util const isConsumedImageAvailable = materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false - setIsLoadingOlderImages(true) + setDeployViewState((prevState) => ({ + ...prevState, + isLoadingOlderImages: true, + })) try { const newMaterialsResponse = await genericCDMaterialsService( @@ -335,7 +320,7 @@ const DeployImageModal = ({ ? 'No new eligible images found.' : `${eligibleImages} new eligible images found.` - if (filterView === FilterConditionViews.ELIGIBLE) { + if (deployViewState.filterView === FilterConditionViews.ELIGIBLE) { ToastManager.showToast({ variant: ToastVariantType.info, description: `${baseSuccessMessage} ${infoMessage}`, @@ -355,32 +340,18 @@ const DeployImageModal = ({ } catch (error) { showError(error) } finally { - setIsLoadingOlderImages(false) + setDeployViewState((prevState) => ({ + ...prevState, + isLoadingOlderImages: false, + })) } } } - const handleImageSelection: DeployImageContentProps['handleImageSelection'] = (materialIndex) => { - const updatedMaterialList = materialList.map((material, index) => ({ - ...material, - isSelected: index === materialIndex, - })) - - setInitialData((prevData) => { - const updatedMaterialResponse = structuredClone(prevData[0]) - updatedMaterialResponse.materials = updatedMaterialList - return [updatedMaterialResponse, prevData[1], prevData[2]] - }) - } - const handleReviewConfigParams = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.REVIEW_CONFIG) const handleNavigateToListView = () => onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) - const onRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { - setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) - } - const isDeployButtonDisabled = () => { const selectedImage = materialList.find((artifact) => artifact.isSelected) @@ -435,7 +406,10 @@ const DeployImageModal = ({ computedWfrId?: number, ) => { const updatedRuntimeParamsErrorState = validateRuntimeParameters(runtimeParamsList) - onRuntimeParamsError(updatedRuntimeParamsErrorState) + setDeployViewState((prevState) => ({ + ...prevState, + runtimeParamsErrorState: updatedRuntimeParamsErrorState, + })) if (!updatedRuntimeParamsErrorState.isValid) { ToastManager.showToast({ variant: ToastVariantType.error, @@ -554,16 +528,6 @@ const DeployImageModal = ({ } } - const handleShowAppliedFilters: DeployImageContentProps['handleShowAppliedFilters'] = (materialData) => { - setAppliedFilterList(materialData?.appliedFilters ?? []) - setShowAppliedFilters(true) - } - - const handleDisableAppliedFiltersView = () => { - setAppliedFilterList([]) - setShowAppliedFilters(false) - } - const getDeployButtonStyle = ( userActionState: string, canDeployWithoutApproval: boolean, @@ -578,78 +542,23 @@ const DeployImageModal = ({ return ButtonStyleType.default } - const setTagsEditable: DeployImageContentProps['setTagsEditable'] = (tagsEditable) => { - const newMaterialResponse = structuredClone(materialResponse) - newMaterialResponse.tagsEditable = tagsEditable - setInitialData((prevData) => [newMaterialResponse, prevData[1], prevData[2]]) - } - - const setAppReleaseTagNames: DeployImageContentProps['setAppReleaseTagNames'] = (appReleaseTagNames) => { - const newMaterialResponse = structuredClone(materialResponse) - newMaterialResponse.appReleaseTagNames = appReleaseTagNames - setInitialData((prevData) => [newMaterialResponse, prevData[1], prevData[2]]) - } - - // TODO: This state can be in DeployImageContent ig - const toggleCardMode: DeployImageContentProps['toggleCardMode'] = (index: number) => { - setMaterialInEditModeMap((prevMap) => { - const newMap = new Map(prevMap) - newMap.set(index, !newMap.get(index)) - return newMap - }) - } - - const updateCurrentAppMaterial: DeployImageContentProps['updateCurrentAppMaterial'] = ( - matId, - imageReleaseTags, - imageComment, - ) => { - const updatedMaterialList = materialList.map((material) => { - if (+material.id === +matId) { - return { - ...material, - imageReleaseTags, - imageComment, - } - } - return material - }) - setInitialData((prevData) => { - const updatedMaterialResponse = structuredClone(prevData[0]) - updatedMaterialResponse.materials = updatedMaterialList - return [updatedMaterialResponse, prevData[1], prevData[2]] - }) - } - const onSearchApply = (newSearchText: string) => { - setSearchText(newSearchText) const newParams = new URLSearchParams({ ...searchParams, search: newSearchText, }) + setDeployViewState((prevState) => ({ + ...prevState, + searchText: newSearchText, + })) + history.push({ pathname, search: newParams.toString(), }) } - const onSearchTextChange = (newSearchText: string) => { - setSearchText(newSearchText) - } - - const handleRuntimeParamsChange: HandleRuntimeParamChange = (updatedRuntimeParamsList) => { - setInitialData((prevData) => { - const updatedMaterialResponse = structuredClone(prevData[0]) - updatedMaterialResponse.runtimeParams = updatedRuntimeParamsList - return [updatedMaterialResponse, prevData[1], prevData[2]] - }) - } - - const handleRuntimeParamsError = (updatedRuntimeParamsErrorState: typeof runtimeParamsErrorState) => { - setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) - } - const uploadRuntimeParamsFile: DeployImageContentProps['uploadRuntimeParamsFile'] = ({ file, allowedExtensions, @@ -691,6 +600,13 @@ const DeployImageModal = ({ ) } + const setMaterialResponse: DeployImageContentProps['setMaterialResponse'] = (callback) => { + setInitialData((prevData) => { + const updatedMaterialResponse = callback(structuredClone(prevData[0])) + return [updatedMaterialResponse, prevData[1], prevData[2]] + }) + } + const renderFooter = () => { const disableDeployButton = isDeployButtonDisabled() || @@ -769,6 +685,11 @@ const DeployImageModal = ({ ) } + const deployViewStateProps: DeployImageContentProps['deployViewState'] = { + ...deployViewState, + appliedSearchText: searchImageTag, + } + const renderContent = () => { if (isInitialDataLoading) { return ( @@ -778,9 +699,9 @@ const DeployImageModal = ({ {isPreOrPostCD && ( )} @@ -826,43 +747,21 @@ const DeployImageModal = ({ pipelineId={pipelineId} handleClose={handleClose} isRedirectedFromAppDetails={isRedirectedFromAppDetails} - isSearchApplied={!!searchImageTag} - searchText={searchText} + deployViewState={deployViewStateProps} onSearchApply={onSearchApply} - onSearchTextChange={onSearchTextChange} - filterView={filterView} - showConfiguredFilters={showConfiguredFilters} stageType={stageType} - currentSidebarTab={currentSidebarTab} - handleRuntimeParamsChange={handleRuntimeParamsChange} - runtimeParamsErrorState={runtimeParamsErrorState} - handleRuntimeParamsError={handleRuntimeParamsError} uploadRuntimeParamsFile={uploadRuntimeParamsFile} - handleSidebarTabChange={handleSidebarTabChange} appName={appName} isSecurityModuleInstalled={isSecurityModuleInstalled} envName={envName} - handleShowAppliedFilters={handleShowAppliedFilters} reloadMaterials={reloadInitialData} parentEnvironmentName={parentEnvironmentName} isVirtualEnvironment={isVirtualEnvironment} - handleImageSelection={handleImageSelection} - setAppReleaseTagNames={setAppReleaseTagNames} - materialInEditModeMap={materialInEditModeMap} - toggleCardMode={toggleCardMode} - setTagsEditable={setTagsEditable} - updateCurrentAppMaterial={updateCurrentAppMaterial} - handleEnableFiltersView={handleEnableFiltersView} - handleDisableFiltersView={handleDisableFiltersView} - handleFilterTabsChange={handleFilterTabsChange} loadOlderImages={loadOlderImages} - isLoadingOlderImages={isLoadingOlderImages} policyConsequences={policyConsequences} - handleAllImagesView={handleAllImagesView} triggerType={triggerType} - handleDisableAppliedFiltersView={handleDisableAppliedFiltersView} - showAppliedFilters={showAppliedFilters} - appliedFilterList={appliedFilterList} + setMaterialResponse={setMaterialResponse} + setDeployViewState={setDeployViewState} /> ) } diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index bdbfef4f37..f46929597e 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -7,24 +7,14 @@ import { DeploymentAppTypes, DeploymentNodeType, DeploymentWindowProfileMetaData, - ImageTaggingContainerType, PolicyConsequencesDTO, - SegmentedControlProps, ServerErrors, UploadFileDTO, UploadFileProps, useSearchString, } from '@devtron-labs/devtron-fe-common-lib' -import { - FilterConditionViews, - HandleRuntimeParamChange, - HandleRuntimeParamErrorState, - MATERIAL_TYPE, - RuntimeParamsErrorState, -} from '../types' - -export interface DeployViewStateType {} +import { FilterConditionViews, MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' export type DeployImageModalProps = { appId: number @@ -89,6 +79,32 @@ export interface GetTriggerArtifactInfoPropsType reloadMaterials: () => void } +export interface DeployViewStateType { + /** + * The search text for filtering images in the deploy view, need to be in state so as to persist the search text + */ + searchText: string + appliedSearchText: string + /** + * The value of segment control to show whether we are showing eligible images or all images + */ + filterView: FilterConditionViews + /** + * Show a modal to display configured image filters + */ + showConfiguredFilters: boolean + currentSidebarTab: CDMaterialSidebarType + runtimeParamsErrorState: RuntimeParamsErrorState + materialInEditModeMap: Map + /** + * Will show filters that blocked the auto trigger + */ + showAppliedFilters: boolean + appliedFilterList: CDMaterialType['appliedFilters'] + isLoadingOlderImages: boolean + showSearchBar: boolean +} + export type DeployImageContentProps = Pick< DeployImageModalProps, | 'handleClose' @@ -104,42 +120,21 @@ export type DeployImageContentProps = Pick< | 'configurePluginURL' | 'triggerType' > & - Pick & { + Pick & { materialResponse: CDMaterialResponseType deploymentWindowMetadata: DeploymentWindowProfileMetaData policyConsequences: PolicyConsequencesDTO isRollbackTrigger: boolean - // The states for material list: Can move to object - isSearchApplied: boolean - searchText: string - filterView: FilterConditionViews - showConfiguredFilters: boolean - currentSidebarTab: CDMaterialSidebarType - runtimeParamsErrorState: RuntimeParamsErrorState - handleRuntimeParamsError: HandleRuntimeParamErrorState - materialInEditModeMap: Map - onSearchTextChange: (searchText: string) => void - onSearchApply: (searchText: string) => void - showAppliedFilters: boolean - handleDisableFiltersView: () => void - handleDisableAppliedFiltersView: () => void - appliedFilterList: CDMaterialType['appliedFilters'] - - handleRuntimeParamsChange: HandleRuntimeParamChange - handleImageSelection: (materialIndex: number) => void uploadRuntimeParamsFile: (props: UploadFileProps) => Promise isSecurityModuleInstalled: boolean - handleShowAppliedFilters: (materialData: CDMaterialType) => void reloadMaterials: () => void - setAppReleaseTagNames: (appReleaseTagNames: string[]) => void - toggleCardMode: (index: number) => void - setTagsEditable: (tagsEditable: boolean) => void - updateCurrentAppMaterial: ImageTaggingContainerType['updateCurrentAppMaterial'] - handleEnableFiltersView: () => void - handleFilterTabsChange: SegmentedControlProps['onChange'] + setMaterialResponse: ( + param: (previousMaterialResponse: CDMaterialResponseType) => CDMaterialResponseType, + ) => void + setDeployViewState: (param: (previousDeployViewState: DeployViewStateType) => DeployViewStateType) => void + deployViewState: DeployViewStateType loadOlderImages: () => void - isLoadingOlderImages: boolean - handleAllImagesView: () => void + onSearchApply: (searchText: string) => void } & ( | { isBulkTrigger: true @@ -149,8 +144,8 @@ export type DeployImageContentProps = Pick< } ) -export interface GetConsumedAndAvailableMaterialListProps - extends Pick { +export interface GetConsumedAndAvailableMaterialListProps extends Pick { + isSearchApplied: boolean isExceptionUser: boolean isApprovalConfigured: boolean materials: CDMaterialType[] @@ -180,7 +175,6 @@ export interface MaterialListEmptyStateProps | 'isRollbackTrigger' | 'stageType' | 'appId' - | 'isSearchApplied' | 'policyConsequences' | 'isTriggerBlockedDueToPlugin' | 'configurePluginURL' @@ -189,7 +183,6 @@ export interface MaterialListEmptyStateProps | 'triggerType' | 'loadOlderImages' | 'onSearchApply' - | 'handleAllImagesView' > { isExceptionUser: boolean isConsumedImagePresent: boolean @@ -197,4 +190,6 @@ export interface MaterialListEmptyStateProps viewAllImages: () => void eligibleImagesCount: number handleEnableFiltersView: () => void + isSearchApplied: boolean + handleAllImagesView: () => void } From db45cd1a3894661c4a1cd50c65382001e5ed068b Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 30 Jul 2025 18:16:04 +0530 Subject: [PATCH 09/40] feat: Update DeployImageModal layout for improved filter visibility and responsiveness --- .../DeployImageModal/DeployImageContent.tsx | 2 +- .../DeployImageModal/DeployImageModal.tsx | 54 ++++++++++--------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 1245d45e53..245c032521 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -526,7 +526,7 @@ const DeployImageContent = ({ )}
{renderSidebar()} diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 868ed1593b..7021d46a17 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -774,32 +774,34 @@ const DeployImageModal = ({ onClick={stopPropagation} >
- - {showConfigDiffView && selectedMaterial && ( - - )} - + {!deployViewState.showAppliedFilters && !deployViewState.showConfiguredFilters && ( + + {showConfigDiffView && selectedMaterial && ( + + )} + + )}
{renderContent()}
From ce9e83438c8535202657ec5371b057a643ecde0e Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Mon, 4 Aug 2025 21:35:14 +0530 Subject: [PATCH 10/40] feat: Enhance Deploy Image Modal with Bulk Deployment Features - Added support for bulk deployment of images, including new tag selection options. - Introduced constants for bulk deployment image tags. - Updated DeployImageContent to handle bulk trigger scenarios and render appropriate UI elements. - Enhanced DeployImageHeader to accept a custom title prop for better flexibility. - Refactored rendering logic in DeployImageContent to improve readability and maintainability. - Added new types for better type safety and clarity in props. - Implemented error handling and empty state management for various deployment scenarios. --- .../ApplicationGroup/AppGroup.types.ts | 107 +- .../Details/TriggerView/BulkCDTrigger.tsx | 1025 ---------------- .../Details/TriggerView/EnvTriggerView.tsx | 453 +------ .../TriggerView/TriggerResponseModal.tsx | 4 +- .../Details/TriggerView/utils.ts | 52 +- .../details/triggerView/BranchRegexModal.tsx | 55 +- .../BuildImageModal/BuildImageModal.tsx | 12 +- .../BuildImageModal/BulkBuildImageModal.tsx | 12 +- .../BuildImageModal/TriggerBuildSidebar.tsx | 2 +- .../DeployImageModal/BulkDeployModal.tsx | 1085 +++++++++++++++++ .../DeployImageModal/DeployImageContent.tsx | 461 +++++-- .../DeployImageModal/DeployImageHeader.tsx | 14 +- .../DeployImageModal/DeployImageModal.tsx | 2 +- .../triggerView/DeployImageModal/constants.ts | 16 + .../triggerView/DeployImageModal/index.ts | 1 + .../triggerView/DeployImageModal/types.ts | 34 +- 16 files changed, 1602 insertions(+), 1733 deletions(-) delete mode 100644 src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/constants.ts diff --git a/src/components/ApplicationGroup/AppGroup.types.ts b/src/components/ApplicationGroup/AppGroup.types.ts index 74a566b626..e12bfc56c2 100644 --- a/src/components/ApplicationGroup/AppGroup.types.ts +++ b/src/components/ApplicationGroup/AppGroup.types.ts @@ -14,33 +14,27 @@ * limitations under the License. */ -import { Dispatch, SetStateAction } from 'react' import { MultiValue } from 'react-select' import { ACTION_STATE, AppInfoListType, - ApprovalConfigDataType, - CDModalTabType, CIMaterialType, CommonNodeAttr, DeploymentNodeType, - DeploymentStrategyTypeWithDefault, - FilterConditionsListType, GVKType, MODAL_TYPE, OptionType, - PipelineIdsVsDeploymentStrategyMap, ResponseType, RuntimePluginVariables, ServerErrors, + TriggerBlockedInfo, UseUrlFiltersReturnType, - WorkflowNodeType, WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' import { GitInfoMaterialProps } from '@Components/app/details/triggerView/BuildImageModal/types' -import { CDMaterialProps, RuntimeParamsErrorState } from '@Components/app/details/triggerView/types' +import { DeployImageContentProps } from '@Components/app/details/triggerView/DeployImageModal/types' import { AppConfigState, EnvConfigurationsNavProps, @@ -72,35 +66,39 @@ export interface BulkCIDetailType extends BulkTriggerAppDetailType { ignoreCache: boolean } +export type BulkCDDetailDerivedFromNode = Required< + Pick< + DeployImageContentProps, + | 'pipelineId' + | 'appId' + | 'parentEnvironmentName' + | 'isTriggerBlockedDueToPlugin' + | 'configurePluginURL' + | 'triggerType' + | 'appName' + > +> & { + stageNotAvailable: boolean + warningMessage: string + triggerBlockedInfo: TriggerBlockedInfo + consequence: CommonNodeAttr['pluginBlockState'] + showPluginWarning: CommonNodeAttr['showPluginWarning'] +} + +export type BulkCDDetailType = BulkCDDetailDerivedFromNode & + Pick & { + /** + * True in cases when we reload materials on single app + */ + areMaterialsLoading: boolean + materialError: ServerErrors | null + } + export interface BulkCDDetailTypeResponse { bulkCDDetailType: BulkCDDetailType[] uniqueReleaseTags: string[] } -export interface BulkCDDetailType - extends BulkTriggerAppDetailType, - Pick, - Partial> { - workFlowId: string - cdPipelineName?: string - cdPipelineId?: string - stageType?: DeploymentNodeType - triggerType?: string - envName: string - envId: number - parentPipelineId?: string - parentPipelineType?: WorkflowNodeType - parentEnvironmentName?: string - approvalConfigData?: ApprovalConfigDataType - requestedUserId?: number - appReleaseTags?: string[] - tagsEditable?: boolean - ciPipelineId?: number - hideImageTaggingHardDelete?: boolean - resourceFilters?: FilterConditionsListType[] - isExceptionUser?: boolean -} - export type TriggerVirtualEnvResponseRowType = | { isVirtual: true @@ -122,45 +120,6 @@ export type ResponseRowType = { envId?: number } & TriggerVirtualEnvResponseRowType -interface BulkRuntimeParamsType { - runtimeParams: Record - setRuntimeParams: React.Dispatch>> - runtimeParamsErrorState: Record - setRuntimeParamsErrorState: React.Dispatch>> -} - -export interface BulkCDTriggerType extends BulkRuntimeParamsType { - stage: DeploymentNodeType - appList: BulkCDDetailType[] - closePopup: (e) => void - updateBulkInputMaterial: (materialList: Record) => void - onClickTriggerBulkCD: ( - skipIfHibernated: boolean, - pipelineIdVsStrategyMap: PipelineIdsVsDeploymentStrategyMap, - appsToRetry?: Record, - ) => void - changeTab?: ( - materrialId: string | number, - artifactId: number, - tab: CDModalTabType, - selectedCDDetail?: { id: number; type: DeploymentNodeType }, - ) => void - toggleSourceInfo?: (materialIndex: number, selectedCDDetail?: { id: number; type: DeploymentNodeType }) => void - selectImage?: ( - index: number, - materialType: string, - selectedCDDetail?: { id: number; type: DeploymentNodeType }, - ) => void - responseList: ResponseRowType[] - isLoading: boolean - setLoading: React.Dispatch> - isVirtualEnv?: boolean - uniqueReleaseTags: string[] - feasiblePipelineIds: Set - bulkDeploymentStrategy: DeploymentStrategyTypeWithDefault - setBulkDeploymentStrategy: Dispatch> -} - export interface ProcessWorkFlowStatusType { cicdInProgress: boolean workflows: WorkflowType[] @@ -194,15 +153,11 @@ export interface TriggerResponseModalBodyProps { type RetryFailedType = | { - onClickRetryDeploy: BulkCDTriggerType['onClickTriggerBulkCD'] - skipHibernatedApps: boolean - pipelineIdVsStrategyMap: PipelineIdsVsDeploymentStrategyMap + onClickRetryDeploy: (appsToRetry: Record) => void onClickRetryBuild?: never } | { onClickRetryDeploy?: never - skipHibernatedApps?: never - pipelineIdVsStrategyMap?: never onClickRetryBuild: (appsToRetry: Record) => void } diff --git a/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx b/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx deleted file mode 100644 index 91f4ba2c93..0000000000 --- a/src/components/ApplicationGroup/Details/TriggerView/BulkCDTrigger.tsx +++ /dev/null @@ -1,1025 +0,0 @@ -/* - * Copyright (c) 2024. Devtron Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { SyntheticEvent, useEffect, useRef, useState } from 'react' -import { useHistory, useLocation } from 'react-router-dom' - -import { - ACTION_STATE, - AnimatedDeployButton, - ApiQueuingWithBatch, - Button, - ButtonStyleType, - ButtonVariantType, - CD_MATERIAL_SIDEBAR_TABS, - CDMaterialResponseType, - CDMaterialServiceEnum, - CDMaterialSidebarType, - CDMaterialType, - CommonNodeAttr, - ComponentSizeType, - DEPLOYMENT_WINDOW_TYPE, - DeploymentNodeType, - DeploymentWindowProfileMetaData, - Drawer, - FilterStates, - genericCDMaterialsService, - GenericEmptyState, - Icon, - ImageComment, - MODAL_TYPE, - PipelineIdsVsDeploymentStrategyMap, - ReleaseTag, - RuntimePluginVariables, - SelectPicker, - showError, - stopPropagation, - ToastManager, - ToastVariantType, - TriggerBlockType, - uploadCDPipelineFile, - UploadFileProps, - useGetUserRoles, - useMainContext, -} from '@devtron-labs/devtron-fe-common-lib' - -import { ReactComponent as UnAuthorized } from '@Icons/ic-locked.svg' -import { ReactComponent as Tag } from '@Icons/ic-tag.svg' -import { ReactComponent as Error } from '@Icons/ic-warning.svg' -import { getIsMaterialApproved } from '@Components/app/details/triggerView/cdMaterials.utils' - -import emptyPreDeploy from '../../../../assets/img/empty-pre-deploy.webp' -import { ReactComponent as MechanicalOperation } from '../../../../assets/img/ic-mechanical-operation.svg' -import notAuthorized from '../../../../assets/img/ic-not-authorized.svg' -import { BulkSelectionEvents, MATERIAL_TYPE, RuntimeParamsErrorState } from '../../../app/details/triggerView/types' -import { importComponentFromFELibrary } from '../../../common' -import { BulkCDDetailType, BulkCDTriggerType } from '../../AppGroup.types' -import { BULK_CD_DEPLOYMENT_STATUS, BULK_CD_MATERIAL_STATUS, BULK_CD_MESSAGING, BUTTON_TITLE } from '../../Constants' -import { BULK_ERROR_MESSAGES } from './constants' -import TriggerResponseModalBody, { TriggerResponseModalFooter } from './TriggerResponseModal' -import { - getIsImageApprovedByDeployerSelected, - getIsNonApprovedImageSelected, - getSelectedAppListForBulkStrategy, -} from './utils' - -const DeploymentWindowInfoBar = importComponentFromFELibrary('DeploymentWindowInfoBar') -const BulkDeployResistanceTippy = importComponentFromFELibrary('BulkDeployResistanceTippy') -const processDeploymentWindowMetadata = importComponentFromFELibrary( - 'processDeploymentWindowMetadata', - null, - 'function', -) -const getDeploymentWindowStateAppGroup = importComponentFromFELibrary( - 'getDeploymentWindowStateAppGroup', - null, - 'function', -) -const RuntimeParamTabs = importComponentFromFELibrary('RuntimeParamTabs', null, 'function') -const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlockState', null, 'function') -const PolicyEnforcementMessage = importComponentFromFELibrary('PolicyEnforcementMessage') -const TriggerBlockedError = importComponentFromFELibrary('TriggerBlockedError', null, 'function') -const TriggerBlockEmptyState = importComponentFromFELibrary('TriggerBlockEmptyState', null, 'function') -const validateRuntimeParameters = importComponentFromFELibrary( - 'validateRuntimeParameters', - () => ({ isValid: true, cellError: {} }), - 'function', -) -const SkipHibernatedCheckbox = importComponentFromFELibrary('SkipHibernatedCheckbox', null, 'function') -const SelectDeploymentStrategy = importComponentFromFELibrary('SelectDeploymentStrategy', null, 'function') -const BulkCDStrategy = importComponentFromFELibrary('BulkCDStrategy', null, 'function') - -// TODO: Fix release tags selection -const BulkCDTrigger = ({ - stage, - appList, - closePopup, - // NOTE: Using this to update the appList in the parent component, should remove this later - updateBulkInputMaterial, - // NOTE: Should trigger the bulk cd here only but since its also calling another parent function not refactoring right now - onClickTriggerBulkCD, - feasiblePipelineIds, - responseList, - isLoading, - setLoading, - isVirtualEnv, - uniqueReleaseTags, - runtimeParams, - setRuntimeParams, - bulkDeploymentStrategy, - setBulkDeploymentStrategy, - runtimeParamsErrorState, - setRuntimeParamsErrorState, -}: BulkCDTriggerType) => { - const { canFetchHelmAppStatus } = useMainContext() - const [selectedApp, setSelectedApp] = useState( - appList.find((app) => !app.warningMessage) || appList[0], - ) - const [tagNotFoundWarningsMap, setTagNotFoundWarningsMap] = useState>(new Map()) - const [unauthorizedAppList, setUnauthorizedAppList] = useState>({}) - const abortControllerRef = useRef(new AbortController()) - const [appSearchTextMap, setAppSearchTextMap] = useState>({}) - const [selectedImages, setSelectedImages] = useState>({}) - // This signifies any action that needs to be propagated to the child - const [selectedImageFromBulk, setSelectedImageFromBulk] = useState(null) - const [appDeploymentWindowMap, setAppDeploymentWindowMap] = useState< - Record - >({}) - const [isPartialActionAllowed, setIsPartialActionAllowed] = useState(false) - const [showResistanceBox, setShowResistanceBox] = useState(false) - const [currentSidebarTab, setCurrentSidebarTab] = useState(CDMaterialSidebarType.IMAGE) - const [skipHibernatedApps, setSkipHibernatedApps] = useState(false) - const [showStrategyFeasibilityPage, setShowStrategyFeasibilityPage] = useState(false) - const [pipelineIdVsStrategyMap, setPipelineIdVsStrategyMap] = useState({}) - - const location = useLocation() - const history = useHistory() - const { isSuperAdmin } = useGetUserRoles() - const isBulkDeploymentTriggered = useRef(false) - - const showRuntimeParams = - RuntimeParamTabs && (stage === DeploymentNodeType.PRECD || stage === DeploymentNodeType.POSTCD) - - useEffect(() => { - const searchParams = new URLSearchParams(location.search) - const search = searchParams.get('search') - const _appSearchTextMap = { ...appSearchTextMap } - - if (search) { - _appSearchTextMap[selectedApp.appId] = search - } else { - delete _appSearchTextMap[selectedApp.appId] - } - - setAppSearchTextMap(_appSearchTextMap) - }, [location]) - - const closeBulkCDModal = (e: React.MouseEvent): void => { - e.stopPropagation() - abortControllerRef.current.abort() - closePopup(e) - } - - const [selectedTagName, setSelectedTagName] = useState<{ label: string; value: string }>({ - label: 'latest', - value: 'latest', - }) - - const handleSidebarTabChange = (e: React.ChangeEvent) => { - setCurrentSidebarTab(e.target.value as CDMaterialSidebarType) - } - - const handleRuntimeParamError = (errorState: RuntimeParamsErrorState) => { - setRuntimeParamsErrorState((prevErrorState) => ({ - ...prevErrorState, - [selectedApp.appId]: errorState, - })) - } - - const handleRuntimeParamChange = (currentAppRuntimeParams: RuntimePluginVariables[]) => { - const clonedRuntimeParams = structuredClone(runtimeParams) - clonedRuntimeParams[selectedApp.appId] = currentAppRuntimeParams - setRuntimeParams(clonedRuntimeParams) - } - - const bulkUploadFile = ({ file, allowedExtensions, maxUploadSize }: UploadFileProps) => - uploadCDPipelineFile({ - file, - allowedExtensions, - maxUploadSize, - appId: selectedApp.appId, - envId: selectedApp.envId, - }) - - const getDeploymentWindowData = async (_cdMaterialResponse) => { - const currentEnv = appList[0].envId - const appEnvMap = [] - let _isPartialActionAllowed = false - for (const appDetails of appList) { - if (_cdMaterialResponse[appDetails.appId]) { - appEnvMap.push({ appId: appDetails.appId, envId: appDetails.envId }) - } - } - const { result } = await getDeploymentWindowStateAppGroup(appEnvMap) - const _appDeploymentWindowMap = {} - result?.appData?.forEach((data) => { - _appDeploymentWindowMap[data.appId] = processDeploymentWindowMetadata( - data.deploymentProfileList, - currentEnv, - ) - if (!_isPartialActionAllowed) { - _isPartialActionAllowed = - _appDeploymentWindowMap[data.appId].type === DEPLOYMENT_WINDOW_TYPE.BLACKOUT || - !_appDeploymentWindowMap[data.appId].isActive - ? _appDeploymentWindowMap[data.appId].userActionState === ACTION_STATE.PARTIAL - : false - } - }) - setIsPartialActionAllowed(_isPartialActionAllowed) - setAppDeploymentWindowMap(_appDeploymentWindowMap) - } - - const resolveMaterialData = (_cdMaterialResponse, _unauthorizedAppList) => (response) => { - if (response.status === 'fulfilled') { - setRuntimeParams((prevState) => { - const updatedRuntimeParams = { ...prevState } - updatedRuntimeParams[response.value.appId] = response.value.runtimeParams || [] - return updatedRuntimeParams - }) - - _cdMaterialResponse[response.value.appId] = response.value - // if first image does not have filerState.ALLOWED then unselect all images and set SELECT_NONE for selectedImage and for first app send the trigger of SELECT_NONE from selectedImageFromBulk - if ( - response.value.materials?.length > 0 && - (response.value.materials[0].filterState !== FilterStates.ALLOWED || - response.value.materials[0].vulnerable) - ) { - const updatedMaterials = response.value.materials.map((mat) => ({ - ...mat, - isSelected: false, - })) - _cdMaterialResponse[response.value.appId] = { - ...response.value, - materials: updatedMaterials, - } - setSelectedImages((prevSelectedImages) => ({ - ...prevSelectedImages, - [response.value.appId]: BulkSelectionEvents.SELECT_NONE, - })) - - const _warningMessage = response.value.materials[0].vulnerable - ? 'has security vulnerabilities' - : 'is not eligible' - - setTagNotFoundWarningsMap((prevTagNotFoundWarningsMap) => { - const _tagNotFoundWarningsMap = new Map(prevTagNotFoundWarningsMap) - _tagNotFoundWarningsMap.set( - response.value.appId, - `Tag '${ - selectedTagName.value?.length > 15 - ? `${selectedTagName.value.substring(0, 10)}...` - : selectedTagName.value - }' ${_warningMessage}`, - ) - return _tagNotFoundWarningsMap - }) - if (response.value.appId === selectedApp.appId) { - setSelectedImageFromBulk(BulkSelectionEvents.SELECT_NONE) - } - } else if (response.value.materials?.length === 0) { - setTagNotFoundWarningsMap((prevTagNotFoundWarningsMap) => { - const _tagNotFoundWarningsMap = new Map(prevTagNotFoundWarningsMap) - _tagNotFoundWarningsMap.set( - response.value.appId, - `Tag '${ - selectedTagName.value?.length > 15 - ? `${selectedTagName.value.substring(0, 10)}...` - : selectedTagName.value - }' not found`, - ) - return _tagNotFoundWarningsMap - }) - } - - delete _unauthorizedAppList[response.value.appId] - } else { - const errorReason = response?.reason - if (errorReason?.code === 403) { - _unauthorizedAppList[errorReason.appId] = true - } - } - } - - const getCDMaterialFunction = (appDetails) => () => - // Not sending any query params since its not necessary on mount and filters and handled by other service) - genericCDMaterialsService( - CDMaterialServiceEnum.CD_MATERIALS, - Number(appDetails.cdPipelineId), - appDetails.stageType, - abortControllerRef.current.signal, - { - offset: 0, - size: 20, - }, - ) - .then((data) => ({ appId: appDetails.appId, ...data })) - .catch((e) => { - if (!abortControllerRef.current.signal.aborted) { - throw { response: e?.response, appId: appDetails.appId } - } - }) - - /** - * Gets triggered during the mount state of the component through useEffect - * Fetches the material data pushes them into promise list - * Promise list is resolved using Promise.allSettled - * If the promise is fulfilled, the data is pushed into cdMaterialResponse - */ - const getMaterialData = (): void => { - abortControllerRef.current = new AbortController() - const _unauthorizedAppList: Record = {} - const _cdMaterialResponse: Record = {} - abortControllerRef.current = new AbortController() - const _cdMaterialFunctionsList = [] - for (const appDetails of appList) { - if (!appDetails.warningMessage) { - _unauthorizedAppList[appDetails.appId] = false - _cdMaterialFunctionsList.push(getCDMaterialFunction(appDetails)) - } - } - - if (!_cdMaterialFunctionsList.length) { - setLoading(false) - return - } - - ApiQueuingWithBatch(_cdMaterialFunctionsList) - .then(async (responses: any[]) => { - responses.forEach(resolveMaterialData(_cdMaterialResponse, _unauthorizedAppList)) - if (getDeploymentWindowStateAppGroup) { - await getDeploymentWindowData(_cdMaterialResponse) - } - updateBulkInputMaterial(_cdMaterialResponse) - setUnauthorizedAppList(_unauthorizedAppList) - setLoading(false) - }) - .catch((error) => { - setLoading(false) - showError(error) - }) - } - - useEffect(() => { - getMaterialData() - }, []) - - const handleBackFromStrategySelection = () => { - setShowStrategyFeasibilityPage(false) - setPipelineIdVsStrategyMap({}) - } - - const renderHeaderSection = (): JSX.Element => ( -
-
- {showStrategyFeasibilityPage && ( -
-
- ) - - const changeApp = (e): void => { - const updatedErrorState = validateRuntimeParameters(runtimeParams[selectedApp.appId]) - handleRuntimeParamError(updatedErrorState) - if (!updatedErrorState.isValid) { - ToastManager.showToast({ - variant: ToastVariantType.error, - description: BULK_ERROR_MESSAGES.CHANGE_APPLICATION, - }) - return - } - - const _selectedApp = appList[e.currentTarget.dataset.index] - setSelectedApp(_selectedApp) - setSelectedImageFromBulk(selectedImages[_selectedApp.appId]) - - if (appSearchTextMap[_selectedApp.appId]) { - const newSearchParams = new URLSearchParams(location.search) - newSearchParams.set('search', appSearchTextMap[_selectedApp.appId]) - - history.push({ - search: newSearchParams.toString(), - }) - } else { - history.push({ - search: '', - }) - } - } - - const renderEmptyView = (): JSX.Element => { - if (selectedApp.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { - return - } - - if (selectedApp.isTriggerBlockedDueToPlugin) { - const commonNodeAttrType: CommonNodeAttr['type'] = - selectedApp.stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' - - return ( - - ) - } - - if (unauthorizedAppList[selectedApp.appId]) { - return ( - - ) - } - return ( - - ) - } - - const renderDeploymentWithoutApprovalWarning = (app: BulkCDDetailType) => { - if (!app.isExceptionUser) { - return null - } - - const selectedMaterial: CDMaterialType = app.material?.find((mat: CDMaterialType) => mat.isSelected) - - if (!selectedMaterial || getIsMaterialApproved(selectedMaterial?.userApprovalMetadata)) { - return null - } - - return ( -
- -

Non-approved image selected

-
- ) - } - - const renderAppWarningAndErrors = (app: BulkCDDetailType) => { - const commonNodeAttrType: CommonNodeAttr['type'] = - app.stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' - - const warningMessage = app.warningMessage || appDeploymentWindowMap[app.appId]?.warningMessage - - const isAppSelected = selectedApp.appId === app.appId - - if (unauthorizedAppList[app.appId]) { - return ( -
- - {BULK_CD_MESSAGING.unauthorized.title} -
- ) - } - - if (tagNotFoundWarningsMap.has(app.appId)) { - return ( -
- - - {tagNotFoundWarningsMap.get(app.appId)} -
- ) - } - - if (app.isTriggerBlockedDueToPlugin) { - return ( - - ) - } - - if (app.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { - return - } - - if (!!warningMessage && !app.showPluginWarning) { - return ( -
- - {warningMessage} -
- ) - } - - if (app.showPluginWarning) { - return ( - - ) - } - - return null - } - - const responseListLength = responseList.length - - const renderBodySection = (): JSX.Element => { - if (responseListLength) { - return ( - - ) - } - - if (isLoading) { - const message = isBulkDeploymentTriggered.current - ? BULK_CD_DEPLOYMENT_STATUS(appList.length, appList[0].envName) - : BULK_CD_MATERIAL_STATUS(appList.length) - return ( - - ) - } - - const updateCurrentAppMaterial = (matId: number, releaseTags?: ReleaseTag[], imageComment?: ImageComment) => { - const updatedCurrentApp = selectedApp - updatedCurrentApp?.material.forEach((mat) => { - if (mat.id === matId) { - if (releaseTags) { - mat.imageReleaseTags = releaseTags - } - if (imageComment) { - mat.imageComment = imageComment - } - } - }) - updatedCurrentApp && setSelectedApp(updatedCurrentApp) - } - - const _currentApp = appList.find((app) => app.appId === selectedApp.appId) ?? ({} as BulkCDDetailType) - uniqueReleaseTags.sort((a, b) => a.localeCompare(b)) - - const tagsList = ['latest', 'active'] - - tagsList.push(...uniqueReleaseTags) - const options = tagsList.map((tag) => ({ label: tag, value: tag })) - - const appWiseTagsToArtifactIdMapMappings = {} - appList.forEach((app) => { - if (!app.material?.length) { - appWiseTagsToArtifactIdMapMappings[app.appId] = {} - } else { - const tagsToArtifactIdMap = { latest: 0 } - for (let i = 0; i < app.material?.length; i++) { - const mat = app.material?.[i] - mat.imageReleaseTags?.forEach((imageTag) => { - tagsToArtifactIdMap[imageTag.tagName] = i - }) - - if (mat.deployed && mat.latest) { - tagsToArtifactIdMap['active'] = i - } - } - appWiseTagsToArtifactIdMapMappings[app.appId] = tagsToArtifactIdMap - } - }) - - // Don't use it as single, use it through update function - const selectImageLocal = (index: number, appId: number, selectedImageTag: string) => { - setSelectedImages({ ...selectedImages, [appId]: selectedImageTag }) - - if (appWiseTagsToArtifactIdMapMappings[appId][selectedTagName.value] !== index) { - const _tagNotFoundWarningsMap = tagNotFoundWarningsMap - _tagNotFoundWarningsMap.delete(appId) - setTagNotFoundWarningsMap(_tagNotFoundWarningsMap) - setSelectedTagName({ value: 'Multiple tags', label: 'Multiple tags' }) - } else { - // remove warning if any - const _tagNotFoundWarningsMap = new Map(tagNotFoundWarningsMap) - _tagNotFoundWarningsMap.delete(appId) - setTagNotFoundWarningsMap(_tagNotFoundWarningsMap) - } - } - - const parseApplistIntoCDMaterialResponse = ( - appListData: BulkCDDetailType, - updatedMaterials?: CDMaterialType, - ) => ({ - materials: updatedMaterials ?? appListData.material, - requestedUserId: appListData.requestedUserId, - approvalConfigData: appListData.approvalConfigData, - appReleaseTagNames: appListData.appReleaseTags, - tagsEditable: appListData.tagsEditable, - }) - - const handleTagChange = (selectedTag) => { - setSelectedTagName(selectedTag) - const _tagNotFoundWarningsMap = new Map() - const _cdMaterialResponse: Record = {} - - for (let i = 0; i < (appList?.length ?? 0); i++) { - const app = appList[i] - const tagsToArtifactIdMap = appWiseTagsToArtifactIdMapMappings[app.appId] - let artifactIndex = -1 - if (typeof tagsToArtifactIdMap[selectedTag.value] !== 'undefined') { - artifactIndex = tagsToArtifactIdMap[selectedTag.value] - } - - // Handling the behavior for excluded filter state - if (artifactIndex !== -1) { - const selectedImageFilterState = app.material?.[artifactIndex]?.filterState - - if (selectedImageFilterState !== FilterStates.ALLOWED) { - artifactIndex = -1 - _tagNotFoundWarningsMap.set( - app.appId, - `Tag '${ - selectedTag.value?.length > 15 - ? `${selectedTag.value.substring(0, 10)}...` - : selectedTag.value - }' is not eligible`, - ) - } else if (app.material?.[artifactIndex]?.vulnerable) { - artifactIndex = -1 - _tagNotFoundWarningsMap.set( - app.appId, - `Tag '${ - selectedTag.value?.length > 15 - ? `${selectedTag.value.substring(0, 10)}...` - : selectedTag.value - }' has security vulnerabilities`, - ) - } - } else { - _tagNotFoundWarningsMap.set( - app.appId, - `Tag '${ - selectedTag.value?.length > 15 - ? `${selectedTag.value.substring(0, 10)}...` - : selectedTag.value - }' not found`, - ) - } - - if (artifactIndex !== -1 && selectedTag.value !== 'latest' && selectedTag.value !== 'active') { - const releaseTag = app.material[artifactIndex]?.imageReleaseTags.find( - (releaseTag) => releaseTag.tagName === selectedTag.value, - ) - if (releaseTag?.deleted) { - artifactIndex = -1 - _tagNotFoundWarningsMap.set( - app.appId, - `Tag '${ - selectedTag.value?.length > 15 - ? `${selectedTag.value.substring(0, 10)}...` - : selectedTag.value - }' is soft-deleted`, - ) - } - } - - if (artifactIndex !== -1) { - const selectedImageName = app.material?.[artifactIndex]?.image - const updatedMaterials: any = app.material?.map((mat, index) => ({ - ...mat, - isSelected: index === artifactIndex, - })) - - _cdMaterialResponse[app.appId] = parseApplistIntoCDMaterialResponse(app, updatedMaterials) - - setSelectedImages((prevSelectedImages) => ({ - ...prevSelectedImages, - [app.appId]: selectedImageName, - })) - } else { - const updatedMaterials: any = app.material?.map((mat) => ({ - ...mat, - isSelected: false, - })) - - _cdMaterialResponse[app.appId] = parseApplistIntoCDMaterialResponse(app, updatedMaterials) - - setSelectedImages((prevSelectedImages) => ({ - ...prevSelectedImages, - [app.appId]: BulkSelectionEvents.SELECT_NONE, - })) - } - } - - updateBulkInputMaterial(_cdMaterialResponse) - - // Handling to behviour of current app to send a trigger to child - const selectedImageName = _cdMaterialResponse[selectedApp.appId]?.materials?.find( - (mat: CDMaterialType) => mat.isSelected === true, - )?.image - - if (selectedImageName) { - setSelectedImageFromBulk(selectedImageName) - } else { - setSelectedImageFromBulk(BulkSelectionEvents.SELECT_NONE) - } - - setTagNotFoundWarningsMap(_tagNotFoundWarningsMap) - } - - const updateBulkCDMaterialsItem = (singleCDMaterialResponse) => { - const _cdMaterialResponse: Record = {} - _cdMaterialResponse[selectedApp.appId] = singleCDMaterialResponse - - updateBulkInputMaterial(_cdMaterialResponse) - - const selectedArtifact = singleCDMaterialResponse?.materials?.find( - (mat: CDMaterialType) => mat.isSelected === true, - ) - - if (selectedArtifact) { - selectImageLocal(selectedArtifact.index, selectedApp.appId, selectedArtifact.image) - } - - // Setting it to null since since only wants to trigger change inside if user changes app or tag - setSelectedImageFromBulk(null) - } - - return ( -
-
-
- {showRuntimeParams && ( -
- -
- )} - {currentSidebarTab === CDMaterialSidebarType.IMAGE && ( - <> - Select image by release tag -
- } - onChange={handleTagChange} - isDisabled={false} - classNamePrefix="build-config__select-repository-containing-code" - autoFocus - /> -
- - )} -
- APPLICATIONS -
-
- {appList.map((app, index) => ( -
- {app.name} - {renderDeploymentWithoutApprovalWarning(app)} - {renderAppWarningAndErrors(app)} -
- ))} -
-
- {selectedApp.warningMessage || unauthorizedAppList[selectedApp.appId] ? ( - renderEmptyView() - ) : ( - // TODO: Handle isSuperAdmin prop - - <> - {DeploymentWindowInfoBar && - appDeploymentWindowMap[selectedApp.appId] && - appDeploymentWindowMap[selectedApp.appId].warningMessage && ( - - )} - {/* TODO: Replace with BulkDeployModal */} - {/* */} -
- - )} -
-
- ) - } - const hideResistanceBox = (): void => { - setShowResistanceBox(false) - } - - const onClickDeploy = (e: SyntheticEvent) => { - if (showStrategyFeasibilityPage) { - setShowStrategyFeasibilityPage(false) - } - if (isPartialActionAllowed && BulkDeployResistanceTippy && !showResistanceBox) { - setShowResistanceBox(true) - } else { - isBulkDeploymentTriggered.current = true - stopPropagation(e) - onClickTriggerBulkCD(skipHibernatedApps, pipelineIdVsStrategyMap) - setShowResistanceBox(false) - } - } - - const onClickStartDeploy = (e): void => { - if (BulkCDStrategy && bulkDeploymentStrategy !== 'DEFAULT') { - setShowStrategyFeasibilityPage(true) - return - } - onClickDeploy(e) - } - - const isDeployDisabled = (): boolean => - appList.every((app) => app.warningMessage || tagNotFoundWarningsMap.has(app.appId) || !app.material?.length) - - const renderFooterSection = (): JSX.Element => { - if (responseListLength) { - return ( - - ) - } - - const isCDStage = stage === DeploymentNodeType.CD - const isDeployButtonDisabled: boolean = isDeployDisabled() - const canDeployWithoutApproval = getIsNonApprovedImageSelected(appList) - const canImageApproverDeploy = getIsImageApprovedByDeployerSelected(appList) - const showSkipHibernatedCheckbox = !!SkipHibernatedCheckbox && canFetchHelmAppStatus - - return ( -
- {showSkipHibernatedCheckbox && ( - app.appId)} - skipHibernated={skipHibernatedApps} - setSkipHibernated={setSkipHibernatedApps} - /> - )} -
- {isCDStage && SelectDeploymentStrategy && !isLoading && !responseListLength && ( - +app.cdPipelineId)} - isBulkStrategyChange - deploymentStrategy={bulkDeploymentStrategy} - setDeploymentStrategy={setBulkDeploymentStrategy} - /> - )} -
- } - onButtonClick={onClickStartDeploy} - disabled={isDeployButtonDisabled} - isLoading={isLoading} - animateStartIcon={isCDStage} - style={ - canDeployWithoutApproval || canImageApproverDeploy - ? ButtonStyleType.warning - : ButtonStyleType.default - } - tooltipContent={ - canDeployWithoutApproval || canImageApproverDeploy - ? 'You are authorized to deploy as an exception user for some applications' - : '' - } - /> -
-
-
- ) - } - - return ( - -
- {renderHeaderSection()} - {BulkCDStrategy && showStrategyFeasibilityPage ? ( - - ) : ( - <> - {renderBodySection()} - {renderFooterSection()} - - )} -
- {showResistanceBox && ( - - )} -
- ) -} - -export default BulkCDTrigger diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index 087128459a..9cc9508676 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -15,44 +15,35 @@ */ import React, { useEffect, useRef, useState } from 'react' -import ReactGA from 'react-ga4' import { Prompt, Route, Switch, useHistory, useLocation, useParams, useRouteMatch } from 'react-router-dom' import Tippy from '@tippyjs/react' import { - API_STATUS_CODES, - ApiQueuingWithBatch, Button, ButtonStyleType, ButtonVariantType, - CDMaterialResponseType, Checkbox, CHECKBOX_VALUE, CommonNodeAttr, ComponentSizeType, DEFAULT_ROUTE_PROMPT_MESSAGE, DeploymentNodeType, - DeploymentStrategyTypeWithDefault, ErrorScreenManager, - getStageTitle, handleAnalyticsEvent, - PipelineIdsVsDeploymentStrategyMap, PopupMenu, Progressing, - RuntimePluginVariables, ServerErrors, showError, sortCallback, ToastManager, ToastVariantType, - TriggerBlockType, - triggerCDNode, usePrompt, WorkflowNodeType, WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' import { BuildImageModal, BulkBuildImageModal } from '@Components/app/details/triggerView/BuildImageModal' +import { BulkDeployModal, DeployImageModal } from '@Components/app/details/triggerView/DeployImageModal' import { shouldRenderWebhookAddImageModal } from '@Components/app/details/triggerView/TriggerView.utils' import { getExternalCIConfig } from '@Components/ciPipeline/Webhook/webhook.service' @@ -65,7 +56,7 @@ import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' import { AppNotConfigured } from '../../../app/details/appDetails/AppDetails' import { TriggerViewContext } from '../../../app/details/triggerView/config' import { TRIGGER_VIEW_PARAMS } from '../../../app/details/triggerView/Constants' -import { CIMaterialRouterProps, MATERIAL_TYPE, RuntimeParamsErrorState } from '../../../app/details/triggerView/types' +import { CIMaterialRouterProps, MATERIAL_TYPE } from '../../../app/details/triggerView/types' import { Workflow } from '../../../app/details/triggerView/workflow/Workflow' import { triggerBranchChange } from '../../../app/service' import { getCDPipelineURL, importComponentFromFELibrary, sortObjectArrayAlphabetically } from '../../../common' @@ -73,31 +64,23 @@ import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManag import { getWorkflows, getWorkflowStatus } from '../../AppGroup.service' import { AppGroupDetailDefaultType, - BulkCDDetailType, - BulkCDDetailTypeResponse, ProcessWorkFlowStatusType, ResponseRowType, - TriggerVirtualEnvResponseRowType, WorkflowAppSelectionType, } from '../../AppGroup.types' import { processWorkflowStatuses } from '../../AppGroup.utils' import { - BULK_CD_RESPONSE_STATUS_TEXT, - BULK_CI_RESPONSE_STATUS_TEXT, - BULK_VIRTUAL_RESPONSE_STATUS, BulkResponseStatus, ENV_TRIGGER_VIEW_GA_EVENTS, GetBranchChangeStatus, SKIPPED_RESOURCES_MESSAGE, SKIPPED_RESOURCES_STATUS_TEXT, } from '../../Constants' -import BulkCDTrigger from './BulkCDTrigger' import BulkSourceChange from './BulkSourceChange' import { RenderCDMaterialContentProps } from './types' -import { getSelectedCDNode, getSelectedNodeAndAppId, getSelectedNodeAndMeta } from './utils' +import { getSelectedNodeAndAppId, getSelectedNodeAndMeta } from './utils' import './EnvTriggerView.scss' -import { DeployImageModal } from '@Components/app/details/triggerView/DeployImageModal' const ApprovalMaterialModal = importComponentFromFELibrary('ApprovalMaterialModal') const processDeploymentWindowStateAppGroup = importComponentFromFELibrary( @@ -105,12 +88,6 @@ const processDeploymentWindowStateAppGroup = importComponentFromFELibrary( null, 'function', ) -const getRuntimeParamsPayload = importComponentFromFELibrary('getRuntimeParamsPayload', null, 'function') -const validateRuntimeParameters = importComponentFromFELibrary( - 'validateRuntimeParameters', - () => ({ isValid: true, cellError: {} }), - 'function', -) const ChangeImageSource = importComponentFromFELibrary('ChangeImageSource', null, 'function') const WebhookAddImageModal = importComponentFromFELibrary('WebhookAddImageModal', null, 'function') @@ -140,19 +117,12 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const [filteredWorkflows, setFilteredWorkflows] = useState([]) const [filteredCIPipelines, setFilteredCIPipelines] = useState(null) const [bulkTriggerType, setBulkTriggerType] = useState(null) - // TODO: Not needed - const [materialType, setMaterialType] = useState(MATERIAL_TYPE.inputMaterialList) const [responseList, setResponseList] = useState([]) const [isSelectAll, setSelectAll] = useState(false) const [selectAllValue, setSelectAllValue] = useState(CHECKBOX_VALUE.CHECKED) - // Mapping pipelineId (in case of CI) and appId (in case of CD) to runtime params - const [runtimeParams, setRuntimeParams] = useState>({}) - const [runtimeParamsErrorState, setRuntimeParamsErrorState] = useState>({}) - const [isBulkTriggerLoading, setIsBulkTriggerLoading] = useState(false) const [selectedWebhookNode, setSelectedWebhookNode] = useState<{ appId: number; id: number }>(null) - const [bulkDeploymentStrategy, setBulkDeploymentStrategy] = useState('DEFAULT') - const enableRoutePrompt = isBranchChangeLoading || isBulkTriggerLoading + const enableRoutePrompt = isBranchChangeLoading usePrompt({ shouldPrompt: enableRoutePrompt }) useEffect(() => { @@ -497,9 +467,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT setCDLoading(false) setShowBulkCDModal(false) setResponseList([]) - setBulkDeploymentStrategy('DEFAULT') - setRuntimeParams({}) - setRuntimeParamsErrorState({}) history.push({ search: '', @@ -507,21 +474,13 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT } const onShowBulkCDModal = (e) => { - setCDLoading(true) setBulkTriggerType(e.currentTarget.dataset.triggerType) - setMaterialType(MATERIAL_TYPE.inputMaterialList) - setTimeout(() => { - setShowBulkCDModal(true) - }, 100) } const hideBulkCIModal = () => { setCILoading(false) setShowBulkCIModal(false) setResponseList([]) - - setRuntimeParams({}) - setRuntimeParamsErrorState({}) } const onShowBulkCIModal = () => { @@ -551,146 +510,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT setShowBulkSourceChangeModal(true) } - const updateBulkCDInputMaterial = (cdMaterialResponse: Record): void => { - const _workflows = filteredWorkflows.map((wf) => { - if (wf.isSelected && cdMaterialResponse[wf.appId]) { - const _appId = wf.appId - const _cdNode = wf.nodes.find( - (node) => node.type === WorkflowNodeType.CD && node.environmentId === +envId, - ) - let _selectedNode: CommonNodeAttr - const _materialData = cdMaterialResponse[_appId] - - if (bulkTriggerType === DeploymentNodeType.PRECD) { - _selectedNode = _cdNode.preNode - } else if (bulkTriggerType === DeploymentNodeType.CD) { - _selectedNode = _cdNode - _selectedNode.requestedUserId = _materialData.requestedUserId - _selectedNode.approvalConfigData = _materialData.deploymentApprovalInfo?.approvalConfigData - } else if (bulkTriggerType === DeploymentNodeType.POSTCD) { - _selectedNode = _cdNode.postNode - } - - if (_selectedNode) { - _selectedNode.inputMaterialList = _materialData.materials - } - wf.appReleaseTags = _materialData?.appReleaseTagNames - wf.tagsEditable = _materialData?.tagsEditable - wf.canApproverDeploy = _materialData?.canApproverDeploy ?? false - wf.isExceptionUser = _materialData?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false - } - - return wf - }) - setFilteredWorkflows(_workflows) - } - - const validateBulkRuntimeParams = (): boolean => { - let isRuntimeParamErrorPresent = false - - const updatedRuntimeParamsErrorState = Object.keys(runtimeParams).reduce((acc, key) => { - const validationState = validateRuntimeParameters(runtimeParams[key]) - acc[key] = validationState - isRuntimeParamErrorPresent = !isRuntimeParamErrorPresent && !validationState.isValid - return acc - }, {}) - setRuntimeParamsErrorState(updatedRuntimeParamsErrorState) - - if (isRuntimeParamErrorPresent) { - setCDLoading(false) - setCILoading(false) - ToastManager.showToast({ - variant: ToastVariantType.error, - description: 'Please resolve all the runtime parameter errors before triggering the pipeline', - }) - return false - } - - return true - } - - // Helper to get selected CD nodes - const getSelectedCDNodesWithArtifacts = ( - selectedWorkflows: WorkflowType[], - ): { node: CommonNodeAttr; wf: WorkflowType }[] => - selectedWorkflows - .filter((wf) => wf.isSelected) - .map((wf) => { - const _cdNode = wf.nodes.find( - (node) => node.type === WorkflowNodeType.CD && node.environmentId === +envId, - ) - if (!_cdNode) return null - - const _selectedNode: CommonNodeAttr | undefined = getSelectedCDNode(bulkTriggerType, _cdNode) - - const selectedArtifacts = _selectedNode?.[materialType]?.filter((artifact) => artifact.isSelected) ?? [] - if (selectedArtifacts.length > 0) { - return { node: _selectedNode, wf } - } - return null - }) - .filter(Boolean) - - const onClickTriggerBulkCD = ( - skipIfHibernated: boolean, - pipelineIdVsStrategyMap: PipelineIdsVsDeploymentStrategyMap, - appsToRetry?: Record, - ) => { - if (isCDLoading || !validateBulkRuntimeParams()) { - return - } - - ReactGA.event(ENV_TRIGGER_VIEW_GA_EVENTS.BulkCDTriggered(bulkTriggerType)) - setCDLoading(true) - const _appIdMap = new Map() - const nodeList: CommonNodeAttr[] = [] - const triggeredAppList: { appId: number; envId?: number; appName: string }[] = [] - - const eligibleNodes = getSelectedCDNodesWithArtifacts( - filteredWorkflows.filter((wf) => !appsToRetry || appsToRetry[wf.appId]), - ) - eligibleNodes.forEach(({ node: eligibleNode, wf }) => { - nodeList.push(eligibleNode) - _appIdMap.set(eligibleNode.id, wf.appId.toString()) - triggeredAppList.push({ appId: wf.appId, appName: wf.name, envId: eligibleNode.environmentId }) - }) - - const _CDTriggerPromiseFunctionList = [] - nodeList.forEach((node, index) => { - let ciArtifact = null - const currentAppId = _appIdMap.get(node.id) - - node[materialType].forEach((artifact) => { - if (artifact.isSelected == true) { - ciArtifact = artifact - } - }) - const pipelineId = Number(node.id) - const strategy = pipelineIdVsStrategyMap[pipelineId] - - // skip app if bulkDeploymentStrategy is not default and strategy is not configured for app - if (ciArtifact && (bulkDeploymentStrategy === 'DEFAULT' || !!strategy)) { - _CDTriggerPromiseFunctionList.push(() => - triggerCDNode({ - pipelineId, - ciArtifactId: Number(ciArtifact.id), - appId: Number(currentAppId), - stageType: bulkTriggerType, - ...(getRuntimeParamsPayload - ? { runtimeParamsPayload: getRuntimeParamsPayload(runtimeParams[currentAppId] ?? []) } - : {}), - skipIfHibernated, - // strategy DEFAULT means custom chart - ...(strategy && strategy !== 'DEFAULT' ? { strategy } : {}), - }), - ) - } else { - triggeredAppList.splice(index, 1) - } - }) - handleBulkTrigger(_CDTriggerPromiseFunctionList, triggeredAppList, WorkflowNodeType.CD) - } - const updateResponseListData = (_responseList) => { setResponseList((prevList) => { const resultMap = new Map(_responseList.map((data) => [data.appId, data])) @@ -701,233 +520,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT }) } - const filterStatusType = ( - type: WorkflowNodeType, - CIStatus: string, - VirtualStatus: string, - CDStatus: string, - ): string => { - if (type === WorkflowNodeType.CI) { - return CIStatus - } - if (isVirtualEnv) { - return VirtualStatus - } - return CDStatus - } - - const handleBulkTrigger = ( - promiseFunctionList: any[], - triggeredAppList: { appId: number; envId?: number; appName: string }[], - type: WorkflowNodeType, - skippedResources: ResponseRowType[] = [], - ): void => { - setIsBulkTriggerLoading(true) - const _responseList = skippedResources - if (promiseFunctionList.length) { - ApiQueuingWithBatch(promiseFunctionList).then((responses: any[]) => { - responses.forEach((response, index) => { - if (response.status === 'fulfilled') { - const statusType = filterStatusType( - type, - BULK_CI_RESPONSE_STATUS_TEXT[BulkResponseStatus.PASS], - BULK_VIRTUAL_RESPONSE_STATUS[BulkResponseStatus.PASS], - BULK_CD_RESPONSE_STATUS_TEXT[BulkResponseStatus.PASS], - ) - - const virtualEnvResponseRowType: TriggerVirtualEnvResponseRowType = - [DeploymentNodeType.CD, DeploymentNodeType.POSTCD, DeploymentNodeType.PRECD].includes( - bulkTriggerType, - ) && isVirtualEnv - ? { - isVirtual: true, - helmPackageName: response.value?.result?.helmPackageName, - cdWorkflowType: bulkTriggerType, - } - : {} - - _responseList.push({ - appId: triggeredAppList[index].appId, - appName: triggeredAppList[index].appName, - statusText: statusType, - status: BulkResponseStatus.PASS, - envId: triggeredAppList[index].envId, - message: '', - ...virtualEnvResponseRowType, - }) - } else { - const errorReason = response.reason - if (errorReason.code === API_STATUS_CODES.EXPECTATION_FAILED) { - const statusType = filterStatusType( - type, - BULK_CI_RESPONSE_STATUS_TEXT[BulkResponseStatus.SKIP], - BULK_VIRTUAL_RESPONSE_STATUS[BulkResponseStatus.SKIP], - BULK_CD_RESPONSE_STATUS_TEXT[BulkResponseStatus.SKIP], - ) - _responseList.push({ - appId: triggeredAppList[index].appId, - appName: triggeredAppList[index].appName, - statusText: statusType, - status: BulkResponseStatus.SKIP, - message: errorReason.errors[0].userMessage, - }) - } else if (errorReason.code === 403 || errorReason.code === 422) { - // Adding 422 to handle the unauthorized state due to deployment window - const statusType = filterStatusType( - type, - BULK_CI_RESPONSE_STATUS_TEXT[BulkResponseStatus.UNAUTHORIZE], - BULK_VIRTUAL_RESPONSE_STATUS[BulkResponseStatus.UNAUTHORIZE], - BULK_CD_RESPONSE_STATUS_TEXT[BulkResponseStatus.UNAUTHORIZE], - ) - _responseList.push({ - appId: triggeredAppList[index].appId, - appName: triggeredAppList[index].appName, - statusText: statusType, - status: BulkResponseStatus.UNAUTHORIZE, - message: errorReason.errors[0].userMessage, - }) - } else { - const statusType = filterStatusType( - type, - BULK_CI_RESPONSE_STATUS_TEXT[BulkResponseStatus.FAIL], - BULK_VIRTUAL_RESPONSE_STATUS[BulkResponseStatus.FAIL], - BULK_CD_RESPONSE_STATUS_TEXT[BulkResponseStatus.FAIL], - ) - _responseList.push({ - appId: triggeredAppList[index].appId, - appName: triggeredAppList[index].appName, - statusText: statusType, - status: BulkResponseStatus.FAIL, - message: errorReason.errors[0].userMessage, - }) - } - } - }) - updateResponseListData(_responseList) - setCDLoading(false) - setCILoading(false) - setIsBulkTriggerLoading(false) - getWorkflowStatusData(workflows) - }) - } else { - setCDLoading(false) - setCILoading(false) - setIsBulkTriggerLoading(false) - - if (!skippedResources.length) { - hideBulkCIModal() - hideBulkCDModal() - } else { - updateResponseListData(_responseList) - } - } - } - - // Would only set data no need to get data related to materials from it, we will get that in bulk trigger - const createBulkCDTriggerData = (): BulkCDDetailTypeResponse => { - const uniqueReleaseTags: string[] = [] - const uniqueTagsSet = new Set() - const _selectedAppWorkflowList: BulkCDDetailType[] = [] - - filteredWorkflows.forEach((wf) => { - if (wf.isSelected) { - // extract unique tags for this workflow - wf.appReleaseTags?.forEach((tag) => { - if (!uniqueTagsSet.has(tag)) { - uniqueReleaseTags.push(tag) - } - uniqueTagsSet.add(tag) - }) - const _cdNode = wf.nodes.find( - (node) => node.type === WorkflowNodeType.CD && node.environmentId === +envId, - ) - const selectedCINode = wf.nodes.find( - (node) => node.type === WorkflowNodeType.CI || node.type === WorkflowNodeType.WEBHOOK, - ) - const doesWorkflowContainsWebhook = selectedCINode?.type === WorkflowNodeType.WEBHOOK - - let _selectedNode: CommonNodeAttr - if (bulkTriggerType === DeploymentNodeType.PRECD) { - _selectedNode = _cdNode.preNode - } else if (bulkTriggerType === DeploymentNodeType.CD) { - _selectedNode = _cdNode - } else if (bulkTriggerType === DeploymentNodeType.POSTCD) { - _selectedNode = _cdNode.postNode - } - if (_selectedNode) { - const stageType = DeploymentNodeType[_selectedNode.type] - const isTriggerBlockedDueToPlugin = - _selectedNode.isTriggerBlocked && _selectedNode.showPluginWarning - const isTriggerBlockedDueToMandatoryTags = - _selectedNode.isTriggerBlocked && - _selectedNode.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG - const stageText = getStageTitle(stageType) - - _selectedAppWorkflowList.push({ - workFlowId: wf.id, - appId: wf.appId, - name: wf.name, - cdPipelineName: _cdNode.title, - cdPipelineId: _cdNode.id, - stageType, - triggerType: _cdNode.triggerType, - envName: _selectedNode.environmentName, - envId: _selectedNode.environmentId, - parentPipelineId: _selectedNode.parentPipelineId, - parentPipelineType: WorkflowNodeType[_selectedNode.parentPipelineType], - parentEnvironmentName: _selectedNode.parentEnvironmentName, - material: _selectedNode.inputMaterialList, - approvalConfigData: _selectedNode.approvalConfigData, - requestedUserId: _selectedNode.requestedUserId, - appReleaseTags: wf.appReleaseTags, - tagsEditable: wf.tagsEditable, - ciPipelineId: _selectedNode.connectingCiPipelineId, - hideImageTaggingHardDelete: wf.hideImageTaggingHardDelete, - showPluginWarning: _selectedNode.showPluginWarning, - isTriggerBlockedDueToPlugin, - configurePluginURL: getCDPipelineURL( - String(wf.appId), - wf.id, - doesWorkflowContainsWebhook ? '0' : selectedCINode?.id, - doesWorkflowContainsWebhook, - _selectedNode.id, - true, - ), - consequence: _selectedNode.pluginBlockState, - warningMessage: - isTriggerBlockedDueToPlugin || isTriggerBlockedDueToMandatoryTags - ? `${stageText} is blocked` - : '', - triggerBlockedInfo: _selectedNode.triggerBlockedInfo, - isExceptionUser: wf.isExceptionUser, - }) - } else { - let warningMessage = '' - if (bulkTriggerType === DeploymentNodeType.PRECD) { - warningMessage = 'No pre-deployment stage' - } else if (bulkTriggerType === DeploymentNodeType.CD) { - warningMessage = 'No deployment stage' - } else if (bulkTriggerType === DeploymentNodeType.POSTCD) { - warningMessage = 'No post-deployment stage' - } - _selectedAppWorkflowList.push({ - workFlowId: wf.id, - appId: wf.appId, - name: wf.name, - warningMessage, - envName: _cdNode.environmentName, - envId: _cdNode.environmentId, - }) - } - } - }) - _selectedAppWorkflowList.sort((a, b) => sortCallback('name', a, b)) - return { - bulkCDDetailType: _selectedAppWorkflowList, - uniqueReleaseTags, - } - } - if (pageViewType === ViewType.LOADING) { return } @@ -947,36 +539,13 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT return null } - const bulkCDDetailTypeResponse = createBulkCDTriggerData() - const _selectedAppWorkflowList: BulkCDDetailType[] = bulkCDDetailTypeResponse.bulkCDDetailType - - const { uniqueReleaseTags } = bulkCDDetailTypeResponse - - const feasiblePipelineIds = new Set( - getSelectedCDNodesWithArtifacts(filteredWorkflows).map(({ node }) => +node.id), - ) - - // Have to look for its each prop carefully - // No need to send uniqueReleaseTags will get those in BulkCDTrigger itself return ( - ) } @@ -1098,7 +667,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT { const isShowRetryButton = responseList?.some((response) => response.status === BulkResponseStatus.FAIL) @@ -52,7 +50,7 @@ export const TriggerResponseModalFooter = ({ if (onClickRetryBuild) { onClickRetryBuild(appsToRetry) } else { - onClickRetryDeploy(skipHibernatedApps, pipelineIdVsStrategyMap, appsToRetry) + onClickRetryDeploy(appsToRetry) } } diff --git a/src/components/ApplicationGroup/Details/TriggerView/utils.ts b/src/components/ApplicationGroup/Details/TriggerView/utils.ts index d88cdf7789..e0ae7f0ea5 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/utils.ts +++ b/src/components/ApplicationGroup/Details/TriggerView/utils.ts @@ -16,36 +16,11 @@ import { CommonNodeAttr, DeploymentNodeType, WorkflowNodeType, WorkflowType } from '@devtron-labs/devtron-fe-common-lib' -import { getIsMaterialApproved } from '@Components/app/details/triggerView/cdMaterials.utils' +import { DeployImageContentProps } from '@Components/app/details/triggerView/DeployImageModal/types' import { getNodeIdAndTypeFromSearch } from '@Components/app/details/triggerView/TriggerView.utils' import { BulkCDDetailType } from '../../AppGroup.types' -export const getIsNonApprovedImageSelected = (appList: BulkCDDetailType[]): boolean => - appList.some((app) => { - if (!app.isExceptionUser) { - return false - } - - return (app.material || []).some( - (material) => material.isSelected && !getIsMaterialApproved(material.userApprovalMetadata), - ) - }) - -export const getIsImageApprovedByDeployerSelected = (appList: BulkCDDetailType[]): boolean => - appList.some((app) => { - if (!app.isExceptionUser) { - return false - } - - return (app.material || []).some( - (material) => - material.isSelected && - !material.canApproverDeploy && - material.userApprovalMetadata?.hasCurrentUserApproved, - ) - }) - export const getSelectedCDNode = (bulkTriggerType: DeploymentNodeType, _cdNode: CommonNodeAttr) => { if (bulkTriggerType === DeploymentNodeType.PRECD) { return _cdNode.preNode @@ -59,10 +34,27 @@ export const getSelectedCDNode = (bulkTriggerType: DeploymentNodeType, _cdNode: return null } -export const getSelectedAppListForBulkStrategy = (appList: BulkCDDetailType[], feasiblePipelineIds: Set) => - appList - .map((app) => ({ pipelineId: +app.cdPipelineId, appName: app.name })) - .filter(({ pipelineId }) => feasiblePipelineIds.has(pipelineId)) +export const getSelectedAppListForBulkStrategy = ( + appInfoRes: DeployImageContentProps['appInfoMap'], +): Pick[] => { + const feasiblePipelineIds: Set = Object.values(appInfoRes).reduce((acc, appDetails) => { + const materials = appDetails.materialResponse?.materials || [] + const isMaterialSelected = materials.some((material) => material.isSelected) + + if (isMaterialSelected) { + acc.add(+appDetails.pipelineId) + } + + return acc + }, new Set()) + + const appList: Pick[] = Object.values(appInfoRes).map((appDetails) => ({ + pipelineId: +appDetails.pipelineId, + appName: appDetails.appName, + })) + + return appList.filter(({ pipelineId }) => feasiblePipelineIds.has(pipelineId)) +} export const getSelectedNodeAndMeta = ( workflows: WorkflowType[], diff --git a/src/components/app/details/triggerView/BranchRegexModal.tsx b/src/components/app/details/triggerView/BranchRegexModal.tsx index 4270f19616..b7b2debcbe 100644 --- a/src/components/app/details/triggerView/BranchRegexModal.tsx +++ b/src/components/app/details/triggerView/BranchRegexModal.tsx @@ -84,42 +84,39 @@ const BranchRegexModal = ({ const handleSave = async (e: React.FormEvent) => { e.preventDefault() setIsSavingRegexValue(true) - const payload: Parameters[0] = { - appId: +appId, - id: +workflowId, - ciPipelineMaterial: [], - } - if (selectedCIPipeline?.ciMaterial?.length) { - payload.ciPipelineMaterial = selectedCIPipeline.ciMaterial.map((_cm) => { - const regVal = regexValue[_cm.gitMaterialId] - let _updatedCM + try { + const payload: Parameters[0] = { + appId: +appId, + id: +workflowId, + ciPipelineMaterial: selectedCIPipeline.ciMaterial.map((_cm) => { + const regVal = regexValue[_cm.gitMaterialId] + let _updatedCM - if (regVal?.value && _cm.source.regex) { - isRegexValueInvalid(_cm) + if (regVal?.value && _cm.source.regex) { + isRegexValueInvalid(_cm) - _updatedCM = { - ..._cm, - type: SourceTypeMap.BranchFixed, - value: regVal.value, - regex: _cm.source.regex, + _updatedCM = { + ..._cm, + type: SourceTypeMap.BranchFixed, + value: regVal.value, + regex: _cm.source.regex, + } + } else { + // Maintain the flattened object structure for unchanged values + _updatedCM = { + ..._cm, + ..._cm.source, + } } - } else { - // Maintain the flattened object structure for unchanged values - _updatedCM = { - ..._cm, - ..._cm.source, - } - } - // Deleting as it's not required in the request payload - delete _updatedCM.source + // Deleting as it's not required in the request payload + delete _updatedCM.source - return _updatedCM - }) - } + return _updatedCM + }), + } - try { await savePipeline(payload, { isRegexMaterial: true, isTemplateView: false, diff --git a/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx b/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx index d61b5133d3..0836a65db8 100644 --- a/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/BuildImageModal.tsx @@ -171,7 +171,7 @@ const BuildImageModal = ({ } const getCIPipelineURLWrapper = (): string => - getCIPipelineURL(String(appId), String(workflowId), true, ciNodeId, false, ciNode?.isJobCI, false) + getCIPipelineURL(String(appId), String(workflowId), true, ciNodeId, false, ciNode.isJobCI, false) const redirectToCIPipeline = () => { push(getCIPipelineURLWrapper()) @@ -249,7 +249,7 @@ const BuildImageModal = ({ return (
+ + {materialList.length === 0 ? ( + 0} + envName={envName} + materialResponse={materialResponse} + // TODO: Move to util and remove prop + isExceptionUser={isExceptionUser} + isLoadingMore={isLoadingOlderImages} + viewAllImages={viewAllImages} + triggerType={triggerType} + loadOlderImages={loadOlderImages} + onSearchApply={onSearchApply} + eligibleImagesCount={eligibleImagesCount} + handleEnableFiltersView={handleShowConfiguredFilters} + handleAllImagesView={handleAllImagesView} + /> + ) : ( + renderMaterialList(materialList, false) + )} + + {!areNoMoreImagesPresent && !!materialList?.length && ( + + )} + + ) + } + + return ( +
+ +
+ ) + } + if (ConfiguredFilters && (showConfiguredFilters || showAppliedFilters)) { return ( {renderSidebar()} - {currentSidebarTab === CDMaterialSidebarType.IMAGE || !RuntimeParameters ? ( - <> - {isApprovalConfigured && renderMaterialList(consumedImage, true)} - -
- {showActionBar ? ( - - ) : ( - {titleText} - )} - - - {showSearchBar || isSearchApplied ? ( - renderSearch() - ) : ( -
- - {materialList.length === 0 ? ( - 0} - envName={envName} - materialResponse={materialResponse} - // TODO: Move to util and remove prop - isExceptionUser={isExceptionUser} - isLoadingMore={isLoadingOlderImages} - viewAllImages={viewAllImages} - triggerType={triggerType} - loadOlderImages={loadOlderImages} - onSearchApply={onSearchApply} - eligibleImagesCount={eligibleImagesCount} - handleEnableFiltersView={handleShowConfiguredFilters} - handleAllImagesView={handleAllImagesView} - /> - ) : ( - renderMaterialList(materialList, false) - )} - - {!areNoMoreImagesPresent && !!materialList?.length && ( - - )} - - ) : ( -
- -
- )} + {renderContent()}
diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx index df0673c611..2b5330f2ea 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageHeader.tsx @@ -17,6 +17,7 @@ const DeployImageHeader = ({ isVirtualEnvironment, handleNavigateToMaterialListView, children, + title, }: DeployImageHeaderProps) => (
@@ -34,12 +35,13 @@ const DeployImageHeader = ({ )}

- {getCDModalHeaderText({ - isRollbackTrigger, - stageType, - envName, - isVirtualEnvironment, - })} + {title || + getCDModalHeaderText({ + isRollbackTrigger, + stageType, + envName, + isVirtualEnvironment, + })}

{children} diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 7021d46a17..126372c7ca 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -147,7 +147,7 @@ const DeployImageModal = ({ const [deploymentStrategy, setDeploymentStrategy] = useState(null) const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) - + // TODO: Handle reload states const [deployViewState, setDeployViewState] = useState>({ searchText: searchImageTag, filterView: FilterConditionViews.ALL, diff --git a/src/components/app/details/triggerView/DeployImageModal/constants.ts b/src/components/app/details/triggerView/DeployImageModal/constants.ts new file mode 100644 index 0000000000..4b5802567b --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/constants.ts @@ -0,0 +1,16 @@ +import { SelectPickerOptionType } from '@devtron-labs/devtron-fe-common-lib' + +export const BULK_DEPLOY_LATEST_IMAGE_TAG: SelectPickerOptionType = { + value: 'Latest', + label: 'Latest', +} + +export const BULK_DEPLOY_ACTIVE_IMAGE_TAG: SelectPickerOptionType = { + value: 'Active', + label: 'Active', +} + +export const BULK_DEPLOY_MIXED_IMAGE_TAG: SelectPickerOptionType = { + value: 'Mixed', + label: 'Mixed', +} diff --git a/src/components/app/details/triggerView/DeployImageModal/index.ts b/src/components/app/details/triggerView/DeployImageModal/index.ts index 1794ddcc84..cec8fb4497 100644 --- a/src/components/app/details/triggerView/DeployImageModal/index.ts +++ b/src/components/app/details/triggerView/DeployImageModal/index.ts @@ -1 +1,2 @@ +export { default as BulkDeployModal } from './BulkDeployModal' export { default as DeployImageModal } from './DeployImageModal' diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index f46929597e..926c0dc8c0 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -8,12 +8,16 @@ import { DeploymentNodeType, DeploymentWindowProfileMetaData, PolicyConsequencesDTO, + SelectPickerOptionType, ServerErrors, UploadFileDTO, UploadFileProps, useSearchString, + WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' +import { BulkCDDetailType } from '@Components/ApplicationGroup/AppGroup.types' + import { FilterConditionViews, MATERIAL_TYPE, RuntimeParamsErrorState } from '../types' export type DeployImageModalProps = { @@ -48,6 +52,7 @@ export type DeployImageHeaderProps = Pick< isRollbackTrigger: boolean handleNavigateToMaterialListView?: () => void children?: React.ReactNode + title?: string } export interface RuntimeParamsSidebarProps { @@ -123,7 +128,6 @@ export type DeployImageContentProps = Pick< Pick & { materialResponse: CDMaterialResponseType deploymentWindowMetadata: DeploymentWindowProfileMetaData - policyConsequences: PolicyConsequencesDTO isRollbackTrigger: boolean uploadRuntimeParamsFile: (props: UploadFileProps) => Promise isSecurityModuleInstalled: boolean @@ -138,9 +142,19 @@ export type DeployImageContentProps = Pick< } & ( | { isBulkTrigger: true + appInfoMap: Record + selectedTagName: string + handleTagChange: (tagOption: SelectPickerOptionType) => void + changeApp: (appId: number) => void + policyConsequences?: never } | { isBulkTrigger?: false + selectedTagName?: never + appInfoMap?: never + handleTagChange?: never + policyConsequences: PolicyConsequencesDTO + changeApp?: never } ) @@ -193,3 +207,21 @@ export interface MaterialListEmptyStateProps isSearchApplied: boolean handleAllImagesView: () => void } + +export interface BuildDeployModalProps { + handleClose: () => void + stageType: DeploymentNodeType + workflows: WorkflowType[] + isVirtualEnvironment: boolean + envId: number +} + +export type GetInitialAppListProps = + | { + appIdToReload: number + searchText: string + } + | { + appIdToReload?: never + searchText?: never + } From 67f6b6655c06dba9c4f691c1edfd326a2b423529 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 5 Aug 2025 12:33:15 +0530 Subject: [PATCH 11/40] feat: Implement bulk deployment modal enhancements and improve error handling --- .../Details/TriggerView/EnvTriggerView.tsx | 1 + .../DeployImageModal/BulkDeployModal.tsx | 11 ++-- .../DeployImageModal/DeployImageContent.tsx | 51 ++++++++++--------- .../triggerView/DeployImageModal/utils.tsx | 6 +-- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index 9cc9508676..4fcc52ca02 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -475,6 +475,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const onShowBulkCDModal = (e) => { setBulkTriggerType(e.currentTarget.dataset.triggerType) + setShowBulkCDModal(true) } const hideBulkCIModal = () => { diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index acc2f83f60..afa2de6370 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -357,7 +357,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme const updatedWarningMessage = baseBulkCDDetailMap[appId].warningMessage || - deploymentWindowMap[appId].warningMessage || + deploymentWindowMap[appId]?.warningMessage || parsedTagsWarning // In case of search and reload even though method gives whole state, will only update deploymentWindowMetadata, warningMessage and materialResponse @@ -409,7 +409,8 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme return bulkCDDetailsMap } - const [isLoadingAppInfoMap, appInfoMap, , , unTypedSetAppInfoMap] = useAsync(() => getInitialAppList({})) + const [isLoadingAppInfoMap, _appInfoMap, , , unTypedSetAppInfoMap] = useAsync(() => getInitialAppList({})) + const appInfoMap: typeof _appInfoMap = _appInfoMap || {} const setAppInfoMap: Dispatch> = unTypedSetAppInfoMap const reloadOrSearchSelectedApp = async (searchText?: string) => { @@ -1064,11 +1065,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme
{renderContent()}
- {isLoadingAppInfoMap || showStrategyFeasibilityPage ? null : ( -
- {renderFooter()} -
- )} + {isLoadingAppInfoMap || showStrategyFeasibilityPage ? null : renderFooter()}
{showResistanceBox && ( diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 45082d095c..da92174076 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -11,7 +11,6 @@ import { CDMaterialType, CommonNodeAttr, ComponentSizeType, - ConditionalWrap, DEPLOYMENT_WINDOW_TYPE, DeploymentNodeType, ErrorScreenManager, @@ -49,6 +48,7 @@ import { getIsMaterialApproved } from '../cdMaterials.utils' import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' +import { BULK_DEPLOY_ACTIVE_IMAGE_TAG, BULK_DEPLOY_LATEST_IMAGE_TAG } from './constants' import ImageSelectionCTA from './ImageSelectionCTA' import MaterialListEmptyState from './MaterialListEmptyState' import MaterialListSkeleton from './MaterialListSkeleton' @@ -77,10 +77,6 @@ const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlock const PolicyEnforcementMessage = importComponentFromFELibrary('PolicyEnforcementMessage') const TriggerBlockedError = importComponentFromFELibrary('TriggerBlockedError', null, 'function') -const renderMaterialListBodyWrapper = (children: JSX.Element) => ( -
{children}
-) - const DeployImageContent = ({ appId, envId, @@ -117,8 +113,6 @@ const DeployImageContent = ({ const { isSuperAdmin } = useMainContext() const { onClickApprovalNode } = useContext(TriggerViewContext) - const { triggerBlockedInfo, warningMessage, materialError } = appInfoMap[appId] || {} - const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false const requestedUserId = materialResponse?.requestedUserId const isApprovalConfigured = getIsApprovalPolicyConfigured( @@ -140,9 +134,11 @@ const DeployImageContent = ({ app.materialResponse?.appReleaseTagNames?.forEach((tag) => tagNames.add(tag)) }) - return Array.from(tagNames) - .sort(stringComparatorBySortOrder) - .map((tag) => ({ label: tag, value: tag })) + return [BULK_DEPLOY_LATEST_IMAGE_TAG, BULK_DEPLOY_ACTIVE_IMAGE_TAG].concat( + Array.from(tagNames) + .sort(stringComparatorBySortOrder) + .map((tag) => ({ label: tag, value: tag })), + ) }, [appInfoMap]) const selectedTagOption = useMemo(() => { @@ -431,11 +427,11 @@ const DeployImageContent = ({ return } - if (!!warningMessage && !app.showPluginWarning) { + if (!!app.warningMessage && !app.showPluginWarning) { return (
- {warningMessage} + {app.warningMessage}
) } @@ -457,8 +453,8 @@ const DeployImageContent = ({ const renderSidebar = () => { if (isBulkTrigger) { return ( -
-
+
+
{showRuntimeParams && (
Select image by release tag -
+
)} - APPLICATIONS +
+ APPLICATIONS +
{sortedAppValues.map((appDetails) => ( @@ -681,7 +679,7 @@ const DeployImageContent = ({ const renderEmptyView = (): JSX.Element => { const selectedApp = appInfoMap[+appId] - if (triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { + if (selectedApp.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { return } @@ -698,8 +696,14 @@ const DeployImageContent = ({ ) } - if (materialError) { - return + if (selectedApp.materialError) { + return ( + + ) } return ( @@ -713,7 +717,7 @@ const DeployImageContent = ({ const renderContent = () => { if (isBulkTrigger) { - const { areMaterialsLoading } = appInfoMap[+appId] || {} + const { areMaterialsLoading, triggerBlockedInfo, materialError } = appInfoMap[+appId] || {} if (currentSidebarTab === CDMaterialSidebarType.IMAGE && areMaterialsLoading) { return } @@ -872,13 +876,10 @@ const DeployImageContent = ({ )}
{renderSidebar()} - - - {renderContent()} - +
{renderContent()}
) diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index 0148a98fc9..a8bf1a6ee7 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -81,11 +81,11 @@ export const getIsCDTriggerBlockedThroughConsequences = ( ) => { switch (stageType) { case DeploymentNodeType.PRECD: - return cdPolicyConsequences.pre.isBlocked + return cdPolicyConsequences?.pre?.isBlocked case DeploymentNodeType.POSTCD: - return cdPolicyConsequences.post.isBlocked + return cdPolicyConsequences?.post?.isBlocked case DeploymentNodeType.CD: - return cdPolicyConsequences.node.isBlocked + return cdPolicyConsequences?.node?.isBlocked default: return false } From c8efa5316a1a0985df151cbf4a6fd1efacca6277 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 5 Aug 2025 12:44:25 +0530 Subject: [PATCH 12/40] feat: Refactor EnvTriggerView and AppDetailsCDModal to remove unnecessary loading states and props --- .../Details/TriggerView/EnvTriggerView.tsx | 33 +++---------------- .../details/appDetails/AppDetailsCDModal.tsx | 5 --- .../triggerView/DeployImageModal/types.ts | 19 ++++++++--- 3 files changed, 19 insertions(+), 38 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index 4fcc52ca02..e5c9625fe4 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -91,7 +91,6 @@ const processDeploymentWindowStateAppGroup = importComponentFromFELibrary( const ChangeImageSource = importComponentFromFELibrary('ChangeImageSource', null, 'function') const WebhookAddImageModal = importComponentFromFELibrary('WebhookAddImageModal', null, 'function') -// FIXME: IN CIMaterials we are sending isCDLoading while in CD materials we are sending isCILoading let inprogressStatusTimer const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultType) => { const { envId } = useParams<{ envId: string }>() @@ -100,11 +99,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT const match = useRouteMatch() const { url } = useRouteMatch() - const abortControllerRef = useRef(new AbortController()) - const [pageViewType, setPageViewType] = useState(ViewType.LOADING) - const [isCILoading, setCILoading] = useState(false) - const [isCDLoading, setCDLoading] = useState(false) const [isBranchChangeLoading, setIsBranchChangeLoading] = useState(false) const [showPreDeployment, setShowPreDeployment] = useState(false) const [showPostDeployment, setShowPostDeployment] = useState(false) @@ -417,8 +412,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT if (!appIds.length) { updateResponseListData(skippedResources) setIsBranchChangeLoading(false) - setCDLoading(false) - setCILoading(false) return } @@ -436,8 +429,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT }) }) updateResponseListData([..._responseList, ...skippedResources]) - setCDLoading(false) - setCILoading(false) }) .catch((error) => { showError(error) @@ -448,8 +439,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT } const closeCDModal = (): void => { - abortControllerRef.current.abort() - setCDLoading(false) history.push({ search: '', }) @@ -464,7 +453,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT } const hideBulkCDModal = () => { - setCDLoading(false) setShowBulkCDModal(false) setResponseList([]) @@ -479,16 +467,12 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT } const hideBulkCIModal = () => { - setCILoading(false) setShowBulkCIModal(false) setResponseList([]) } const onShowBulkCIModal = () => { - setCILoading(true) - setTimeout(() => { - setShowBulkCIModal(true) - }, 100) + setShowBulkCIModal(true) } const hideChangeSourceModal = () => { @@ -666,7 +650,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT return (
{_showPopupMenu && renderDeployPopupMenu()}
diff --git a/src/components/app/details/appDetails/AppDetailsCDModal.tsx b/src/components/app/details/appDetails/AppDetailsCDModal.tsx index 48b6e15962..1f339a0960 100644 --- a/src/components/app/details/appDetails/AppDetailsCDModal.tsx +++ b/src/components/app/details/appDetails/AppDetailsCDModal.tsx @@ -85,11 +85,6 @@ const AppDetailsCDModal = ({ isRedirectedFromAppDetails handleSuccess={handleSuccess} appName={appName} - // TODO: Not needed since not pre-post cd here - showPluginWarningBeforeTrigger={null} - consequence={null} - configurePluginURL={null} - isTriggerBlockedDueToPlugin={null} /> ) diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 926c0dc8c0..0becc1411b 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -29,9 +29,6 @@ export type DeployImageModalProps = { materialType: (typeof MATERIAL_TYPE)[keyof typeof MATERIAL_TYPE] handleClose: () => void envName: string - showPluginWarningBeforeTrigger: boolean - consequence: ConsequenceType - configurePluginURL: string /** * In case of appDetails trigger re-fetch of app details */ @@ -40,9 +37,21 @@ export type DeployImageModalProps = { isVirtualEnvironment: boolean isRedirectedFromAppDetails: boolean parentEnvironmentName: string - isTriggerBlockedDueToPlugin: boolean triggerType: CommonNodeAttr['triggerType'] -} +} & ( + | { + showPluginWarningBeforeTrigger: boolean + consequence: ConsequenceType + configurePluginURL: string + isTriggerBlockedDueToPlugin: boolean + } + | { + showPluginWarningBeforeTrigger?: never + consequence?: never + configurePluginURL?: never + isTriggerBlockedDueToPlugin?: never + } +) export type DeployImageHeaderProps = Pick< DeployImageModalProps, From 55074383c9843f1be5e28fb7beb873926816ceb2 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 5 Aug 2025 13:28:25 +0530 Subject: [PATCH 13/40] feat: Enhance DeployImageModal and related components with improved error handling, prop defaults, and layout adjustments --- .../DeployImageModal/DeployImageContent.tsx | 52 ++++++++++--------- .../DeployImageModal/DeployImageModal.tsx | 7 ++- .../DeployImageModal/MaterialListSkeleton.tsx | 2 +- .../triggerView/DeployImageModal/service.ts | 2 +- .../triggerView/DeployImageModal/types.ts | 5 +- .../triggerView/DeployImageModal/utils.tsx | 4 +- 6 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index da92174076..76369cede7 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -104,7 +104,7 @@ const DeployImageContent = ({ deployViewState, setDeployViewState, setMaterialResponse, - appInfoMap, + appInfoMap = {}, selectedTagName, handleTagChange, changeApp, @@ -784,28 +784,30 @@ const DeployImageContent = ({
{materialList.length === 0 ? ( - 0} - envName={envName} - materialResponse={materialResponse} - // TODO: Move to util and remove prop - isExceptionUser={isExceptionUser} - isLoadingMore={isLoadingOlderImages} - viewAllImages={viewAllImages} - triggerType={triggerType} - loadOlderImages={loadOlderImages} - onSearchApply={onSearchApply} - eligibleImagesCount={eligibleImagesCount} - handleEnableFiltersView={handleShowConfiguredFilters} - handleAllImagesView={handleAllImagesView} - /> +
+ 0} + envName={envName} + materialResponse={materialResponse} + // TODO: Move to util and remove prop + isExceptionUser={isExceptionUser} + isLoadingMore={isLoadingOlderImages} + viewAllImages={viewAllImages} + triggerType={triggerType} + loadOlderImages={loadOlderImages} + onSearchApply={onSearchApply} + eligibleImagesCount={eligibleImagesCount} + handleEnableFiltersView={handleShowConfiguredFilters} + handleAllImagesView={handleAllImagesView} + /> +
) : ( renderMaterialList(materialList, false) )} @@ -876,10 +878,10 @@ const DeployImageContent = ({ )}
{renderSidebar()} -
{renderContent()}
+
{renderContent()}
) diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 126372c7ca..0d3fa88c6d 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -98,7 +98,7 @@ const DeployImageModal = ({ stageType, pipelineId, materialType, - handleClose, + handleClose: handleCloseProp, handleSuccess, deploymentAppType, isVirtualEnvironment, @@ -263,6 +263,11 @@ const DeployImageModal = ({ }) } + const handleClose = () => { + onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) + handleCloseProp?.() + } + const loadOlderImages = async () => { // TODO: Move to util handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) diff --git a/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx b/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx index d673c8519f..0d742b37dc 100644 --- a/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/MaterialListSkeleton.tsx @@ -1,5 +1,5 @@ const MaterialListSkeleton = () => ( -
+
diff --git a/src/components/app/details/triggerView/DeployImageModal/service.ts b/src/components/app/details/triggerView/DeployImageModal/service.ts index 01ca9be058..7140056251 100644 --- a/src/components/app/details/triggerView/DeployImageModal/service.ts +++ b/src/components/app/details/triggerView/DeployImageModal/service.ts @@ -53,7 +53,7 @@ export const getMaterialResponseList = async ({ getPolicyConsequences ? getPolicyConsequences({ appId, envId }) : null, ]) - if (getPolicyConsequences && getIsCDTriggerBlockedThroughConsequences(response[2].cd, stageType)) { + if (getPolicyConsequences && getIsCDTriggerBlockedThroughConsequences(response[2]?.cd, stageType)) { return [null, null, response[2]] } return response diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 0becc1411b..965c3b8d76 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -35,6 +35,9 @@ export type DeployImageModalProps = { handleSuccess?: () => void deploymentAppType: DeploymentAppTypes isVirtualEnvironment: boolean + /** + * If opening pre/post cd make sure BE sends plugin details as well, otherwise those props will be undefined + */ isRedirectedFromAppDetails: boolean parentEnvironmentName: string triggerType: CommonNodeAttr['triggerType'] @@ -77,7 +80,7 @@ export interface GetMaterialResponseListProps initialSearch: string } -export interface HandleTriggerErrorMessageForHelmManifestPushProps { +export interface HelmManifestErrorHandlerProps { serverError: ServerErrors searchParams: ReturnType['searchParams'] redirectToDeploymentStepsPage: () => void diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index a8bf1a6ee7..9b4ea511b3 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -37,7 +37,7 @@ import { GetConsumedAndAvailableMaterialListProps, GetSequentialCDCardTitlePropsType, GetTriggerArtifactInfoPropsType, - HandleTriggerErrorMessageForHelmManifestPushProps, + HelmManifestErrorHandlerProps, } from './types' const ApprovalInfoTippy = importComponentFromFELibrary('ApprovalInfoTippy') @@ -104,7 +104,7 @@ export const handleTriggerErrorMessageForHelmManifestPush = ({ serverError, searchParams, redirectToDeploymentStepsPage, -}: HandleTriggerErrorMessageForHelmManifestPushProps) => { +}: HelmManifestErrorHandlerProps) => { if ( serverError instanceof ServerErrors && Array.isArray(serverError.errors) && From 978e37d98952f61bcc67ceba8c95c489b58bef22 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Tue, 5 Aug 2025 17:16:19 +0530 Subject: [PATCH 14/40] feat: Refactor BulkDeployModal and DeployImageModal to improve loadOlderImages functionality and enhance error handling --- .../DeployImageModal/BulkDeployModal.tsx | 102 ++++---------- .../DeployImageModal/DeployImageModal.tsx | 129 ++++++------------ .../triggerView/DeployImageModal/service.ts | 76 ++++++++++- .../triggerView/DeployImageModal/types.ts | 13 ++ .../triggerView/DeployImageModal/utils.tsx | 4 + .../triggerView/PipelineConfigDiff/types.ts | 1 + .../usePipelineDeploymentConfig.ts | 5 +- 7 files changed, 163 insertions(+), 167 deletions(-) diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index afa2de6370..af6b4682ea 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -19,7 +19,6 @@ import { genericCDMaterialsService, GenericEmptyState, getStageTitle, - handleAnalyticsEvent, Icon, MODAL_TYPE, ModuleNameMap, @@ -64,11 +63,11 @@ import { getCDPipelineURL, importComponentFromFELibrary, useAppContext } from '@ import { getModuleInfo } from '@Components/v2/devtronStackManager/DevtronStackManager.service' import { getIsMaterialApproved } from '../cdMaterials.utils' -import { CD_MATERIAL_GA_EVENT } from '../Constants' import { FilterConditionViews } from '../types' import { BULK_DEPLOY_ACTIVE_IMAGE_TAG, BULK_DEPLOY_LATEST_IMAGE_TAG } from './constants' import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' +import { loadOlderImages } from './service' import { BuildDeployModalProps, DeployImageContentProps, GetInitialAppListProps } from './types' const BulkCDStrategy = importComponentFromFELibrary('BulkCDStrategy', null, 'function') @@ -460,55 +459,34 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme })) } - const loadOlderImages = async () => { - handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) - // Even if user changes selectedAppId this will persist since a state closure - const selectedApp = appInfoMap[selectedAppId] - - setAppInfoMap((prev) => ({ - ...prev, - [selectedAppId]: { - ...prev[selectedAppId], - deployViewState: { - ...prev[selectedAppId].deployViewState, - isLoadingOlderImages: true, - }, - }, - })) - - const materialList = selectedApp.materialResponse?.materials || [] - const appDetails = appInfoMap[selectedAppId] - const isConsumedImageAvailable = - materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false - - // TODO: Common + const handleLoadOlderImages = async () => { try { - const newMaterialsResponse = await genericCDMaterialsService( - CDMaterialServiceEnum.CD_MATERIALS, - appDetails.pipelineId, - stageType, - null, - { - offset: materialList.length - Number(isConsumedImageAvailable), - size: 20, - search: appDetails.deployViewState.appliedSearchText || '', - }, - ) + // Even if user changes selectedAppId this will persist since a state closure + const selectedApp = appInfoMap[selectedAppId] - // NOTE: Looping through _newResponse and removing elements that are already deployed and latest - // NOTE: This is done to avoid duplicate images - const filteredNewMaterialResponse = [...newMaterialsResponse.materials].filter( - (materialItem) => !(materialItem.deployed && materialItem.latest), - ) - - // updating the index of materials to maintain consistency - const _newMaterialsResponse = filteredNewMaterialResponse.map((materialItem, index) => ({ - ...materialItem, - index: materialList.length + index, + setAppInfoMap((prev) => ({ + ...prev, + [selectedAppId]: { + ...prev[selectedAppId], + deployViewState: { + ...prev[selectedAppId].deployViewState, + isLoadingOlderImages: true, + }, + }, })) - const newMaterials = structuredClone(materialList).concat(_newMaterialsResponse) + const materialList = selectedApp.materialResponse?.materials || [] + const appDetails = appInfoMap[selectedAppId] const { materialResponse, deployViewState } = appDetails + const newMaterials = await loadOlderImages({ + materialList, + resourceFilters: materialResponse?.resourceFilters, + filterView: deployViewState.filterView, + appliedSearchText: appDetails.deployViewState.appliedSearchText || '', + stageType, + isRollbackTrigger: false, + pipelineId: appDetails.pipelineId, + }) setAppInfoMap((prev) => ({ ...prev, @@ -524,36 +502,6 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme }, }, })) - - const baseSuccessMessage = `Fetched ${_newMaterialsResponse.length} images.` - - if (materialResponse?.resourceFilters?.length && !deployViewState.appliedSearchText) { - const eligibleImages = _newMaterialsResponse.filter( - (mat) => mat.filterState === FilterStates.ALLOWED, - ).length - - const infoMessage = - eligibleImages === 0 - ? 'No new eligible images found.' - : `${eligibleImages} new eligible images found.` - - if (deployViewState.filterView === FilterConditionViews.ELIGIBLE) { - ToastManager.showToast({ - variant: ToastVariantType.info, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: baseSuccessMessage, - }) - } } catch (error) { showError(error) } finally { @@ -968,7 +916,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme reloadMaterials={reloadOrSearchSelectedApp} parentEnvironmentName={parentEnvironmentName} isVirtualEnvironment={isVirtualEnvironment} - loadOlderImages={loadOlderImages} + loadOlderImages={handleLoadOlderImages} triggerType={triggerType} deployViewState={deployViewState} setDeployViewState={setDeployViewState} diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 0d3fa88c6d..b310d2f9a9 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -7,7 +7,6 @@ import { API_STATUS_CODES, ArtifactInfo, ButtonStyleType, - CDMaterialServiceEnum, CDMaterialSidebarType, ConditionalWrap, DEFAULT_ROUTE_PROMPT_MESSAGE, @@ -20,7 +19,6 @@ import { EnvResourceType, ErrorScreenManager, FilterStates, - genericCDMaterialsService, getIsApprovalPolicyConfigured, handleAnalyticsEvent, Icon, @@ -51,7 +49,7 @@ import { URLS } from '@Config/routes' import { getCanDeployWithoutApproval, getCanImageApproverDeploy, getWfrId } from '../cdMaterials.utils' import { CDButtonLabelMap } from '../config' -import { CD_MATERIAL_GA_EVENT, TRIGGER_VIEW_GA_EVENTS } from '../Constants' +import { TRIGGER_VIEW_GA_EVENTS } from '../Constants' import { PipelineConfigDiff, usePipelineDeploymentConfig } from '../PipelineConfigDiff' import { PipelineConfigDiffStatusTile } from '../PipelineConfigDiff/PipelineConfigDiffStatusTile' import { FilterConditionViews, MATERIAL_TYPE } from '../types' @@ -59,7 +57,7 @@ import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import MaterialListSkeleton from './MaterialListSkeleton' import RuntimeParamsSidebar from './RuntimeParamsSidebar' -import { getMaterialResponseList } from './service' +import { getMaterialResponseList, loadOlderImages } from './service' import { DeployImageContentProps, DeployImageModalProps, DeployViewStateType } from './types' import { getAllowWarningWithTippyNodeTypeProp, @@ -67,6 +65,7 @@ import { getConfigToDeployValue, getDeployButtonIcon, getInitialSelectedConfigToDeploy, + getIsExceptionUser, getIsImageApprover, getTriggerArtifactInfoProps, handleTriggerErrorMessageForHelmManifestPush, @@ -116,6 +115,7 @@ const DeployImageModal = ({ const { searchParams } = useSearchString() const { handleDownload } = useDownload() const searchImageTag = searchParams.search || '' + const isCDNode = stageType === DeploymentNodeType.CD const [isInitialDataLoading, initialDataResponse, initialDataError, reloadInitialData, unTypedSetInitialData] = useAsync( @@ -133,21 +133,20 @@ const DeployImageModal = ({ const [, moduleInfoRes] = useAsync(() => getModuleInfo(ModuleNameMap.SECURITY)) - const isSecurityModuleInstalled = moduleInfoRes && moduleInfoRes?.result?.status === ModuleStatus.INSTALLED + const isSecurityModuleInstalled = moduleInfoRes?.result?.status === ModuleStatus.INSTALLED const setInitialData: Dispatch> = unTypedSetInitialData const [pipelineStrategiesLoading, pipelineStrategies, pipelineStrategiesError, reloadStrategies] = useAsync( () => getDeploymentStrategies([pipelineId]), [pipelineId], - !!getDeploymentStrategies && !!pipelineId, + !!getDeploymentStrategies && !!pipelineId && isCDNode, ) const [isDeploymentLoading, setIsDeploymentLoading] = useState(false) const [deploymentStrategy, setDeploymentStrategy] = useState(null) const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) - // TODO: Handle reload states const [deployViewState, setDeployViewState] = useState>({ searchText: searchImageTag, filterView: FilterConditionViews.ALL, @@ -164,24 +163,23 @@ const DeployImageModal = ({ showSearchBar: false, }) - const isCDNode = stageType === DeploymentNodeType.CD const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD - const materialResponse = initialDataResponse?.[0] || null const deploymentWindowMetadata = initialDataResponse?.[1] ?? ({} as (typeof initialDataResponse)[1]) const policyConsequences = initialDataResponse?.[2] ?? ({} as (typeof initialDataResponse)[2]) const materialList = materialResponse?.materials || [] const selectedMaterial = materialList.find((material) => material.isSelected) const isRollbackTrigger = materialType === MATERIAL_TYPE.rollbackMaterialList - const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false + const isExceptionUser = getIsExceptionUser(materialResponse) const isApprovalConfigured = getIsApprovalPolicyConfigured( materialResponse?.deploymentApprovalInfo?.approvalConfigData, ) const canApproverDeploy = materialResponse?.canApproverDeploy ?? false const showConfigDiffView = searchParams.mode === URL_PARAM_MODE_TYPE.REVIEW_CONFIG && searchParams.deploy const isSelectImageTrigger = materialType === MATERIAL_TYPE.inputMaterialList - const areMaterialsPassingFilters = - materialList.filter((materialDetails) => materialDetails.filterState === FilterStates.ALLOWED).length > 0 + const areSomeMaterialsPassingFilters = materialList.some( + (materialDetails) => materialDetails.filterState === FilterStates.ALLOWED, + ) const selectedConfigToDeploy = getInitialSelectedConfigToDeploy(materialType, searchParams) const showPluginWarningBeforeTrigger = _showPluginWarningBeforeTrigger && isPreOrPostCD const allowWarningWithTippyNodeTypeProp = getAllowWarningWithTippyNodeTypeProp(stageType) @@ -227,8 +225,21 @@ const DeployImageModal = ({ isRollbackTriggerSelected: isRollbackTrigger, pipelineId, wfrId, + isCDNode, }) + const handleReload = () => { + reloadInitialData() + setDeployViewState((prev) => ({ + ...prev, + runtimeParamsErrorState: { + isValid: true, + cellError: {}, + }, + materialInEditModeMap: new Map(), + })) + } + const handleClosePluginWarningOverlay = () => { setShowPluginWarningOverlay(false) } @@ -268,80 +279,30 @@ const DeployImageModal = ({ handleCloseProp?.() } - const loadOlderImages = async () => { - // TODO: Move to util - handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) + const handleLoadOlderImages = async () => { if (!deployViewState.isLoadingOlderImages) { - // TODO: Move to util - const isConsumedImageAvailable = - materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false - - setDeployViewState((prevState) => ({ - ...prevState, - isLoadingOlderImages: true, - })) - try { - const newMaterialsResponse = await genericCDMaterialsService( - isRollbackTrigger ? CDMaterialServiceEnum.ROLLBACK : CDMaterialServiceEnum.CD_MATERIALS, - pipelineId, - stageType, - null, - { - offset: materialList.length - Number(isConsumedImageAvailable), - size: 20, - search: searchImageTag, - }, - ) - - // NOTE: Looping through _newResponse and removing elements that are already deployed and latest - // NOTE: This is done to avoid duplicate images - const filteredNewMaterialResponse = [...newMaterialsResponse.materials].filter( - (materialItem) => !(materialItem.deployed && materialItem.latest), - ) - - // updating the index of materials to maintain consistency - const _newMaterialsResponse = filteredNewMaterialResponse.map((materialItem, index) => ({ - ...materialItem, - index: materialList.length + index, + setDeployViewState((prevState) => ({ + ...prevState, + isLoadingOlderImages: true, })) - const newMaterials = structuredClone(materialList).concat(_newMaterialsResponse) + const newMaterials = await loadOlderImages({ + materialList, + resourceFilters: materialResponse?.resourceFilters, + filterView: deployViewState.filterView, + appliedSearchText: searchImageTag, + stageType, + isRollbackTrigger, + pipelineId, + }) + // Made a change not updating whole response rather updating only materials setInitialData((prevData) => { const updatedMaterialResponse = structuredClone(prevData[0]) updatedMaterialResponse.materials = newMaterials return [updatedMaterialResponse, prevData[1], prevData[2]] }) - - const baseSuccessMessage = `Fetched ${_newMaterialsResponse.length} images.` - if (materialResponse?.resourceFilters?.length && !searchImageTag) { - const eligibleImages = _newMaterialsResponse.filter( - (mat) => mat.filterState === FilterStates.ALLOWED, - ).length - - const infoMessage = - eligibleImages === 0 - ? 'No new eligible images found.' - : `${eligibleImages} new eligible images found.` - - if (deployViewState.filterView === FilterConditionViews.ELIGIBLE) { - ToastManager.showToast({ - variant: ToastVariantType.info, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: `${baseSuccessMessage} ${infoMessage}`, - }) - } - } else { - ToastManager.showToast({ - variant: ToastVariantType.success, - description: baseSuccessMessage, - }) - } } catch (error) { showError(error) } finally { @@ -362,14 +323,14 @@ const DeployImageModal = ({ return ( !selectedImage || - !areMaterialsPassingFilters || + !areSomeMaterialsPassingFilters || (isRollbackTrigger && (pipelineDeploymentConfigLoading || !canDeployWithConfig())) || (selectedConfigToDeploy.value === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG && noLastDeploymentConfig) ) } const renderDeployCTATippyContent = () => { - if (!areMaterialsPassingFilters) { + if (!areSomeMaterialsPassingFilters) { return ( <>

No eligible images found!

@@ -718,11 +679,7 @@ const DeployImageModal = ({ if (initialDataError) { return ( - + ) } @@ -759,10 +716,10 @@ const DeployImageModal = ({ appName={appName} isSecurityModuleInstalled={isSecurityModuleInstalled} envName={envName} - reloadMaterials={reloadInitialData} + reloadMaterials={handleReload} parentEnvironmentName={parentEnvironmentName} isVirtualEnvironment={isVirtualEnvironment} - loadOlderImages={loadOlderImages} + loadOlderImages={handleLoadOlderImages} policyConsequences={policyConsequences} triggerType={triggerType} setMaterialResponse={setMaterialResponse} @@ -800,7 +757,7 @@ const DeployImageModal = ({ appId, pipelineId, isExceptionUser, - reloadMaterials: reloadInitialData, + reloadMaterials: handleReload, requestedUserId, })} /> diff --git a/src/components/app/details/triggerView/DeployImageModal/service.ts b/src/components/app/details/triggerView/DeployImageModal/service.ts index 7140056251..f2b8ac82f3 100644 --- a/src/components/app/details/triggerView/DeployImageModal/service.ts +++ b/src/components/app/details/triggerView/DeployImageModal/service.ts @@ -1,17 +1,23 @@ import { CDMaterialResponseType, CDMaterialServiceEnum, + CDMaterialType, DeploymentNodeType, DeploymentWindowProfileMetaData, + FilterStates, genericCDMaterialsService, GetPolicyConsequencesProps, + handleAnalyticsEvent, PolicyConsequencesDTO, + ToastManager, + ToastVariantType, } from '@devtron-labs/devtron-fe-common-lib' import { importComponentFromFELibrary } from '@Components/common' -import { MATERIAL_TYPE } from '../types' -import { GetMaterialResponseListProps } from './types' +import { CD_MATERIAL_GA_EVENT } from '../Constants' +import { FilterConditionViews, MATERIAL_TYPE } from '../types' +import { GetMaterialResponseListProps, LoadOlderImagesProps } from './types' import { getIsCDTriggerBlockedThroughConsequences } from './utils' const getPolicyConsequences: ({ appId, envId }: GetPolicyConsequencesProps) => Promise = @@ -58,3 +64,69 @@ export const getMaterialResponseList = async ({ } return response } + +export const loadOlderImages = async ({ + materialList, + resourceFilters, + filterView, + appliedSearchText, + stageType, + isRollbackTrigger = false, + pipelineId, +}: LoadOlderImagesProps) => { + handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) + + const isConsumedImageAvailable = + materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false + + const newMaterialsResponse = await genericCDMaterialsService( + isRollbackTrigger ? CDMaterialServiceEnum.ROLLBACK : CDMaterialServiceEnum.CD_MATERIALS, + pipelineId, + stageType, + null, + { + offset: materialList.length - Number(isConsumedImageAvailable), + size: 20, + search: appliedSearchText, + }, + ) + + // NOTE: Looping through _newResponse and removing elements that are already deployed and latest and updating the index of materials to maintain consistency + // NOTE: This is done to avoid duplicate images + const _newMaterialsResponse = [...newMaterialsResponse.materials] + .filter((materialItem) => !(materialItem.deployed && materialItem.latest)) + .map((materialItem, index) => ({ + ...materialItem, + index: materialList.length + index, + })) + + const newMaterials = materialList.concat(_newMaterialsResponse) + + const baseSuccessMessage = `Fetched ${_newMaterialsResponse.length} images.` + + if (resourceFilters?.length && !appliedSearchText) { + const eligibleImages = _newMaterialsResponse.filter((mat) => mat.filterState === FilterStates.ALLOWED).length + + const infoMessage = + eligibleImages === 0 ? 'No new eligible images found.' : `${eligibleImages} new eligible images found.` + + if (filterView === FilterConditionViews.ELIGIBLE) { + ToastManager.showToast({ + variant: ToastVariantType.info, + description: `${baseSuccessMessage} ${infoMessage}`, + }) + } else { + ToastManager.showToast({ + variant: ToastVariantType.success, + description: `${baseSuccessMessage} ${infoMessage}`, + }) + } + } else { + ToastManager.showToast({ + variant: ToastVariantType.success, + description: baseSuccessMessage, + }) + } + + return newMaterials +} diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 965c3b8d76..2dbbbf564a 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -237,3 +237,16 @@ export type GetInitialAppListProps = appIdToReload?: never searchText?: never } + +export interface LoadOlderImagesProps { + materialList: CDMaterialType[] + resourceFilters: CDMaterialResponseType['resourceFilters'] + filterView: FilterConditionViews + stageType: DeploymentNodeType + pipelineId: number + /** + * @default false + */ + isRollbackTrigger?: boolean + appliedSearchText?: string +} diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index 9b4ea511b3..b0113d7b97 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -2,6 +2,7 @@ import { ACTION_STATE, ApprovalRuntimeStateType, ArtifactInfoProps, + CDMaterialResponseType, CDMaterialType, CommonNodeAttr, DeploymentNodeType, @@ -328,3 +329,6 @@ export const getFilterActionBarTabs = ( export const getAllowWarningWithTippyNodeTypeProp = (stageType: DeploymentNodeType): CommonNodeAttr['type'] => stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' + +export const getIsExceptionUser = (materialResponse: CDMaterialResponseType): boolean => + materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false diff --git a/src/components/app/details/triggerView/PipelineConfigDiff/types.ts b/src/components/app/details/triggerView/PipelineConfigDiff/types.ts index d7280742ac..3ea5e5d000 100644 --- a/src/components/app/details/triggerView/PipelineConfigDiff/types.ts +++ b/src/components/app/details/triggerView/PipelineConfigDiff/types.ts @@ -36,6 +36,7 @@ export interface UsePipelineDeploymentConfigProps { deploymentStrategy: DeploymentStrategyType setDeploymentStrategy: Dispatch> pipelineStrategyOptions: Strategy[] + isCDNode: boolean } export type PipelineConfigDiffProps = Pick< diff --git a/src/components/app/details/triggerView/PipelineConfigDiff/usePipelineDeploymentConfig.ts b/src/components/app/details/triggerView/PipelineConfigDiff/usePipelineDeploymentConfig.ts index d7119c9b68..913a4f20a8 100644 --- a/src/components/app/details/triggerView/PipelineConfigDiff/usePipelineDeploymentConfig.ts +++ b/src/components/app/details/triggerView/PipelineConfigDiff/usePipelineDeploymentConfig.ts @@ -62,6 +62,7 @@ export const usePipelineDeploymentConfig = ({ deploymentStrategy, setDeploymentStrategy, pipelineStrategyOptions, + isCDNode, }: UsePipelineDeploymentConfigProps) => { // HOOKS const { pathname, search } = useLocation() @@ -91,7 +92,7 @@ export const usePipelineDeploymentConfig = ({ ({ result }) => getDefaultVersionAndPreviousDeploymentOptions(result).previousDeployments, ), [envId], - !!appId && !!envId, + !!appId && !!envId && isCDNode, ) const isLastDeployedConfigAvailable = previousDeployments?.length !== 0 @@ -204,7 +205,7 @@ export const usePipelineDeploymentConfig = ({ return _pipelineDeploymentConfigRes }, [isRollbackTriggerSelected && wfrId, deploymentStrategy], - !previousDeploymentsLoader && !!previousDeployments && (!isRollbackTriggerSelected || !!wfrId), + isCDNode && !previousDeploymentsLoader && !!previousDeployments && (!isRollbackTriggerSelected || !!wfrId), ) // CONSTANTS From 27ca8bd010a058251a7df226b3363c29accf1f97 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 6 Aug 2025 11:27:52 +0530 Subject: [PATCH 15/40] feat: Refactor DeployImageModal and EnvTriggerView to improve deployment handling and error messaging --- .../Details/TriggerView/EnvTriggerView.tsx | 8 +- .../DeployImageModal/DeployImageModal.tsx | 108 ++++++++---------- .../triggerView/DeployImageModal/types.ts | 6 + .../triggerView/DeployImageModal/utils.tsx | 22 ++++ 4 files changed, 78 insertions(+), 66 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index e5c9625fe4..b9c4ca5b82 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -438,12 +438,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT }) } - const closeCDModal = (): void => { - history.push({ - search: '', - }) - } - const closeApprovalModal = (e: React.MouseEvent): void => { e.stopPropagation() history.push({ @@ -597,7 +591,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT envName={node?.environmentName} pipelineId={+node.id} materialType={cdMaterialType} - handleClose={closeCDModal} + handleClose={revertToPreviousURL} handleSuccess={getWorkflowStatusData} deploymentAppType={node?.deploymentAppType} isVirtualEnvironment={isVirtualEnv} diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index b310d2f9a9..13a5437e94 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -6,7 +6,6 @@ import { AnimatedDeployButton, API_STATUS_CODES, ArtifactInfo, - ButtonStyleType, CDMaterialSidebarType, ConditionalWrap, DEFAULT_ROUTE_PROMPT_MESSAGE, @@ -58,17 +57,19 @@ import DeployImageHeader from './DeployImageHeader' import MaterialListSkeleton from './MaterialListSkeleton' import RuntimeParamsSidebar from './RuntimeParamsSidebar' import { getMaterialResponseList, loadOlderImages } from './service' -import { DeployImageContentProps, DeployImageModalProps, DeployViewStateType } from './types' +import { DeployImageContentProps, DeployImageModalProps, DeployViewStateType, HandleDeploymentProps } from './types' import { getAllowWarningWithTippyNodeTypeProp, getCDArtifactId, getConfigToDeployValue, getDeployButtonIcon, + getDeployButtonStyle, getInitialSelectedConfigToDeploy, getIsExceptionUser, getIsImageApprover, getTriggerArtifactInfoProps, handleTriggerErrorMessageForHelmManifestPush, + renderDeployCTATippyData, showErrorIfNotAborted, } from './utils' @@ -177,8 +178,8 @@ const DeployImageModal = ({ const canApproverDeploy = materialResponse?.canApproverDeploy ?? false const showConfigDiffView = searchParams.mode === URL_PARAM_MODE_TYPE.REVIEW_CONFIG && searchParams.deploy const isSelectImageTrigger = materialType === MATERIAL_TYPE.inputMaterialList - const areSomeMaterialsPassingFilters = materialList.some( - (materialDetails) => materialDetails.filterState === FilterStates.ALLOWED, + const areAllImagesExcluded = materialList.every( + (materialDetails) => materialDetails.filterState !== FilterStates.ALLOWED, ) const selectedConfigToDeploy = getInitialSelectedConfigToDeploy(materialType, searchParams) const showPluginWarningBeforeTrigger = _showPluginWarningBeforeTrigger && isPreOrPostCD @@ -275,7 +276,9 @@ const DeployImageModal = ({ } const handleClose = () => { - onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) + if (isRedirectedFromAppDetails && showConfigDiffView) { + onClickSetInitialParams(URL_PARAM_MODE_TYPE.LIST) + } handleCloseProp?.() } @@ -323,34 +326,32 @@ const DeployImageModal = ({ return ( !selectedImage || - !areSomeMaterialsPassingFilters || + areAllImagesExcluded || (isRollbackTrigger && (pipelineDeploymentConfigLoading || !canDeployWithConfig())) || (selectedConfigToDeploy.value === DeploymentWithConfigType.LATEST_TRIGGER_CONFIG && noLastDeploymentConfig) ) } const renderDeployCTATippyContent = () => { - if (!areSomeMaterialsPassingFilters) { - return ( - <> -

No eligible images found!

-

- Please select an image that passes the configured filters to deploy -

- + const isSelectedImagePresent = materialList.some((artifact) => artifact.isSelected) + + if (areAllImagesExcluded) { + return renderDeployCTATippyData( + 'No eligible images found!', + 'Please select an image that passes the configured filters to deploy', ) } - return ( - <> -

Selected Config not available!

-

- {selectedConfigToDeploy.value === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG && - noSpecificDeploymentConfig - ? 'Please select a different image or configuration to deploy' - : 'Please select a different configuration to deploy'} -

- + if (!isSelectedImagePresent) { + return renderDeployCTATippyData('No image selected!', 'Please select an image to deploy') + } + + return renderDeployCTATippyData( + 'Selected Config not available!', + selectedConfigToDeploy.value === DeploymentWithConfigType.SPECIFIC_TRIGGER_CONFIG && + noSpecificDeploymentConfig + ? 'Please select a different image or configuration to deploy' + : 'Please select a different configuration to deploy', ) } @@ -361,16 +362,10 @@ const DeployImageModal = ({ ) const redirectToDeploymentStepsPage = () => { - history.push(`/app/${appId}/cd-details/${envId}/${pipelineId}`) + history.push(`${URLS.APP}/${appId}/${URLS.APP_CD_DETAILS}/${envId}/${pipelineId}`) } - const handleDeployment = ( - nodeType: DeploymentNodeType, - _appId: number, - ciArtifactId: number, - deploymentWithConfig?: string, - computedWfrId?: number, - ) => { + const handleDeployment = ({ ciArtifactId, deploymentWithConfig, computedWfrId }: HandleDeploymentProps) => { const updatedRuntimeParamsErrorState = validateRuntimeParameters(runtimeParamsList) setDeployViewState((prevState) => ({ ...prevState, @@ -384,15 +379,15 @@ const DeployImageModal = ({ return } - handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.CDTriggered(nodeType)) + handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.CDTriggered(stageType)) setIsDeploymentLoading(true) - if (_appId && pipelineId && ciArtifactId) { + if (appId && pipelineId && ciArtifactId) { triggerCDNode({ pipelineId: Number(pipelineId), ciArtifactId: Number(ciArtifactId), - appId: Number(_appId), - stageType: nodeType, + appId: Number(appId), + stageType, deploymentWithConfig, wfrId: computedWfrId, abortControllerRef: null, @@ -408,10 +403,10 @@ const DeployImageModal = ({ if (isVirtualEnvironment && deploymentAppType === DeploymentAppTypes.MANIFEST_DOWNLOAD) { const { helmPackageName } = response.result downloadManifestForVirtualEnvironment?.({ - appId: _appId, + appId, envId, helmPackageName, - cdWorkflowType: nodeType, + cdWorkflowType: stageType, handleDownload, }) } @@ -444,7 +439,7 @@ const DeployImageModal = ({ setIsDeploymentLoading(false) }) } else { - let message = _appId ? '' : 'app id missing ' + let message = appId ? '' : 'app id missing ' message += pipelineId ? '' : 'pipeline id missing ' message += ciArtifactId ? '' : 'Artifact id missing ' ToastManager.showToast({ @@ -456,7 +451,7 @@ const DeployImageModal = ({ } const deployTrigger = (e: SyntheticEvent) => { - e.stopPropagation() + stopPropagation(e) handleConfirmationClose(e) // Blocking the deploy action if already deploying or config is not available if (isDeployButtonDisabled()) { @@ -467,15 +462,21 @@ const DeployImageModal = ({ if (isRollbackTrigger || isSelectImageTrigger) { const computedWfrId = isRollbackTrigger ? wfrId : lastDeploymentWfrId - handleDeployment(stageType, appId, artifactId, selectedConfigToDeploy.value, computedWfrId) + handleDeployment({ + ciArtifactId: artifactId, + deploymentWithConfig: selectedConfigToDeploy.value, + computedWfrId, + }) return } - handleDeployment(stageType, appId, artifactId) + // Not sure when this call will come into play, but keeping it for now for backward compatibility + handleDeployment({ ciArtifactId: artifactId }) } - const onClickDeploy = (e, disableDeployButton: boolean) => { - e.stopPropagation() + const onClickDeploy = (e: SyntheticEvent, disableDeployButton: boolean) => { + stopPropagation(e) + if (!disableDeployButton) { if (!showPluginWarningOverlay && showPluginWarningBeforeTrigger) { setShowPluginWarningOverlay(true) @@ -494,19 +495,8 @@ const DeployImageModal = ({ } } - const getDeployButtonStyle = ( - userActionState: string, - canDeployWithoutApproval: boolean, - canImageApproverDeploy: boolean, - ): ButtonStyleType => { - if (userActionState === ACTION_STATE.BLOCKED) { - return ButtonStyleType.negative - } - if (userActionState === ACTION_STATE.PARTIAL || canDeployWithoutApproval || canImageApproverDeploy) { - return ButtonStyleType.warning - } - return ButtonStyleType.default - } + const getOnClickDeploy = (disableDeployButton: boolean) => (e: SyntheticEvent) => + onClickDeploy(e, disableDeployButton) const onSearchApply = (newSearchText: string) => { const newParams = new URLSearchParams({ @@ -540,7 +530,7 @@ const DeployImageModal = ({ onClickDeploy(e, disableDeployButton)} + onButtonClick={getOnClickDeploy(disableDeployButton)} startIcon={getDeployButtonIcon(deploymentWindowMetadata, stageType)} text={ canDeployWithoutApproval @@ -635,7 +625,7 @@ const DeployImageModal = ({ consequence={consequence} configurePluginURL={configurePluginURL} showTriggerButton - onTrigger={(e) => onClickDeploy(e, disableDeployButton)} + onTrigger={getOnClickDeploy(disableDeployButton)} nodeType={allowWarningWithTippyNodeTypeProp} visible={showPluginWarningOverlay} onClose={handleClosePluginWarningOverlay} diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 2dbbbf564a..a8c4e89a31 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -250,3 +250,9 @@ export interface LoadOlderImagesProps { isRollbackTrigger?: boolean appliedSearchText?: string } + +export interface HandleDeploymentProps { + ciArtifactId: number + deploymentWithConfig?: string + computedWfrId?: number +} diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index b0113d7b97..cbb8748921 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -2,6 +2,7 @@ import { ACTION_STATE, ApprovalRuntimeStateType, ArtifactInfoProps, + ButtonStyleType, CDMaterialResponseType, CDMaterialType, CommonNodeAttr, @@ -332,3 +333,24 @@ export const getAllowWarningWithTippyNodeTypeProp = (stageType: DeploymentNodeTy export const getIsExceptionUser = (materialResponse: CDMaterialResponseType): boolean => materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false + +export const renderDeployCTATippyData = (title: string, description: string) => ( + <> +

{title}

+

{description}

+ +) + +export const getDeployButtonStyle = ( + userActionState: string, + canDeployWithoutApproval: boolean, + canImageApproverDeploy: boolean, +): ButtonStyleType => { + if (userActionState === ACTION_STATE.BLOCKED) { + return ButtonStyleType.negative + } + if (userActionState === ACTION_STATE.PARTIAL || canDeployWithoutApproval || canImageApproverDeploy) { + return ButtonStyleType.warning + } + return ButtonStyleType.default +} From 8fc1c44a44563d64340388c9cb81fc8734535c6b Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 6 Aug 2025 12:20:59 +0530 Subject: [PATCH 16/40] feat: Refactor BulkDeployModal and DeployImageContent to improve image tag handling and remove unused constants --- .../DeployImageModal/BulkDeployModal.tsx | 3 +- .../DeployImageModal/DeployImageContent.tsx | 89 +++++++++++-------- .../triggerView/DeployImageModal/constants.ts | 16 ---- .../triggerView/DeployImageModal/service.ts | 5 +- .../triggerView/DeployImageModal/utils.tsx | 3 + 5 files changed, 61 insertions(+), 55 deletions(-) delete mode 100644 src/components/app/details/triggerView/DeployImageModal/constants.ts diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index af6b4682ea..5028fedd7b 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -5,6 +5,8 @@ import { AnimatedDeployButton, API_STATUS_CODES, ApiQueuingWithBatch, + BULK_DEPLOY_ACTIVE_IMAGE_TAG, + BULK_DEPLOY_LATEST_IMAGE_TAG, ButtonStyleType, CDMaterialResponseType, CDMaterialServiceEnum, @@ -64,7 +66,6 @@ import { getModuleInfo } from '@Components/v2/devtronStackManager/DevtronStackMa import { getIsMaterialApproved } from '../cdMaterials.utils' import { FilterConditionViews } from '../types' -import { BULK_DEPLOY_ACTIVE_IMAGE_TAG, BULK_DEPLOY_LATEST_IMAGE_TAG } from './constants' import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import { loadOlderImages } from './service' diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 76369cede7..20dc78d974 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -3,6 +3,8 @@ import { useHistory } from 'react-router-dom' import { API_STATUS_CODES, + BULK_DEPLOY_ACTIVE_IMAGE_TAG, + BULK_DEPLOY_LATEST_IMAGE_TAG, Button, ButtonStyleType, ButtonVariantType, @@ -48,7 +50,6 @@ import { getIsMaterialApproved } from '../cdMaterials.utils' import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' -import { BULK_DEPLOY_ACTIVE_IMAGE_TAG, BULK_DEPLOY_LATEST_IMAGE_TAG } from './constants' import ImageSelectionCTA from './ImageSelectionCTA' import MaterialListEmptyState from './MaterialListEmptyState' import MaterialListSkeleton from './MaterialListSkeleton' @@ -58,6 +59,8 @@ import { getApprovedImageClass, getConsumedAndAvailableMaterialList, getFilterActionBarTabs, + getIsConsumedImageAvailable, + getIsExceptionUser, getIsImageApprover, getSequentialCDCardTitleProps, getTriggerArtifactInfoProps, @@ -113,7 +116,8 @@ const DeployImageContent = ({ const { isSuperAdmin } = useMainContext() const { onClickApprovalNode } = useContext(TriggerViewContext) - const isExceptionUser = materialResponse?.deploymentApprovalInfo?.approvalConfigData?.isExceptionUser ?? false + // Assumption: isExceptionUser is a global trait + const isExceptionUser = getIsExceptionUser(materialResponse) const requestedUserId = materialResponse?.requestedUserId const isApprovalConfigured = getIsApprovalPolicyConfigured( materialResponse?.deploymentApprovalInfo?.approvalConfigData, @@ -122,10 +126,9 @@ const DeployImageContent = ({ const canApproverDeploy = materialResponse?.canApproverDeploy ?? false const resourceFilters = materialResponse?.resourceFilters ?? [] const hideImageTaggingHardDelete = materialResponse?.hideImageTaggingHardDelete ?? false - const isConsumedImageAvailable = - materials.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false - const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD const runtimeParamsList = materialResponse?.runtimeParams || [] + const isConsumedImageAvailable = getIsConsumedImageAvailable(materials) + const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD const isCDNode = stageType === DeploymentNodeType.CD const tagOptions: SelectPickerOptionType[] = useMemo(() => { @@ -143,13 +146,33 @@ const DeployImageContent = ({ const selectedTagOption = useMemo(() => { const selectedTag = tagOptions.find((option) => option.value === selectedTagName) - return selectedTag || { label: 'Multiple Tags', value: '' } - }, [selectedTagName, tagOptions]) + const areMultipleTagsPresent = Object.values(appInfoMap).some((appDetails) => { + const selectedImage = appDetails.materialResponse?.materials?.find( + (material: CDMaterialType) => material.isSelected, + ) + + if (!selectedImage) { + return false + } + + return !selectedImage.imageReleaseTags?.some((tagDetails) => tagDetails.tagName === selectedTagName) + }) + + if (areMultipleTagsPresent || !selectedTag) { + return { label: 'Multiple Tags', value: '' } + } - const showRuntimeParams = !!(isBulkTrigger && RuntimeParamTabs && isPreOrPostCD) + return selectedTag + }, [selectedTagName, tagOptions, appInfoMap]) + + const sortedAppValues = useMemo( + () => Object.values(appInfoMap).sort((a, b) => stringComparatorBySortOrder(a.appName, b.appName)), + [appInfoMap], + ) const getHandleAppChange = (newAppId: number) => (e: SyntheticEvent) => { stopPropagation(e) + if ('key' in e && e.key !== 'Enter' && e.key !== ' ') { return } @@ -186,11 +209,6 @@ const DeployImageContent = ({ const showActionBar = FilterActionBar && !isSearchApplied && !!resourceFilters?.length && !showConfiguredFilters const areNoMoreImagesPresent = materials.length >= materialResponse?.totalCount - const sortedAppValues = useMemo( - () => Object.values(appInfoMap || {}).sort((a, b) => stringComparatorBySortOrder(a.appName, b.appName)), - [appInfoMap], - ) - const handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] = (e) => { setDeployViewState((prevState) => ({ ...prevState, @@ -258,15 +276,15 @@ const DeployImageContent = ({ } const handleImageSelection: ImageSelectionCTAProps['handleImageSelection'] = (materialIndex) => { - const updatedMaterialList = materialList.map((material, index) => ({ - ...material, - isSelected: index === materialIndex, - })) - setMaterialResponse((prevData) => { const updatedMaterialResponse = structuredClone(prevData) - updatedMaterialResponse.materials = updatedMaterialList - return updatedMaterialResponse + return { + ...updatedMaterialResponse, + materials: updatedMaterialResponse.materials.map((material, index) => ({ + ...material, + isSelected: index === materialIndex, + })), + } }) } @@ -299,7 +317,7 @@ const DeployImageContent = ({ placeholder: 'Search by image tag', autoFocus: true, }} - dataTestId="cd-trigger-search-by-commit-hash" + dataTestId="cd-trigger-search-by-image-tag" /> ) @@ -308,21 +326,21 @@ const DeployImageContent = ({ imageReleaseTags, imageComment, ) => { - const updatedMaterialList = materialList.map((material) => { - if (+material.id === +matId) { - return { - ...material, - imageReleaseTags, - imageComment, - } - } - return material - }) - setMaterialResponse((prevData) => { const updatedMaterialResponse = structuredClone(prevData) - updatedMaterialResponse.materials = updatedMaterialList - return updatedMaterialResponse + return { + ...updatedMaterialResponse, + materials: updatedMaterialResponse.materials.map((material) => { + if (+material.id === +matId) { + return { + ...material, + imageReleaseTags, + imageComment, + } + } + return material + }), + } }) } @@ -455,7 +473,7 @@ const DeployImageContent = ({ return (
- {showRuntimeParams && ( + {!!(RuntimeParamTabs && isPreOrPostCD) && (
{appDetails.appName} diff --git a/src/components/app/details/triggerView/DeployImageModal/constants.ts b/src/components/app/details/triggerView/DeployImageModal/constants.ts deleted file mode 100644 index 4b5802567b..0000000000 --- a/src/components/app/details/triggerView/DeployImageModal/constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SelectPickerOptionType } from '@devtron-labs/devtron-fe-common-lib' - -export const BULK_DEPLOY_LATEST_IMAGE_TAG: SelectPickerOptionType = { - value: 'Latest', - label: 'Latest', -} - -export const BULK_DEPLOY_ACTIVE_IMAGE_TAG: SelectPickerOptionType = { - value: 'Active', - label: 'Active', -} - -export const BULK_DEPLOY_MIXED_IMAGE_TAG: SelectPickerOptionType = { - value: 'Mixed', - label: 'Mixed', -} diff --git a/src/components/app/details/triggerView/DeployImageModal/service.ts b/src/components/app/details/triggerView/DeployImageModal/service.ts index f2b8ac82f3..468a640927 100644 --- a/src/components/app/details/triggerView/DeployImageModal/service.ts +++ b/src/components/app/details/triggerView/DeployImageModal/service.ts @@ -18,7 +18,7 @@ import { importComponentFromFELibrary } from '@Components/common' import { CD_MATERIAL_GA_EVENT } from '../Constants' import { FilterConditionViews, MATERIAL_TYPE } from '../types' import { GetMaterialResponseListProps, LoadOlderImagesProps } from './types' -import { getIsCDTriggerBlockedThroughConsequences } from './utils' +import { getIsCDTriggerBlockedThroughConsequences, getIsConsumedImageAvailable } from './utils' const getPolicyConsequences: ({ appId, envId }: GetPolicyConsequencesProps) => Promise = importComponentFromFELibrary('getPolicyConsequences', null, 'function') @@ -76,8 +76,7 @@ export const loadOlderImages = async ({ }: LoadOlderImagesProps) => { handleAnalyticsEvent(CD_MATERIAL_GA_EVENT.FetchMoreImagesClicked) - const isConsumedImageAvailable = - materialList.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false + const isConsumedImageAvailable = getIsConsumedImageAvailable(materialList) const newMaterialsResponse = await genericCDMaterialsService( isRollbackTrigger ? CDMaterialServiceEnum.ROLLBACK : CDMaterialServiceEnum.CD_MATERIALS, diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index cbb8748921..179713b4c5 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -354,3 +354,6 @@ export const getDeployButtonStyle = ( } return ButtonStyleType.default } + +export const getIsConsumedImageAvailable = (materials: CDMaterialType[]) => + materials.some((materialItem) => materialItem.deployed && materialItem.latest) ?? false From 5645325679eb1a5cccdd969fa10d0a08d32c561a Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 6 Aug 2025 12:33:44 +0530 Subject: [PATCH 17/40] feat: Add BulkTriggerSidebar component and integrate it into DeployImageContent for improved image selection handling --- .../DeployImageModal/BulkTriggerSidebar.tsx | 235 ++++++++++++++++++ .../DeployImageModal/DeployImageContent.tsx | 211 +--------------- .../triggerView/DeployImageModal/types.ts | 11 + 3 files changed, 258 insertions(+), 199 deletions(-) create mode 100644 src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx new file mode 100644 index 0000000000..10b3ac72ab --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx @@ -0,0 +1,235 @@ +import { SyntheticEvent, useMemo } from 'react' + +import { + API_STATUS_CODES, + BULK_DEPLOY_ACTIVE_IMAGE_TAG, + BULK_DEPLOY_LATEST_IMAGE_TAG, + CD_MATERIAL_SIDEBAR_TABS, + CDMaterialSidebarType, + CDMaterialType, + CommonNodeAttr, + DeploymentNodeType, + Icon, + SelectPicker, + SelectPickerOptionType, + stopPropagation, + stringComparatorBySortOrder, + Tooltip, + TriggerBlockType, +} from '@devtron-labs/devtron-fe-common-lib' + +import { BulkCDDetailType } from '@Components/ApplicationGroup/AppGroup.types' +import { BULK_CD_MESSAGING } from '@Components/ApplicationGroup/Constants' +import { importComponentFromFELibrary } from '@Components/common' + +import { getIsMaterialApproved } from '../cdMaterials.utils' +import { BulkTriggerSidebarProps } from './types' +import { getIsExceptionUser } from './utils' + +const RuntimeParamTabs = importComponentFromFELibrary('RuntimeParamTabs', null, 'function') +const PolicyEnforcementMessage = importComponentFromFELibrary('PolicyEnforcementMessage') +const TriggerBlockedError = importComponentFromFELibrary('TriggerBlockedError', null, 'function') + +const BulkTriggerSidebar = ({ + appId, + stageType, + appInfoMap, + selectedTagName, + handleTagChange, + changeApp, + handleSidebarTabChange, + currentSidebarTab, +}: BulkTriggerSidebarProps) => { + const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD + + const tagOptions: SelectPickerOptionType[] = useMemo(() => { + const tagNames = new Set() + Object.values(appInfoMap).forEach((app) => { + app.materialResponse?.appReleaseTagNames?.forEach((tag) => tagNames.add(tag)) + }) + + return [BULK_DEPLOY_LATEST_IMAGE_TAG, BULK_DEPLOY_ACTIVE_IMAGE_TAG].concat( + Array.from(tagNames) + .sort(stringComparatorBySortOrder) + .map((tag) => ({ label: tag, value: tag })), + ) + }, [appInfoMap]) + + const selectedTagOption = useMemo(() => { + const selectedTag = tagOptions.find((option) => option.value === selectedTagName) + const areMultipleTagsPresent = Object.values(appInfoMap).some((appDetails) => { + const selectedImage = appDetails.materialResponse?.materials?.find( + (material: CDMaterialType) => material.isSelected, + ) + + if (!selectedImage) { + return false + } + + return !selectedImage.imageReleaseTags?.some((tagDetails) => tagDetails.tagName === selectedTagName) + }) + + if (areMultipleTagsPresent || !selectedTag) { + return { label: 'Multiple Tags', value: '' } + } + + return selectedTag + }, [selectedTagName, tagOptions, appInfoMap]) + + const sortedAppValues = useMemo( + () => Object.values(appInfoMap).sort((a, b) => stringComparatorBySortOrder(a.appName, b.appName)), + [appInfoMap], + ) + + const getHandleAppChange = (newAppId: number) => (e: SyntheticEvent) => { + stopPropagation(e) + + if ('key' in e && e.key !== 'Enter' && e.key !== ' ') { + return + } + + changeApp(newAppId) + } + + const renderDeploymentWithoutApprovalWarning = (app: BulkCDDetailType) => { + const isExceptionUser = getIsExceptionUser(app.materialResponse) + + if (!isExceptionUser) { + return null + } + + const selectedMaterial: CDMaterialType = app.materialResponse?.materials?.find( + (mat: CDMaterialType) => mat.isSelected, + ) + + if (!selectedMaterial || getIsMaterialApproved(selectedMaterial?.userApprovalMetadata)) { + return null + } + + return ( +
+ +

Non-approved image selected

+
+ ) + } + + const renderAppWarningAndErrors = (app: BulkCDDetailType) => { + const isAppSelected = app.appId === appId + // We don't support cd for mandatory plugins + const blockedPluginNodeType: CommonNodeAttr['type'] = + stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' + + if (app.materialError?.code === API_STATUS_CODES.UNAUTHORIZED) { + return ( +
+ + {BULK_CD_MESSAGING.unauthorized.title} +
+ ) + } + + if (app.isTriggerBlockedDueToPlugin) { + return ( + + ) + } + + if (app.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { + return + } + + if (!!app.warningMessage && !app.showPluginWarning) { + return ( +
+ + {app.warningMessage} +
+ ) + } + + if (app.showPluginWarning) { + return ( + + ) + } + + return null + } + + return ( +
+
+ {!!(RuntimeParamTabs && isPreOrPostCD) && ( +
+ +
+ )} + + {currentSidebarTab === CDMaterialSidebarType.IMAGE && ( + <> + Select image by release tag +
+ } + onChange={handleTagChange} + isDisabled={false} + // Not changing it for backward compatibility for automation + classNamePrefix="build-config__select-repository-containing-code" + autoFocus + /> +
+ + )} +
+ APPLICATIONS +
+
+ + {sortedAppValues.map((appDetails) => ( +
+ + {appDetails.appName} + + {renderDeploymentWithoutApprovalWarning(appDetails)} + {renderAppWarningAndErrors(appDetails)} +
+ ))} +
+ ) +} + +export default BulkTriggerSidebar diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 20dc78d974..9c2722057c 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -1,14 +1,10 @@ -import { SyntheticEvent, useContext, useMemo } from 'react' +import { useContext } from 'react' import { useHistory } from 'react-router-dom' import { - API_STATUS_CODES, - BULK_DEPLOY_ACTIVE_IMAGE_TAG, - BULK_DEPLOY_LATEST_IMAGE_TAG, Button, ButtonStyleType, ButtonVariantType, - CD_MATERIAL_SIDEBAR_TABS, CDMaterialSidebarType, CDMaterialType, CommonNodeAttr, @@ -32,24 +28,18 @@ import { Progressing, SearchBar, SegmentedControlProps, - SelectPicker, - SelectPickerOptionType, - stopPropagation, - stringComparatorBySortOrder, - Tooltip, TriggerBlockType, useMainContext, } from '@devtron-labs/devtron-fe-common-lib' import emptyPreDeploy from '@Images/empty-pre-deploy.webp' -import { BulkCDDetailType } from '@Components/ApplicationGroup/AppGroup.types' import { BULK_CD_MESSAGING } from '@Components/ApplicationGroup/Constants' import { importComponentFromFELibrary } from '@Components/common' -import { getIsMaterialApproved } from '../cdMaterials.utils' import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' +import BulkTriggerSidebar from './BulkTriggerSidebar' import ImageSelectionCTA from './ImageSelectionCTA' import MaterialListEmptyState from './MaterialListEmptyState' import MaterialListSkeleton from './MaterialListSkeleton' @@ -74,11 +64,8 @@ const RuntimeParameters = importComponentFromFELibrary('RuntimeParameters', null const SecurityModalSidebar = importComponentFromFELibrary('SecurityModalSidebar', null, 'function') const CDMaterialInfo = importComponentFromFELibrary('CDMaterialInfo') const ConfiguredFilters = importComponentFromFELibrary('ConfiguredFilters') -const RuntimeParamTabs = importComponentFromFELibrary('RuntimeParamTabs', null, 'function') const TriggerBlockEmptyState = importComponentFromFELibrary('TriggerBlockEmptyState', null, 'function') const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlockState', null, 'function') -const PolicyEnforcementMessage = importComponentFromFELibrary('PolicyEnforcementMessage') -const TriggerBlockedError = importComponentFromFELibrary('TriggerBlockedError', null, 'function') const DeployImageContent = ({ appId, @@ -131,55 +118,6 @@ const DeployImageContent = ({ const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD const isCDNode = stageType === DeploymentNodeType.CD - const tagOptions: SelectPickerOptionType[] = useMemo(() => { - const tagNames = new Set() - Object.values(appInfoMap).forEach((app) => { - app.materialResponse?.appReleaseTagNames?.forEach((tag) => tagNames.add(tag)) - }) - - return [BULK_DEPLOY_LATEST_IMAGE_TAG, BULK_DEPLOY_ACTIVE_IMAGE_TAG].concat( - Array.from(tagNames) - .sort(stringComparatorBySortOrder) - .map((tag) => ({ label: tag, value: tag })), - ) - }, [appInfoMap]) - - const selectedTagOption = useMemo(() => { - const selectedTag = tagOptions.find((option) => option.value === selectedTagName) - const areMultipleTagsPresent = Object.values(appInfoMap).some((appDetails) => { - const selectedImage = appDetails.materialResponse?.materials?.find( - (material: CDMaterialType) => material.isSelected, - ) - - if (!selectedImage) { - return false - } - - return !selectedImage.imageReleaseTags?.some((tagDetails) => tagDetails.tagName === selectedTagName) - }) - - if (areMultipleTagsPresent || !selectedTag) { - return { label: 'Multiple Tags', value: '' } - } - - return selectedTag - }, [selectedTagName, tagOptions, appInfoMap]) - - const sortedAppValues = useMemo( - () => Object.values(appInfoMap).sort((a, b) => stringComparatorBySortOrder(a.appName, b.appName)), - [appInfoMap], - ) - - const getHandleAppChange = (newAppId: number) => (e: SyntheticEvent) => { - stopPropagation(e) - - if ('key' in e && e.key !== 'Enter' && e.key !== ' ') { - return - } - - changeApp(newAppId) - } - const { searchText, appliedSearchText, @@ -394,144 +332,19 @@ const DeployImageContent = ({ isSuperAdmin, }) - const renderDeploymentWithoutApprovalWarning = (app: BulkCDDetailType) => { - if (!isExceptionUser) { - return null - } - - const selectedMaterial: CDMaterialType = app.materialResponse?.materials?.find( - (mat: CDMaterialType) => mat.isSelected, - ) - - if (!selectedMaterial || getIsMaterialApproved(selectedMaterial?.userApprovalMetadata)) { - return null - } - - return ( -
- -

Non-approved image selected

-
- ) - } - - const renderAppWarningAndErrors = (app: BulkCDDetailType) => { - const isAppSelected = app.appId === appId - // We don't support cd for mandatory plugins - const blockedPluginNodeType: CommonNodeAttr['type'] = - stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' - - if (app.materialError?.code === API_STATUS_CODES.UNAUTHORIZED) { - return ( -
- - {BULK_CD_MESSAGING.unauthorized.title} -
- ) - } - - if (app.isTriggerBlockedDueToPlugin) { - return ( - - ) - } - - if (app.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { - return - } - - if (!!app.warningMessage && !app.showPluginWarning) { - return ( -
- - {app.warningMessage} -
- ) - } - - if (app.showPluginWarning) { - return ( - - ) - } - - return null - } - const renderSidebar = () => { if (isBulkTrigger) { return ( -
-
- {!!(RuntimeParamTabs && isPreOrPostCD) && ( -
- -
- )} - - {currentSidebarTab === CDMaterialSidebarType.IMAGE && ( - <> - Select image by release tag -
- } - onChange={handleTagChange} - isDisabled={false} - // Not changing it for backward compatibility for automation - classNamePrefix="build-config__select-repository-containing-code" - autoFocus - /> -
- - )} -
- APPLICATIONS -
-
- - {sortedAppValues.map((appDetails) => ( -
- - {appDetails.appName} - - {renderDeploymentWithoutApprovalWarning(appDetails)} - {renderAppWarningAndErrors(appDetails)} -
- ))} -
+ ) } diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index a8c4e89a31..5c3c98d5b5 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -256,3 +256,14 @@ export interface HandleDeploymentProps { deploymentWithConfig?: string computedWfrId?: number } + +export interface BulkTriggerSidebarProps + extends Required< + Pick< + DeployImageContentProps, + 'appId' | 'stageType' | 'appInfoMap' | 'selectedTagName' | 'handleTagChange' | 'changeApp' + > + >, + Pick { + handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] +} From 9b3d43a06edf1095cbaf0310684eda6ade293855 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 6 Aug 2025 13:36:29 +0530 Subject: [PATCH 18/40] feat: Implement BulkDeployEmptyState component and integrate it into DeployImageContent for enhanced empty state handling --- .../DeployImageModal/BulkDeployEmptyState.tsx | 61 ++++++++++ .../DeployImageModal/BulkDeployModal.tsx | 26 +---- .../DeployImageModal/BulkTriggerSidebar.tsx | 10 +- .../DeployImageModal/DeployImageContent.tsx | 109 +++++++----------- .../triggerView/DeployImageModal/constants.ts | 21 ++++ .../triggerView/DeployImageModal/types.ts | 10 +- 6 files changed, 142 insertions(+), 95 deletions(-) create mode 100644 src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx create mode 100644 src/components/app/details/triggerView/DeployImageModal/constants.ts diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx new file mode 100644 index 0000000000..a044b750d6 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx @@ -0,0 +1,61 @@ +import { + CommonNodeAttr, + DeploymentNodeType, + ErrorScreenManager, + GenericEmptyState, + TriggerBlockType, +} from '@devtron-labs/devtron-fe-common-lib' + +import emptyPreDeploy from '@Images/empty-pre-deploy.webp' +import { BULK_CD_MESSAGING } from '@Components/ApplicationGroup/Constants' +import { importComponentFromFELibrary } from '@Components/common' + +import { BulkDeployEmptyStateProps } from './types' + +const TriggerBlockEmptyState = importComponentFromFELibrary('TriggerBlockEmptyState', null, 'function') +const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlockState', null, 'function') + +const BulkDeployEmptyState = ({ + selectedApp, + stageType, + appId, + isTriggerBlockedDueToPlugin, + handleClose, + reloadMaterials, +}: BulkDeployEmptyStateProps) => { + if (TriggerBlockEmptyState && selectedApp.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { + return + } + + if (isTriggerBlockedDueToPlugin) { + // It can't be CD + const commonNodeAttrType: CommonNodeAttr['type'] = stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' + + return ( + + ) + } + + if (selectedApp.materialError) { + return ( + + ) + } + + return ( + + ) +} + +export default BulkDeployEmptyState diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index 5028fedd7b..6e43f9fc80 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -10,7 +10,6 @@ import { ButtonStyleType, CDMaterialResponseType, CDMaterialServiceEnum, - CDMaterialSidebarType, CDMaterialType, DEPLOYMENT_WINDOW_TYPE, DeploymentNodeType, @@ -65,7 +64,7 @@ import { getCDPipelineURL, importComponentFromFELibrary, useAppContext } from '@ import { getModuleInfo } from '@Components/v2/devtronStackManager/DevtronStackManager.service' import { getIsMaterialApproved } from '../cdMaterials.utils' -import { FilterConditionViews } from '../types' +import { INITIAL_DEPLOY_VIEW_STATE } from './constants' import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import { loadOlderImages } from './service' @@ -369,31 +368,16 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme }, deploymentWindowMetadata: deploymentWindowMap[appId], areMaterialsLoading: false, - deployViewState: { - searchText: '', - appliedSearchText: '', - filterView: FilterConditionViews.ALL, - showConfiguredFilters: false, - currentSidebarTab: CDMaterialSidebarType.IMAGE, - runtimeParamsErrorState: { - isValid: true, - cellError: {}, - }, - materialInEditModeMap: new Map(), - showAppliedFilters: false, - appliedFilterList: [], - isLoadingOlderImages: false, - showSearchBar: true, - }, + deployViewState: structuredClone(INITIAL_DEPLOY_VIEW_STATE), warningMessage: updatedWarningMessage, materialError: null, } } else { bulkCDDetailsMap[appId] = { ...baseBulkCDDetailMap[appId], - materialResponse: null, - deploymentWindowMetadata: null, - deployViewState: null, + materialResponse: {} as CDMaterialResponseType, + deploymentWindowMetadata: {} as DeploymentWindowProfileMetaData, + deployViewState: structuredClone(INITIAL_DEPLOY_VIEW_STATE), materialError: materialResponse.reason, areMaterialsLoading: false, } diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx index 10b3ac72ab..ad304e6d9e 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkTriggerSidebar.tsx @@ -144,11 +144,13 @@ const BulkTriggerSidebar = ({ return } - if (!!app.warningMessage && !app.showPluginWarning) { + if ((!!app.warningMessage && !app.showPluginWarning) || app.materialError?.errors?.length) { return (
- - {app.warningMessage} + + + {app.warningMessage || app.materialError?.errors?.[0]?.userMessage} +
) } @@ -195,7 +197,7 @@ const BulkTriggerSidebar = ({ isSearchable options={tagOptions} value={selectedTagOption} - icon={} + icon={} onChange={handleTagChange} isDisabled={false} // Not changing it for backward compatibility for automation diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 9c2722057c..589c0c8bf9 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -7,12 +7,9 @@ import { ButtonVariantType, CDMaterialSidebarType, CDMaterialType, - CommonNodeAttr, ComponentSizeType, DEPLOYMENT_WINDOW_TYPE, DeploymentNodeType, - ErrorScreenManager, - GenericEmptyState, getGitCommitInfo, getIsApprovalPolicyConfigured, getIsMaterialInfoAvailable, @@ -32,13 +29,12 @@ import { useMainContext, } from '@devtron-labs/devtron-fe-common-lib' -import emptyPreDeploy from '@Images/empty-pre-deploy.webp' -import { BULK_CD_MESSAGING } from '@Components/ApplicationGroup/Constants' import { importComponentFromFELibrary } from '@Components/common' import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' +import BulkDeployEmptyState from './BulkDeployEmptyState' import BulkTriggerSidebar from './BulkTriggerSidebar' import ImageSelectionCTA from './ImageSelectionCTA' import MaterialListEmptyState from './MaterialListEmptyState' @@ -64,8 +60,6 @@ const RuntimeParameters = importComponentFromFELibrary('RuntimeParameters', null const SecurityModalSidebar = importComponentFromFELibrary('SecurityModalSidebar', null, 'function') const CDMaterialInfo = importComponentFromFELibrary('CDMaterialInfo') const ConfiguredFilters = importComponentFromFELibrary('ConfiguredFilters') -const TriggerBlockEmptyState = importComponentFromFELibrary('TriggerBlockEmptyState', null, 'function') -const MissingPluginBlockState = importComponentFromFELibrary('MissingPluginBlockState', null, 'function') const DeployImageContent = ({ appId, @@ -99,6 +93,7 @@ const DeployImageContent = ({ handleTagChange, changeApp, }: DeployImageContentProps) => { + // WARNING: Pls try not to create a useState in this component, it is supposed to be a dumb component. const history = useHistory() const { isSuperAdmin } = useMainContext() const { onClickApprovalNode } = useContext(TriggerViewContext) @@ -144,9 +139,11 @@ const DeployImageContent = ({ }) const selectImageTitle = isRollbackTrigger ? 'Select from previously deployed images' : 'Select Image' const titleText = isApprovalConfigured && !isExceptionUser ? 'Approved images' : selectImageTitle - const showActionBar = FilterActionBar && !isSearchApplied && !!resourceFilters?.length && !showConfiguredFilters + const showActionBar = !!FilterActionBar && !isSearchApplied && !!resourceFilters?.length && !showConfiguredFilters const areNoMoreImagesPresent = materials.length >= materialResponse?.totalCount + const showFiltersView = !!(ConfiguredFilters && (showConfiguredFilters || showAppliedFilters)) + const handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] = (e) => { setDeployViewState((prevState) => ({ ...prevState, @@ -348,7 +345,7 @@ const DeployImageContent = ({ ) } - if (isPreOrPostCD) { + if (isPreOrPostCD && !showFiltersView) { return ( ( + + ) + const renderGitMaterialInfo = (materialData: CDMaterialType) => ( <> {materialData.materialInfo.map((mat: MaterialInfo, index) => { const _gitCommit = getGitCommitInfo(mat) if ( + CDMaterialInfo && (materialData.appliedFilters?.length > 0 || materialData.deploymentBlockedState?.isBlocked || - materialData.deploymentWindowArtifactMetadata?.type) && - CDMaterialInfo + materialData.deploymentWindowArtifactMetadata?.type) ) { return ( { - const selectedApp = appInfoMap[+appId] - - if (selectedApp.triggerBlockedInfo?.blockedBy === TriggerBlockType.MANDATORY_TAG) { - return - } - - if (isTriggerBlockedDueToPlugin) { - // It can't be CD - const commonNodeAttrType: CommonNodeAttr['type'] = - stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' - - return ( - - ) - } - - if (selectedApp.materialError) { - return ( - - ) - } - - return ( - - ) - } - const renderContent = () => { if (isBulkTrigger) { + if (showFiltersView) { + return renderConfiguredFilters() + } + const { areMaterialsLoading, triggerBlockedInfo, materialError } = appInfoMap[+appId] || {} if (currentSidebarTab === CDMaterialSidebarType.IMAGE && areMaterialsLoading) { return @@ -562,7 +534,16 @@ const DeployImageContent = ({ materialError || selectedApp?.stageNotAvailable ) { - return renderEmptyView() + return ( + + ) } } @@ -587,7 +568,7 @@ const DeployImageContent = ({ {titleText} )} - +
{showSearchBar || isSearchApplied ? ( renderSearch() ) : ( @@ -612,7 +593,7 @@ const DeployImageContent = ({ showAriaLabelInTippy={false} size={ComponentSizeType.small} /> - +
{materialList.length === 0 ? ( @@ -628,7 +609,6 @@ const DeployImageContent = ({ isConsumedImagePresent={consumedImage.length > 0} envName={envName} materialResponse={materialResponse} - // TODO: Move to util and remove prop isExceptionUser={isExceptionUser} isLoadingMore={isLoadingOlderImages} viewAllImages={viewAllImages} @@ -672,34 +652,25 @@ const DeployImageContent = ({ ) } - if (ConfiguredFilters && (showConfiguredFilters || showAppliedFilters)) { - return ( - - ) + if (showFiltersView && !isBulkTrigger) { + return renderConfiguredFilters() } return ( <> - {isApprovalConfigured && + {!showFiltersView && + isApprovalConfigured && !isExceptionUser && ApprovedImagesMessage && (isRollbackTrigger || materials.length - Number(isConsumedImageAvailable) > 0) && ( } /> )} - {!isBulkTrigger && + {!showFiltersView && + !isBulkTrigger && MaintenanceWindowInfoBar && deploymentWindowMetadata.type === DEPLOYMENT_WINDOW_TYPE.MAINTENANCE && deploymentWindowMetadata.isActive && ( diff --git a/src/components/app/details/triggerView/DeployImageModal/constants.ts b/src/components/app/details/triggerView/DeployImageModal/constants.ts new file mode 100644 index 0000000000..56fecc0cf7 --- /dev/null +++ b/src/components/app/details/triggerView/DeployImageModal/constants.ts @@ -0,0 +1,21 @@ +import { CDMaterialSidebarType } from '@devtron-labs/devtron-fe-common-lib' + +import { FilterConditionViews } from '../types' +import { DeployViewStateType } from './types' + +export const INITIAL_DEPLOY_VIEW_STATE: DeployViewStateType = { + searchText: '', + appliedSearchText: '', + filterView: FilterConditionViews.ALL, + showConfiguredFilters: false, + currentSidebarTab: CDMaterialSidebarType.IMAGE, + runtimeParamsErrorState: { + isValid: true, + cellError: {}, + }, + materialInEditModeMap: new Map(), + showAppliedFilters: false, + appliedFilterList: [], + isLoadingOlderImages: false, + showSearchBar: true, +} diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index 5c3c98d5b5..879836fe1c 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -138,7 +138,7 @@ export type DeployImageContentProps = Pick< | 'triggerType' > & Pick & { - materialResponse: CDMaterialResponseType + materialResponse: CDMaterialResponseType | null deploymentWindowMetadata: DeploymentWindowProfileMetaData isRollbackTrigger: boolean uploadRuntimeParamsFile: (props: UploadFileProps) => Promise @@ -267,3 +267,11 @@ export interface BulkTriggerSidebarProps Pick { handleSidebarTabChange: RuntimeParamsSidebarProps['handleSidebarTabChange'] } + +export interface BulkDeployEmptyStateProps + extends Pick< + DeployImageContentProps, + 'stageType' | 'appId' | 'isTriggerBlockedDueToPlugin' | 'handleClose' | 'reloadMaterials' + > { + selectedApp: BulkCDDetailType +} From 7d9b6a7046313a58e5a876ef4036ad73c24eb9ad Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Wed, 6 Aug 2025 13:43:20 +0530 Subject: [PATCH 19/40] feat: Refactor DeployImageModal and ImageSelectionCTA to improve state initialization and streamline image selection handling --- .../DeployImageModal/DeployImageModal.tsx | 22 +++++-------------- .../DeployImageModal/ImageSelectionCTA.tsx | 16 ++++++-------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx index 13a5437e94..600407097c 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageModal.tsx @@ -6,7 +6,6 @@ import { AnimatedDeployButton, API_STATUS_CODES, ArtifactInfo, - CDMaterialSidebarType, ConditionalWrap, DEFAULT_ROUTE_PROMPT_MESSAGE, DEPLOYMENT_CONFIG_DIFF_SORT_KEY, @@ -51,7 +50,8 @@ import { CDButtonLabelMap } from '../config' import { TRIGGER_VIEW_GA_EVENTS } from '../Constants' import { PipelineConfigDiff, usePipelineDeploymentConfig } from '../PipelineConfigDiff' import { PipelineConfigDiffStatusTile } from '../PipelineConfigDiff/PipelineConfigDiffStatusTile' -import { FilterConditionViews, MATERIAL_TYPE } from '../types' +import { MATERIAL_TYPE } from '../types' +import { INITIAL_DEPLOY_VIEW_STATE } from './constants' import DeployImageContent from './DeployImageContent' import DeployImageHeader from './DeployImageHeader' import MaterialListSkeleton from './MaterialListSkeleton' @@ -148,21 +148,9 @@ const DeployImageModal = ({ const [deploymentStrategy, setDeploymentStrategy] = useState(null) const [showPluginWarningOverlay, setShowPluginWarningOverlay] = useState(false) const [showDeploymentWindowConfirmation, setShowDeploymentWindowConfirmation] = useState(false) - const [deployViewState, setDeployViewState] = useState>({ - searchText: searchImageTag, - filterView: FilterConditionViews.ALL, - showConfiguredFilters: false, - currentSidebarTab: CDMaterialSidebarType.IMAGE, - runtimeParamsErrorState: { - isValid: true, - cellError: {}, - }, - materialInEditModeMap: new Map(), - showAppliedFilters: false, - appliedFilterList: [], - isLoadingOlderImages: false, - showSearchBar: false, - }) + const [deployViewState, setDeployViewState] = useState>( + structuredClone(INITIAL_DEPLOY_VIEW_STATE), + ) const isPreOrPostCD = stageType === DeploymentNodeType.PRECD || stageType === DeploymentNodeType.POSTCD const materialResponse = initialDataResponse?.[0] || null diff --git a/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx b/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx index 8bcd92b57d..7d1daf8c5c 100644 --- a/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/ImageSelectionCTA.tsx @@ -25,7 +25,7 @@ const ImageSelectionCTA = ({ pipelineId, canApproverDeploy, isExceptionUser, - handleImageSelection, + handleImageSelection: handleImageSelectionProp, }: ImageSelectionCTAProps) => { const isApprovalRequester = material.userApprovalMetadata?.requestedUserData && @@ -40,16 +40,14 @@ const ImageSelectionCTA = ({ isMaterialApproved && material.userApprovalMetadata?.canCurrentUserApprove + const handleImageSelection = () => { + handleImageSelectionProp(material.index) + } + const renderMaterialCTA = () => { if (material.filterState !== FilterStates.ALLOWED) { return ( - + Excluded ) @@ -92,7 +90,7 @@ const ImageSelectionCTA = ({ return (
- {materialList.length === 0 ? ( -
- 0} - envName={envName} - materialResponse={materialResponse} - isExceptionUser={isExceptionUser} - isLoadingMore={isLoadingOlderImages} - viewAllImages={viewAllImages} - triggerType={triggerType} - loadOlderImages={loadOlderImages} - onSearchApply={onSearchApply} - eligibleImagesCount={eligibleImagesCount} - handleEnableFiltersView={handleShowConfiguredFilters} - handleAllImagesView={handleAllImagesView} - /> -
- ) : ( - renderMaterialList(materialList, false) - )} + {materialList.length === 0 + ? renderMaterialListEmptyState() + : renderMaterialList(materialList, false)} {!areNoMoreImagesPresent && !!materialList?.length && (
) From 219ac6e3e121e5a989f602027ac2a01fcbaf329a Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Wed, 6 Aug 2025 18:58:51 +0530 Subject: [PATCH 24/40] feat: refactor trigger view component --- .eslintignore | 1 - .../Details/TriggerView/EnvTriggerView.tsx | 18 +- .../BuildImageModal/BulkBuildImageModal.tsx | 2 +- .../triggerView/BuildImageModal/types.ts | 4 +- .../DeployImageModal/BulkDeployModal.tsx | 1 - .../DeployImageModal/DeployImageContent.tsx | 6 +- .../triggerView/DeployImageModal/types.ts | 30 +- .../triggerView/TriggerView.service.ts | 114 ++++ .../app/details/triggerView/TriggerView.tsx | 543 ++++++------------ .../app/details/triggerView/config.ts | 10 - .../app/details/triggerView/types.ts | 59 +- .../details/triggerView/workflow/Workflow.tsx | 23 +- .../workflow/nodes/triggerCDNode.tsx | 145 +++-- .../workflow/nodes/triggerPrePostCDNode.tsx | 109 ++-- src/components/app/service.ts | 4 +- 15 files changed, 479 insertions(+), 590 deletions(-) create mode 100644 src/components/app/details/triggerView/TriggerView.service.ts diff --git a/.eslintignore b/.eslintignore index abcb78c9d3..bd0bdff6bc 100755 --- a/.eslintignore +++ b/.eslintignore @@ -101,7 +101,6 @@ src/components/app/details/testViewer/TestRunDetails.tsx src/components/app/details/testViewer/TestRunList.tsx src/components/app/details/triggerView/EmptyStateCIMaterial.tsx src/components/app/details/triggerView/MaterialSource.tsx -src/components/app/details/triggerView/TriggerView.tsx src/components/app/details/triggerView/__tests__/triggerview.test.tsx src/components/app/details/triggerView/cdMaterial.tsx src/components/app/details/triggerView/ciMaterial.tsx diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index b9c4ca5b82..ac2593811f 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -54,7 +54,6 @@ import { ReactComponent as Pencil } from '../../../../assets/icons/ic-pencil.svg import { URLS, ViewType } from '../../../../config' import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' import { AppNotConfigured } from '../../../app/details/appDetails/AppDetails' -import { TriggerViewContext } from '../../../app/details/triggerView/config' import { TRIGGER_VIEW_PARAMS } from '../../../app/details/triggerView/Constants' import { CIMaterialRouterProps, MATERIAL_TYPE } from '../../../app/details/triggerView/types' import { Workflow } from '../../../app/details/triggerView/workflow/Workflow' @@ -600,8 +599,8 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT configurePluginURL={configurePluginURL} isTriggerBlockedDueToPlugin={node?.showPluginWarning && node?.isTriggerBlocked} triggerType={node?.triggerType} - isRedirectedFromAppDetails={false} parentEnvironmentName={node?.parentEnvironmentName} + onClickApprovalNode={onClickApprovalNode} /> ) } @@ -824,6 +823,10 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT index={index} handleWebhookAddImageClick={handleWebhookAddImageClick(workflow.appId)} openCIMaterialModal={openCIMaterialModal} + onClickApprovalNode={onClickApprovalNode} + onClickCDMaterial={onClickCDMaterial} + onClickRollbackMaterial={onClickRollbackMaterial} + reloadTriggerView={reloadTriggerView} /> ))} @@ -848,14 +851,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT - + <> {renderWorkflow()} @@ -876,7 +872,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT {renderBulkCIMaterial()} {renderApprovalMaterial()} {renderBulkSourceChange()} - +
{!!selectedAppList.length && ( diff --git a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx index 1e5ae6c7a4..e710d43a50 100644 --- a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx @@ -164,7 +164,7 @@ const BulkBuildImageModal = ({ const handleReloadSelectedMaterialWithWorkflows = async () => { try { const newWorkflows = await reloadWorkflows() - await reloadSelectedAppMaterialList(newWorkflows) + await reloadSelectedAppMaterialList(newWorkflows as WorkflowType[]) } catch (error) { showError(error) } diff --git a/src/components/app/details/triggerView/BuildImageModal/types.ts b/src/components/app/details/triggerView/BuildImageModal/types.ts index 2f283f804b..e5649425ec 100644 --- a/src/components/app/details/triggerView/BuildImageModal/types.ts +++ b/src/components/app/details/triggerView/BuildImageModal/types.ts @@ -19,10 +19,10 @@ import { BuildImageModalProps, CIPipelineMaterialDTO, FilteredCIPipelineMapType, + FilteredCIPipelinesType, HandleRuntimeParamChange, MaterialSourceProps, RuntimeParamsErrorState, - TriggerViewState, } from '../types' export interface TriggerBuildSidebarProps { @@ -55,7 +55,7 @@ export type GitInfoMaterialProps = Pick & { handleRuntimeParamError: (errorState: RuntimeParamsErrorState) => void runtimeParams: RuntimePluginVariables[] handleDisplayWebhookModal: () => void - selectedCIPipeline: TriggerViewState['filteredCIPipelines'][number] + selectedCIPipeline: FilteredCIPipelinesType handleReloadWithWorkflows: () => Promise appId: number /** diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index 504bf47245..2611a18010 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -594,7 +594,6 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme deploymentWindowMetadata={deploymentWindowMetadata} pipelineId={pipelineId} handleClose={handleClose} - isRedirectedFromAppDetails={false} onSearchApply={reloadOrSearchSelectedApp} stageType={stageType} uploadRuntimeParamsFile={uploadRuntimeParamsFile} diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index 589c0c8bf9..fa7a5fb73e 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -1,4 +1,3 @@ -import { useContext } from 'react' import { useHistory } from 'react-router-dom' import { @@ -31,9 +30,8 @@ import { import { importComponentFromFELibrary } from '@Components/common' -import { TriggerViewContext } from '../config' import { TRIGGER_VIEW_PARAMS } from '../Constants' -import { FilterConditionViews, HandleRuntimeParamChange, TriggerViewContextType } from '../types' +import { FilterConditionViews, HandleRuntimeParamChange } from '../types' import BulkDeployEmptyState from './BulkDeployEmptyState' import BulkTriggerSidebar from './BulkTriggerSidebar' import ImageSelectionCTA from './ImageSelectionCTA' @@ -92,11 +90,11 @@ const DeployImageContent = ({ selectedTagName, handleTagChange, changeApp, + onClickApprovalNode, }: DeployImageContentProps) => { // WARNING: Pls try not to create a useState in this component, it is supposed to be a dumb component. const history = useHistory() const { isSuperAdmin } = useMainContext() - const { onClickApprovalNode } = useContext(TriggerViewContext) // Assumption: isExceptionUser is a global trait const isExceptionUser = getIsExceptionUser(materialResponse) diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index a6ad1bc35e..a9f280f124 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -43,23 +43,32 @@ export type DeployImageModalProps = { /** * If opening pre/post cd make sure BE sends plugin details as well, otherwise those props will be undefined */ - isRedirectedFromAppDetails: boolean parentEnvironmentName: string triggerType: CommonNodeAttr['triggerType'] } & ( | { - showPluginWarningBeforeTrigger: boolean - consequence: ConsequenceType - configurePluginURL: string - isTriggerBlockedDueToPlugin: boolean + isRedirectedFromAppDetails: true + onClickApprovalNode?: never } | { - showPluginWarningBeforeTrigger?: never - consequence?: never - configurePluginURL?: never - isTriggerBlockedDueToPlugin?: never + isRedirectedFromAppDetails?: never + onClickApprovalNode: (cdNodeId: number) => void } -) +) & + ( + | { + showPluginWarningBeforeTrigger: boolean + consequence: ConsequenceType + configurePluginURL: string + isTriggerBlockedDueToPlugin: boolean + } + | { + showPluginWarningBeforeTrigger?: never + consequence?: never + configurePluginURL?: never + isTriggerBlockedDueToPlugin?: never + } + ) export type DeployImageHeaderProps = Pick< DeployImageModalProps, @@ -141,6 +150,7 @@ export type DeployImageContentProps = Pick< | 'isTriggerBlockedDueToPlugin' | 'configurePluginURL' | 'triggerType' + | 'onClickApprovalNode' > & Pick & { materialResponse: CDMaterialResponseType | null diff --git a/src/components/app/details/triggerView/TriggerView.service.ts b/src/components/app/details/triggerView/TriggerView.service.ts new file mode 100644 index 0000000000..024e466af0 --- /dev/null +++ b/src/components/app/details/triggerView/TriggerView.service.ts @@ -0,0 +1,114 @@ +import { useRef } from 'react' + +import { DEFAULT_ENV, getEnvironmentListMinPublic, useQuery, useQueryClient } from '@devtron-labs/devtron-fe-common-lib' + +import { getWorkflowStatus } from '@Components/app/service' +import { processWorkflowStatuses } from '@Components/ApplicationGroup/AppGroup.utils' +import { sortObjectArrayAlphabetically } from '@Components/common' +import { getHostURLConfiguration } from '@Services/service' + +import { UseTriggerViewServicesParams } from './types' +import { getTriggerWorkflows } from './workflow.service' + +export const useTriggerViewServices = ({ appId, isJobView, filteredEnvIds }: UseTriggerViewServicesParams) => { + const queryClient = useQueryClient() + const refetchIntervalRef = useRef(30000) + + const { data: hostUrlConfig } = useQuery({ + queryKey: ['hostUrlConfig'], + queryFn: () => getHostURLConfiguration(), + select: (response) => response.result, + }) + + const { isFetching: isEnvListLoading, data: environmentList } = useQuery({ + queryKey: ['triggerViewEnvList'], + queryFn: ({ signal }) => getEnvironmentListMinPublic(false, { signal }), + select: (response) => { + const list = [] + list.push({ + id: 0, + clusterName: '', + name: DEFAULT_ENV, + active: false, + isClusterActive: false, + description: 'System default', + }) + response.result?.forEach((env) => { + if (env.cluster_name !== 'default_cluster' && env.isClusterCdActive) { + list.push({ + id: env.id, + clusterName: env.cluster_name, + name: env.environment_name, + active: false, + isClusterActive: env.isClusterActive, + description: env.description, + }) + } + }) + sortObjectArrayAlphabetically(list, 'name') + return list + }, + }) + + const { + isFetching: isWorkflowsLoading, + data: wfData, + error: workflowsError, + } = useQuery({ + queryKey: [appId, isJobView, filteredEnvIds, 'triggerViewWorkflowList'], + queryFn: async () => { + const result = await getTriggerWorkflows(appId, !isJobView, isJobView, filteredEnvIds) + return { + code: 200, + status: 'OK', + result, + } + }, + select: (response) => ({ + appName: response.result?.appName || '', + workflows: response.result?.workflows || [], + filteredCIPipelines: response.result?.filteredCIPipelines || [], + }), + }) + + const { workflows, filteredCIPipelines } = wfData ?? { workflows: [], filteredCIPipelines: [] } + + const { data: updatedWfWithStatus } = useQuery({ + queryKey: [appId, 'triggerViewWorkflowStatus'], + queryFn: ({ signal }) => getWorkflowStatus(+appId, { signal }), + select: (response) => { + const processedWorkflowsData = processWorkflowStatuses( + response.result?.ciWorkflowStatus ?? [], + response.result?.cdWorkflowStatus ?? [], + workflows, + ) + refetchIntervalRef.current = processedWorkflowsData.cicdInProgress ? 10000 : 30000 + return processedWorkflowsData.workflows || [] + }, + refetchInterval: refetchIntervalRef.current, + enabled: !!appId && !!workflows.length, + }) + + const isLoading = isEnvListLoading || isWorkflowsLoading + + const reloadWorkflowStatus = async () => { + await queryClient.invalidateQueries({ queryKey: [appId, 'triggerViewWorkflowStatus'] }) + } + + const reloadWorkflows = async () => { + await queryClient.invalidateQueries({ queryKey: [appId, isJobView, filteredEnvIds, 'triggerViewWorkflowList'] }) + // Invalidate status query to refetch workflow status + await reloadWorkflowStatus() + } + + return { + isLoading, + hostUrlConfig, + environmentList, + workflows: updatedWfWithStatus ?? workflows, + filteredCIPipelines, + workflowsError, + reloadWorkflows, + reloadWorkflowStatus, + } +} diff --git a/src/components/app/details/triggerView/TriggerView.tsx b/src/components/app/details/triggerView/TriggerView.tsx index 922bb7a130..201d1b8701 100644 --- a/src/components/app/details/triggerView/TriggerView.tsx +++ b/src/components/app/details/triggerView/TriggerView.tsx @@ -14,295 +14,152 @@ * limitations under the License. */ -import React, { Component } from 'react' -import { withRouter, Route, Switch } from 'react-router-dom' +import React, { useState } from 'react' +import { Route, Switch, useHistory, useLocation, useParams, useRouteMatch } from 'react-router-dom' import { - ServerErrors, - showError, - Progressing, - ErrorScreenManager, - stopPropagation, - VisibleModal, - DeploymentNodeType, CommonNodeAttr, - getEnvironmentListMinPublic, + DeploymentNodeType, DocLink, - DEFAULT_ENV, + ErrorScreenManager, handleAnalyticsEvent, - WorkflowType, + Progressing, } from '@devtron-labs/devtron-fe-common-lib' -import { getWorkflowStatus } from '../../service' +import { getExternalCIConfig } from '@Components/ciPipeline/Webhook/webhook.service' + +import { URLS } from '../../../../config' +import { APP_DETAILS } from '../../../../config/constantMessaging' +import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' import { getCDPipelineURL, importComponentFromFELibrary, InValidHostUrlWarningBlock, - sortObjectArrayAlphabetically, - withAppContext, + useAppContext, } from '../../../common' -import { getTriggerWorkflows } from './workflow.service' -import { Workflow } from './workflow/Workflow' -import { MATERIAL_TYPE, TriggerViewProps, TriggerViewState } from './types' -import { URLS, ViewType } from '../../../../config' -import { AppNotConfigured } from '../appDetails/AppDetails' -import { getHostURLConfiguration } from '../../../../services/service' -import { TriggerViewContext } from './config' -import { TRIGGER_VIEW_PARAMS, TRIGGER_VIEW_GA_EVENTS } from './Constants' -import { APP_DETAILS } from '../../../../config/constantMessaging' -import { processWorkflowStatuses } from '../../../ApplicationGroup/AppGroup.utils' import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManager.service' -import { LinkedCIDetail } from '../../../../Pages/Shared/LinkedCIDetailsModal' -import { getExternalCIConfig } from '@Components/ciPipeline/Webhook/webhook.service' -import { getSelectedNodeFromWorkflows, shouldRenderWebhookAddImageModal } from './TriggerView.utils' +import { AppNotConfigured } from '../appDetails/AppDetails' +import { Workflow } from './workflow/Workflow' import { BuildImageModal } from './BuildImageModal' +import { TRIGGER_VIEW_GA_EVENTS, TRIGGER_VIEW_PARAMS } from './Constants' import { DeployImageModal } from './DeployImageModal' +import { useTriggerViewServices } from './TriggerView.service' +import { getSelectedNodeFromWorkflows, shouldRenderWebhookAddImageModal } from './TriggerView.utils' +import { CIMaterialRouterProps, MATERIAL_TYPE, TriggerViewProps } from './types' const ApprovalMaterialModal = importComponentFromFELibrary('ApprovalMaterialModal') const WorkflowActionRouter = importComponentFromFELibrary('WorkflowActionRouter', null, 'function') const WebhookAddImageModal = importComponentFromFELibrary('WebhookAddImageModal', null, 'function') -class TriggerView extends Component { - timerRef - - inprogressStatusTimer - - abortController: AbortController - - abortCIBuild: AbortController - - constructor(props: TriggerViewProps) { - super(props) - this.state = { - code: 0, - view: ViewType.LOADING, - workflows: [], - workflowId: 0, - isLoading: false, - hostURLConfig: undefined, - filteredCIPipelines: [], - isSaveLoading: false, - environmentLists: [], - appReleaseTags: [], - tagsEditable: false, - configs: false, - isDefaultConfigPresent: false, - searchImageTag: '', - resourceFilters: [], - selectedWebhookNodeId: null, - isEnvListLoading: false, - } - this.abortController = new AbortController() - this.abortCIBuild = new AbortController() - } - - componentWillUnmount() { - clearInterval(this.timerRef) - this.inprogressStatusTimer && clearTimeout(this.inprogressStatusTimer) - } - - componentDidMount() { - this.getHostURLConfig() - this.getWorkflows() - this.getEnvironments() - } - - reloadTriggerView = () => { - this.setState({ - view: ViewType.LOADING, - }) - this.inprogressStatusTimer && clearTimeout(this.inprogressStatusTimer) - this.getWorkflows() - } - - getEnvironments = () => { - this.setState({ isEnvListLoading: true }) - getEnvironmentListMinPublic() - .then((response) => { - const list = [] - list.push({ - id: 0, - clusterName: '', - name: DEFAULT_ENV, - active: false, - isClusterActive: false, - description: 'System default', - }) - response.result?.forEach((env) => { - if (env.cluster_name !== 'default_cluster' && env.isClusterCdActive) { - list.push({ - id: env.id, - clusterName: env.cluster_name, - name: env.environment_name, - active: false, - isClusterActive: env.isClusterActive, - description: env.description, - }) - } - }) - sortObjectArrayAlphabetically(list, 'name') - this.setState({ environmentLists: list }) - }) - .catch((error) => { - showError(error) - }) - .finally(() => { - this.setState({ isEnvListLoading: false }) - }) - } - - getWorkflows = async (): Promise => { - try { - const result = await getTriggerWorkflows( - this.props.match.params.appId, - !this.props.isJobView, - this.props.isJobView, - this.props.filteredEnvIds, - ) - - const _filteredCIPipelines = result.filteredCIPipelines || [] - const workflows = result.workflows || [] - this.setState({ workflows, view: ViewType.FORM, filteredCIPipelines: _filteredCIPipelines }, () => { - this.getWorkflowStatus() - this.timerRef && clearInterval(this.timerRef) - this.timerRef = setInterval(() => { - this.getWorkflowStatus() - }, 30000) - }) - - return workflows - } catch (errors) { - showError(errors) - this.setState({ code: errors.code, view: ViewType.ERROR }) - return this.state.workflows - } - } - - getHostURLConfig() { - getHostURLConfiguration() - .then((response) => { - this.setState({ hostURLConfig: response.result }) - }) - .catch(() => {}) +const JobNotConfiguredSubtitle = () => ( + <> + {APP_DETAILS.JOB_FULLY_NOT_CONFIGURED.subTitle}  + + +) + +const TriggerView = ({ isJobView, filteredEnvIds }: TriggerViewProps) => { + const { appId, envId } = useParams() + const history = useHistory() + const location = useLocation() + const match = useRouteMatch() + + const { currentAppName } = useAppContext() + + const [selectedWebhookNodeId, setSelectedWebhookNodeId] = useState(null) + + const { + isLoading, + hostUrlConfig, + environmentList, + workflows, + filteredCIPipelines, + workflowsError, + reloadWorkflows, + reloadWorkflowStatus, + } = useTriggerViewServices({ appId, isJobView, filteredEnvIds }) + + const openCIMaterialModal = (ciNodeId: string) => { + history.push(`${match.url}${URLS.BUILD}/${ciNodeId}`) } - componentDidUpdate(prevProps) { - if ( - this.props.match.params.appId !== prevProps.match.params.appId || - prevProps.filteredEnvIds !== this.props.filteredEnvIds - ) { - this.setState({ - view: ViewType.LOADING, - }) - this.getWorkflows() - } - } - - getWorkflowStatus = () => { - getWorkflowStatus(this.props.match.params.appId) - .then((response) => { - const _processedWorkflowsData = processWorkflowStatuses( - response?.result?.ciWorkflowStatus ?? [], - response?.result?.cdWorkflowStatus ?? [], - this.state.workflows, - ) - this.inprogressStatusTimer && clearTimeout(this.inprogressStatusTimer) - if (_processedWorkflowsData.cicdInProgress) { - this.inprogressStatusTimer = setTimeout(() => { - this.getWorkflowStatus() - }, 10000) - } - this.setState({ workflows: _processedWorkflowsData.workflows }) - }) - .catch((errors: ServerErrors) => { - showError(errors) - }) - } - - openCIMaterialModal = (ciNodeId: string) => { - this.props.history.push(`${this.props.match.url}${URLS.BUILD}/${ciNodeId}`) - } - - onClickApprovalNode = (cdNodeId: number) => { + const onClickApprovalNode = (cdNodeId: number) => { handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked) const newParams = new URLSearchParams([ [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], ]) - this.props.history.push({ search: newParams.toString() }) + history.push({ search: newParams.toString() }) } - onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { + const onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ImageClicked) const newParams = new URLSearchParams([ [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], ]) - this.props.history.push({ + history.push({ search: newParams.toString(), }) } - onClickRollbackMaterial = (cdNodeId: number) => { + const onClickRollbackMaterial = (cdNodeId: number) => { handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.RollbackClicked) const newParams = new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]) - this.props.history.push({ + history.push({ search: newParams.toString(), }) } - closeCDModal = (e?: React.MouseEvent): void => { - e?.stopPropagation() - this.setState({ searchImageTag: '' }) - this.props.history.push({ - search: '', - }) - this.getWorkflowStatus() - } - - closeApprovalModal = (e: React.MouseEvent): void => { + const closeApprovalModal = (e: React.MouseEvent): void => { e.stopPropagation() - this.props.history.push({ + history.push({ search: '', }) - this.getWorkflowStatus() + // eslint-disable-next-line @typescript-eslint/no-floating-promises + reloadWorkflowStatus() } - getWebhookDetails = () => - getExternalCIConfig(this.props.match.params.appId, this.state.selectedWebhookNodeId, false) + const getWebhookDetails = () => getExternalCIConfig(appId, selectedWebhookNodeId, false) + + const handleWebhookAddImageClick = (webhookId: number) => { + setSelectedWebhookNodeId(webhookId) + } - handleWebhookAddImageClick = (webhookId: number) => { - this.setState({ selectedWebhookNodeId: webhookId }) + const handleWebhookAddImageModalClose = () => { + setSelectedWebhookNodeId(null) } - handleWebhookAddImageModalClose = () => { - this.setState({ selectedWebhookNodeId: null }) + const revertToPreviousURL = () => { + history.push(match.url) } - renderCDMaterial() { + const renderCDMaterial = () => { if ( - this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) || - this.props.location.search.includes(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) + location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) || + location.search.includes(TRIGGER_VIEW_PARAMS.ROLLBACK_NODE) ) { - const cdNode: CommonNodeAttr = getSelectedNodeFromWorkflows( - this.state.workflows, - this.props.location.search, - ) + const cdNode: CommonNodeAttr = getSelectedNodeFromWorkflows(workflows, location.search) if (!cdNode.id) { return null } - const materialType = this.props.location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) + const materialType = location.search.includes(TRIGGER_VIEW_PARAMS.CD_NODE) ? MATERIAL_TYPE.inputMaterialList : MATERIAL_TYPE.rollbackMaterialList - const selectedWorkflow = this.state.workflows.find((wf) => wf.nodes.some((node) => node.id === cdNode.id)) + const selectedWorkflow = workflows.find((wf) => wf.nodes.some((node) => node.id === cdNode.id)) const selectedCINode = selectedWorkflow?.nodes.find((node) => node.type === 'CI' || node.type === 'WEBHOOK') const doesWorkflowContainsWebhook = selectedCINode?.type === 'WEBHOOK' const configurePluginURL = getCDPipelineURL( - this.props.match.params.appId, + appId, selectedWorkflow.id, doesWorkflowContainsWebhook ? '0' : selectedCINode?.id, doesWorkflowContainsWebhook, @@ -313,14 +170,14 @@ class TriggerView extends Component { return ( { configurePluginURL={configurePluginURL} isTriggerBlockedDueToPlugin={cdNode?.showPluginWarning && cdNode?.isTriggerBlocked} triggerType={cdNode.triggerType} - isRedirectedFromAppDetails={false} parentEnvironmentName={cdNode.parentEnvironmentName} + onClickApprovalNode={onClickApprovalNode} /> ) } @@ -337,9 +194,9 @@ class TriggerView extends Component { return null } - renderApprovalMaterial() { - if (ApprovalMaterialModal && this.props.location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { - const node = getSelectedNodeFromWorkflows(this.state.workflows, this.props.location.search) + const renderApprovalMaterial = () => { + if (ApprovalMaterialModal && location.search.includes(TRIGGER_VIEW_PARAMS.APPROVAL_NODE)) { + const node = getSelectedNodeFromWorkflows(workflows, location.search) if (!node.id) { return null @@ -347,16 +204,16 @@ class TriggerView extends Component { return ( ) } @@ -364,63 +221,52 @@ class TriggerView extends Component { return null } - renderWebhookAddImageModal() { - if ( - WebhookAddImageModal && - shouldRenderWebhookAddImageModal(this.props.location) && - this.state.selectedWebhookNodeId - ) { + const renderWebhookAddImageModal = () => { + if (WebhookAddImageModal && shouldRenderWebhookAddImageModal(location) && selectedWebhookNodeId) { return ( - + ) } return null } - revertToPreviousURL = () => { - this.props.history.push(this.props.match.url) - } - - renderWorkflow() { - return ( - <> - {this.state.workflows.map((workflow, index) => { - return ( - - ) - })} - - {this.renderWebhookAddImageModal()} - - ) - } - - renderHostErrorMessage() { - if (!this.state.hostURLConfig || this.state.hostURLConfig.value !== window.location.origin) { + const renderWorkflow = () => ( + <> + {workflows.map((workflow, index) => ( + + ))} + + {renderWebhookAddImageModal()} + + ) + + const renderHostErrorMessage = () => { + if (!hostUrlConfig || hostUrlConfig.value !== window.location.origin) { return (
@@ -431,90 +277,67 @@ class TriggerView extends Component { return null } - jobNotConfiguredSubtitle = () => { - return ( - <> - {APP_DETAILS.JOB_FULLY_NOT_CONFIGURED.subTitle}  - - - ) + if (isLoading) { + return } - render() { - if (this.state.view === ViewType.LOADING || this.state.isEnvListLoading) { - return - } - if (this.state.view === ViewType.ERROR) { - return - } - if (!this.state.workflows.length) { - return ( -
- {this.props.isJobView ? ( - - ) : ( - - )} -
- ) - } + if (workflowsError) { + return + } + if (!workflows.length) { return ( - <> -
- - {this.renderHostErrorMessage()} - {this.renderWorkflow()} - - - - - - - - {this.renderCDMaterial()} - {this.renderApprovalMaterial()} - -
- {WorkflowActionRouter && ( - + {isJobView ? ( + } + buttonTitle={APP_DETAILS.JOB_FULLY_NOT_CONFIGURED.buttonTitle} + isJobView={isJobView} /> + ) : ( + )} - +
) } + + return ( + <> +
+ {renderHostErrorMessage()} + {renderWorkflow()} + + + + + + + + {renderCDMaterial()} + {renderApprovalMaterial()} +
+ {WorkflowActionRouter && ( + + )} + + ) } -export default withRouter(withAppContext(TriggerView)) +export default TriggerView diff --git a/src/components/app/details/triggerView/config.ts b/src/components/app/details/triggerView/config.ts index 12cbcbb29a..cb860cedac 100644 --- a/src/components/app/details/triggerView/config.ts +++ b/src/components/app/details/triggerView/config.ts @@ -14,20 +14,10 @@ * limitations under the License. */ -import { createContext } from 'react' -import { DeploymentNodeType } from '@devtron-labs/devtron-fe-common-lib' -import { TriggerViewContextType } from './types' import { importComponentFromFELibrary } from '@Components/common' const WebhookAddImageButton = importComponentFromFELibrary('WebhookAddImageButton', null, 'function') -export const TriggerViewContext = createContext({ - onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => {}, - onClickRollbackMaterial: (cdNodeId: number) => {}, - reloadTriggerView: () => {}, - onClickApprovalNode: (cdNodeId: number) => {}, -}) - export enum WorkflowDimensionType { TRIGGER = 'trigger', CREATE = 'create', diff --git a/src/components/app/details/triggerView/types.ts b/src/components/app/details/triggerView/types.ts index 7700818ca2..bf9cf6e025 100644 --- a/src/components/app/details/triggerView/types.ts +++ b/src/components/app/details/triggerView/types.ts @@ -50,9 +50,7 @@ import { import { CIPipelineBuildType } from '@Components/ciPipeline/types' import { EnvironmentWithSelectPickerType } from '@Components/CIPipelineN/types' -import { AppContextType } from '@Components/common' -import { HostURLConfig } from '../../../../services/service.types' import { Offset, WorkflowDimensions } from './config' export interface RuntimeParamsErrorState { @@ -241,7 +239,8 @@ interface InputMaterials { export interface TriggerCDNodeProps extends RouteComponentProps<{ appId: string; envId: string }>, - Partial> { + Partial>, + Pick { x: number y: number height: number @@ -280,7 +279,8 @@ export interface TriggerCDNodeState { export interface TriggerPrePostCDNodeProps extends RouteComponentProps<{ appId: string; envId: string }>, - Partial> { + Partial>, + Pick { x: number y: number height: number @@ -333,13 +333,10 @@ export interface WorkflowProps filteredCIPipelines?: any[] handleWebhookAddImageClick?: (webhookId: number) => void openCIMaterialModal: (ciNodeId: string) => void -} - -export interface TriggerViewContextType { - onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => void onClickApprovalNode: (cdNodeId: number) => void + onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => void onClickRollbackMaterial: (cdNodeId: number) => void - reloadTriggerView: () => void + reloadTriggerView: () => Promise | void } export enum BulkSelectionEvents { @@ -351,13 +348,12 @@ export interface TriggerViewRouterProps { envId: string } -export interface TriggerViewProps extends RouteComponentProps { +export interface TriggerViewProps { isJobView?: boolean filteredEnvIds?: string - appContext: AppContextType } -interface FilteredCIPipelinesType { +export interface FilteredCIPipelinesType { active: boolean ciMaterial: CiMaterial[] dockerArgs: any @@ -378,32 +374,10 @@ interface FilteredCIPipelinesType { scanEnabled: boolean } -export interface TriggerViewState { - code: number - view: string - workflows: WorkflowType[] - isLoading: boolean - hostURLConfig: HostURLConfig - workflowId: number - filteredCIPipelines: FilteredCIPipelinesType[] - isSaveLoading?: boolean - environmentLists?: any[] - appReleaseTags?: string[] - tagsEditable?: boolean - hideImageTaggingHardDelete?: boolean - configs?: boolean - isDefaultConfigPresent?: boolean - searchImageTag?: string - resourceFilters?: FilterConditionsListType[] - selectedWebhookNodeId: number - isEnvListLoading?: boolean -} - -export type FilteredCIPipelineMapType = Map +export type FilteredCIPipelineMapType = Map export type BuildImageModalProps = Pick & { handleClose: () => void - reloadWorkflows: () => Promise workflows: WorkflowType[] /** * If not present would extract from selected workflow @@ -413,12 +387,14 @@ export type BuildImageModalProps = Pick & { reloadWorkflowStatus: () => void } & ( | { - filteredCIPipelines: TriggerViewState['filteredCIPipelines'] + filteredCIPipelines: FilteredCIPipelinesType[] filteredCIPipelineMap?: never + reloadWorkflows: () => Promise } | { filteredCIPipelineMap: FilteredCIPipelineMapType filteredCIPipelines?: never + reloadWorkflows: () => Promise } ) @@ -669,14 +645,13 @@ export interface WebhookReceivedFiltersType { match: boolean } -export interface CiWebhookModalProps - extends Pick, - Pick { +export interface CiWebhookModalProps extends Pick { ciPipelineMaterialId: number gitMaterialUrl: string ciPipelineId: number appId: string isJobCI: boolean + workflowId: number } export interface CIWebhookPayload { @@ -707,3 +682,9 @@ export interface CIPipelineMaterialDTO { } } } + +export interface UseTriggerViewServicesParams { + appId: string + isJobView: boolean + filteredEnvIds: string +} diff --git a/src/components/app/details/triggerView/workflow/Workflow.tsx b/src/components/app/details/triggerView/workflow/Workflow.tsx index d212ea731e..3f0929352c 100644 --- a/src/components/app/details/triggerView/workflow/Workflow.tsx +++ b/src/components/app/details/triggerView/workflow/Workflow.tsx @@ -14,11 +14,10 @@ * limitations under the License. */ -import React, { Component } from 'react' +import { Component } from 'react' import { Checkbox, CHECKBOX_VALUE, - DeploymentNodeType, noop, WorkflowNodeType, PipelineType, @@ -31,19 +30,15 @@ import { TriggerLinkedCINode } from './nodes/TriggerLinkedCINode' import { TriggerCDNode } from './nodes/triggerCDNode' import { TriggerPrePostCDNode } from './nodes/triggerPrePostCDNode' import { getCIPipelineURL, importComponentFromFELibrary, RectangularEdge as Edge } from '../../../../common' -import { WorkflowProps, TriggerViewContextType } from '../types' +import { WorkflowProps } from '../types' import { WebhookNode } from '../../../../workflowEditor/nodes/WebhookNode' import { GIT_BRANCH_NOT_CONFIGURED } from '../../../../../config' -import { TriggerViewContext } from '../config' -import { TRIGGER_VIEW_PARAMS } from '../Constants' - const ApprovalNodeEdge = importComponentFromFELibrary('ApprovalNodeEdge') const LinkedCDNode = importComponentFromFELibrary('LinkedCDNode') const ImagePromotionLink = importComponentFromFELibrary('ImagePromotionLink', null, 'function') const BulkDeployLink = importComponentFromFELibrary('BulkDeployLink', null, 'function') export class Workflow extends Component { - static contextType?: React.Context = TriggerViewContext goToWorkFlowEditor = (node: CommonNodeAttr) => { if (node.branch === GIT_BRANCH_NOT_CONFIGURED) { @@ -270,6 +265,9 @@ export class Workflow extends Component { appId={this.props.appId} isDeploymentBlocked={node.isDeploymentBlocked} isTriggerBlocked={node.isTriggerBlocked} + onClickCDMaterial={this.props.onClickCDMaterial} + onClickRollbackMaterial={this.props.onClickRollbackMaterial} + reloadTriggerView={this.props.reloadTriggerView} /> ) } @@ -303,6 +301,8 @@ export class Workflow extends Component { appId={this.props.appId} isDeploymentBlocked={node.isDeploymentBlocked} isTriggerBlocked={node.isTriggerBlocked} + onClickCDMaterial={this.props.onClickCDMaterial} + reloadTriggerView={this.props.reloadTriggerView} /> ) } @@ -320,13 +320,6 @@ export class Workflow extends Component { }, []) } - onClickNodeEdge = (nodeId: number) => { - this.context.onClickApprovalModal(nodeId) - this.props.history.push({ - search: `${TRIGGER_VIEW_PARAMS.APPROVAL_NODE}=${nodeId}`, - }) - } - renderEdgeList() { const edges = this.getEdges() // In the SVG, the bottom elements are rendered on top. @@ -339,7 +332,7 @@ export class Workflow extends Component { key={`trigger-edge-${edgeNode.startNode.id}${edgeNode.startNode.x}${edgeNode.startNode.y}-${edgeNode.endNode.id}`} startNode={edgeNode.startNode} endNode={edgeNode.endNode} - onClickEdge={() => this.onClickNodeEdge(edgeNode.endNode.id)} + onClickEdge={() => this.props.onClickApprovalNode(edgeNode.endNode.id)} edges={edges} /> ) diff --git a/src/components/app/details/triggerView/workflow/nodes/triggerCDNode.tsx b/src/components/app/details/triggerView/workflow/nodes/triggerCDNode.tsx index 779c92068f..87b0deaaf9 100644 --- a/src/components/app/details/triggerView/workflow/nodes/triggerCDNode.tsx +++ b/src/components/app/details/triggerView/workflow/nodes/triggerCDNode.tsx @@ -29,7 +29,6 @@ import { import { TriggerCDNodeProps, TriggerCDNodeState } from '../../types' import { ReactComponent as Rollback } from '../../../../../../assets/icons/ic-rollback.svg' import { DEFAULT_STATUS } from '../../../../../../config' -import { TriggerViewContext } from '../../config' import { envDescriptionTippy, getNodeSideHeadingAndClass } from './workflow.utils' import NoGitOpsRepoConfiguredWarning, { ReloadNoGitOpsRepoConfiguredModal, @@ -64,7 +63,7 @@ export class TriggerCDNode extends Component, prevState: Readonly): void { + componentDidUpdate(prevProps: Readonly): void { if (prevProps.isGitOpsRepoNotConfigured !== this.props.isGitOpsRepoNotConfigured) { this.setState({ gitOpsRepoWarningCondition: @@ -131,15 +130,15 @@ export class TriggerCDNode extends Component { - !this.state.gitOpsRepoWarningCondition && context.onClickRollbackMaterial(+this.props.id) + handleRollbackClick = (): void => { + !this.state.gitOpsRepoWarningCondition && this.props.onClickRollbackMaterial(+this.props.id) this.handleShowGitOpsRepoConfiguredWarning() } - handleImageSelection = (event, context): void => { + handleImageSelection = (event): void => { event.stopPropagation() !this.state.gitOpsRepoWarningCondition && - context.onClickCDMaterial(this.props.id, DeploymentNodeType[this.props.type]) + this.props.onClickCDMaterial(+this.props.id, DeploymentNodeType[this.props.type]) this.handleShowGitOpsRepoConfiguredWarning() } @@ -150,78 +149,70 @@ export class TriggerCDNode extends Component - {(context) => { - return ( - <> -
-
+
+
+ {heading} +
+
+
+ + Deploy: {this.props.deploymentStrategy} + + {envDescriptionTippy(this.props.environmentName, this.props.description)} +
+
+ +
+
+ {this.renderStatus()} +
+ {!this.props.isVirtualEnvironment && ( + +
-
-
- - Deploy: {this.props.deploymentStrategy} - - {envDescriptionTippy(this.props.environmentName, this.props.description)} -
-
- -
-
- {this.renderStatus()} -
- {!this.props.isVirtualEnvironment && ( - - - - )} - -
-
- {this.state.showGitOpsRepoConfiguredWarning && ( - - )} - {this.state.reloadNoGitOpsRepoConfiguredModal && ( - - )} - - ) - }} - + + + + )} + +
+
+ {this.state.showGitOpsRepoConfiguredWarning && ( + + )} + {this.state.reloadNoGitOpsRepoConfiguredModal && ( + + )} + ) } diff --git a/src/components/app/details/triggerView/workflow/nodes/triggerPrePostCDNode.tsx b/src/components/app/details/triggerView/workflow/nodes/triggerPrePostCDNode.tsx index dc502acaf7..524e6df928 100644 --- a/src/components/app/details/triggerView/workflow/nodes/triggerPrePostCDNode.tsx +++ b/src/components/app/details/triggerView/workflow/nodes/triggerPrePostCDNode.tsx @@ -25,7 +25,6 @@ import { import { TriggerPrePostCDNodeProps, TriggerPrePostCDNodeState } from '../../types' import { TriggerStatus } from '../../../../config' import { BUILD_STATUS, URLS, DEFAULT_STATUS } from '../../../../../../config' -import { TriggerViewContext } from '../../config' import NoGitOpsRepoConfiguredWarning from '../../../../../workflowEditor/NoGitOpsRepoConfiguredWarning' import { getNodeSideHeadingAndClass } from './workflow.utils' import { getAppGroupDeploymentHistoryLink } from '../../../../../ApplicationGroup/AppGroup.utils' @@ -93,9 +92,9 @@ export class TriggerPrePostCDNode extends Component { + handleImageSelection = (event): void => { event.stopPropagation() - !this.gitOpsRepoWarningCondition && context.onClickCDMaterial(this.props.id, this.props.type) + !this.gitOpsRepoWarningCondition && this.props.onClickCDMaterial(+this.props.id, this.props.type) this.handleShowGitOpsRepoConfiguredWarning() } @@ -107,61 +106,57 @@ export class TriggerPrePostCDNode extends Component - {(context) => { - const { heading, className: nodeSideClass } = getNodeSideHeadingAndClass( - this.props.isTriggerBlocked, - this.props.isDeploymentBlocked, - this.props.triggerType, - ) - return ( - <> -
{ - if (isClickable) { - this.redirectToCDDetails() - } - }} - > -
- {heading} -
-
-
- Stage - {stage} -
- -
- {this.renderStatus(isClickable, status)} -
- -
-
- {this.state.showGitOpsRepoConfiguredWarning && ( - - )} - - ) - }} - + <> +
{ + if (isClickable) { + this.redirectToCDDetails() + } + }} + > +
+ {heading} +
+
+
+ Stage + {stage} +
+ +
+ {this.renderStatus(isClickable, status)} +
+ +
+
+ {this.state.showGitOpsRepoConfiguredWarning && ( + + )} + ) } diff --git a/src/components/app/service.ts b/src/components/app/service.ts index 8556762afa..fbb80f0ad1 100644 --- a/src/components/app/service.ts +++ b/src/components/app/service.ts @@ -240,9 +240,9 @@ export const triggerBranchChange = (appIds: number[], envId: number, value: stri }) } -export const getWorkflowStatus = (appId: string) => { +export const getWorkflowStatus = (appId: number, options: APIOptions) => { const URL = `${Routes.APP_WORKFLOW_STATUS}/${appId}/${Routes.APP_LIST_V2}` - return get(URL) + return get(URL, options) } export const getCIPipelines = (appId, filteredEnvIds?: string, callback?: (...args) => void) => { From eba48c0b055604d5c47090f280c57094fd7c1f84 Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 7 Aug 2025 00:48:03 +0530 Subject: [PATCH 25/40] feat: Enhance BulkDeployModal and MaterialListEmptyState for improved material handling and error management --- .../DeployImageModal/BulkDeployModal.tsx | 51 +++++++++++-------- .../MaterialListEmptyState.tsx | 2 +- .../triggerView/DeployImageModal/utils.tsx | 5 +- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index 504bf47245..94c9317159 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -236,6 +236,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme ...prev, [selectedAppId]: { ...prev[selectedAppId], + materialResponse: response[selectedAppId]?.materialResponse, warningMessage, materialError, deploymentWindowMetadata, @@ -251,6 +252,10 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme })) } + const reloadMaterials = async () => { + await reloadOrSearchSelectedApp() + } + const handleLoadOlderImages = async () => { try { // Even if user changes selectedAppId this will persist since a state closure @@ -452,29 +457,31 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme const handleTagChange: DeployImageContentProps['handleTagChange'] = (tagOption) => { setSelectedImageTagOption(tagOption) - const selectedApp = appInfoMap[selectedAppId] - const updatedMaterials = getUpdatedMaterialsForTagSelection( - tagOption.value, - selectedApp.materialResponse?.materials || [], - ) - const { tagsWarning, updatedMaterials: newMaterials } = updatedMaterials + setAppInfoMap((prev) => { + const updatedAppInfoMap = structuredClone(prev) + Object.values(updatedAppInfoMap).forEach((appDetails) => { + const { tagsWarning, updatedMaterials } = getUpdatedMaterialsForTagSelection( + tagOption.value, + appDetails.materialResponse?.materials || [], + ) - const { tagsWarning: previousTagWarning } = getUpdatedMaterialsForTagSelection( - selectedImageTagOption.value, - selectedApp.materialResponse?.materials || [], - ) + const { tagsWarning: previousTagWarning } = getUpdatedMaterialsForTagSelection( + selectedImageTagOption.value, + appDetails.materialResponse?.materials || [], + ) - setAppInfoMap((prev) => ({ - ...prev, - [selectedAppId]: { - ...prev[selectedAppId], - materialResponse: { - ...prev[selectedAppId].materialResponse, - materials: newMaterials, - }, - warningMessage: previousTagWarning ? tagsWarning : prev[selectedAppId].warningMessage, - }, - })) + updatedAppInfoMap[appDetails.appId] = { + ...appDetails, + materialResponse: { + ...appDetails.materialResponse, + materials: updatedMaterials, + }, + warningMessage: + previousTagWarning || !appDetails.warningMessage ? tagsWarning : appDetails.warningMessage, + } + }) + return updatedAppInfoMap + }) } const changeApp: DeployImageContentProps['changeApp'] = (appId) => { @@ -601,7 +608,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme appName={appName} isSecurityModuleInstalled={isSecurityModuleInstalled} envName={envName} - reloadMaterials={reloadOrSearchSelectedApp} + reloadMaterials={reloadMaterials} parentEnvironmentName={parentEnvironmentName} isVirtualEnvironment={isVirtualEnvironment} loadOlderImages={handleLoadOlderImages} diff --git a/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx b/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx index f5f2371f21..96343fa451 100644 --- a/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx @@ -42,7 +42,7 @@ const MaterialListEmptyState = ({ const isApprovalConfigured = getIsApprovalPolicyConfigured( materialResponse?.deploymentApprovalInfo?.approvalConfigData, ) - const areNoMoreImagesPresent = materialResponse && materialResponse.materials.length >= materialResponse.totalCount + const areNoMoreImagesPresent = materialResponse && material.length >= materialResponse.totalCount const resourceFilters = materialResponse?.resourceFilters ?? [] const clearSearch = () => { diff --git a/src/components/app/details/triggerView/DeployImageModal/utils.tsx b/src/components/app/details/triggerView/DeployImageModal/utils.tsx index d66c6ea87a..97d7466ae0 100644 --- a/src/components/app/details/triggerView/DeployImageModal/utils.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/utils.tsx @@ -520,7 +520,7 @@ export const getBulkCDDetailsMapFromResponse: GetBulkCDDetailsMapFromResponseTyp cdMaterialResponseList.forEach((materialResponse, index) => { const { appId } = validWorkflows[index] - if (materialResponse.status === PromiseAllStatusType.FULFILLED) { + if (materialResponse.status === PromiseAllStatusType.FULFILLED && materialResponse.value) { const { tagsWarning, updatedMaterials } = getUpdatedMaterialsForTagSelection( selectedTagName, materialResponse.value.materials, @@ -552,7 +552,8 @@ export const getBulkCDDetailsMapFromResponse: GetBulkCDDetailsMapFromResponseTyp materialResponse: {} as CDMaterialResponseType, deploymentWindowMetadata: {} as DeploymentWindowProfileMetaData, deployViewState: structuredClone(INITIAL_DEPLOY_VIEW_STATE), - materialError: materialResponse.reason, + materialError: + materialResponse.status === PromiseAllStatusType.REJECTED ? materialResponse.reason : null, areMaterialsLoading: false, } } From 8e7d7aadb948620ceebd76ff48622a4e1067e237 Mon Sep 17 00:00:00 2001 From: Arun Jain Date: Thu, 7 Aug 2025 14:34:12 +0530 Subject: [PATCH 26/40] feat: add util for on click search params --- .../Details/TriggerView/EnvTriggerView.tsx | 79 +++++-------------- .../BuildImageModal/BulkBuildImageModal.tsx | 2 +- .../triggerView/BuildImageModal/types.ts | 3 +- .../DeployImageModal/BulkDeployModal.tsx | 16 +++- .../DeployImageModal/DeployImageContent.tsx | 11 ++- .../triggerView/DeployImageModal/types.ts | 31 +++----- .../triggerView/TriggerView.service.ts | 8 +- .../app/details/triggerView/TriggerView.tsx | 40 +--------- .../details/triggerView/TriggerView.utils.tsx | 42 +++++++++- .../app/details/triggerView/types.ts | 22 ++++-- .../details/triggerView/workflow/Workflow.tsx | 41 ++++++++-- 11 files changed, 159 insertions(+), 136 deletions(-) diff --git a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx index ac2593811f..bcd169b71b 100644 --- a/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx +++ b/src/components/ApplicationGroup/Details/TriggerView/EnvTriggerView.tsx @@ -336,38 +336,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT ) } - const onClickApprovalNode = (cdNodeId: number) => { - handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked) - - const newParams = new URLSearchParams([ - [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], - [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], - ]) - history.push({ search: newParams.toString() }) - } - - const onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { - handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.ImageClicked) - - const newParams = new URLSearchParams([ - [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], - [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], - ]) - history.push({ - search: newParams.toString(), - }) - } - - // Assuming that rollback has only CD as nodeType - const onClickRollbackMaterial = (cdNodeId: number) => { - handleAnalyticsEvent(ENV_TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - - const newParams = new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]) - history.push({ - search: newParams.toString(), - }) - } - const isBuildAndBranchTriggerAllowed = (node: CommonNodeAttr): boolean => !node.isLinkedCI && !node.isLinkedCD && node.type !== WorkflowNodeType.WEBHOOK @@ -524,6 +492,7 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT workflows={filteredWorkflows} isVirtualEnvironment={isVirtualEnv} envId={+envId} + handleSuccess={reloadTriggerView} /> ) } @@ -600,7 +569,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT isTriggerBlockedDueToPlugin={node?.showPluginWarning && node?.isTriggerBlocked} triggerType={node?.triggerType} parentEnvironmentName={node?.parentEnvironmentName} - onClickApprovalNode={onClickApprovalNode} /> ) } @@ -823,9 +791,6 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT index={index} handleWebhookAddImageClick={handleWebhookAddImageClick(workflow.appId)} openCIMaterialModal={openCIMaterialModal} - onClickApprovalNode={onClickApprovalNode} - onClickCDMaterial={onClickCDMaterial} - onClickRollbackMaterial={onClickRollbackMaterial} reloadTriggerView={reloadTriggerView} /> ))} @@ -851,28 +816,26 @@ const EnvTriggerView = ({ filteredAppIds, isVirtualEnv }: AppGroupDetailDefaultT - <> - {renderWorkflow()} - - - - - - - {renderCDMaterial()} - {renderBulkCDMaterial()} - {renderBulkCIMaterial()} - {renderApprovalMaterial()} - {renderBulkSourceChange()} - + {renderWorkflow()} + + + + + + + {renderCDMaterial()} + {renderBulkCDMaterial()} + {renderBulkCIMaterial()} + {renderApprovalMaterial()} + {renderBulkSourceChange()}
{!!selectedAppList.length && ( diff --git a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx index e710d43a50..1e5ae6c7a4 100644 --- a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx @@ -164,7 +164,7 @@ const BulkBuildImageModal = ({ const handleReloadSelectedMaterialWithWorkflows = async () => { try { const newWorkflows = await reloadWorkflows() - await reloadSelectedAppMaterialList(newWorkflows as WorkflowType[]) + await reloadSelectedAppMaterialList(newWorkflows) } catch (error) { showError(error) } diff --git a/src/components/app/details/triggerView/BuildImageModal/types.ts b/src/components/app/details/triggerView/BuildImageModal/types.ts index e5649425ec..6b3c215fdc 100644 --- a/src/components/app/details/triggerView/BuildImageModal/types.ts +++ b/src/components/app/details/triggerView/BuildImageModal/types.ts @@ -80,8 +80,9 @@ export type GitInfoMaterialProps = Pick & { ) export interface BulkBuildImageModalProps - extends Pick { + extends Pick { filteredCIPipelineMap: FilteredCIPipelineMapType + reloadWorkflows: () => Promise } export interface BuildImageHeaderProps { diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index 2611a18010..752a59451f 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -67,7 +67,14 @@ const validateRuntimeParameters = importComponentFromFELibrary( 'function', ) -const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironment, envId }: BuildDeployModalProps) => { +const BulkDeployModal = ({ + handleClose: handleCloseProp, + handleSuccess, + stageType, + workflows, + isVirtualEnvironment, + envId, +}: BuildDeployModalProps) => { const { currentEnvironmentName: envName } = useAppContext() const { canFetchHelmAppStatus } = useMainContext() @@ -531,6 +538,13 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme [appInfoMap], ) + const handleClose = () => { + handleCloseProp() + if (responseList.length) { + handleSuccess() + } + } + const renderContent = () => { if (BulkCDStrategy && showStrategyFeasibilityPage) { return ( diff --git a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx index fa7a5fb73e..f8ec005776 100644 --- a/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/DeployImageContent.tsx @@ -31,7 +31,8 @@ import { import { importComponentFromFELibrary } from '@Components/common' import { TRIGGER_VIEW_PARAMS } from '../Constants' -import { FilterConditionViews, HandleRuntimeParamChange } from '../types' +import { getCDNodeActionSearch } from '../TriggerView.utils' +import { CDNodeActions, FilterConditionViews, HandleRuntimeParamChange } from '../types' import BulkDeployEmptyState from './BulkDeployEmptyState' import BulkTriggerSidebar from './BulkTriggerSidebar' import ImageSelectionCTA from './ImageSelectionCTA' @@ -90,7 +91,6 @@ const DeployImageContent = ({ selectedTagName, handleTagChange, changeApp, - onClickApprovalNode, }: DeployImageContentProps) => { // WARNING: Pls try not to create a useState in this component, it is supposed to be a dumb component. const history = useHistory() @@ -236,7 +236,12 @@ const DeployImageContent = ({ }) } else { handleClose() - onClickApprovalNode(pipelineId) + const search = getCDNodeActionSearch({ + actionType: CDNodeActions.APPROVAL, + cdNodeId: pipelineId, + fromAppGroup: isBulkTrigger, + }) + history.push({ search }) } } diff --git a/src/components/app/details/triggerView/DeployImageModal/types.ts b/src/components/app/details/triggerView/DeployImageModal/types.ts index a9f280f124..db4d51ba62 100644 --- a/src/components/app/details/triggerView/DeployImageModal/types.ts +++ b/src/components/app/details/triggerView/DeployImageModal/types.ts @@ -45,30 +45,21 @@ export type DeployImageModalProps = { */ parentEnvironmentName: string triggerType: CommonNodeAttr['triggerType'] + isRedirectedFromAppDetails?: boolean } & ( | { - isRedirectedFromAppDetails: true - onClickApprovalNode?: never + showPluginWarningBeforeTrigger: boolean + consequence: ConsequenceType + configurePluginURL: string + isTriggerBlockedDueToPlugin: boolean } | { - isRedirectedFromAppDetails?: never - onClickApprovalNode: (cdNodeId: number) => void + showPluginWarningBeforeTrigger?: never + consequence?: never + configurePluginURL?: never + isTriggerBlockedDueToPlugin?: never } -) & - ( - | { - showPluginWarningBeforeTrigger: boolean - consequence: ConsequenceType - configurePluginURL: string - isTriggerBlockedDueToPlugin: boolean - } - | { - showPluginWarningBeforeTrigger?: never - consequence?: never - configurePluginURL?: never - isTriggerBlockedDueToPlugin?: never - } - ) +) export type DeployImageHeaderProps = Pick< DeployImageModalProps, @@ -150,7 +141,6 @@ export type DeployImageContentProps = Pick< | 'isTriggerBlockedDueToPlugin' | 'configurePluginURL' | 'triggerType' - | 'onClickApprovalNode' > & Pick & { materialResponse: CDMaterialResponseType | null @@ -241,6 +231,7 @@ export interface BuildDeployModalProps { workflows: WorkflowType[] isVirtualEnvironment: boolean envId: number + handleSuccess: () => void } export type GetInitialAppListProps = diff --git a/src/components/app/details/triggerView/TriggerView.service.ts b/src/components/app/details/triggerView/TriggerView.service.ts index 024e466af0..478ceb6421 100644 --- a/src/components/app/details/triggerView/TriggerView.service.ts +++ b/src/components/app/details/triggerView/TriggerView.service.ts @@ -10,6 +10,9 @@ import { getHostURLConfiguration } from '@Services/service' import { UseTriggerViewServicesParams } from './types' import { getTriggerWorkflows } from './workflow.service' +const DEFAULT_POLLING_INTERVAL = 30000 +const PROGRESSING_POLLING_INTERVAL = 10000 + export const useTriggerViewServices = ({ appId, isJobView, filteredEnvIds }: UseTriggerViewServicesParams) => { const queryClient = useQueryClient() const refetchIntervalRef = useRef(30000) @@ -82,7 +85,10 @@ export const useTriggerViewServices = ({ appId, isJobView, filteredEnvIds }: Use response.result?.cdWorkflowStatus ?? [], workflows, ) - refetchIntervalRef.current = processedWorkflowsData.cicdInProgress ? 10000 : 30000 + refetchIntervalRef.current = processedWorkflowsData.cicdInProgress + ? PROGRESSING_POLLING_INTERVAL + : DEFAULT_POLLING_INTERVAL + return processedWorkflowsData.workflows || [] }, refetchInterval: refetchIntervalRef.current, diff --git a/src/components/app/details/triggerView/TriggerView.tsx b/src/components/app/details/triggerView/TriggerView.tsx index 201d1b8701..0496bd9976 100644 --- a/src/components/app/details/triggerView/TriggerView.tsx +++ b/src/components/app/details/triggerView/TriggerView.tsx @@ -22,7 +22,6 @@ import { DeploymentNodeType, DocLink, ErrorScreenManager, - handleAnalyticsEvent, Progressing, } from '@devtron-labs/devtron-fe-common-lib' @@ -41,7 +40,7 @@ import { getModuleInfo } from '../../../v2/devtronStackManager/DevtronStackManag import { AppNotConfigured } from '../appDetails/AppDetails' import { Workflow } from './workflow/Workflow' import { BuildImageModal } from './BuildImageModal' -import { TRIGGER_VIEW_GA_EVENTS, TRIGGER_VIEW_PARAMS } from './Constants' +import { TRIGGER_VIEW_PARAMS } from './Constants' import { DeployImageModal } from './DeployImageModal' import { useTriggerViewServices } from './TriggerView.service' import { getSelectedNodeFromWorkflows, shouldRenderWebhookAddImageModal } from './TriggerView.utils' @@ -88,37 +87,6 @@ const TriggerView = ({ isJobView, filteredEnvIds }: TriggerViewProps) => { history.push(`${match.url}${URLS.BUILD}/${ciNodeId}`) } - const onClickApprovalNode = (cdNodeId: number) => { - handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked) - - const newParams = new URLSearchParams([ - [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], - [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], - ]) - history.push({ search: newParams.toString() }) - } - - const onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { - handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.ImageClicked) - - const newParams = new URLSearchParams([ - [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], - [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], - ]) - history.push({ - search: newParams.toString(), - }) - } - - const onClickRollbackMaterial = (cdNodeId: number) => { - handleAnalyticsEvent(TRIGGER_VIEW_GA_EVENTS.RollbackClicked) - - const newParams = new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]) - history.push({ - search: newParams.toString(), - }) - } - const closeApprovalModal = (e: React.MouseEvent): void => { e.stopPropagation() history.push({ @@ -186,7 +154,6 @@ const TriggerView = ({ isJobView, filteredEnvIds }: TriggerViewProps) => { isTriggerBlockedDueToPlugin={cdNode?.showPluginWarning && cdNode?.isTriggerBlocked} triggerType={cdNode.triggerType} parentEnvironmentName={cdNode.parentEnvironmentName} - onClickApprovalNode={onClickApprovalNode} /> ) } @@ -254,9 +221,6 @@ const TriggerView = ({ isJobView, filteredEnvIds }: TriggerViewProps) => { appId={+appId} handleWebhookAddImageClick={handleWebhookAddImageClick} openCIMaterialModal={openCIMaterialModal} - onClickApprovalNode={onClickApprovalNode} - onClickCDMaterial={onClickCDMaterial} - onClickRollbackMaterial={onClickRollbackMaterial} reloadTriggerView={reloadWorkflows} /> ))} @@ -282,7 +246,7 @@ const TriggerView = ({ isJobView, filteredEnvIds }: TriggerViewProps) => { } if (workflowsError) { - return + return } if (!workflows.length) { diff --git a/src/components/app/details/triggerView/TriggerView.utils.tsx b/src/components/app/details/triggerView/TriggerView.utils.tsx index b52440bcea..e552a172dd 100644 --- a/src/components/app/details/triggerView/TriggerView.utils.tsx +++ b/src/components/app/details/triggerView/TriggerView.utils.tsx @@ -21,15 +21,17 @@ import { DeploymentHistoryDetail, DeploymentNodeType, DeploymentWithConfigType, + handleAnalyticsEvent, showError, WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' +import { ENV_TRIGGER_VIEW_GA_EVENTS } from '@Components/ApplicationGroup/Constants' import { URLS } from '@Config/routes' import { deepEqual } from '../../../common' -import { TRIGGER_VIEW_PARAMS } from './Constants' -import { TriggerViewDeploymentConfigType } from './types' +import { TRIGGER_VIEW_GA_EVENTS, TRIGGER_VIEW_PARAMS } from './Constants' +import { CDNodeActions, GetCDNodeSearchParams, TriggerViewDeploymentConfigType } from './types' export const DEPLOYMENT_CONFIGURATION_NAV_MAP = { DEPLOYMENT_TEMPLATE: { @@ -229,3 +231,39 @@ export const getSelectedNodeFromWorkflows = (workflows: WorkflowType[], search: showError('Invalid node id') return {} as CommonNodeAttr } + +export const getCDNodeActionSearch = ({ + actionType, + cdNodeId, + nodeType = DeploymentNodeType.CD, + fromAppGroup, +}: GetCDNodeSearchParams) => { + switch (actionType) { + case CDNodeActions.APPROVAL: + handleAnalyticsEvent( + fromAppGroup + ? ENV_TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked + : TRIGGER_VIEW_GA_EVENTS.ApprovalNodeClicked, + ) + return new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.APPROVAL_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.APPROVAL_STATE, TRIGGER_VIEW_PARAMS.APPROVAL], + ]).toString() + + case CDNodeActions.ROLLBACK_MATERIAL: + handleAnalyticsEvent( + fromAppGroup ? ENV_TRIGGER_VIEW_GA_EVENTS.RollbackClicked : TRIGGER_VIEW_GA_EVENTS.RollbackClicked, + ) + return new URLSearchParams([[TRIGGER_VIEW_PARAMS.ROLLBACK_NODE, cdNodeId.toString()]]).toString() + + case CDNodeActions.CD_MATERIAL: + default: + handleAnalyticsEvent( + fromAppGroup ? ENV_TRIGGER_VIEW_GA_EVENTS.MaterialClicked : TRIGGER_VIEW_GA_EVENTS.ImageClicked, + ) + return new URLSearchParams([ + [TRIGGER_VIEW_PARAMS.CD_NODE, cdNodeId.toString()], + [TRIGGER_VIEW_PARAMS.NODE_TYPE, nodeType], + ]).toString() + } +} diff --git a/src/components/app/details/triggerView/types.ts b/src/components/app/details/triggerView/types.ts index bf9cf6e025..2f075ec190 100644 --- a/src/components/app/details/triggerView/types.ts +++ b/src/components/app/details/triggerView/types.ts @@ -240,7 +240,7 @@ interface InputMaterials { export interface TriggerCDNodeProps extends RouteComponentProps<{ appId: string; envId: string }>, Partial>, - Pick { + Pick { x: number y: number height: number @@ -268,6 +268,8 @@ export interface TriggerCDNodeProps deploymentAppType: DeploymentAppTypes appId: number isDeploymentBlocked?: boolean + onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => void + onClickRollbackMaterial: (cdNodeId: number) => void } export interface TriggerCDNodeState { @@ -280,7 +282,7 @@ export interface TriggerCDNodeState { export interface TriggerPrePostCDNodeProps extends RouteComponentProps<{ appId: string; envId: string }>, Partial>, - Pick { + Pick { x: number y: number height: number @@ -333,9 +335,6 @@ export interface WorkflowProps filteredCIPipelines?: any[] handleWebhookAddImageClick?: (webhookId: number) => void openCIMaterialModal: (ciNodeId: string) => void - onClickApprovalNode: (cdNodeId: number) => void - onClickCDMaterial: (cdNodeId: number, nodeType: DeploymentNodeType) => void - onClickRollbackMaterial: (cdNodeId: number) => void reloadTriggerView: () => Promise | void } @@ -688,3 +687,16 @@ export interface UseTriggerViewServicesParams { isJobView: boolean filteredEnvIds: string } + +export enum CDNodeActions { + APPROVAL = 'approval', + CD_MATERIAL = 'cdMaterial', + ROLLBACK_MATERIAL = 'rollbackMaterial', +} + +export interface GetCDNodeSearchParams { + actionType: CDNodeActions + cdNodeId: number + nodeType?: DeploymentNodeType + fromAppGroup?: boolean +} diff --git a/src/components/app/details/triggerView/workflow/Workflow.tsx b/src/components/app/details/triggerView/workflow/Workflow.tsx index 3f0929352c..6a7a52dd7e 100644 --- a/src/components/app/details/triggerView/workflow/Workflow.tsx +++ b/src/components/app/details/triggerView/workflow/Workflow.tsx @@ -22,6 +22,7 @@ import { WorkflowNodeType, PipelineType, CommonNodeAttr, + DeploymentNodeType, } from '@devtron-labs/devtron-fe-common-lib' import { StaticNode } from './nodes/staticNode' import { TriggerCINode } from './nodes/triggerCINode' @@ -30,16 +31,16 @@ import { TriggerLinkedCINode } from './nodes/TriggerLinkedCINode' import { TriggerCDNode } from './nodes/triggerCDNode' import { TriggerPrePostCDNode } from './nodes/triggerPrePostCDNode' import { getCIPipelineURL, importComponentFromFELibrary, RectangularEdge as Edge } from '../../../../common' -import { WorkflowProps } from '../types' +import { CDNodeActions, WorkflowProps } from '../types' import { WebhookNode } from '../../../../workflowEditor/nodes/WebhookNode' import { GIT_BRANCH_NOT_CONFIGURED } from '../../../../../config' +import { getCDNodeActionSearch } from '../TriggerView.utils' const ApprovalNodeEdge = importComponentFromFELibrary('ApprovalNodeEdge') const LinkedCDNode = importComponentFromFELibrary('LinkedCDNode') const ImagePromotionLink = importComponentFromFELibrary('ImagePromotionLink', null, 'function') const BulkDeployLink = importComponentFromFELibrary('BulkDeployLink', null, 'function') export class Workflow extends Component { - goToWorkFlowEditor = (node: CommonNodeAttr) => { if (node.branch === GIT_BRANCH_NOT_CONFIGURED) { const ciPipelineURL = getCIPipelineURL( @@ -63,6 +64,34 @@ export class Workflow extends Component { } } + onClickCDMaterial = (cdNodeId: number, nodeType: DeploymentNodeType) => { + const search = getCDNodeActionSearch({ + actionType: CDNodeActions.CD_MATERIAL, + cdNodeId, + nodeType, + fromAppGroup: this.props.fromAppGrouping, + }) + this.props.history.push({ search }) + } + + onClickRollbackMaterial = (cdNodeId: number) => { + const search = getCDNodeActionSearch({ + actionType: CDNodeActions.ROLLBACK_MATERIAL, + cdNodeId, + fromAppGroup: this.props.fromAppGrouping, + }) + this.props.history.push({ search }) + } + + onClickApprovalNode = (cdNodeId: number) => { + const search = getCDNodeActionSearch({ + actionType: CDNodeActions.APPROVAL, + cdNodeId, + fromAppGroup: this.props.fromAppGrouping, + }) + this.props.history.push({ search }) + } + renderNodes() { return this.props.nodes.map((node: any) => { if (node.type === WorkflowNodeType.GIT) { @@ -265,8 +294,8 @@ export class Workflow extends Component { appId={this.props.appId} isDeploymentBlocked={node.isDeploymentBlocked} isTriggerBlocked={node.isTriggerBlocked} - onClickCDMaterial={this.props.onClickCDMaterial} - onClickRollbackMaterial={this.props.onClickRollbackMaterial} + onClickCDMaterial={this.onClickCDMaterial} + onClickRollbackMaterial={this.onClickRollbackMaterial} reloadTriggerView={this.props.reloadTriggerView} /> ) @@ -301,7 +330,7 @@ export class Workflow extends Component { appId={this.props.appId} isDeploymentBlocked={node.isDeploymentBlocked} isTriggerBlocked={node.isTriggerBlocked} - onClickCDMaterial={this.props.onClickCDMaterial} + onClickCDMaterial={this.onClickCDMaterial} reloadTriggerView={this.props.reloadTriggerView} /> ) @@ -332,7 +361,7 @@ export class Workflow extends Component { key={`trigger-edge-${edgeNode.startNode.id}${edgeNode.startNode.x}${edgeNode.startNode.y}-${edgeNode.endNode.id}`} startNode={edgeNode.startNode} endNode={edgeNode.endNode} - onClickEdge={() => this.props.onClickApprovalNode(edgeNode.endNode.id)} + onClickEdge={() => this.onClickApprovalNode(edgeNode.endNode.id)} edges={edges} /> ) From 3046d0bf2d41613b5020b167f46cd3222709d0ef Mon Sep 17 00:00:00 2001 From: AbhishekA1509 Date: Thu, 7 Aug 2025 15:09:55 +0530 Subject: [PATCH 27/40] fix: review comments --- .../BuildImageModal/BulkBuildImageModal.tsx | 3 +-- .../DeployImageModal/BulkDeployEmptyState.tsx | 2 +- .../DeployImageModal/BulkDeployModal.tsx | 3 +-- .../DeployImageModal/ImageSelectionCTA.tsx | 2 +- .../DeployImageModal/MaterialListEmptyState.tsx | 13 +++---------- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx index 1e5ae6c7a4..f5ff95e856 100644 --- a/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx +++ b/src/components/app/details/triggerView/BuildImageModal/BulkBuildImageModal.tsx @@ -27,7 +27,6 @@ import { WorkflowType, } from '@devtron-labs/devtron-fe-common-lib' -import { ReactComponent as MechanicalOperation } from '@Images/ic-mechanical-operation.svg' import { BulkCIDetailType, ResponseRowType } from '@Components/ApplicationGroup/AppGroup.types' import { BULK_CI_BUILD_STATUS, @@ -417,7 +416,7 @@ const BulkBuildImageModal = ({ ? BULK_CI_BUILD_STATUS(numberOfAppsLoading) : BULK_CI_MATERIAL_STATUS(numberOfAppsLoading) - return + return } if (isLoadingSingleAppInfoMap) { diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx index a044b750d6..e9803bebf7 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployEmptyState.tsx @@ -27,7 +27,7 @@ const BulkDeployEmptyState = ({ return } - if (isTriggerBlockedDueToPlugin) { + if (MissingPluginBlockState && isTriggerBlockedDueToPlugin) { // It can't be CD const commonNodeAttrType: CommonNodeAttr['type'] = stageType === DeploymentNodeType.PRECD ? 'PRECD' : 'POSTCD' diff --git a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx index 94c9317159..8d727bfaa5 100644 --- a/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/BulkDeployModal.tsx @@ -29,7 +29,6 @@ import { useMainContext, } from '@devtron-labs/devtron-fe-common-lib' -import { ReactComponent as MechanicalOperation } from '@Images/ic-mechanical-operation.svg' import { ResponseRowType } from '@Components/ApplicationGroup/AppGroup.types' import { BULK_CD_DEPLOYMENT_STATUS, @@ -569,7 +568,7 @@ const BulkDeployModal = ({ handleClose, stageType, workflows, isVirtualEnvironme return ( -
+
)} diff --git a/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx b/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx index 96343fa451..80bd895303 100644 --- a/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx +++ b/src/components/app/details/triggerView/DeployImageModal/MaterialListEmptyState.tsx @@ -3,6 +3,7 @@ import { ButtonVariantType, EMPTY_STATE_STATUS, GenericEmptyState, + GenericFilterEmptyState, getIsApprovalPolicyConfigured, } from '@devtron-labs/devtron-fe-common-lib' @@ -49,12 +50,6 @@ const MaterialListEmptyState = ({ onSearchApply('') } - const renderGenerateButton = () => ( - - ) - const renderFilterEmptyStateSubtitle = (): JSX.Element => (