Fix #4850: Airflow Dags in the Open Metadata lineage not showing the tasks flow (#5323)

This commit is contained in:
darth-coder00 2022-06-16 03:30:58 +05:30 committed by GitHub
parent 1c6046cf8e
commit 7a517cd26d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 213 additions and 152 deletions

View File

@ -11,12 +11,9 @@
* limitations under the License.
*/
import classNames from 'classnames';
import { compare } from 'fast-json-patch';
import { isNil, isUndefined } from 'lodash';
import { EntityFieldThreads, EntityTags, ExtraInfo } from 'Models';
import React, { Fragment, RefObject, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityTags, ExtraInfo } from 'Models';
import React, { RefObject, useEffect, useState } from 'react';
import AppState from '../../AppState';
import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants';
import { getTeamAndUserDetailsPath } from '../../constants/constants';
@ -24,7 +21,6 @@ import { observerOptions } from '../../constants/Mydata.constants';
import { EntityType } from '../../enums/entity.enum';
import { OwnerType } from '../../enums/user.enum';
import { Pipeline, Task } from '../../generated/entity/data/pipeline';
import { Operation } from '../../generated/entity/policies/accessControl/rule';
import { EntityReference } from '../../generated/type/entityReference';
import { Paging } from '../../generated/type/paging';
import { LabelType, State } from '../../generated/type/tagLabel';
@ -33,24 +29,15 @@ import {
getCurrentUserId,
getEntityName,
getEntityPlaceHolder,
getHtmlForNonAdminAction,
isEven,
} from '../../utils/CommonUtils';
import { getEntityFeedLink } from '../../utils/EntityUtils';
import {
getDefaultValue,
getFieldThreadElement,
} from '../../utils/FeedElementUtils';
import { getDefaultValue } from '../../utils/FeedElementUtils';
import { getEntityFieldThreadCounts } from '../../utils/FeedUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { getTagsWithoutTier } from '../../utils/TableUtils';
import ActivityFeedList from '../ActivityFeed/ActivityFeedList/ActivityFeedList';
import ActivityThreadPanel from '../ActivityFeed/ActivityThreadPanel/ActivityThreadPanel';
import Description from '../common/description/Description';
import EntityPageInfo from '../common/entityPageInfo/EntityPageInfo';
import NonAdminAction from '../common/non-admin-action/NonAdminAction';
import PopOver from '../common/popover/PopOver';
import RichTextEditorPreviewer from '../common/rich-text-editor/RichTextEditorPreviewer';
import TabsPane from '../common/TabsPane/TabsPane';
import PageContainer from '../containers/PageContainer';
import Entitylineage from '../EntityLineage/EntityLineage.component';
@ -59,6 +46,7 @@ import ManageTabComponent from '../ManageTab/ManageTab.component';
import { ModalWithMarkdownEditor } from '../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
import RequestDescriptionModal from '../Modals/RequestDescriptionModal/RequestDescriptionModal';
import PipelineStatusList from '../PipelineStatusList/PipelineStatusList.component';
import TasksDAGView from '../TasksDAGView/TasksDAGView';
import { PipeLineDetailsProp } from './PipelineDetails.interface';
const PipelineDetails = ({
@ -252,10 +240,6 @@ const PipelineDetails = ({
}
};
const handleUpdateTask = (task: Task, index: number) => {
setEditTask({ task, index });
};
const closeEditTaskModal = (): void => {
setEditTask(undefined);
};
@ -384,15 +368,15 @@ const PipelineDetails = ({
versionHandler={versionHandler}
onThreadLinkSelect={onThreadLinkSelect}
/>
<div className="tw-mt-4 tw-flex tw-flex-col tw-flex-grow">
<div className="tw-mt-4 tw-flex tw-flex-col tw-flex-grow tw-w-full">
<TabsPane
activeTab={activeTab}
setActiveTab={setActiveTabHandler}
tabs={tabs}
/>
<div className="tw-flex-grow tw-flex tw-flex-col tw--mx-6 tw-px-7 tw-py-4">
<div className="tw-bg-white tw-flex-grow tw-p-4 tw-shadow tw-rounded-md">
<div className="tw-flex-grow tw-flex tw-flex-col tw--mx-6 tw-px-7 tw-py-4 tw-w-full">
<div className="tw-flex-grow tw-flex tw-flex-col tw-bg-white tw-p-4 tw-shadow tw-rounded-md tw-w-full">
{activeTab === 1 && (
<>
<div className="tw-grid tw-grid-cols-4 tw-gap-4 tw-w-full">
@ -418,135 +402,9 @@ const PipelineDetails = ({
/>
</div>
</div>
<div className="tw-table-responsive tw-my-6">
<div className="tw-flex-grow tw-w-full tw-h-full">
{tasks ? (
<table className="tw-w-full" data-testid="tasks-table">
<thead>
<tr className="tableHead-row">
<th className="tableHead-cell">Task Name</th>
<th className="tableHead-cell">Description</th>
<th className="tableHead-cell">Task Type</th>
</tr>
</thead>
<tbody className="tableBody">
{tasks?.map((task, index) => (
<tr
className={classNames(
'tableBody-row',
!isEven(index + 1) ? 'odd-row' : null
)}
key={index}>
<td className="tableBody-cell">
<Link
target="_blank"
to={{ pathname: task.taskUrl }}>
<span className="tw-flex">
<span className="tw-mr-1">
{task.displayName}
</span>
<SVGIcons
alt="external-link"
className="tw-align-middle"
icon="external-link"
width="12px"
/>
</span>
</Link>
</td>
<td className="tw-group tableBody-cell tw-relative">
<div
className="tw-cursor-pointer tw-flex"
data-testid="description">
<div>
{task.description ? (
<RichTextEditorPreviewer
markdown={task.description}
/>
) : (
<span className="tw-no-description">
No description{' '}
</span>
)}
</div>
{!deleted && (
<Fragment>
<NonAdminAction
html={getHtmlForNonAdminAction(
Boolean(owner)
)}
isOwner={hasEditAccess()}
permission={Operation.UpdateDescription}
position="top">
<button
className="tw-self-start tw-w-8 tw-h-auto tw-opacity-0 tw-ml-1 group-hover:tw-opacity-100 focus:tw-outline-none"
onClick={() =>
handleUpdateTask(task, index)
}>
<SVGIcons
alt="edit"
icon="icon-edit"
title="Edit"
width="12px"
/>
</button>
</NonAdminAction>
{!isNil(
getFieldThreadElement(
task.name,
'description',
getEntityFieldThreadCounts(
'tasks',
entityFieldThreadCount
) as EntityFieldThreads[],
onThreadLinkSelect
)
) &&
!isUndefined(onEntityFieldSelect) &&
!task.description ? (
<button
className="focus:tw-outline-none tw-ml-1 tw-opacity-0 group-hover:tw-opacity-100 tw--mt-2"
data-testid="request-description"
onClick={() =>
onEntityFieldSelect?.(
`tasks/${task.name}/description`
)
}>
<PopOver
position="top"
title="Request description"
trigger="mouseenter">
<SVGIcons
alt="request-description"
className="tw-mt-2.5"
icon={Icons.REQUEST}
/>
</PopOver>
</button>
) : null}
{getFieldThreadElement(
task.name,
'description',
getEntityFieldThreadCounts(
'tasks',
entityFieldThreadCount
) as EntityFieldThreads[],
onThreadLinkSelect,
EntityType.PIPELINE,
pipelineFQN,
`tasks/${task.name}/description`,
Boolean(task.description)
)}
</Fragment>
)}
</div>
</td>
<td className="tableBody-cell">
{task.taskType}
</td>
</tr>
))}
</tbody>
</table>
<TasksDAGView tasks={tasks} />
) : (
<div className="tw-mt-4 tw-ml-4 tw-flex tw-justify-center tw-font-medium tw-items-center tw-border tw-border-main tw-rounded-md tw-p-8">
<span>No task data is available</span>

View File

@ -22,6 +22,15 @@ import { Paging } from '../../generated/type/paging';
import { TagLabel } from '../../generated/type/tagLabel';
import PipelineDetails from './PipelineDetails.component';
/**
* mock implementation of ResizeObserver
*/
window.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
jest.mock('../../authentication/auth-provider/AuthProvider', () => {
return {
useAuthContext: jest.fn(() => ({
@ -149,6 +158,10 @@ jest.mock('../PipelineStatusList/PipelineStatusList.component', () => {
.mockReturnValue(<p data-testid="pipeline-status-list">Pipeline Status</p>);
});
jest.mock('../TasksDAGView/TasksDAGView', () => {
return jest.fn().mockReturnValue(<p data-testid="tasks-dag">Tasks DAG</p>);
});
jest.mock('../../utils/CommonUtils', () => ({
addToRecentViewed: jest.fn(),
getCountBadge: jest.fn(),
@ -192,7 +205,7 @@ describe('Test PipelineDetails component', () => {
wrapper: MemoryRouter,
}
);
const taskDetail = await findByTestId(container, 'tasks-table');
const taskDetail = await findByTestId(container, 'tasks-dag');
expect(taskDetail).toBeInTheDocument();
});

View File

@ -0,0 +1,86 @@
/*
* 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 { findByTestId, render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import TasksDAGView from './TasksDAGView';
/**
* mock implementation of ResizeObserver
*/
window.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
jest.mock('../../authentication/auth-provider/AuthProvider', () => {
return {
useAuthContext: jest.fn(() => ({
isAuthDisabled: false,
isAuthenticated: true,
isProtectedRoute: jest.fn().mockReturnValue(true),
isTourRoute: jest.fn().mockReturnValue(false),
onLogoutHandler: jest.fn(),
})),
};
});
const TasksDAGViewProps = {
tasks: [
{
name: 'task1',
},
{
name: 'task2',
},
{
name: 'task3',
},
],
};
jest.mock('../../utils/EntityLineageUtils', () => ({
dragHandle: jest.fn(),
getDataLabel: jest
.fn()
.mockReturnValue(<span data-testid="lineage-entity">datalabel</span>),
getDeletedLineagePlaceholder: jest
.fn()
.mockReturnValue(<p>Task data is not available for deleted entities.</p>),
getHeaderLabel: jest.fn().mockReturnValue(<p>Header label</p>),
getLayoutedElements: jest.fn().mockReturnValue([]),
getLineageData: jest.fn().mockReturnValue([]),
getModalBodyText: jest.fn(),
onLoad: jest.fn(),
onNodeContextMenu: jest.fn(),
onNodeMouseEnter: jest.fn(),
onNodeMouseLeave: jest.fn(),
onNodeMouseMove: jest.fn(),
getUniqueFlowElements: jest.fn().mockReturnValue([]),
}));
describe('Test PipelineDetails component', () => {
it('Checks if the PipelineDetails component has all the proper components rendered', async () => {
const { container } = render(<TasksDAGView {...TasksDAGViewProps} />, {
wrapper: MemoryRouter,
});
const reactFlowElement = await findByTestId(
container,
'react-flow-component'
);
expect(reactFlowElement).toBeInTheDocument();
});
});

View File

@ -0,0 +1,100 @@
/*
* 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 React, { useCallback, useEffect, useMemo, useState } from 'react';
import ReactFlow, {
ArrowHeadType,
Edge,
Elements,
Node,
} from 'react-flow-renderer';
import { Task } from '../../generated/entity/data/pipeline';
import { EntityReference } from '../../generated/type/entityReference';
import { getEntityName, replaceSpaceWith_ } from '../../utils/CommonUtils';
import { getLayoutedElements, onLoad } from '../../utils/EntityLineageUtils';
export interface Props {
tasks: Task[];
}
const TasksDAGView = ({ tasks }: Props) => {
const [elements, setElements] = useState<Elements>([]);
const getNodeType = useCallback(
(index: number) => {
return index === 0
? 'input'
: index === tasks.length - 1
? 'output'
: 'default';
},
[tasks]
);
const nodes: Node[] = useMemo(() => {
const posY = 0;
let posX = 0;
const deltaX = 250;
return tasks.map((task, index) => {
posX += deltaX;
return {
className: 'leaf-node',
id: replaceSpaceWith_(task.name),
type: getNodeType(index),
data: { label: getEntityName(task as EntityReference) },
position: { x: posX, y: posY },
};
});
}, [tasks]);
const edges: Edge[] = useMemo(() => {
return tasks.reduce((prev, task) => {
const src = replaceSpaceWith_(task.name);
const taskEdges = (task.downstreamTasks || []).map((dwTask) => {
const dest = replaceSpaceWith_(dwTask);
return {
arrowHeadType: ArrowHeadType.ArrowClosed,
id: `${src}-${dest}`,
type: 'straight',
source: src,
target: dest,
label: '',
} as Edge;
});
return [...prev, ...taskEdges];
}, [] as Edge[]);
}, [tasks]);
useEffect(() => {
setElements(getLayoutedElements([...nodes, ...edges]));
}, [nodes, edges]);
return (
<ReactFlow
data-testid="react-flow-component"
elements={elements}
maxZoom={2}
minZoom={0.5}
selectNodesOnDrag={false}
zoomOnDoubleClick={false}
zoomOnScroll={false}
onLoad={onLoad}
/>
);
};
export default TasksDAGView;

View File

@ -627,3 +627,7 @@ export const getExploreLinkByFilter = (
`${filter}=${getOwnerIds(filter, userDetails, nonSecureUserDetails).join()}`
);
};
export const replaceSpaceWith_ = (text: string) => {
return text.replace(/\s/g, '_');
};