From 4d898a120fd4410a8ed660db252bc270710e9323 Mon Sep 17 00:00:00 2001 From: Onkar Ravgan Date: Sun, 17 Jul 2022 21:55:37 +0530 Subject: [PATCH] Pipeline lineage edge UI (#5891) Pipeline lineage edge UI (#5891) --- .../catalog/jdbi3/LineageRepository.java | 34 ++- .../json/schema/type/entityLineage.json | 45 ++-- .../examples/sample_data/lineage/lineage.json | 27 ++- .../lineage/utils.py | 42 ++-- .../ingestion/source/database/sample_data.py | 12 +- .../ingestion/source/pipeline/airbyte.py | 24 +- .../ingestion/source/pipeline/airflow.py | 66 +++--- .../EntityLineage/AddPipeLineModal.test.tsx | 71 ++++++ .../EntityLineage/AddPipeLineModal.tsx | 104 ++++++++ .../CustomEdge.component.test.tsx | 64 +++-- .../EntityLineage/CustomEdge.component.tsx | 120 ++++++++-- .../EntityLineage/EntityLineage.component.tsx | 224 +++++++++++++++++- .../EntityLineage/EntityLineage.interface.ts | 2 + .../ui/src/constants/Lineage.constants.ts | 6 +- .../ui/src/utils/EntityLineageUtils.tsx | 17 +- 15 files changed, 685 insertions(+), 173 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.tsx diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/LineageRepository.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/LineageRepository.java index c7de6f83419..de2487ac86b 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/LineageRepository.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/LineageRepository.java @@ -13,8 +13,6 @@ package org.openmetadata.catalog.jdbi3; -import static org.openmetadata.common.utils.CommonUtil.listOrEmpty; - import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -65,6 +63,16 @@ public class LineageRepository { EntityReference to = addLineage.getEdge().getToEntity(); to = Entity.getEntityReferenceById(to.getType(), to.getId(), Include.NON_DELETED); + if (addLineage.getEdge().getLineageDetails() != null + && addLineage.getEdge().getLineageDetails().getPipeline() != null) { + + // Validate pipeline entity + EntityReference pipeline = addLineage.getEdge().getLineageDetails().getPipeline(); + pipeline = Entity.getEntityReferenceById(pipeline.getType(), pipeline.getId(), Include.NON_DELETED); + + // Add pipeline entity details to lineage details + addLineage.getEdge().getLineageDetails().withPipeline(pipeline); + } // Validate lineage details String detailsJson = validateLineageDetails(from, to, addLineage.getEdge().getLineageDetails()); @@ -75,7 +83,7 @@ public class LineageRepository { private String validateLineageDetails(EntityReference from, EntityReference to, LineageDetails details) throws IOException { - if (details == null || listOrEmpty(details.getColumnsLineage()).isEmpty()) { + if (details == null) { return null; } @@ -86,17 +94,19 @@ public class LineageRepository { Table fromTable = dao.tableDAO().findEntityById(from.getId()); Table toTable = dao.tableDAO().findEntityById(to.getId()); - for (ColumnLineage columnLineage : columnsLineage) { - for (String fromColumn : columnLineage.getFromColumns()) { - // From column belongs to the fromNode - if (fromColumn.startsWith(fromTable.getFullyQualifiedName())) { - TableRepository.validateColumnFQN(fromTable, fromColumn); - } else { - Table otherTable = dao.tableDAO().findEntityByName(FullyQualifiedName.getTableFQN(fromColumn)); - TableRepository.validateColumnFQN(otherTable, fromColumn); + if (columnsLineage != null) { + for (ColumnLineage columnLineage : columnsLineage) { + for (String fromColumn : columnLineage.getFromColumns()) { + // From column belongs to the fromNode + if (fromColumn.startsWith(fromTable.getFullyQualifiedName())) { + TableRepository.validateColumnFQN(fromTable, fromColumn); + } else { + Table otherTable = dao.tableDAO().findEntityByName(FullyQualifiedName.getTableFQN(fromColumn)); + TableRepository.validateColumnFQN(otherTable, fromColumn); + } } + TableRepository.validateColumnFQN(toTable, columnLineage.getToColumn()); } - TableRepository.validateColumnFQN(toTable, columnLineage.getToColumn()); } return JsonUtils.pojoToJson(details); } diff --git a/catalog-rest-service/src/main/resources/json/schema/type/entityLineage.json b/catalog-rest-service/src/main/resources/json/schema/type/entityLineage.json index 11d0bb7554e..192c9d402e1 100644 --- a/catalog-rest-service/src/main/resources/json/schema/type/entityLineage.json +++ b/catalog-rest-service/src/main/resources/json/schema/type/entityLineage.json @@ -7,46 +7,45 @@ "javaType": "org.openmetadata.catalog.type.EntityLineage", "definitions": { "columnLineage": { - "type" : "object", + "type": "object", "properties": { - "fromColumns" : { + "fromColumns": { "description": "One or more source columns identified by fully qualified column name used by transformation function to create destination column.", - "type" : "array", - "items" : { - "$ref" : "../type/basic.json#/definitions/fullyQualifiedEntityName" + "type": "array", + "items": { + "$ref": "../type/basic.json#/definitions/fullyQualifiedEntityName" } }, - "toColumn" : { + "toColumn": { "description": "Destination column identified by fully qualified column name created by the transformation of source columns.", - "$ref" : "../type/basic.json#/definitions/fullyQualifiedEntityName" + "$ref": "../type/basic.json#/definitions/fullyQualifiedEntityName" }, - "function" : { + "function": { "description": "Transformation function applied to source columns to create destination column. That is `function(fromColumns) -> toColumn`.", - "$ref" : "../type/basic.json#/definitions/sqlFunction" + "$ref": "../type/basic.json#/definitions/sqlFunction" } } }, - "lineageDetails" : { - "description" : "Lineage details including sqlQuery + pipeline + columnLineage.", - "type" : "object", + "lineageDetails": { + "description": "Lineage details including sqlQuery + pipeline + columnLineage.", + "type": "object", "properties": { - "sqlQuery" : { + "sqlQuery": { "description": "SQL used for transformation.", - "$ref" : "../type/basic.json#/definitions/sqlQuery" + "$ref": "../type/basic.json#/definitions/sqlQuery" }, - "columnsLineage" : { - "description" : "Lineage information of how upstream columns were combined to get downstream column.", - "type" : "array", - "items" : { - "$ref" : "#/definitions/columnLineage" + "columnsLineage": { + "description": "Lineage information of how upstream columns were combined to get downstream column.", + "type": "array", + "items": { + "$ref": "#/definitions/columnLineage" } }, - "pipeline" : { + "pipeline": { "description": "Pipeline where the sqlQuery is periodically run.", - "$ref" : "../type/entityReference.json" + "$ref": "../type/entityReference.json" } - }, - "required": ["sqlQuery", "columnsLineage"] + } }, "edge": { "description": "Edge in the lineage graph from one entity to another by entity IDs.", diff --git a/ingestion/examples/sample_data/lineage/lineage.json b/ingestion/examples/sample_data/lineage/lineage.json index cca62851a66..14f5ba12fa2 100644 --- a/ingestion/examples/sample_data/lineage/lineage.json +++ b/ingestion/examples/sample_data/lineage/lineage.json @@ -1,25 +1,26 @@ [{ "from": { "fqn":"sample_data.ecommerce_db.shopify.raw_customer", "type": "table"}, - "to": { "fqn":"sample_airflow.dim_address_etl", "type": "pipeline"} - }, - { - "from": {"fqn":"sample_airflow.dim_address_etl", "type": "pipeline"}, - "to": {"fqn":"sample_data.ecommerce_db.shopify.dim_address", "type": "table"} + "to": {"fqn":"sample_data.ecommerce_db.shopify.dim_address", "type": "table"}, + "edge_meta": { "fqn":"sample_airflow.dim_address_etl", "type": "pipeline"} }, { "from": {"fqn":"sample_data.ecommerce_db.shopify.raw_order", "type": "table"}, - "to": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} + "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product\"", "type": "table"}, + "edge_meta": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} + }, + { + "from": {"fqn":"sample_data.ecommerce_db.shopify.raw_order", "type": "table"}, + "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product.variant\"", "type": "table"}, + "edge_meta": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} }, { "from": {"fqn":"sample_data.ecommerce_db.shopify.raw_customer", "type": "table"}, - "to": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} + "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product\"", "type": "table"}, + "edge_meta": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} }, { - "from": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"}, - "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product\"", "type": "table"} - }, - { - "from": {"fqn": "sample_airflow.dim_product_etl", "type": "pipeline"}, - "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product.variant\"", "type": "table"} + "from": {"fqn":"sample_data.ecommerce_db.shopify.raw_customer", "type": "table"}, + "to": {"fqn":"sample_data.ecommerce_db.shopify.\"dim.product.variant\"", "type": "table"}, + "edge_meta": {"fqn":"sample_airflow.dim_product_etl", "type": "pipeline"} } ] diff --git a/ingestion/src/airflow_provider_openmetadata/lineage/utils.py b/ingestion/src/airflow_provider_openmetadata/lineage/utils.py index 748d602b6dc..a949bdd0ff3 100644 --- a/ingestion/src/airflow_provider_openmetadata/lineage/utils.py +++ b/ingestion/src/airflow_provider_openmetadata/lineage/utils.py @@ -43,7 +43,7 @@ from metadata.generated.schema.entity.services.pipelineService import ( PipelineService, PipelineServiceType, ) -from metadata.generated.schema.type.entityLineage import EntitiesEdge +from metadata.generated.schema.type.entityLineage import EntitiesEdge, LineageDetails from metadata.generated.schema.type.entityReference import EntityReference from metadata.ingestion.ometa.ometa_api import OpenMetadata from metadata.utils.helpers import datetime_to_ts @@ -351,31 +351,27 @@ def parse_lineage( airflow_service_entity=airflow_service_entity, metadata=metadata, ) + lineage_details = LineageDetails( + pipeline=EntityReference(id=pipeline.id, type="pipeline") + ) operator.log.info("Parsing Lineage") - for table in inlets if inlets else []: - table_entity = metadata.get_by_name(entity=Table, fqn=table) - operator.log.debug(f"from entity {table_entity}") - lineage = AddLineageRequest( - edge=EntitiesEdge( - fromEntity=EntityReference(id=table_entity.id, type="table"), - toEntity=EntityReference(id=pipeline.id, type="pipeline"), + for from_table in inlets if inlets else []: + from_entity = metadata.get_by_name(entity=Table, fqn=from_table) + operator.log.debug(f"from entity {from_entity}") + for to_table in outlets if outlets else []: + to_entity = metadata.get_by_name(entity=Table, fqn=to_table) + operator.log.debug(f"To entity {to_entity}") + lineage = AddLineageRequest( + edge=EntitiesEdge( + fromEntity=EntityReference(id=from_entity.id, type="table"), + toEntity=EntityReference(id=to_entity.id, type="table"), + ) ) - ) - operator.log.debug(f"From lineage {lineage}") - metadata.add_lineage(lineage) - - for table in outlets if outlets else []: - table_entity = metadata.get_by_name(entity=Table, fqn=table) - operator.log.debug(f"To entity {table_entity}") - lineage = AddLineageRequest( - edge=EntitiesEdge( - fromEntity=EntityReference(id=pipeline.id, type="pipeline"), - toEntity=EntityReference(id=table_entity.id, type="table"), - ) - ) - operator.log.debug(f"To lineage {lineage}") - metadata.add_lineage(lineage) + if lineage_details: + lineage.edge.lineageDetails = lineage_details + operator.log.debug(f"Lineage {lineage}") + metadata.add_lineage(lineage) return pipeline diff --git a/ingestion/src/metadata/ingestion/source/database/sample_data.py b/ingestion/src/metadata/ingestion/source/database/sample_data.py index 562f8886e81..2fe5adad50e 100644 --- a/ingestion/src/metadata/ingestion/source/database/sample_data.py +++ b/ingestion/src/metadata/ingestion/source/database/sample_data.py @@ -63,7 +63,7 @@ from metadata.generated.schema.metadataIngestion.workflow import ( from metadata.generated.schema.tests.basic import TestCaseResult from metadata.generated.schema.tests.columnTest import ColumnTestCase from metadata.generated.schema.tests.tableTest import TableTestCase -from metadata.generated.schema.type.entityLineage import EntitiesEdge +from metadata.generated.schema.type.entityLineage import EntitiesEdge, LineageDetails from metadata.generated.schema.type.entityReference import EntityReference from metadata.ingestion.api.common import Entity from metadata.ingestion.api.source import InvalidSourceException, Source, SourceStatus @@ -552,8 +552,16 @@ class SampleDataSource(Source[Entity]): for edge in self.lineage: from_entity_ref = get_lineage_entity_ref(edge["from"], self.metadata_config) to_entity_ref = get_lineage_entity_ref(edge["to"], self.metadata_config) + edge_entity_ref = get_lineage_entity_ref( + edge["edge_meta"], self.metadata_config + ) + lineage_details = LineageDetails(pipeline=edge_entity_ref) lineage = AddLineageRequest( - edge=EntitiesEdge(fromEntity=from_entity_ref, toEntity=to_entity_ref) + edge=EntitiesEdge( + fromEntity=from_entity_ref, + toEntity=to_entity_ref, + lineageDetails=lineage_details, + ) ) yield lineage diff --git a/ingestion/src/metadata/ingestion/source/pipeline/airbyte.py b/ingestion/src/metadata/ingestion/source/pipeline/airbyte.py index f4c5d47aa6d..f1c1cd862ef 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/airbyte.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/airbyte.py @@ -39,7 +39,7 @@ from metadata.generated.schema.metadataIngestion.pipelineServiceMetadataPipeline from metadata.generated.schema.metadataIngestion.workflow import ( Source as WorkflowSource, ) -from metadata.generated.schema.type.entityLineage import EntitiesEdge +from metadata.generated.schema.type.entityLineage import EntitiesEdge, LineageDetails from metadata.generated.schema.type.entityReference import EntityReference from metadata.ingestion.api.common import Entity from metadata.ingestion.api.source import InvalidSourceException, Source, SourceStatus @@ -211,6 +211,10 @@ class AirbyteSource(Source[CreatePipelineRequest]): if not source_service or not destination_service: return + lineage_details = LineageDetails( + pipeline=EntityReference(id=pipeline_entity.id, type="pipeline") + ) + for task in connection.get("syncCatalog", {}).get("streams") or []: stream = task.get("stream") from_fqn = fqn.build( @@ -231,23 +235,21 @@ class AirbyteSource(Source[CreatePipelineRequest]): service_name=destination_connection.get("name"), ) - if not from_fqn and not to_fqn: - continue - from_entity = self.metadata.get_by_name(entity=Table, fqn=from_fqn) to_entity = self.metadata.get_by_name(entity=Table, fqn=to_fqn) - yield AddLineageRequest( + + if not from_entity or not to_entity: + continue + + lineage = AddLineageRequest( edge=EntitiesEdge( fromEntity=EntityReference(id=from_entity.id, type="table"), - toEntity=EntityReference(id=pipeline_entity.id, type="pipeline"), - ) - ) - yield AddLineageRequest( - edge=EntitiesEdge( toEntity=EntityReference(id=to_entity.id, type="table"), - fromEntity=EntityReference(id=pipeline_entity.id, type="pipeline"), ) ) + if lineage_details: + lineage.edge.lineageDetails = lineage_details + yield lineage def next_record(self) -> Iterable[Entity]: """ diff --git a/ingestion/src/metadata/ingestion/source/pipeline/airflow.py b/ingestion/src/metadata/ingestion/source/pipeline/airflow.py index 764fa01abc9..6f01db7d329 100644 --- a/ingestion/src/metadata/ingestion/source/pipeline/airflow.py +++ b/ingestion/src/metadata/ingestion/source/pipeline/airflow.py @@ -47,7 +47,7 @@ from metadata.generated.schema.metadataIngestion.pipelineServiceMetadataPipeline from metadata.generated.schema.metadataIngestion.workflow import ( Source as WorkflowSource, ) -from metadata.generated.schema.type.entityLineage import EntitiesEdge +from metadata.generated.schema.type.entityLineage import EntitiesEdge, LineageDetails from metadata.generated.schema.type.entityReference import EntityReference from metadata.ingestion.api.common import Entity from metadata.ingestion.api.source import InvalidSourceException, Source, SourceStatus @@ -290,48 +290,40 @@ class AirflowSource(Source[CreatePipelineRequest]): :return: Lineage from inlets and outlets """ dag: SerializedDAG = serialized_dag.dag - + lineage_details = LineageDetails( + pipeline=EntityReference(id=pipeline_entity.id, type="pipeline") + ) for task in dag.tasks: - for table_fqn in self.get_inlets(task) or []: - table_entity: Table = self.metadata.get_by_name( - entity=Table, fqn=table_fqn - ) - if table_entity: - yield AddLineageRequest( - edge=EntitiesEdge( - fromEntity=EntityReference( - id=table_entity.id, type="table" - ), - toEntity=EntityReference( - id=pipeline_entity.id, type="pipeline" - ), - ) - ) + for from_fqn in self.get_inlets(task) or []: + from_entity = self.metadata.get_by_name(entity=Table, fqn=from_fqn) + if from_entity: + for to_fqn in self.get_outlets(task) or []: + to_entity = self.metadata.get_by_name(entity=Table, fqn=to_fqn) + if to_entity: + lineage = AddLineageRequest( + edge=EntitiesEdge( + fromEntity=EntityReference( + id=from_entity.id, type="table" + ), + toEntity=EntityReference( + id=to_entity.id, type="table" + ), + ) + ) + if lineage_details: + lineage.edge.lineageDetails = lineage_details + yield lineage + else: + logger.warn( + f"Could not find Table [{to_fqn}] from " + f"[{pipeline_entity.fullyQualifiedName.__root__}] outlets" + ) else: logger.warn( - f"Could not find Table [{table_fqn}] from " + f"Could not find Table [{from_fqn}] from " f"[{pipeline_entity.fullyQualifiedName.__root__}] inlets" ) - for table_fqn in self.get_outlets(task) or []: - table_entity: Table = self.metadata.get_by_name( - entity=Table, fqn=table_fqn - ) - if table_entity: - yield AddLineageRequest( - edge=EntitiesEdge( - fromEntity=EntityReference( - id=pipeline_entity.id, type="pipeline" - ), - toEntity=EntityReference(id=table_entity.id, type="table"), - ) - ) - else: - logger.warn( - f"Could not find Table [{table_fqn}] from " - f"[{pipeline_entity.fullyQualifiedName.__root__}] outlets" - ) - def next_record(self) -> Iterable[Entity]: """ Extract metadata information to create Pipelines with Tasks diff --git a/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.test.tsx new file mode 100644 index 00000000000..ede5e081f8e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright 2021 Collate + * 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 { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import AddPipeLineModal from './AddPipeLineModal'; + +const mockProps = { + showAddPipelineModal: true, + pipelineSearchValue: '', + selectedPipelineId: undefined, + pipelineOptions: [ + { + displayName: 'Pipeline 1', + name: 'Pipeline 1', + id: 'test-pipeline-1', + type: 'pipeline', + }, + ], + handleModalCancel: jest.fn(), + handleModalSave: jest.fn(), + onClear: jest.fn(), + handleRemoveEdgeClick: jest.fn(), + onSearch: jest.fn(), + onSelect: jest.fn(), +}; + +describe('Test CustomEdge Component', () => { + it('AddPipeLineModal should render properly', async () => { + render(); + + const pipelineModal = await screen.findByTestId('add-pipeline-modal'); + const fieldSelect = await screen.findByTestId('field-select'); + const removeEdge = await screen.findByTestId('remove-edge-button'); + const saveButton = await screen.findByTestId('save-button'); + + expect(pipelineModal).toBeInTheDocument(); + expect(fieldSelect).toBeInTheDocument(); + expect(removeEdge).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + }); + + it('CTA should work properly', async () => { + render( + + ); + + const removeEdge = await screen.findByTestId('remove-edge-button'); + const saveButton = await screen.findByTestId('save-button'); + + expect(removeEdge).toBeInTheDocument(); + expect(saveButton).toBeInTheDocument(); + + userEvent.click(removeEdge); + userEvent.click(saveButton); + + expect(mockProps.handleRemoveEdgeClick).toHaveBeenCalled(); + expect(mockProps.handleModalSave).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.tsx new file mode 100644 index 00000000000..22fc2a08957 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/EntityLineage/AddPipeLineModal.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2021 Collate + * 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 { Modal, Select } from 'antd'; +import { isUndefined } from 'lodash'; +import React from 'react'; +import { EntityReference } from '../../generated/api/services/createPipelineService'; +import { getEntityName } from '../../utils/CommonUtils'; +import { Button } from '../buttons/Button/Button'; + +interface AddPipeLineModalType { + showAddPipelineModal: boolean; + pipelineSearchValue: string; + selectedPipelineId: string | undefined; + pipelineOptions: EntityReference[]; + handleModalCancel: () => void; + handleModalSave: () => void; + onClear: () => void; + handleRemoveEdgeClick: (evt: React.MouseEvent) => void; + onSearch: (value: string) => void; + onSelect: (value: string) => void; +} + +const AddPipeLineModal = ({ + showAddPipelineModal, + pipelineOptions, + pipelineSearchValue, + selectedPipelineId, + handleRemoveEdgeClick, + handleModalCancel, + handleModalSave, + onClear, + onSearch, + onSelect, +}: AddPipeLineModalType) => { + const Footer = () => { + return ( +
+ + + +
+ ); + }; + + return ( + } + title={isUndefined(selectedPipelineId) ? 'Add Pipeline' : 'Edit Pipeline'} + visible={showAddPipelineModal} + onCancel={handleModalCancel}> +