diff --git a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java index 38c7d38c409..1267a8ef451 100644 --- a/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java +++ b/catalog-rest-service/src/main/java/org/openmetadata/catalog/jdbi3/CollectionDAO.java @@ -2784,7 +2784,8 @@ public interface CollectionDAO { .withMlmodelCount(rs.getInt("mlmodelCount")) .withServicesCount(rs.getInt("servicesCount")) .withUserCount(rs.getInt("userCount")) - .withTeamCount(rs.getInt("teamCount")); + .withTeamCount(rs.getInt("teamCount")) + .withTestSuiteCount(rs.getInt("testSuiteCount")); } } @@ -2814,7 +2815,8 @@ public interface CollectionDAO { + "(SELECT COUNT(*) FROM pipeline_service_entity)+ " + "(SELECT COUNT(*) FROM mlmodel_service_entity)) as servicesCount, " + "(SELECT COUNT(*) FROM user_entity WHERE JSON_EXTRACT(json, '$.isBot') IS NULL OR JSON_EXTRACT(json, '$.isBot') = FALSE) as userCount, " - + "(SELECT COUNT(*) FROM team_entity) as teamCount", + + "(SELECT COUNT(*) FROM team_entity) as teamCount, " + + "(SELECT COUNT(*) FROM test_suite) as testSuiteCount", connectionType = MYSQL) @ConnectionAwareSqlQuery( value = @@ -2829,7 +2831,8 @@ public interface CollectionDAO { + "(SELECT COUNT(*) FROM pipeline_service_entity)+ " + "(SELECT COUNT(*) FROM mlmodel_service_entity)) as servicesCount, " + "(SELECT COUNT(*) FROM user_entity WHERE json#>'{isBot}' IS NULL OR ((json#>'{isBot}')::boolean) = FALSE) as userCount, " - + "(SELECT COUNT(*) FROM team_entity) as teamCount", + + "(SELECT COUNT(*) FROM team_entity) as teamCount, " + + "(SELECT COUNT(*) FROM test_suite) as testSuiteCount", connectionType = POSTGRES) @RegisterRowMapper(EntitiesCountRowMapper.class) EntitiesCount getAggregatedEntitiesCount() throws StatementException; diff --git a/catalog-rest-service/src/main/resources/json/schema/entity/utils/entitiesCount.json b/catalog-rest-service/src/main/resources/json/schema/entity/utils/entitiesCount.json index f2c66f912b5..048b19876df 100644 --- a/catalog-rest-service/src/main/resources/json/schema/entity/utils/entitiesCount.json +++ b/catalog-rest-service/src/main/resources/json/schema/entity/utils/entitiesCount.json @@ -37,6 +37,10 @@ "teamCount": { "description": "Team Count", "type": "integer" + }, + "testSuiteCount": { + "description": "Test Suite Count", + "type": "integer" } }, "additionalProperties": false diff --git a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/util/UtilResourceTest.java b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/util/UtilResourceTest.java index fbc589f4336..8a1ecce1b96 100644 --- a/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/util/UtilResourceTest.java +++ b/catalog-rest-service/src/test/java/org/openmetadata/catalog/resources/util/UtilResourceTest.java @@ -25,10 +25,12 @@ import org.openmetadata.catalog.api.services.CreateMlModelService; import org.openmetadata.catalog.api.services.CreatePipelineService; import org.openmetadata.catalog.api.teams.CreateTeam; import org.openmetadata.catalog.api.teams.CreateUser; +import org.openmetadata.catalog.api.tests.CreateTestSuite; import org.openmetadata.catalog.entity.data.Table; import org.openmetadata.catalog.resources.EntityResourceTest; import org.openmetadata.catalog.resources.dashboards.DashboardResourceTest; import org.openmetadata.catalog.resources.databases.TableResourceTest; +import org.openmetadata.catalog.resources.dqtests.TestSuiteResourceTest; import org.openmetadata.catalog.resources.pipelines.PipelineResourceTest; import org.openmetadata.catalog.resources.services.DashboardServiceResourceTest; import org.openmetadata.catalog.resources.services.DatabaseServiceResourceTest; @@ -73,6 +75,7 @@ public class UtilResourceTest extends CatalogApplicationTest { int beforeServiceCount = getEntitiesCount().getServicesCount(); int beforeUserCount = getEntitiesCount().getUserCount(); int beforeTeamCount = getEntitiesCount().getTeamCount(); + int beforeTestSuiteCount = getEntitiesCount().getTestSuiteCount(); // Create Table TableResourceTest tableResourceTest = new TableResourceTest(); @@ -109,6 +112,11 @@ public class UtilResourceTest extends CatalogApplicationTest { CreateTeam createTeam = teamResourceTest.createRequest(test); teamResourceTest.createEntity(createTeam, ADMIN_AUTH_HEADERS); + // Create Test Suite + TestSuiteResourceTest testSuiteResourceTest = new TestSuiteResourceTest(); + CreateTestSuite createTestSuite = testSuiteResourceTest.createRequest(test); + testSuiteResourceTest.createEntity(createTestSuite, ADMIN_AUTH_HEADERS); + // Get count after adding entities int afterTableCount = getEntitiesCount().getTableCount(); int afterDashboardCount = getEntitiesCount().getDashboardCount(); @@ -117,6 +125,7 @@ public class UtilResourceTest extends CatalogApplicationTest { int afterServiceCount = getEntitiesCount().getServicesCount(); int afterUserCount = getEntitiesCount().getUserCount(); int afterTeamCount = getEntitiesCount().getTeamCount(); + int afterTestSuiteCount = getEntitiesCount().getTestSuiteCount(); int actualCount = 1; @@ -127,6 +136,7 @@ public class UtilResourceTest extends CatalogApplicationTest { Assertions.assertEquals(afterTableCount - beforeTableCount, actualCount); Assertions.assertEquals(afterTeamCount - beforeTeamCount, actualCount); Assertions.assertEquals(afterTopicCount - beforeTopicCount, actualCount); + Assertions.assertEquals(afterTestSuiteCount - beforeTestSuiteCount, actualCount); } @Test diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-down.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-down.svg new file mode 100644 index 00000000000..aabe1c22600 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-right.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-right.svg new file mode 100644 index 00000000000..bbdf8c80bed --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/ingestionPipelineAPI.ts b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/ingestionPipelineAPI.ts index f7313d93f2d..d871d003b30 100644 --- a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/ingestionPipelineAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/ingestionPipelineAPI.ts @@ -12,6 +12,7 @@ */ import { AxiosResponse } from 'axios'; +import { Operation } from 'fast-json-patch'; import { CreateIngestionPipeline } from '../generated/api/services/ingestionPipelines/createIngestionPipeline'; import { IngestionPipeline } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { Paging } from '../generated/type/paging'; @@ -96,6 +97,19 @@ export const updateIngestionPipeline = async ( return response.data; }; +export const patchIngestionPipeline = async (id: string, data: Operation[]) => { + const configOptions = { + headers: { 'Content-type': 'application/json-patch+json' }, + }; + + const response = await APIClient.patch< + Operation[], + AxiosResponse + >(`/services/ingestionPipelines/${id}`, data, configOptions); + + return response.data; +}; + export const checkAirflowStatus = (): Promise => { return APIClient.get('/services/ingestionPipelines/status'); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/testAPI.ts b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/testAPI.ts index 77ef8ec1413..c741b4d608a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/testAPI.ts +++ b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/testAPI.ts @@ -11,6 +11,8 @@ * limitations under the License. */ +import { AxiosResponse } from 'axios'; +import { Operation } from 'fast-json-patch'; import { CreateTestCase } from '../generated/api/tests/createTestCase'; import { CreateTestSuite } from '../generated/api/tests/createTestSuite'; import { TestCase, TestCaseResult } from '../generated/tests/testCase'; @@ -83,20 +85,16 @@ export const createTestCase = async (data: CreateTestCase) => { return response.data; }; -// testSuite section -export const getListTestSuites = async (params?: ListParams) => { - const response = await APIClient.get<{ - data: TestSuite[]; - paging: Paging; - }>(testSuiteUrl, { - params, - }); +export const updateTestCaseById = async (id: string, data: Operation[]) => { + const configOptions = { + headers: { 'Content-type': 'application/json-patch+json' }, + }; - return response.data; -}; - -export const createTestSuites = async (data: CreateTestSuite) => { - const response = await APIClient.post(testSuiteUrl, data); + const response = await APIClient.patch>( + `${testCaseUrl}/${id}`, + data, + configOptions + ); return response.data; }; @@ -114,3 +112,60 @@ export const getListTestDefinitions = async ( return response.data; }; +export const getTestDefinitionById = async ( + id: string, + params?: Pick +) => { + const response = await APIClient.get( + `${testDefinitionUrl}/${id}`, + { + params, + } + ); + + return response.data; +}; + +// testSuite Section +export const getListTestSuites = async (params?: ListParams) => { + const response = await APIClient.get<{ + data: TestSuite[]; + paging: Paging; + }>(testSuiteUrl, { + params, + }); + + return response.data; +}; + +export const createTestSuites = async (data: CreateTestSuite) => { + const response = await APIClient.post(testSuiteUrl, data); + + return response.data; +}; + +export const getTestSuiteByName = async ( + name: string, + params?: ListTestCaseParams +) => { + const response = await APIClient.get( + `${testSuiteUrl}/name/${name}`, + { params } + ); + + return response.data; +}; + +export const updateTestSuiteById = async (id: string, data: Operation[]) => { + const configOptions = { + headers: { 'Content-type': 'application/json-patch+json' }, + }; + + const response = await APIClient.patch>( + `${testSuiteUrl}/${id}`, + data, + configOptions + ); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTest.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTest.interface.ts index ae271bb6e6a..831b657df2f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTest.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTest.interface.ts @@ -13,6 +13,7 @@ import { ReactNode } from 'react'; import { Table } from '../../generated/entity/data/table'; +import { IngestionPipeline } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { TestCase } from '../../generated/tests/testCase'; import { TestDefinition } from '../../generated/tests/testDefinition'; import { TestSuite } from '../../generated/tests/testSuite'; @@ -34,10 +35,12 @@ export interface TestCaseFormProps { export interface TestSuiteIngestionProps { testSuite: TestSuite; + ingestionPipeline?: IngestionPipeline; onCancel: () => void; } export interface TestSuiteSchedulerProps { + initialData?: string; onSubmit: (repeatFrequency: string) => void; onCancel: () => void; } @@ -59,3 +62,10 @@ export type SelectTestSuiteType = { export interface ParameterFormProps { definition: TestDefinition; } + +export interface EditTestCaseModalProps { + visible: boolean; + testCase: TestCase; + onCancel: () => void; + onUpdate?: () => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTestV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTestV1.tsx index e896974b244..f72ea4c617f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTestV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/AddDataQualityTestV1.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Col, Row } from 'antd'; +import { Col, Row, Typography } from 'antd'; import { AxiosError } from 'axios'; import { isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -40,7 +40,7 @@ import { getNameFromFQN, getPartialNameFromTableFQN, } from '../../utils/CommonUtils'; -import { getSettingPath } from '../../utils/RouterUtils'; +import { getTestSuitePath } from '../../utils/RouterUtils'; import { serviceTypeLogo } from '../../utils/ServiceUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import SuccessScreen from '../common/success-screen/SuccessScreen'; @@ -126,7 +126,7 @@ const AddDataQualityTestV1: React.FC = ({ table }) => { }, [table, entityTypeFQN, isColumnFqn]); const handleViewTestSuiteClick = () => { - history.push(getSettingPath()); + history.push(getTestSuitePath(testSuiteData?.fullyQualifiedName || '')); }; const handleAirflowStatusCheck = (): Promise => { @@ -284,9 +284,11 @@ const AddDataQualityTestV1: React.FC = ({ table }) => { ) : ( -
+ {`Add ${isColumnFqn ? 'Column' : 'Table'} Test`} -
+ = ({ + visible, + testCase, + onCancel, + onUpdate, +}) => { + const [form] = Form.useForm(); + const [selectedDefinition, setSelectedDefinition] = + useState(); + const [sqlQuery, setSqlQuery] = useState( + testCase?.parameterValues?.[0] ?? { + name: 'sqlExpression', + value: '', + } + ); + const [isLoading, setIsLoading] = useState(true); + const markdownRef = useRef(); + + const GenerateParamsField = useCallback(() => { + if (selectedDefinition && selectedDefinition.parameterDefinition) { + const name = selectedDefinition.parameterDefinition[0].name; + if (name === 'sqlExpression') { + return ( + + + + Profile Sample Query + + { + setSqlQuery((pre) => ({ ...pre, value })); + }} + onChange={(_Editor, _EditorChange, value) => { + setSqlQuery((pre) => ({ ...pre, value })); + }} + /> + + + ); + } + + return ; + } + + return; + }, [testCase, selectedDefinition, sqlQuery]); + + const fetchTestDefinitionById = async () => { + setIsLoading(true); + try { + const definition = await getTestDefinitionById( + testCase.testDefinition.id || '' + ); + setSelectedDefinition(definition); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }; + + const createTestCaseObj = (value: { + testName: string; + params: Record; + testTypeId: string; + }) => { + const paramsValue = selectedDefinition?.parameterDefinition?.[0]; + + const parameterValues = + paramsValue?.name === 'sqlExpression' + ? [sqlQuery] + : Object.entries(value.params || {}).map(([key, value]) => ({ + name: key, + value: + paramsValue?.dataType === TestDataType.Array + ? // need to send array as string formate + JSON.stringify( + (value as { value: string }[]).map((data) => data.value) + ) + : value, + })); + + return { + parameterValues: parameterValues as TestCaseParameterValue[], + description: markdownRef.current?.getEditorContent(), + }; + }; + + const handleFormSubmit: FormProps['onFinish'] = async (value) => { + const { parameterValues, description } = createTestCaseObj(value); + const updatedTestCase = { ...testCase, parameterValues, description }; + const jsonPatch = compare(testCase, updatedTestCase); + + if (jsonPatch.length) { + try { + await updateTestCaseById(testCase.id || '', jsonPatch); + onUpdate && onUpdate(); + showSuccessToast( + jsonData['api-success-messages']['update-test-case-success'] + ); + onCancel(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + form.resetFields(); + } + } + }; + + const getParamsValue = () => { + return testCase?.parameterValues?.reduce( + (acc, curr) => ({ + ...acc, + [curr.name || '']: + selectedDefinition?.parameterDefinition?.[0].dataType === + TestDataType.Array + ? (JSON.parse(curr.value || '[]') as string[]).map((val) => ({ + value: val, + })) + : curr.value, + }), + {} + ); + }; + + useEffect(() => { + if (testCase) { + fetchTestDefinitionById(); + form.setFieldsValue({ + name: testCase?.name, + testDefinition: testCase?.testDefinition?.name, + params: getParamsValue(), + }); + } + }, [testCase]); + + return ( + { + form.resetFields(); + onCancel(); + }} + title={`Edit ${testCase?.name}`} + visible={visible} + onCancel={onCancel} + onOk={() => form.submit()}> + {isLoading ? ( + + ) : ( +
+ + + + + + + + {GenerateParamsField()} + + + + +
+ )} +
+ ); +}; + +export default EditTestCaseModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/TestSuiteIngestion.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/TestSuiteIngestion.tsx index 5f8b7cd2583..298354fe466 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/TestSuiteIngestion.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/TestSuiteIngestion.tsx @@ -10,14 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Col, Row } from 'antd'; +import { Col, Row, Typography } from 'antd'; import { AxiosError } from 'axios'; +import { compare } from 'fast-json-patch'; import { camelCase } from 'lodash'; import React, { useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; +import { useHistory, useParams } from 'react-router-dom'; import { addIngestionPipeline, deployIngestionPipelineById, + patchIngestionPipeline, } from '../../axiosAPIs/ingestionPipelineAPI'; import { DEPLOYED_PROGRESS_VAL, @@ -32,7 +34,7 @@ import { } from '../../generated/api/services/ingestionPipelines/createIngestionPipeline'; import { IngestionPipeline } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; import jsonData from '../../jsons/en'; -import { getSettingPath } from '../../utils/RouterUtils'; +import { getTestSuitePath } from '../../utils/RouterUtils'; import { showErrorToast } from '../../utils/ToastUtils'; import SuccessScreen from '../common/success-screen/SuccessScreen'; import DeployIngestionLoaderModal from '../Modals/DeployIngestionLoaderModal/DeployIngestionLoaderModal'; @@ -40,23 +42,31 @@ import { TestSuiteIngestionProps } from './AddDataQualityTest.interface'; import TestSuiteScheduler from './components/TestSuiteScheduler'; const TestSuiteIngestion: React.FC = ({ + ingestionPipeline, testSuite, onCancel, }) => { + const { ingestionFQN } = useParams>(); const history = useHistory(); - const [ingestionData, setIngestionData] = useState(); + const [ingestionData, setIngestionData] = useState< + IngestionPipeline | undefined + >(ingestionPipeline); const [showDeployModal, setShowDeployModal] = useState(false); const [showDeployButton, setShowDeployButton] = useState(false); const [ingestionAction, setIngestionAction] = useState( - IngestionActionMessage.CREATING + ingestionFQN + ? IngestionActionMessage.UPDATING + : IngestionActionMessage.CREATING ); const [isIngestionDeployed, setIsIngestionDeployed] = useState(false); const [isIngestionCreated, setIsIngestionCreated] = useState(false); const [ingestionProgress, setIngestionProgress] = useState(0); const getSuccessMessage = useMemo(() => { const createMessage = showDeployButton - ? 'has been created, but failed to deploy' - : 'has been created and deployed successfully'; + ? `has been ${ingestionFQN ? 'updated' : 'created'}, but failed to deploy` + : `has been ${ + ingestionFQN ? 'updated' : 'created' + } and deployed successfully`; return ( @@ -71,27 +81,25 @@ const TestSuiteIngestion: React.FC = ({ const handleIngestionDeploy = (id?: string) => { setShowDeployModal(true); - return new Promise((resolve) => { - setIsIngestionCreated(true); - setIngestionProgress(INGESTION_PROGRESS_END_VAL); - setIngestionAction(IngestionActionMessage.DEPLOYING); + setIsIngestionCreated(true); + setIngestionProgress(INGESTION_PROGRESS_END_VAL); + setIngestionAction(IngestionActionMessage.DEPLOYING); - deployIngestionPipelineById(`${id || ingestionData?.id}`) - .then(() => { - setIsIngestionDeployed(true); - setShowDeployButton(false); - setIngestionProgress(DEPLOYED_PROGRESS_VAL); - setIngestionAction(IngestionActionMessage.DEPLOYED); - }) - .catch((err: AxiosError) => { - setShowDeployButton(true); - setIngestionAction(IngestionActionMessage.DEPLOYING_ERROR); - showErrorToast( - err || jsonData['api-error-messages']['deploy-ingestion-error'] - ); - }) - .finally(() => resolve()); - }); + deployIngestionPipelineById(`${id || ingestionData?.id}`) + .then(() => { + setIsIngestionDeployed(true); + setShowDeployButton(false); + setIngestionProgress(DEPLOYED_PROGRESS_VAL); + setIngestionAction(IngestionActionMessage.DEPLOYED); + }) + .catch((err: AxiosError) => { + setShowDeployButton(true); + setIngestionAction(IngestionActionMessage.DEPLOYING_ERROR); + showErrorToast( + err || jsonData['api-error-messages']['deploy-ingestion-error'] + ); + }) + .finally(() => setTimeout(() => setShowDeployModal(false), 500)); }; const createIngestionPipeline = async (repeatFrequency: string) => { @@ -115,28 +123,62 @@ const TestSuiteIngestion: React.FC = ({ const ingestion = await addIngestionPipeline(ingestionPayload); setIngestionData(ingestion); - handleIngestionDeploy(ingestion.id).finally(() => - setShowDeployModal(false) + handleIngestionDeploy(ingestion.id); + }; + + const updateIngestionPipeline = async (repeatFrequency: string) => { + const updatedPipeline = { + ...ingestionPipeline, + airflowConfig: { + ...ingestionPipeline?.airflowConfig, + scheduleInterval: repeatFrequency, + }, + }; + const jsonPatch = compare( + ingestionPipeline as IngestionPipeline, + updatedPipeline ); + if (jsonPatch.length) { + try { + const response = await patchIngestionPipeline( + ingestionPipeline?.id || '', + jsonPatch + ); + handleIngestionDeploy(response.id); + } catch (error) { + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['update-ingestion-error'] + ); + } + } + }; + + const handleIngestionSubmit = (repeatFrequency: string) => { + if (ingestionFQN) { + updateIngestionPipeline(repeatFrequency); + } else { + createIngestionPipeline(repeatFrequency); + } }; const handleViewTestSuiteClick = () => { - history.push(getSettingPath()); + history.push(getTestSuitePath(testSuite?.fullyQualifiedName || '')); }; const handleDeployClick = () => { setShowDeployModal(true); - handleIngestionDeploy?.().finally(() => { - setTimeout(() => setShowDeployModal(false), 500); - }); + handleIngestionDeploy(); }; return ( -
+ Schedule Ingestion -
+ @@ -154,8 +196,9 @@ const TestSuiteIngestion: React.FC = ({ /> ) : ( )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/RightPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/RightPanel.tsx index cb9a9e4354b..6e7da009ece 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/RightPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/RightPanel.tsx @@ -18,9 +18,11 @@ import { RightPanelProps } from '../AddDataQualityTest.interface'; const RightPanel: React.FC = ({ data }) => { return ( -
+ {data.title} -
+ {data.body}
); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestCaseForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestCaseForm.tsx index ead8d88e4cd..1152796a39f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestCaseForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestCaseForm.tsx @@ -89,12 +89,14 @@ const TestCaseForm: React.FC = ({ } }; - const GenerateParamsField = useCallback(() => { + const getSelectedTestDefinition = () => { const testType = initialValue?.testDefinition?.id ?? selectedTestType; - const selectedDefinition = testDefinitions.find( - (definition) => definition.id === testType - ); + return testDefinitions.find((definition) => definition.id === testType); + }; + + const GenerateParamsField = useCallback(() => { + const selectedDefinition = getSelectedTestDefinition(); if (selectedDefinition && selectedDefinition.parameterDefinition) { const name = selectedDefinition.parameterDefinition[0].name; if (name === 'sqlExpression') { @@ -130,10 +132,7 @@ const TestCaseForm: React.FC = ({ params: Record; testTypeId: string; }) => { - const testType = initialValue?.testDefinition?.id ?? selectedTestType; - const selectedDefinition = testDefinitions.find( - (definition) => definition.id === testType - ); + const selectedDefinition = getSelectedTestDefinition(); const paramsValue = selectedDefinition?.parameterDefinition?.[0]; const parameterValues = @@ -172,6 +171,22 @@ const TestCaseForm: React.FC = ({ onCancel(createTestCaseObj(data)); }; + const getParamsValue = () => { + return initialValue?.parameterValues?.reduce( + (acc, curr) => ({ + ...acc, + [curr.name || '']: + getSelectedTestDefinition()?.parameterDefinition?.[0].dataType === + TestDataType.Array + ? (JSON.parse(curr.value || '[]') as string[]).map((val) => ({ + value: val, + })) + : curr.value, + }), + {} + ); + }; + useEffect(() => { if (testDefinitions.length === 0) { fetchAllTestDefinitions(); @@ -187,13 +202,7 @@ const TestCaseForm: React.FC = ({ initialValues={{ testName: initialValue?.name, testTypeId: initialValue?.testDefinition?.id, - params: initialValue?.parameterValues?.reduce( - (acc, curr) => ({ - ...acc, - [curr.name || '']: curr.value, - }), - {} - ), + params: getParamsValue(), }} layout="vertical" name="tableTestForm" diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestSuiteScheduler.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestSuiteScheduler.tsx index c4c0ef30a00..b49d303314a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestSuiteScheduler.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AddDataQualityTest/components/TestSuiteScheduler.tsx @@ -12,15 +12,24 @@ */ import { Button, Col, Row, Space } from 'antd'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import CronEditor from '../../common/CronEditor/CronEditor'; import { TestSuiteSchedulerProps } from '../AddDataQualityTest.interface'; const TestSuiteScheduler: React.FC = ({ + initialData, onCancel, onSubmit, }) => { - const [repeatFrequency, setRepeatFrequency] = useState(''); + const [repeatFrequency, setRepeatFrequency] = useState( + initialData + ); + + useEffect(() => { + if (initialData) { + setRepeatFrequency(initialData); + } + }, [initialData]); return ( @@ -33,7 +42,9 @@ const TestSuiteScheduler: React.FC = ({ - diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Modals/IngestionLogsModal/IngestionLogsModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Modals/IngestionLogsModal/IngestionLogsModal.tsx index 0dc744bd2c7..b3b0c536188 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Modals/IngestionLogsModal/IngestionLogsModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Modals/IngestionLogsModal/IngestionLogsModal.tsx @@ -54,6 +54,7 @@ const IngestionLogsModal: FC = ({ profiler_task?: string; usage_task?: string; lineage_task?: string; + test_suite_task?: string; }> ) => { switch (pipelineType) { @@ -72,6 +73,10 @@ const IngestionLogsModal: FC = ({ case PipelineType.Lineage: setLogs(gzipToStringConverter(res.data?.lineage_task || '')); + break; + case PipelineType.TestSuite: + setLogs(gzipToStringConverter(res.data?.test_suite_task || '')); + break; default: diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.component.tsx index 35010869ed9..e01194e86ef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.component.tsx @@ -72,6 +72,16 @@ const MyAssetStats: FunctionComponent = ({ link: getExplorePathWithSearch(undefined, 'mlmodels'), dataTestId: 'mlmodels', }, + testSuite: { + icon: Icons.TABLE_GREY, + data: 'Test Suite', + count: entityCounts.testSuiteCount, + link: getSettingPath( + GlobalSettingsMenuCategory.DATA_QUALITY, + GlobalSettingOptions.TEST_SUITE + ), + dataTestId: 'test-suite', + }, service: { icon: Icons.SERVICE, data: 'Services', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.test.tsx index 826743ebbee..7e90f92ea4d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyAssetStats/MyAssetStats.test.tsx @@ -39,6 +39,7 @@ const mockProp = { servicesCount: 193, userCount: 100, teamCount: 7, + testSuiteCount: 1, } as EntitiesCount, }; @@ -53,14 +54,14 @@ describe('Test MyDataHeader Component', () => { expect(myDataHeader).toBeInTheDocument(); }); - it('Should have 8 data summary details', () => { + it('Should have 9 data summary details', () => { const { container } = render(, { wrapper: MemoryRouter, }); const dataSummary = getAllByTestId(container, /-summary$/); - expect(dataSummary.length).toBe(8); + expect(dataSummary.length).toBe(9); }); it('OnClick it should redirect to respective page', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/ProfilerDashboard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/ProfilerDashboard.tsx index 864fab89c68..b026f195169 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/ProfilerDashboard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/ProfilerDashboard.tsx @@ -69,6 +69,7 @@ const ProfilerDashboard: React.FC = ({ testCases, fetchProfilerData, fetchTestCases, + onTestCaseUpdate, profilerData, onTableChange, }) => { @@ -414,7 +415,10 @@ const ProfilerDashboard: React.FC = ({ {activeTab === ProfilerDashboardTab.DATA_QUALITY && ( - + )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/DataQualityTab.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/DataQualityTab.tsx index 503a71cfd26..2e17db1da96 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/DataQualityTab.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ProfilerDashboard/component/DataQualityTab.tsx @@ -11,20 +11,27 @@ * limitations under the License. */ -import { Button, Space, Table, Tooltip } from 'antd'; +import { Button, Row, Space, Table, Tooltip } from 'antd'; import { ColumnsType } from 'antd/lib/table'; import { isUndefined } from 'lodash'; import moment from 'moment'; import React, { useMemo, useState } from 'react'; +import { ReactComponent as ArrowDown } from '../../../assets/svg/arrow-down.svg'; +import { ReactComponent as ArrowRight } from '../../../assets/svg/arrow-right.svg'; import { TestCase, TestCaseResult } from '../../../generated/tests/testCase'; import SVGIcons, { Icons } from '../../../utils/SvgUtils'; import { getTestResultBadgeIcon } from '../../../utils/TableUtils'; +import EditTestCaseModal from '../../AddDataQualityTest/EditTestCaseModal'; import DeleteWidgetModal from '../../common/DeleteWidget/DeleteWidgetModal'; import { DataQualityTabProps } from '../profilerDashboard.interface'; import TestSummary from './TestSummary'; -const DataQualityTab: React.FC = ({ testCases }) => { +const DataQualityTab: React.FC = ({ + testCases, + onTestUpdate, +}) => { const [selectedTestCase, setSelectedTestCase] = useState(); + const [editTestCase, setEditTestCase] = useState(); const columns: ColumnsType = useMemo(() => { return [ { @@ -67,24 +74,47 @@ const DataQualityTab: React.FC = ({ testCases }) => { title: 'Actions', dataIndex: 'actions', key: 'actions', + width: 100, render: (_, record) => ( - + + + )} + {children} + + + ); +}; + +export default TestCaseCommonTabContainer; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TestCasesTab/TestCasesTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TestCasesTab/TestCasesTab.component.tsx new file mode 100644 index 00000000000..340a5458743 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/TestCasesTab/TestCasesTab.component.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2022 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 { Col } from 'antd'; +import { orderBy } from 'lodash'; +import React from 'react'; +import { TestCase } from '../../generated/tests/testCase'; +import { Paging } from '../../generated/type/paging'; +import DataQualityTab from '../ProfilerDashboard/component/DataQualityTab'; +import TestCaseCommonTabContainer from '../TestCaseCommonTabContainer/TestCaseCommonTabContainer.component'; + +const TestCasesTab = ({ + testCases, + testCasesPaging, + currentPage, + onTestUpdate, + testCasePageHandler, +}: { + testCases: Array; + testCasesPaging: Paging; + currentPage: number; + onTestUpdate: () => void; + testCasePageHandler: ( + cursorValue: string | number, + activePage?: number | undefined + ) => void; +}) => { + const sortedTestCases = orderBy(testCases || [], ['name'], 'asc'); + + return ( + + + + + + ); +}; + +export default TestCasesTab; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.component.tsx new file mode 100644 index 00000000000..b7a2ecb2d3b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.component.tsx @@ -0,0 +1,81 @@ +import { Space } from 'antd'; +import React from 'react'; +import { IcDeleteColored } from '../../utils/SvgUtils'; +import { Button } from '../buttons/Button/Button'; +import DeleteWidgetModal from '../common/DeleteWidget/DeleteWidgetModal'; +import Description from '../common/description/Description'; +import EntitySummaryDetails from '../common/EntitySummaryDetails/EntitySummaryDetails'; +import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component'; +import { TestSuiteDetailsProps } from './TestSuiteDetails.interfaces'; + +const TestSuiteDetails = ({ + extraInfo, + slashedBreadCrumb, + handleDeleteWidgetVisible, + isDeleteWidgetVisible, + isDescriptionEditable, + testSuite, + handleUpdateOwner, + testSuiteDescription, + descriptionHandler, + handleDescriptionUpdate, +}: TestSuiteDetailsProps) => { + return ( + <> + + + + handleDeleteWidgetVisible(false)} + /> + + +
+ {extraInfo.map((info, index) => ( + + + + ))} +
+ + + descriptionHandler(false)} + onDescriptionEdit={() => descriptionHandler(true)} + onDescriptionUpdate={handleDescriptionUpdate} + /> + + + ); +}; + +export default TestSuiteDetails; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.interfaces.ts b/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.interfaces.ts new file mode 100644 index 00000000000..b07faef87ff --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/TestSuiteDetails/TestSuiteDetails.interfaces.ts @@ -0,0 +1,17 @@ +import { ExtraInfo } from 'Models'; +import { TestSuite } from '../../generated/tests/testSuite'; +import { TitleBreadcrumbProps } from '../common/title-breadcrumb/title-breadcrumb.interface'; + +export interface TestSuiteDetailsProps { + extraInfo: ExtraInfo[]; + slashedBreadCrumb: TitleBreadcrumbProps['titleLinks']; + handleDeleteWidgetVisible: (isVisible: boolean) => void; + isDeleteWidgetVisible: boolean; + isTagEditable?: boolean; + isDescriptionEditable: boolean; + testSuite: TestSuite | undefined; + handleUpdateOwner: (updatedOwner: TestSuite['owner']) => void; + testSuiteDescription: string | undefined; + descriptionHandler: (value: boolean) => void; + handleDescriptionUpdate: (updatedHTML: string) => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/TestSuitePipelineTab/TestSuitePipelineTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/TestSuitePipelineTab/TestSuitePipelineTab.component.tsx new file mode 100644 index 00000000000..1f5b94bbc23 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/TestSuitePipelineTab/TestSuitePipelineTab.component.tsx @@ -0,0 +1,443 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, Col, Row, Table } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import { AxiosError } from 'axios'; +import cronstrue from 'cronstrue'; +import React, { Fragment, useEffect, useMemo, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { + deleteIngestionPipelineById, + deployIngestionPipelineById, + enableDisableIngestionPipelineById, + getIngestionPipelines, + triggerIngestionPipelineById, +} from '../../axiosAPIs/ingestionPipelineAPI'; +import { fetchAirflowConfig } from '../../axiosAPIs/miscAPI'; +import { TITLE_FOR_NON_ADMIN_ACTION } from '../../constants/constants'; +import { IngestionPipeline } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; +import jsonData from '../../jsons/en'; +import { getIngestionStatuses } from '../../utils/CommonUtils'; +import { getTestSuiteIngestionPath } from '../../utils/RouterUtils'; +import SVGIcons, { Icons } from '../../utils/SvgUtils'; +import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; +import NonAdminAction from '../common/non-admin-action/NonAdminAction'; +import PopOver from '../common/popover/PopOver'; +import Loader from '../Loader/Loader'; +import EntityDeleteModal from '../Modals/EntityDeleteModal/EntityDeleteModal'; +import IngestionLogsModal from '../Modals/IngestionLogsModal/IngestionLogsModal'; +import KillIngestionModal from '../Modals/KillIngestionPipelineModal/KillIngestionPipelineModal'; +import TestCaseCommonTabContainer from '../TestCaseCommonTabContainer/TestCaseCommonTabContainer.component'; + +const TestSuitePipelineTab = () => { + const { testSuiteFQN } = useParams>(); + const history = useHistory(); + const [isLoading, setIsLoading] = useState(true); + const [testSuitePipelines, setTestSuitePipelines] = useState< + IngestionPipeline[] + >([]); + const [airFlowEndPoint, setAirFlowEndPoint] = useState(''); + const [isLogsModalOpen, setIsLogsModalOpen] = useState(false); + const [selectedPipeline, setSelectedPipeline] = useState(); + const [isKillModalOpen, setIsKillModalOpen] = useState(false); + const [deleteSelection, setDeleteSelection] = useState({ + id: '', + name: '', + state: '', + }); + const [isConfirmationModalOpen, setIsConfirmationModalOpen] = useState(false); + const [currTriggerId, setCurrTriggerId] = useState({ id: '', state: '' }); + const [currDeployId, setCurrDeployId] = useState({ id: '', state: '' }); + + const handleCancelConfirmationModal = () => { + setIsConfirmationModalOpen(false); + setDeleteSelection({ + id: '', + name: '', + state: '', + }); + }; + + const getAllIngestionWorkflows = async (paging?: string) => { + try { + setIsLoading(true); + const response = await getIngestionPipelines( + ['owner', 'pipelineStatuses'], + testSuiteFQN, + paging + ); + setTestSuitePipelines(response.data); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }; + + const handleDelete = async (id: string, displayName: string) => { + setDeleteSelection({ id, name: displayName, state: 'waiting' }); + try { + await deleteIngestionPipelineById(id); + setDeleteSelection({ id, name: displayName, state: 'success' }); + getAllIngestionWorkflows(); + } catch (error) { + showErrorToast( + error as AxiosError, + `${jsonData['api-error-messages']['delete-ingestion-error']} ${displayName}` + ); + } finally { + handleCancelConfirmationModal(); + } + }; + + const fetchAirFlowEndPoint = async () => { + try { + const response = await fetchAirflowConfig(); + setAirFlowEndPoint(response.apiEndpoint); + } catch { + setAirFlowEndPoint(''); + } + }; + + const handleEnableDisableIngestion = async (id: string) => { + try { + await enableDisableIngestionPipelineById(id); + getAllIngestionWorkflows(); + } catch (error) { + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['unexpected-server-response'] + ); + } + }; + + const separator = ( + | + ); + + const confirmDelete = (id: string, name: string) => { + setDeleteSelection({ + id, + name, + state: '', + }); + setIsConfirmationModalOpen(true); + }; + + const handleTriggerIngestion = async (id: string, displayName: string) => { + setCurrTriggerId({ id, state: 'waiting' }); + + try { + await triggerIngestionPipelineById(id); + setCurrTriggerId({ id, state: 'success' }); + setTimeout(() => { + setCurrTriggerId({ id: '', state: '' }); + showSuccessToast('Pipeline triggered successfully'); + }, 1500); + getAllIngestionWorkflows(); + } catch (error) { + showErrorToast( + `${jsonData['api-error-messages']['triggering-ingestion-error']} ${displayName}` + ); + setCurrTriggerId({ id: '', state: '' }); + } + }; + + const handleDeployIngestion = async (id: string) => { + setCurrDeployId({ id, state: 'waiting' }); + + try { + await deployIngestionPipelineById(id); + setCurrDeployId({ id, state: 'success' }); + setTimeout(() => setCurrDeployId({ id: '', state: '' }), 1500); + } catch (error) { + setCurrDeployId({ id: '', state: '' }); + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['update-ingestion-error'] + ); + } + }; + + const getTriggerDeployButton = (ingestion: IngestionPipeline) => { + if (ingestion.deployed) { + return ( + + ); + } else { + return ( + + ); + } + }; + + useEffect(() => { + getAllIngestionWorkflows(); + fetchAirFlowEndPoint(); + }, []); + + const pipelineColumns = useMemo(() => { + const column: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (name: string) => { + return ( + <> + + + {name} + + + + + ); + }, + }, + { + title: 'Type', + dataIndex: 'pipelineType', + key: 'pipelineType', + }, + { + title: 'Schedule', + dataIndex: 'airflowConfig', + key: 'airflowEndpoint', + render: (_, record) => { + return ( + <> + {record?.airflowConfig.scheduleInterval ? ( + + {cronstrue.toString( + record.airflowConfig.scheduleInterval || '', + { + use24HourTimeFormat: true, + verbose: true, + } + )} + + } + position="bottom" + theme="light" + trigger="mouseenter"> + {record.airflowConfig.scheduleInterval ?? '--'} + + ) : ( + -- + )} + + ); + }, + }, + { + title: 'Recent Runs', + dataIndex: 'pipelineStatuses', + key: 'recentRuns', + render: (_, record) => ( + {getIngestionStatuses(record)} + ), + }, + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: (_, record) => { + return ( + <> + +
+ {record.enabled ? ( + + {getTriggerDeployButton(record)} + {separator} + + + ) : ( + + )} + {separator} + + {separator} + + {separator} + + {separator} + +
+
+ {isLogsModalOpen && + selectedPipeline && + record.id === selectedPipeline?.id && ( + { + setIsLogsModalOpen(false); + setSelectedPipeline(undefined); + }} + /> + )} + {isKillModalOpen && + selectedPipeline && + record.id === selectedPipeline?.id && ( + { + setIsKillModalOpen(false); + setSelectedPipeline(undefined); + }} + onIngestionWorkflowsUpdate={getAllIngestionWorkflows} + /> + )} + + ); + }, + }, + ]; + + return column; + }, [airFlowEndPoint, isKillModalOpen, isLogsModalOpen, selectedPipeline]); + + if (isLoading) { + return ; + } + + return ( + { + history.push(getTestSuiteIngestionPath(testSuiteFQN)); + }}> + + ({ + ...test, + key: test.name, + }))} + pagination={false} + size="small" + /> + {isConfirmationModalOpen && ( + + handleDelete(deleteSelection.id, deleteSelection.name) + } + /> + )} + + + ); +}; + +export default TestSuitePipelineTab; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetModal.tsx index 3125c3bb8ce..4b3d9b9d7a0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteWidget/DeleteWidgetModal.tsx @@ -98,6 +98,8 @@ const DeleteWidgetModal = ({ return `glossaries`; } else if (entityType === EntityType.POLICY) { return 'policies'; + } else if (entityType === EntityType.TEST_SUITE) { + return entityType; } else { return `${entityType}s`; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.interface.ts index 1545f325d68..65cf1ad0f5c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.interface.ts @@ -16,6 +16,7 @@ import { Table } from '../../../generated/entity/data/table'; import { ThreadType } from '../../../generated/entity/feed/thread'; export interface DescriptionProps { + className?: string; entityName?: string; owner?: Table['owner']; hasEditAccess?: boolean; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.tsx index 5f416fa0b07..e7d2b2ddaf0 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/description/Description.tsx @@ -32,6 +32,7 @@ import RichTextEditorPreviewer from '../rich-text-editor/RichTextEditorPreviewer import { DescriptionProps } from './Description.interface'; const Description: FC = ({ + className, hasEditAccess, onDescriptionEdit, description = '', @@ -181,8 +182,8 @@ const Description: FC = ({ }; return ( -
-
+
+
= [ { name: 'Select/Add Test Suite', step: 1 }, { name: 'Configure Test Case', step: 2 }, - // { name: 'Schedule Interval', step: 3 }, ]; diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts index 773328b3e76..4ddc3e35b26 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts @@ -34,6 +34,7 @@ export enum EntityType { BOT = 'bot', ROLE = 'role', POLICY = 'policy', + TEST_SUITE = 'testSuite', } export enum AssetsType { diff --git a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts index 45e81533917..8640715491a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts +++ b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts @@ -112,6 +112,8 @@ const jsonData = { 'fetch-table-profiler-config-error': 'Error while fetching table profiler config!', 'fetch-column-test-error': 'Error while fetching column test case!', + 'fetch-test-suite-error': 'Error while fetching test suite', + 'fetch-test-cases-error': 'Error while fetching test cases', 'test-connection-error': 'Error while testing connection!', @@ -144,6 +146,7 @@ const jsonData = { 'join-team-error': 'Error while joining the team!', 'leave-team-error': 'Error while leaving the team!', + 'update-test-suite-error': 'Error while updating test suite', }, 'api-success-messages': { 'create-conversation': 'Conversation created successfully!', @@ -161,6 +164,7 @@ const jsonData = { 'user-restored-success': 'User restored successfully!', 'update-profile-congif-success': 'Profile config updated successfully!', + 'update-test-case-success': 'Test case updated successfully!', }, 'form-error-messages': { 'empty-email': 'Email is required.', diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerDashboardPage/ProfilerDashboardPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerDashboardPage/ProfilerDashboardPage.tsx index efe16306d3f..18e1dcfb3e1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerDashboardPage/ProfilerDashboardPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ProfilerDashboardPage/ProfilerDashboardPage.tsx @@ -83,6 +83,10 @@ const ProfilerDashboardPage = () => { } }; + const handleTestCaseUpdate = () => { + fetchTestCases(generateEntityLink(entityTypeFQN)); + }; + const fetchTableEntity = async () => { try { const fqn = isColumnView @@ -155,6 +159,7 @@ const ProfilerDashboardPage = () => { table={table} testCases={testCases} onTableChange={updateTableHandler} + onTestCaseUpdate={handleTestCaseUpdate} /> ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx new file mode 100644 index 00000000000..93a0952a233 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component.tsx @@ -0,0 +1,307 @@ +/* + * Copyright 2022 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 { Col, Row } from 'antd'; +import { AxiosError } from 'axios'; +import { compare } from 'fast-json-patch'; +import { camelCase, startCase } from 'lodash'; +import { ExtraInfo } from 'Models'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { + getListTestCase, + getTestSuiteByName, + ListTestCaseParams, + updateTestSuiteById, +} from '../../axiosAPIs/testAPI'; +import TabsPane from '../../components/common/TabsPane/TabsPane'; +import { TitleBreadcrumbProps } from '../../components/common/title-breadcrumb/title-breadcrumb.interface'; +import PageContainer from '../../components/containers/PageContainer'; +import Loader from '../../components/Loader/Loader'; +import TestCasesTab from '../../components/TestCasesTab/TestCasesTab.component'; +import TestSuiteDetails from '../../components/TestSuiteDetails/TestSuiteDetails.component'; +import TestSuitePipelineTab from '../../components/TestSuitePipelineTab/TestSuitePipelineTab.component'; +import { + getTeamAndUserDetailsPath, + INITIAL_PAGING_VALUE, + PAGE_SIZE, + pagingObject, +} from '../../constants/constants'; +import { + GlobalSettingOptions, + GlobalSettingsMenuCategory, +} from '../../constants/globalSettings.constants'; +import { OwnerType } from '../../enums/user.enum'; +import { TestCase } from '../../generated/tests/testCase'; +import { TestSuite } from '../../generated/tests/testSuite'; +import { Paging } from '../../generated/type/paging'; +import jsonData from '../../jsons/en'; +import { getEntityName, getEntityPlaceHolder } from '../../utils/CommonUtils'; +import { getSettingPath } from '../../utils/RouterUtils'; +import { showErrorToast } from '../../utils/ToastUtils'; +import './TestSuiteDetailsPage.styles.less'; + +const TestSuiteDetailsPage = () => { + const { testSuiteFQN } = useParams>(); + const [testSuite, setTestSuite] = useState(); + const [isDescriptionEditable, setIsDescriptionEditable] = useState(false); + const [isDeleteWidgetVisible, setIsDeleteWidgetVisible] = useState(false); + const [isTestCaseLoaded, setIsTestCaseLoaded] = useState(false); + const [testCaseResult, setTestCaseResult] = useState>([]); + const [currentPage, setCurrentPage] = useState(INITIAL_PAGING_VALUE); + const [testCasesPaging, setTestCasesPaging] = useState(pagingObject); + + const [slashedBreadCrumb, setSlashedBreadCrumb] = useState< + TitleBreadcrumbProps['titleLinks'] + >([]); + + const [activeTab, setActiveTab] = useState(1); + + const tabs = [ + { + name: 'Test Cases', + isProtected: false, + position: 1, + }, + { + name: 'Pipeline', + isProtected: false, + position: 2, + }, + ]; + + const { testSuiteDescription, testSuiteId, testOwner } = useMemo(() => { + return { + testOwner: testSuite?.owner, + testSuiteId: testSuite?.id, + testSuiteDescription: testSuite?.description, + }; + }, [testSuite]); + + const saveAndUpdateTestSuiteData = (updatedData: TestSuite) => { + const jsonPatch = compare(testSuite as TestSuite, updatedData); + + return updateTestSuiteById(testSuiteId as string, jsonPatch); + }; + + const descriptionHandler = (value: boolean) => { + setIsDescriptionEditable(value); + }; + + const fetchTestCases = async (param?: ListTestCaseParams, limit?: number) => { + setIsTestCaseLoaded(false); + try { + const response = await getListTestCase({ + fields: 'testCaseResult,testDefinition', + testSuiteId: testSuiteId, + limit: limit || PAGE_SIZE, + before: param && param.before, + after: param && param.after, + ...param, + }); + + setTestCaseResult(response.data); + setTestCasesPaging(response.paging); + } catch { + setTestCaseResult([]); + showErrorToast(jsonData['api-error-messages']['fetch-test-cases-error']); + } finally { + setIsTestCaseLoaded(true); + } + }; + + const afterSubmitAction = () => { + fetchTestCases(); + }; + + const fetchTestSuiteByName = async () => { + try { + const response = await getTestSuiteByName(testSuiteFQN, { + fields: 'owner', + }); + setSlashedBreadCrumb([ + { + name: 'Test Suites', + url: getSettingPath( + GlobalSettingsMenuCategory.DATA_QUALITY, + GlobalSettingOptions.TEST_SUITE + ), + }, + { + name: startCase( + camelCase(response?.fullyQualifiedName || response?.name) + ), + url: '', + }, + ]); + setTestSuite(response); + fetchTestCases({ testSuiteId: response.id }); + } catch (error) { + setTestSuite(undefined); + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['fetch-test-suite-error'] + ); + } + }; + + const onUpdateOwner = (updatedOwner: TestSuite['owner']) => { + if (updatedOwner) { + const updatedTestSuite = { + ...testSuite, + owner: { + ...testSuite?.owner, + ...updatedOwner, + }, + } as TestSuite; + + saveAndUpdateTestSuiteData(updatedTestSuite) + .then((res) => { + if (res) { + setTestSuite(res); + } else { + showErrorToast( + jsonData['api-error-messages']['unexpected-server-response'] + ); + } + }) + .catch((err: AxiosError) => { + showErrorToast( + err, + jsonData['api-error-messages']['update-owner-error'] + ); + }); + } + }; + + const onDescriptionUpdate = (updatedHTML: string) => { + if (testSuite?.description !== updatedHTML) { + const updatedTestSuite = { ...testSuite, description: updatedHTML }; + saveAndUpdateTestSuiteData(updatedTestSuite as TestSuite) + .then((res) => { + if (res) { + setTestSuite(res); + } else { + throw jsonData['api-error-messages']['unexpected-server-response']; + } + }) + .catch((error: AxiosError) => { + showErrorToast( + error, + jsonData['api-error-messages']['update-test-suite-error'] + ); + }) + .finally(() => { + descriptionHandler(false); + }); + } else { + descriptionHandler(false); + } + }; + + const onSetActiveValue = (tabValue: number) => { + setActiveTab(tabValue); + }; + + const handleDescriptionUpdate = (updatedHTML: string) => { + onDescriptionUpdate(updatedHTML); + }; + + const handleDeleteWidgetVisible = (isVisible: boolean) => { + setIsDeleteWidgetVisible(isVisible); + }; + + const handleTestCasePaging = ( + cursorValue: string | number, + activePage?: number | undefined + ) => { + setCurrentPage(activePage as number); + fetchTestCases({ + [cursorValue]: testCasesPaging[cursorValue as keyof Paging] as string, + }); + }; + + const extraInfo: Array = useMemo( + () => [ + { + key: 'Owner', + value: + testOwner?.type === 'team' + ? getTeamAndUserDetailsPath(testOwner?.name || '') + : getEntityName(testOwner) || '', + placeholderText: + getEntityPlaceHolder( + (testOwner?.displayName as string) || (testOwner?.name as string), + testOwner?.deleted + ) || '', + isLink: testOwner?.type === 'team', + openInNewTab: false, + profileName: + testOwner?.type === OwnerType.USER ? testOwner?.name : undefined, + }, + ], + [testOwner] + ); + + useEffect(() => { + fetchTestSuiteByName(); + }, []); + + return ( + + +
+ + + + +
+ {activeTab === 1 && ( + <> + {isTestCaseLoaded ? ( + + ) : ( + + )} + + )} + {activeTab === 2 && } +
+ + + + ); +}; + +export default TestSuiteDetailsPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.styles.less b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.styles.less new file mode 100644 index 00000000000..a91ce1e3108 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteDetailsPage/TestSuiteDetailsPage.styles.less @@ -0,0 +1,10 @@ +.test-suite-description { + .description-inner-main-container { + padding-left: 0; + padding-right: 0; + } + + .rich-text-editor-container { + padding-left: 0; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteIngestionPage/TestSuiteIngestionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteIngestionPage/TestSuiteIngestionPage.tsx new file mode 100644 index 00000000000..ebeb2a4d5a3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuiteIngestionPage/TestSuiteIngestionPage.tsx @@ -0,0 +1,141 @@ +/* + * 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 { Empty } from 'antd'; +import { AxiosError } from 'axios'; +import { isUndefined, startCase } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { getIngestionPipelineByFqn } from '../../axiosAPIs/ingestionPipelineAPI'; +import { getTestSuiteByName } from '../../axiosAPIs/testAPI'; +import RightPanel from '../../components/AddDataQualityTest/components/RightPanel'; +import { INGESTION_DATA } from '../../components/AddDataQualityTest/rightPanelData'; +import TestSuiteIngestion from '../../components/AddDataQualityTest/TestSuiteIngestion'; +import TitleBreadcrumb from '../../components/common/title-breadcrumb/title-breadcrumb.component'; +import { TitleBreadcrumbProps } from '../../components/common/title-breadcrumb/title-breadcrumb.interface'; +import PageContainerV1 from '../../components/containers/PageContainerV1'; +import PageLayout from '../../components/containers/PageLayout'; +import Loader from '../../components/Loader/Loader'; +import { + GlobalSettingOptions, + GlobalSettingsMenuCategory, +} from '../../constants/globalSettings.constants'; +import { PageLayoutType } from '../../enums/layout.enum'; +import { IngestionPipeline } from '../../generated/entity/services/ingestionPipelines/ingestionPipeline'; +import { TestSuite } from '../../generated/tests/testSuite'; +import jsonData from '../../jsons/en'; +import { getSettingPath, getTestSuitePath } from '../../utils/RouterUtils'; +import { showErrorToast } from '../../utils/ToastUtils'; + +const TestSuiteIngestionPage = () => { + const { testSuiteFQN, ingestionFQN } = useParams>(); + + const history = useHistory(); + const [isLoading, setIsLoading] = useState(true); + const [testSuite, setTestSuite] = useState(); + const [ingestionPipeline, setIngestionPipeline] = + useState(); + const [slashedBreadCrumb, setSlashedBreadCrumb] = useState< + TitleBreadcrumbProps['titleLinks'] + >([]); + + const fetchIngestionByName = async () => { + setIsLoading(true); + try { + const response = await getIngestionPipelineByFqn(ingestionFQN); + + setIngestionPipeline(response); + } catch (error) { + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['fetch-ingestion-error'] + ); + } finally { + setIsLoading(false); + } + }; + + const fetchTestSuiteByName = async () => { + setIsLoading(true); + try { + const response = await getTestSuiteByName(testSuiteFQN, { + fields: 'owner', + }); + setSlashedBreadCrumb([ + { + name: 'Test Suites', + url: getSettingPath( + GlobalSettingsMenuCategory.DATA_QUALITY, + GlobalSettingOptions.TEST_SUITE + ), + }, + { + name: startCase(response.displayName || response.name), + url: getTestSuitePath(response.fullyQualifiedName || ''), + }, + { + name: `${ingestionFQN ? 'Edit' : 'Add'} Ingestion`, + url: '', + }, + ]); + setTestSuite(response); + + if (ingestionFQN) { + await fetchIngestionByName(); + } + } catch (error) { + setTestSuite(undefined); + showErrorToast( + error as AxiosError, + jsonData['api-error-messages']['fetch-test-suite-error'] + ); + } finally { + setIsLoading(false); + } + }; + + const handleCancelBtn = () => { + history.push(getTestSuitePath(testSuiteFQN || '')); + }; + + useEffect(() => { + fetchTestSuiteByName(); + }, []); + + if (isLoading) { + return ; + } + + if (isUndefined(testSuite)) { + return ; + } + + return ( + +
+ } + layout={PageLayoutType['2ColRTL']} + rightPanel={}> + + +
+
+ ); +}; + +export default TestSuiteIngestionPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TestSuitePage/TestSuitePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuitePage/TestSuitePage.tsx new file mode 100644 index 00000000000..0780275713e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TestSuitePage/TestSuitePage.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2022 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 { Col, Row, Table, Typography } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { getListTestSuites } from '../../axiosAPIs/testAPI'; +import Ellipses from '../../components/common/Ellipses/Ellipses'; +import NextPrevious from '../../components/common/next-previous/NextPrevious'; +import { + INITIAL_PAGING_VALUE, + PAGE_SIZE, + pagingObject, +} from '../../constants/constants'; +import { TestSuite } from '../../generated/tests/testSuite'; +import { Paging } from '../../generated/type/paging'; +import { getTestSuitePath } from '../../utils/RouterUtils'; +const { Text } = Typography; + +const TestSuitePage = () => { + const [testSuites, setTestSuites] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + const [testSuitePage, setTestSuitePage] = useState(INITIAL_PAGING_VALUE); + const [testSuitePaging, setTestSuitePaging] = useState(pagingObject); + + const fetchTestSuites = async (param?: Record) => { + try { + setIsLoading(true); + const response = await getListTestSuites({ + fields: 'owner,tests', + limit: PAGE_SIZE, + before: param && param.before, + after: param && param.after, + }); + setTestSuites(response.data); + setTestSuitePaging(response.paging); + } catch (err) { + setTestSuites([]); + setTestSuitePaging(pagingObject); + } finally { + setIsLoading(false); + } + }; + + const columns = useMemo(() => { + const col: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (_, record) => ( + {record.name} + ), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + width: 300, + render: (_, record) => ( + + {record.description} + + ), + }, + { + title: 'No. of Test', + dataIndex: 'noOfTests', + key: 'noOfTests', + render: (_, record) => {record?.tests?.length} Tests, + }, + { + title: 'Owner', + dataIndex: 'owner', + key: 'owner', + render: (_, record) => {record?.owner?.displayName}, + }, + ]; + + return col; + }, [testSuites]); + + const testSuitePagingHandler = ( + cursorValue: string | number, + activePage?: number + ) => { + setTestSuitePage(activePage as number); + fetchTestSuites({ + [cursorValue]: testSuitePaging[cursorValue as keyof Paging] as string, + }); + }; + + useEffect(() => { + fetchTestSuites(); + }, []); + + return ( + +
+
({ ...test, key: test.name }))} + loading={isLoading} + pagination={false} + size="small" + /> + + {testSuites.length > PAGE_SIZE && ( + + + + )} + + ); +}; + +export default TestSuitePage; diff --git a/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx index 3812bd4fd1d..e5f7fda85c8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx @@ -18,7 +18,6 @@ import AppState from '../AppState'; import { usePermissionProvider } from '../components/PermissionProvider/PermissionProvider'; import { ResourceEntity } from '../components/PermissionProvider/PermissionProvider.interface'; import { ROUTES } from '../constants/constants'; -import AddDataQualityTestPage from '../pages/AddDataQualityTestPage/AddDataQualityTestPage'; import { Operation } from '../generated/entity/policies/policy'; import { checkPermission } from '../utils/PermissionsUtils'; import AdminProtectedRoute from './AdminProtectedRoute'; @@ -34,6 +33,24 @@ const ProfilerDashboardPage = withSuspenseFallback( ) ); +const TestSuiteIngestionPage = withSuspenseFallback( + React.lazy( + () => import('../pages/TestSuiteIngestionPage/TestSuiteIngestionPage') + ) +); + +const TestSuiteDetailsPage = withSuspenseFallback( + React.lazy( + () => import('../pages/TestSuiteDetailsPage/TestSuiteDetailsPage.component') + ) +); + +const AddDataQualityTestPage = withSuspenseFallback( + React.lazy( + () => import('../pages/AddDataQualityTestPage/AddDataQualityTestPage') + ) +); + const AddCustomProperty = withSuspenseFallback( React.lazy( () => @@ -466,7 +483,17 @@ const AuthenticatedAppRouter: FunctionComponent = () => { component={GlobalSettingPage} path={ROUTES.SETTINGS_WITH_TAB_FQN} /> - + + + ); diff --git a/openmetadata-ui/src/main/resources/ui/src/router/GlobalSettingRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/router/GlobalSettingRouter.tsx index fa09baed65d..2c13ca433d3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/router/GlobalSettingRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/router/GlobalSettingRouter.tsx @@ -66,6 +66,9 @@ const SlackSettingsPage = withSuspenseFallback( () => import('../pages/SlackSettingsPage/SlackSettingsPage.component') ) ); +const TestSuitePage = withSuspenseFallback( + React.lazy(() => import('../pages/TestSuitePage/TestSuitePage')) +); const MsTeamsPage = withSuspenseFallback( React.lazy(() => import('../pages/MsTeamsPage/MsTeamsPage.component')) ); @@ -100,6 +103,14 @@ const GlobalSettingRouter = () => { true )} /> + {/* Roles route start * Do not change the order of these route */} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/app.less b/openmetadata-ui/src/main/resources/ui/src/styles/app.less new file mode 100644 index 00000000000..ad15190cd94 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/styles/app.less @@ -0,0 +1,25 @@ +/* + * Copyright 2022 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. + */ + +// common css utils file + +.flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +.mx-auto { + margin-right: auto; + margin-left: auto; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/index.js b/openmetadata-ui/src/main/resources/ui/src/styles/index.js index d62429e607c..d60d2aeead2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/index.js +++ b/openmetadata-ui/src/main/resources/ui/src/styles/index.js @@ -14,6 +14,7 @@ import 'tailwindcss/tailwind.css'; import '../fonts/Inter/Inter-VariableFont_slnt,wght.ttf'; import './antd-master.less'; +import './app.less'; import './components/glossary.less'; import './components/step.less'; import './fonts.css'; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx index 9b621d531ba..25e2bbc4be9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx @@ -28,6 +28,7 @@ import { reactLocalStorage } from 'reactjs-localstorage'; import AppState from '../AppState'; import { getFeedCount } from '../axiosAPIs/feedsAPI'; import { Button } from '../components/buttons/Button/Button'; +import PopOver from '../components/common/popover/PopOver'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { imageTypes, @@ -49,6 +50,7 @@ import { Table } from '../generated/entity/data/table'; import { Topic } from '../generated/entity/data/topic'; import { ThreadTaskStatus, ThreadType } from '../generated/entity/feed/thread'; import { Policy } from '../generated/entity/policies/policy'; +import { IngestionPipeline } from '../generated/entity/services/ingestionPipelines/ingestionPipeline'; import { Role } from '../generated/entity/teams/role'; import { Team } from '../generated/entity/teams/team'; import { EntityReference, User } from '../generated/entity/teams/user'; @@ -823,3 +825,57 @@ export const getTeamsUser = ( return; }; + +export const getIngestionStatuses = (ingestion: IngestionPipeline) => { + const lastFiveIngestions = ingestion.pipelineStatuses + ?.sort((a, b) => { + // Turn your strings into millis, and then subtract them + // to get a value that is either negative, positive, or zero. + const date1 = new Date(a.startDate || ''); + const date2 = new Date(b.startDate || ''); + + return date1.getTime() - date2.getTime(); + }) + .slice(Math.max(ingestion.pipelineStatuses.length - 5, 0)); + + return lastFiveIngestions?.map((r, i) => { + const status = + i === lastFiveIngestions.length - 1 ? ( +

+ {capitalize(r.state)} +

+ ) : ( +

+ ); + + return r?.endDate || r?.startDate || r?.timestamp ? ( + + {r.timestamp ? ( +

Execution Date: {new Date(r.timestamp).toUTCString()}

+ ) : null} + {r.startDate ? ( +

Start Date: {new Date(r.startDate).toUTCString()}

+ ) : null} + {r.endDate ? ( +

End Date: {new Date(r.endDate).toUTCString()}

+ ) : null} + + } + key={i} + position="bottom" + theme="light" + trigger="mouseenter"> + {status} + + ) : ( + status + ); + }); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx index 0b16a247be8..55c756266fd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlobalSettingsUtils.tsx @@ -152,6 +152,20 @@ export const getGlobalSettingsMenuWithPermission = ( }, ], }, + { + category: 'Data Quality', + items: [ + { + label: 'Test Suite', + isProtected: checkPermission( + Operation.ViewAll, + ResourceEntity.TEST_SUITE, + permissions + ), + icon: , + }, + ], + }, { category: 'Custom Attributes', items: [ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index fb194084d46..0f86e7a9a69 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -28,6 +28,7 @@ import { PLACEHOLDER_RULE_NAME, PLACEHOLDER_SETTING_CATEGORY, PLACEHOLDER_TAG_NAME, + PLACEHOLDER_TEST_SUITE_FQN, ROUTES, } from '../constants/constants'; import { initialFilterQS } from '../constants/explore.constants'; @@ -343,3 +344,26 @@ export const getAddDataQualityTableTestPath = ( return path; }; + +export const getTestSuitePath = (testSuiteName: string) => { + let path = ROUTES.TEST_SUITES; + path = path.replace(PLACEHOLDER_TEST_SUITE_FQN, testSuiteName); + + return path; +}; + +export const getTestSuiteIngestionPath = ( + testSuiteName: string, + ingestionFQN?: string +) => { + let path = ingestionFQN + ? ROUTES.TEST_SUITES_EDIT_INGESTION + : ROUTES.TEST_SUITES_ADD_INGESTION; + path = path.replace(PLACEHOLDER_TEST_SUITE_FQN, testSuiteName); + + if (ingestionFQN) { + path = path.replace(PLACEHOLDER_ROUTE_INGESTION_FQN, ingestionFQN); + } + + return path; +};