diff --git a/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/miscAPI.ts b/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/miscAPI.ts index 7d9551a3d19..49e87b82cb4 100644 --- a/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/miscAPI.ts +++ b/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/miscAPI.ts @@ -59,7 +59,7 @@ export const getSuggestions: Function = ( queryString: string ): Promise => { return APIClient.get( - `/search/suggest?q=${queryString}&index=${SearchIndex.DASHBOARD},${SearchIndex.TABLE},${SearchIndex.TOPIC} + `/search/suggest?q=${queryString}&index=${SearchIndex.DASHBOARD},${SearchIndex.TABLE},${SearchIndex.TOPIC},${SearchIndex.PIPELINE} ` ); }; diff --git a/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/taskAPI.ts b/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/taskAPI.ts new file mode 100644 index 00000000000..1d0cc6ca6ac --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/axiosAPIs/taskAPI.ts @@ -0,0 +1,24 @@ +import { AxiosResponse } from 'axios'; +import { Task } from '../generated/entity/data/task'; +import { getURLWithQueryFields } from '../utils/APIUtils'; +import APIClient from './index'; + +export const getTaskById: Function = ( + id: string, + arrQueryFields: string +): Promise => { + const url = getURLWithQueryFields(`/tasks/${id}`, arrQueryFields); + + return APIClient.get(url); +}; + +export const updateTask: Function = ( + id: string, + data: Task +): Promise => { + const configOptions = { + headers: { 'Content-type': 'application/json-patch+json' }, + }; + + return APIClient.patch(`/tasks/${id}`, data, configOptions); +}; diff --git a/catalog-rest-service/src/main/resources/ui/src/components/recently-viewed/RecentlyViewed.tsx b/catalog-rest-service/src/main/resources/ui/src/components/recently-viewed/RecentlyViewed.tsx index 5853583f34e..8b767f5b5cc 100644 --- a/catalog-rest-service/src/main/resources/ui/src/components/recently-viewed/RecentlyViewed.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/components/recently-viewed/RecentlyViewed.tsx @@ -18,6 +18,7 @@ import { ColumnTags, FormatedTableData } from 'Models'; import React, { FunctionComponent, useEffect, useState } from 'react'; import { getDashboardByFqn } from '../../axiosAPIs/dashboardAPI'; +import { getPipelineByFqn } from '../../axiosAPIs/pipelineAPI'; import { getTableDetailsByFQN } from '../../axiosAPIs/tableAPI'; import { getTopicByFqn } from '../../axiosAPIs/topicsAPI'; import { EntityType } from '../../enums/entity.enum'; @@ -121,6 +122,36 @@ const RecentlyViewed: FunctionComponent = () => { break; } + + case EntityType.PIPELINE: { + const res = await getPipelineByFqn( + oData.fqn, + 'owner, service, tags, usageSummary' + ); + + const { + description, + id, + displayName, + tags, + owner, + fullyQualifiedName, + } = res.data; + arrData.push({ + description, + fullyQualifiedName, + id, + index: SearchIndex.PIPELINE, + name: displayName, + owner: getOwnerFromId(owner?.id)?.name || '--', + serviceType: oData.serviceType, + tags: (tags as Array).map((tag) => tag.tagFQN), + tier: getTierFromTableTags(tags as Array), + }); + + break; + } + default: break; } diff --git a/catalog-rest-service/src/main/resources/ui/src/constants/constants.ts b/catalog-rest-service/src/main/resources/ui/src/constants/constants.ts index 5326084f0b5..2189b2ed796 100644 --- a/catalog-rest-service/src/main/resources/ui/src/constants/constants.ts +++ b/catalog-rest-service/src/main/resources/ui/src/constants/constants.ts @@ -36,6 +36,7 @@ export const ERROR404 = 'No data found'; export const ERROR500 = 'Something went wrong'; const PLACEHOLDER_ROUTE_DATASET_FQN = ':datasetFQN'; const PLACEHOLDER_ROUTE_TOPIC_FQN = ':topicFQN'; +const PLACEHOLDER_ROUTE_PIPELINE_FQN = ':pipelineFQN'; const PLACEHOLDER_ROUTE_DASHBOARD_FQN = ':dashboardFQN'; const PLACEHOLDER_ROUTE_DATABASE_FQN = ':databaseFQN'; const PLACEHOLDER_ROUTE_SERVICE_FQN = ':serviceFQN'; @@ -121,6 +122,7 @@ export const ROUTES = { TOPIC_DETAILS: `/topic/${PLACEHOLDER_ROUTE_TOPIC_FQN}`, DASHBOARD_DETAILS: `/dashboard/${PLACEHOLDER_ROUTE_DASHBOARD_FQN}`, DATABASE_DETAILS: `/database/${PLACEHOLDER_ROUTE_DATABASE_FQN}`, + PIPELINE_DETAILS: `/pipeline/${PLACEHOLDER_ROUTE_PIPELINE_FQN}`, ONBOARDING: '/onboarding', }; @@ -178,6 +180,12 @@ export const getDashboardDetailsPath = (dashboardFQN: string) => { return path; }; +export const getPipelineDetailsPath = (pipelineFQN: string) => { + let path = ROUTES.PIPELINE_DETAILS; + path = path.replace(PLACEHOLDER_ROUTE_PIPELINE_FQN, pipelineFQN); + + return path; +}; export const LIST_TYPES = ['numbered-list', 'bulleted-list']; diff --git a/catalog-rest-service/src/main/resources/ui/src/enums/entity.enum.ts b/catalog-rest-service/src/main/resources/ui/src/enums/entity.enum.ts index f676ce47c1f..cb783f62f07 100644 --- a/catalog-rest-service/src/main/resources/ui/src/enums/entity.enum.ts +++ b/catalog-rest-service/src/main/resources/ui/src/enums/entity.enum.ts @@ -19,4 +19,5 @@ export enum EntityType { DATASET = 'dataset', TOPIC = 'topic', DASHBOARD = 'dashboard', + PIPELINE = 'pipeline', } diff --git a/catalog-rest-service/src/main/resources/ui/src/interface/types.d.ts b/catalog-rest-service/src/main/resources/ui/src/interface/types.d.ts index 97ad455214a..aa3bb216366 100644 --- a/catalog-rest-service/src/main/resources/ui/src/interface/types.d.ts +++ b/catalog-rest-service/src/main/resources/ui/src/interface/types.d.ts @@ -367,7 +367,7 @@ declare module 'Models' { // topic interface end interface RecentlyViewedData { - entityType: 'dataset' | 'topic' | 'dashboard'; + entityType: 'dataset' | 'topic' | 'dashboard' | 'pipeline'; fqn: string; serviceType?: string; timestamp: number; diff --git a/catalog-rest-service/src/main/resources/ui/src/pages/Pipeline-details/index.tsx b/catalog-rest-service/src/main/resources/ui/src/pages/Pipeline-details/index.tsx new file mode 100644 index 00000000000..4e54c8c900f --- /dev/null +++ b/catalog-rest-service/src/main/resources/ui/src/pages/Pipeline-details/index.tsx @@ -0,0 +1,606 @@ +import { AxiosPromise, AxiosResponse } from 'axios'; +import classNames from 'classnames'; +import { compare } from 'fast-json-patch'; +import { ColumnTags, TableDetail } from 'Models'; +import React, { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import AppState from '../../AppState'; +import { + addFollower, + getPipelineByFqn, + patchPipelineDetails, + removeFollower, +} from '../../axiosAPIs/pipelineAPI'; +import { getServiceById } from '../../axiosAPIs/serviceAPI'; +import { getTaskById, updateTask } from '../../axiosAPIs/taskAPI'; +import Description from '../../components/common/description/Description'; +import EntityPageInfo from '../../components/common/entityPageInfo/EntityPageInfo'; +import NonAdminAction from '../../components/common/non-admin-action/NonAdminAction'; +import RichTextEditorPreviewer from '../../components/common/rich-text-editor/RichTextEditorPreviewer'; +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 { ModalWithMarkdownEditor } from '../../components/Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; +import ManageTab from '../../components/my-data-details/ManageTab'; +import TagsContainer from '../../components/tags-container/tags-container'; +import Tags from '../../components/tags/tags'; +import { getServiceDetailsPath } from '../../constants/constants'; +import { EntityType } from '../../enums/entity.enum'; +import { Pipeline } from '../../generated/entity/data/pipeline'; +import { Task } from '../../generated/entity/data/task'; +import { User } from '../../generated/entity/teams/user'; +import { TagLabel } from '../../generated/type/tagLabel'; +import { useAuth } from '../../hooks/authHooks'; +import { + addToRecentViewed, + getCurrentUserId, + getHtmlForNonAdminAction, + getUserTeams, + isEven, +} from '../../utils/CommonUtils'; +import { serviceTypeLogo } from '../../utils/ServiceUtils'; +import SVGIcons from '../../utils/SvgUtils'; +import { + getOwnerFromId, + getTagsWithoutTier, + getTierFromTableTags, +} from '../../utils/TableUtils'; +import { getTagCategories, getTaglist } from '../../utils/TagsUtils'; + +const MyPipelinePage = () => { + const USERId = getCurrentUserId(); + + const { isAuthDisabled } = useAuth(); + + const [tagList, setTagList] = useState>([]); + const { pipelineFQN } = useParams() as Record; + const [pipelineDetails, setPipelineDetails] = useState( + {} as Pipeline + ); + const [pipelineId, setPipelineId] = useState(''); + const [isLoading, setLoading] = useState(false); + const [description, setDescription] = useState(''); + const [followers, setFollowers] = useState>([]); + const [followersCount, setFollowersCount] = useState(0); + const [isFollowing, setIsFollowing] = useState(false); + const [owner, setOwner] = useState(); + const [tier, setTier] = useState(); + const [tags, setTags] = useState>([]); + const [activeTab, setActiveTab] = useState(1); + const [isEdit, setIsEdit] = useState(false); + const [tasks, setTasks] = useState([]); + const [pipelineUrl, setPipelineUrl] = useState(''); + const [displayName, setDisplayName] = useState(''); + // const [usage, setUsage] = useState(''); + // const [weeklyUsageCount, setWeeklyUsageCount] = useState(''); + const [slashedPipelineName, setSlashedPipelineName] = useState< + TitleBreadcrumbProps['titleLinks'] + >([]); + + const [editTask, setEditTask] = useState<{ + task: Task; + index: number; + }>(); + const [editTaskTags, setEditTaskTags] = useState<{ + task: Task; + index: number; + }>(); + + const hasEditAccess = () => { + if (owner?.type === 'user') { + return owner.id === getCurrentUserId(); + } else { + return getUserTeams().some((team) => team.id === owner?.id); + } + }; + const tabs = [ + { + name: 'Details', + icon: { + alt: 'schema', + name: 'icon-schema', + title: 'Details', + }, + isProtected: false, + position: 1, + }, + { + name: 'Manage', + icon: { + alt: 'manage', + name: 'icon-manage', + title: 'Manage', + }, + isProtected: true, + protectedState: !owner || hasEditAccess(), + position: 2, + }, + ]; + + const extraInfo = [ + { key: 'Owner', value: owner?.name || '' }, + { key: 'Tier', value: tier ? tier.split('.')[1] : '' }, + { key: 'Pipeline Url', value: pipelineUrl, isLink: true }, + // { key: 'Usage', value: usage }, + // { key: 'Queries', value: `${weeklyUsageCount} past week` }, + ]; + const fetchTags = () => { + getTagCategories().then((res) => { + if (res.data) { + setTagList(getTaglist(res.data)); + } + }); + }; + + const fetchTasks = async (tasks: Pipeline['tasks']) => { + let tasksData: Task[] = []; + let promiseArr: Array = []; + if (tasks?.length) { + promiseArr = tasks.map((task) => + getTaskById(task.id, ['service', 'tags']) + ); + await Promise.allSettled(promiseArr).then( + (res: PromiseSettledResult[]) => { + if (res.length) { + tasksData = res + .filter((task) => task.status === 'fulfilled') + .map( + (task) => + (task as PromiseFulfilledResult).value.data + ); + } + } + ); + } + + return tasksData; + }; + + const setFollowersData = (followers: Array) => { + // need to check if already following or not with logedIn user id + setIsFollowing(followers.some(({ id }: { id: string }) => id === USERId)); + setFollowersCount(followers?.length); + }; + + const fetchPipelineDetail = (pipelineFQN: string) => { + setLoading(true); + getPipelineByFqn(pipelineFQN, [ + 'owner', + 'service', + 'followers', + 'tags', + 'usageSummary', + 'tasks', + ]).then((res: AxiosResponse) => { + const { + id, + description, + followers, + fullyQualifiedName, + service, + tags, + owner, + displayName, + tasks, + pipelineUrl, + // usageSummary, + } = res.data; + setDisplayName(displayName); + setPipelineDetails(res.data); + setPipelineId(id); + setDescription(description ?? ''); + setFollowers(followers); + setFollowersData(followers); + setOwner(getOwnerFromId(owner?.id)); + setTier(getTierFromTableTags(tags)); + setTags(getTagsWithoutTier(tags)); + getServiceById('pipelineServices', service?.id).then( + (serviceRes: AxiosResponse) => { + setSlashedPipelineName([ + { + name: serviceRes.data.name, + url: serviceRes.data.name + ? getServiceDetailsPath( + serviceRes.data.name, + serviceRes.data.serviceType + ) + : '', + imgSrc: serviceRes.data.serviceType + ? serviceTypeLogo(serviceRes.data.serviceType) + : undefined, + }, + { + name: displayName, + url: '', + activeTitle: true, + }, + ]); + + addToRecentViewed({ + entityType: EntityType.PIPELINE, + fqn: fullyQualifiedName, + serviceType: serviceRes.data.serviceType, + timestamp: 0, + }); + } + ); + setPipelineUrl(pipelineUrl); + fetchTasks(tasks).then((tasks) => setTasks(tasks)); + // if (!isNil(usageSummary?.weeklyStats.percentileRank)) { + // const percentile = getUsagePercentile( + // usageSummary.weeklyStats.percentileRank + // ); + // setUsage(percentile); + // } else { + // setUsage('--'); + // } + // setWeeklyUsageCount( + // usageSummary?.weeklyStats.count.toLocaleString() || '--' + // ); + + setLoading(false); + }); + }; + + const followPipeline = (): void => { + if (isFollowing) { + removeFollower(pipelineId, USERId).then((res: AxiosResponse) => { + const { followers } = res.data; + setFollowers(followers); + setFollowersCount((preValu) => preValu - 1); + setIsFollowing(false); + }); + } else { + addFollower(pipelineId, USERId).then((res: AxiosResponse) => { + const { followers } = res.data; + setFollowers(followers); + setFollowersCount((preValu) => preValu + 1); + setIsFollowing(true); + }); + } + }; + + const onDescriptionUpdate = (updatedHTML: string) => { + const updatedPipeline = { ...pipelineDetails, description: updatedHTML }; + + const jsonPatch = compare(pipelineDetails, updatedPipeline); + patchPipelineDetails(pipelineId, jsonPatch).then((res: AxiosResponse) => { + setDescription(res.data.description); + }); + setIsEdit(false); + }; + const onDescriptionEdit = (): void => { + setIsEdit(true); + }; + const onCancel = () => { + setIsEdit(false); + }; + + const onSettingsUpdate = ( + newOwner?: TableDetail['owner'], + newTier?: TableDetail['tier'] + ): Promise => { + return new Promise((resolve, reject) => { + if (newOwner || newTier) { + const tierTag: TableDetail['tags'] = newTier + ? [ + ...getTagsWithoutTier(pipelineDetails.tags as ColumnTags[]), + { tagFQN: newTier, labelType: 'Manual', state: 'Confirmed' }, + ] + : (pipelineDetails.tags as ColumnTags[]); + const updatedPipeline = { + ...pipelineDetails, + owner: newOwner + ? { ...pipelineDetails.owner, ...newOwner } + : pipelineDetails.owner, + tags: tierTag, + }; + const jsonPatch = compare(pipelineDetails, updatedPipeline); + patchPipelineDetails(pipelineId, jsonPatch) + .then((res: AxiosResponse) => { + setPipelineDetails(res.data); + setOwner(getOwnerFromId(res.data.owner?.id)); + setTier(getTierFromTableTags(res.data.tags)); + resolve(); + }) + .catch(() => reject()); + } else { + reject(); + } + }); + }; + + const onTagUpdate = (selectedTags?: Array) => { + if (selectedTags) { + const prevTags = pipelineDetails?.tags?.filter((tag) => + selectedTags.includes(tag?.tagFQN as string) + ); + const newTags: Array = selectedTags + .filter((tag) => { + return !prevTags?.map((prevTag) => prevTag.tagFQN).includes(tag); + }) + .map((tag) => ({ + labelType: 'Manual', + state: 'Confirmed', + tagFQN: tag, + })); + const updatedTags = [...(prevTags as TagLabel[]), ...newTags]; + const updatedPipeline = { ...pipelineDetails, tags: updatedTags }; + const jsonPatch = compare(pipelineDetails, updatedPipeline); + patchPipelineDetails(pipelineId, jsonPatch).then((res: AxiosResponse) => { + setTier(getTierFromTableTags(res.data.tags)); + setTags(getTagsWithoutTier(res.data.tags)); + }); + } + }; + + const handleUpdateTask = (task: Task, index: number) => { + setEditTask({ task, index }); + }; + + const closeEditTaskModal = (): void => { + setEditTask(undefined); + }; + + const onTaskUpdate = (taskDescription: string) => { + if (editTask) { + const updatedTask = { + ...editTask.task, + description: taskDescription, + }; + const jsonPatch = compare(tasks[editTask.index], updatedTask); + updateTask(editTask.task.id, jsonPatch).then((res: AxiosResponse) => { + if (res.data) { + setTasks((prevTasks) => { + const tasks = [...prevTasks]; + tasks[editTask.index] = res.data; + + return tasks; + }); + } + }); + setEditTask(undefined); + } else { + setEditTask(undefined); + } + }; + + const handleEditTaskTag = (task: Task, index: number): void => { + setEditTaskTags({ task, index }); + }; + + const handleTaskTagSelection = (selectedTags?: Array) => { + if (selectedTags && editTaskTags) { + const prevTags = editTaskTags.task.tags?.filter((tag) => + selectedTags.some((selectedTag) => selectedTag.tagFQN === tag.tagFQN) + ); + const newTags = selectedTags + .filter( + (selectedTag) => + !editTaskTags.task.tags?.some( + (tag) => tag.tagFQN === selectedTag.tagFQN + ) + ) + .map((tag) => ({ + labelType: 'Manual', + state: 'Confirmed', + tagFQN: tag.tagFQN, + })); + + const updatedTask = { + ...editTaskTags.task, + tags: [...(prevTags as TagLabel[]), ...newTags], + }; + const jsonPatch = compare(tasks[editTaskTags.index], updatedTask); + updateTask(editTaskTags.task.id, jsonPatch).then((res: AxiosResponse) => { + if (res.data) { + setTasks((prevTasks) => { + const tasks = [...prevTasks]; + tasks[editTaskTags.index] = res.data; + + return tasks; + }); + } + }); + setEditTaskTags(undefined); + } else { + setEditTaskTags(undefined); + } + }; + + useEffect(() => { + fetchPipelineDetail(pipelineFQN); + }, [pipelineFQN]); + + useEffect(() => { + if (isAuthDisabled && AppState.users.length && followers.length) { + setFollowersData(followers); + } + }, [AppState.users, followers]); + + useEffect(() => { + fetchTags(); + }, []); + + return ( + + {isLoading ? ( + + ) : ( +
+ +
+ + +
+ {activeTab === 1 && ( + <> +
+
+ +
+
+
+ + + + + + + + + + {tasks.map((task, index) => ( + + + + + + ))} + +
Task NameDescriptionTags
+ + + + {task.displayName} + + + + + +
handleUpdateTask(task, index)}> +
+ {task.description ? ( + + ) : ( + + No description added + + )} +
+ +
+
{ + if (!editTaskTags) { + handleEditTaskTag(task, index); + } + }}> + + { + handleTaskTagSelection(); + }} + onSelectionChange={(tags) => { + handleTaskTagSelection(tags); + }}> + {task.tags?.length ? ( + + ) : ( + + + + )} + + +
+
+ + )} + {activeTab === 2 && ( + + )} +
+
+
+ )} + {editTask && ( + + )} +
+ ); +}; + +export default MyPipelinePage; diff --git a/catalog-rest-service/src/main/resources/ui/src/pages/service/index.tsx b/catalog-rest-service/src/main/resources/ui/src/pages/service/index.tsx index 2ce7c5403c3..10a60b44694 100644 --- a/catalog-rest-service/src/main/resources/ui/src/pages/service/index.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/pages/service/index.tsx @@ -207,6 +207,9 @@ const ServicePage: FunctionComponent = () => { case ServiceCategory.DASHBOARD_SERVICES: return getEntityLink(SearchIndex.DASHBOARD, fqn); + case ServiceCategory.PIPELINE_SERVICES: + return getEntityLink(SearchIndex.PIPELINE, fqn); + case ServiceCategory.DATABASE_SERVICES: default: return `/database/${fqn}`; diff --git a/catalog-rest-service/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx b/catalog-rest-service/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx index 5f345b853a3..5470555ea39 100644 --- a/catalog-rest-service/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx @@ -26,6 +26,7 @@ import DatabaseDetails from '../pages/database-details/index'; import ExplorePage from '../pages/explore'; import MyDataPage from '../pages/my-data'; import MyDataDetailsPage from '../pages/my-data-details'; +import MyPipelinePage from '../pages/Pipeline-details'; import ReportsPage from '../pages/reports'; import Scorecard from '../pages/scorecard'; import ServicePage from '../pages/service'; @@ -67,6 +68,7 @@ const AuthenticatedAppRouter: FunctionComponent = () => { + diff --git a/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx b/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx index 1909f3d243b..5327fa0e1cd 100644 --- a/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx +++ b/catalog-rest-service/src/main/resources/ui/src/utils/TableUtils.tsx @@ -5,6 +5,7 @@ import PopOver from '../components/common/popover/PopOver'; import { getDashboardDetailsPath, getDatasetDetailsPath, + getPipelineDetailsPath, getTopicDetailsPath, } from '../constants/constants'; import { SearchIndex } from '../enums/search.enum'; @@ -167,6 +168,9 @@ export const getEntityLink = ( case SearchIndex.DASHBOARD: return getDashboardDetailsPath(fullyQualifiedName); + case SearchIndex.PIPELINE: + return getPipelineDetailsPath(fullyQualifiedName); + case SearchIndex.TABLE: default: return getDatasetDetailsPath(fullyQualifiedName);