Activity Feed: Show "Request Tag" button for Tags (#5862)

*  Activity Feed: Show "Request Tag" button for Tags

* Fix alignment issues

* Add condition to show task for supported entities.

* Refactor Task detail page

* Refactor task description.

* Add flow for resolve task
This commit is contained in:
Sachin Chaurasiya 2022-07-12 09:36:18 +05:30 committed by GitHub
parent 33dd4602b9
commit d9445fccf3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1141 additions and 322 deletions

View File

@ -466,6 +466,10 @@ const DashboardDetails = ({
<EntityPageInfo
isTagEditable
deleted={deleted}
entityFieldTasks={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldTaskCount
)}
entityFieldThreads={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldThreadCount

View File

@ -585,8 +585,12 @@ const DatasetDetails: React.FC<DatasetDetailsProps> = ({
<EntityPageInfo
isTagEditable
deleted={deleted}
entityFieldTasks={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldTaskCount
)}
entityFieldThreads={getEntityFieldThreadCounts(
'tags',
EntityField.TAGS,
entityFieldThreadCount
)}
entityFqn={datasetFQN}

View File

@ -362,6 +362,10 @@ const PipelineDetails = ({
<EntityPageInfo
isTagEditable
deleted={deleted}
entityFieldTasks={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldTaskCount
)}
entityFieldThreads={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldThreadCount

View File

@ -390,6 +390,10 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
<EntityPageInfo
isTagEditable
deleted={deleted}
entityFieldTasks={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldTaskCount
)}
entityFieldThreads={getEntityFieldThreadCounts(
EntityField.TAGS,
entityFieldThreadCount

View File

@ -13,13 +13,17 @@
import { faExclamationCircle, faStar } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Popover } from 'antd';
import classNames from 'classnames';
import { cloneDeep, isEmpty, isUndefined } from 'lodash';
import { EntityFieldThreads, EntityTags, ExtraInfo, TagOption } from 'Models';
import React, { Fragment, useEffect, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { FOLLOWERS_VIEW_CAP } from '../../../constants/constants';
import { SettledStatus } from '../../../enums/axios.enum';
import { EntityType } from '../../../enums/entity.enum';
import { ThreadType } from '../../../generated/entity/feed/thread';
import { Operation } from '../../../generated/entity/policies/accessControl/rule';
import { EntityReference } from '../../../generated/type/entityReference';
import { LabelType, State, TagLabel } from '../../../generated/type/tagLabel';
@ -32,6 +36,7 @@ import {
} from '../../../utils/GlossaryUtils';
import SVGIcons, { Icons } from '../../../utils/SvgUtils';
import { getTagCategories, getTaglist } from '../../../utils/TagsUtils';
import { getRequestTagsPath, TASK_ENTITIES } from '../../../utils/TasksUtils';
import TagsContainer from '../../tags-container/tags-container';
import TagsViewer from '../../tags-viewer/tags-viewer';
import Tags from '../../tags/tags';
@ -60,7 +65,8 @@ interface Props {
version?: string;
isVersionSelected?: boolean;
entityFieldThreads?: EntityFieldThreads[];
onThreadLinkSelect?: (value: string) => void;
entityFieldTasks?: EntityFieldThreads[];
onThreadLinkSelect?: (value: string, threadType?: ThreadType) => void;
followHandler?: () => void;
tagsHandler?: (selectedTags?: Array<EntityTags>) => void;
versionHandler?: () => void;
@ -88,8 +94,11 @@ const EntityPageInfo = ({
onThreadLinkSelect,
entityFqn,
entityType,
entityFieldTasks,
}: Props) => {
const history = useHistory();
const tagThread = entityFieldThreads?.[0];
const tagTask = entityFieldTasks?.[0];
const [isEditable, setIsEditable] = useState<boolean>(false);
const [entityFollowers, setEntityFollowers] =
useState<Array<EntityReference>>(followersList);
@ -101,6 +110,10 @@ const EntityPageInfo = ({
document.getElementById('version-and-follow-section')?.offsetWidth
);
const handleRequestTags = () => {
history.push(getRequestTagsPath(entityType as string, entityFqn as string));
};
const handleTagSelection = (selectedTags?: Array<EntityTags>) => {
if (selectedTags) {
const prevTags =
@ -269,35 +282,74 @@ const EntityPageInfo = ({
const getThreadElements = () => {
if (!isUndefined(entityFieldThreads)) {
return !isUndefined(tagThread) ? (
<p
className="link-text tw-m-0 tw-ml-1 tw-w-8 tw-flex-none"
return !isUndefined(tagThread) &&
TASK_ENTITIES.includes(entityType as EntityType) ? (
<button
className="tw-w-8 tw-h-8 tw-mr-1 tw-flex-none link-text focus:tw-outline-none"
data-testid="tag-thread"
onClick={() => onThreadLinkSelect?.(tagThread.entityLink)}>
<span className="tw-flex">
<SVGIcons alt="comments" icon={Icons.COMMENT} width="20px" />
<SVGIcons alt="comments" icon={Icons.COMMENT} />
<span className="tw-ml-1" data-testid="tag-thread-count">
{tagThread.count}
</span>
</span>
</p>
</button>
) : (
<p
className="link-text tw-self-start tw-w-8 tw-m-0 tw-ml-1 tw-flex-none"
<button
className="tw-w-8 tw-h-8 tw-mr-1 tw-flex-none link-text focus:tw-outline-none tw-align-top"
data-testid="start-tag-thread"
onClick={() =>
onThreadLinkSelect?.(
getEntityFeedLink(entityType, entityFqn, 'tags')
)
}>
<SVGIcons alt="comments" icon={Icons.COMMENT_PLUS} width="20px" />
</p>
<SVGIcons alt="comments" icon={Icons.COMMENT_PLUS} />
</button>
);
} else {
return null;
}
};
const getRequestTagsElements = useCallback(() => {
const hasTags = !isEmpty(tags);
return onThreadLinkSelect && !hasTags ? (
<button
className="tw-w-8 tw-h-8 tw-mr-1 tw-flex-none link-text focus:tw-outline-none tw-align-top"
data-testid="request-description"
onClick={handleRequestTags}>
<Popover
destroyTooltipOnHide
content="Request tags"
overlayClassName="ant-popover-request-description"
trigger="hover"
zIndex={9999}>
<SVGIcons alt="request-tags" icon={Icons.REQUEST} />
</Popover>
</button>
) : null;
}, [tags]);
const getTaskElement = useCallback(() => {
return !isUndefined(tagTask) ? (
<button
className="tw-w-8 tw-h-8 tw-mr-1 tw-flex-none link-text focus:tw-outline-none"
data-testid="tag-task"
onClick={() =>
onThreadLinkSelect?.(tagTask.entityLink, ThreadType.Task)
}>
<span className="tw-flex">
<SVGIcons alt="comments" icon={Icons.TASK_ICON} />
<span className="tw-ml-1" data-testid="tag-task-count">
{tagTask.count}
</span>
</span>
</button>
) : null;
}, [tagTask]);
useEffect(() => {
setEntityFollowers(followersList);
}, [followersList]);
@ -454,7 +506,7 @@ const EntityPageInfo = ({
position="bottom"
trigger="click">
<div
className="tw-inline-block"
className="tw-inline-block tw-mr-1"
data-testid="tags-wrapper"
onClick={() => {
// Fetch tags and terms only once
@ -479,14 +531,9 @@ const EntityPageInfo = ({
}}>
{tags.length || tier ? (
<button
className=" tw-ml-1 focus:tw-outline-none"
className="tw-w-8 tw-h-auto tw-flex-none focus:tw-outline-none"
data-testid="edit-button">
<SVGIcons
alt="edit"
icon="icon-edit"
title="Edit"
width="16px"
/>
<SVGIcons alt="edit" icon="icon-edit" title="Edit" />
</button>
) : (
<span>
@ -501,7 +548,11 @@ const EntityPageInfo = ({
</TagsContainer>
</div>
</NonAdminAction>
{getThreadElements()}
<div className="tw--mt-1.5">
{getRequestTagsElements()}
{getTaskElement()}
{getThreadElements()}
</div>
</Fragment>
)}
</div>

View File

@ -209,7 +209,9 @@ export const ROUTES = {
// Tasks Routes
REQUEST_DESCRIPTION: `/request-description/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_ENTITY_FQN}`,
REQUEST_TAGS: `/request-tags/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_ENTITY_FQN}`,
UPDATE_DESCRIPTION: `/update-description/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_ENTITY_FQN}`,
UPDATE_TAGS: `/update-tags/${PLACEHOLDER_ROUTE_ENTITY_TYPE}/${PLACEHOLDER_ROUTE_ENTITY_FQN}`,
TASK_DETAIL: `/tasks/${PLACEHOLDER_TASK_ID}`,
ACTIVITY_PUSH_FEED: '/api/v1/push/feed',

View File

@ -0,0 +1,282 @@
/*
* 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 { Button, Card, Input } from 'antd';
import { AxiosError, AxiosResponse } from 'axios';
import { capitalize, isNil } from 'lodash';
import { observer } from 'mobx-react';
import { EntityTags } from 'Models';
import React, {
ChangeEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import AppState from '../../../AppState';
import { postThread } from '../../../axiosAPIs/feedsAPI';
import ProfilePicture from '../../../components/common/ProfilePicture/ProfilePicture';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { EntityField } from '../../../constants/feed.constants';
import { EntityType } from '../../../enums/entity.enum';
import {
CreateThread,
TaskType,
} from '../../../generated/api/feed/createThread';
import { ThreadType } from '../../../generated/entity/feed/thread';
import { TagLabel } from '../../../generated/type/tagLabel';
import { getEntityName } from '../../../utils/CommonUtils';
import {
ENTITY_LINK_SEPARATOR,
getEntityFeedLink,
} from '../../../utils/EntityUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import {
fetchEntityDetail,
fetchOptions,
getBreadCrumbList,
getColumnObject,
getTaskDetailPath,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
import TagSuggestion from '../shared/TagSuggestion';
import TaskPageLayout from '../shared/TaskPageLayout';
import { cardStyles } from '../TaskPage.styles';
import { EntityData, Option } from '../TasksPage.interface';
const RequestTag = () => {
const location = useLocation();
const history = useHistory();
const { entityType, entityFQN } = useParams<{ [key: string]: string }>();
const queryParams = new URLSearchParams(location.search);
const field = queryParams.get('field');
const value = queryParams.get('value');
const [entityData, setEntityData] = useState<EntityData>({} as EntityData);
const [options, setOptions] = useState<Option[]>([]);
const [assignees, setAssignees] = useState<Option[]>([]);
const [title, setTitle] = useState<string>('');
const [suggestion, setSuggestion] = useState<TagLabel[]>([]);
const entityTier = useMemo(() => {
const tierFQN = getTierTags(entityData.tags || [])?.tagFQN;
return tierFQN?.split(FQN_SEPARATOR_CHAR)[1];
}, [entityData.tags]);
const entityTags = useMemo(() => {
const tags: EntityTags[] = getTagsWithoutTier(entityData.tags || []) || [];
return tags.map((tag) => `#${tag.tagFQN}`).join(' ');
}, [entityData.tags]);
const getSanitizeValue = value?.replaceAll(/^"|"$/g, '') || '';
const message = `Request tags for ${getSanitizeValue || entityType}`;
// get current user details
const currentUser = useMemo(
() => AppState.getCurrentUserDetails(),
[AppState.userDetails, AppState.nonSecureUserDetails]
);
const back = () => history.goBack();
const getColumnDetails = useCallback(() => {
if (!isNil(field) && !isNil(value) && field === EntityField.COLUMNS) {
const column = getSanitizeValue.split(FQN_SEPARATOR_CHAR).slice(-1);
const columnObject = getColumnObject(column[0], entityData.columns || []);
return (
<div data-testid="column-details">
<p className="tw-font-semibold">Column Details</p>
<p>
<span className="tw-text-grey-muted">Type:</span>{' '}
<span>{columnObject.dataTypeDisplay}</span>
</p>
<p>{columnObject?.tags?.map((tag) => `#${tag.tagFQN}`)?.join(' ')}</p>
</div>
);
} else {
return null;
}
}, [entityData.columns]);
const onSearch = (query: string) => {
fetchOptions(query, setOptions);
};
const getTaskAbout = () => {
if (field && value) {
return `${field}${ENTITY_LINK_SEPARATOR}${value}${ENTITY_LINK_SEPARATOR}tags`;
} else {
return EntityField.TAGS;
}
};
const onTitleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value: newValue } = e.target;
setTitle(newValue);
};
const onCreateTask = () => {
if (assignees.length) {
const data: CreateThread = {
from: currentUser?.name as string,
message: title || message,
about: getEntityFeedLink(entityType, entityFQN, getTaskAbout()),
taskDetails: {
assignees: assignees.map((assignee) => ({
id: assignee.value,
type: assignee.type,
})),
suggestion: JSON.stringify(suggestion),
type: TaskType.RequestTag,
oldValue: '[]',
},
type: ThreadType.Task,
};
postThread(data)
.then((res: AxiosResponse) => {
showSuccessToast('Task Created Successfully');
history.push(getTaskDetailPath(res.data.task.id));
})
.catch((err: AxiosError) => showErrorToast(err));
} else {
showErrorToast('Cannot create a task without assignee');
}
};
useEffect(() => {
fetchEntityDetail(
entityType as EntityType,
entityFQN as string,
setEntityData
);
}, [entityFQN, entityType]);
useEffect(() => {
const owner = entityData.owner;
if (owner) {
const defaultAssignee = [
{
label: getEntityName(owner),
value: owner.id || '',
type: owner.type,
},
];
setAssignees(defaultAssignee);
setOptions(defaultAssignee);
}
setTitle(message);
}, [entityData]);
return (
<TaskPageLayout>
<TitleBreadcrumb
titleLinks={[
...getBreadCrumbList(entityData, entityType as EntityType),
{ name: 'Create Task', activeTitle: true, url: '' },
]}
/>
<div className="tw-grid tw-grid-cols-3 tw-gap-x-2">
<Card
className="tw-col-span-2"
key="request-tags"
style={{ ...cardStyles }}
title="Create Task">
<div data-testid="title">
<span>Title:</span>{' '}
<Input
placeholder="Task title"
style={{ margin: '4px 0px' }}
value={title}
onChange={onTitleChange}
/>
</div>
<div data-testid="assignees">
<span>Assignees:</span>{' '}
<Assignees
assignees={assignees}
options={options}
onChange={setAssignees}
onSearch={onSearch}
/>
</div>
<p data-testid="tags-label">
<span>Suggest tags:</span>{' '}
</p>
<TagSuggestion selectedTags={suggestion} onChange={setSuggestion} />
<div className="tw-flex tw-justify-end" data-testid="cta-buttons">
<Button className="ant-btn-link-custom" type="link" onClick={back}>
Back
</Button>
<Button
className="ant-btn-primary-custom"
type="primary"
onClick={onCreateTask}>
{suggestion ? 'Suggest' : 'Submit'}
</Button>
</div>
</Card>
<div className="tw-pl-2" data-testid="entity-details">
<h6 className="tw-text-base">{capitalize(entityType)} Details</h6>
<div className="tw-flex tw-mb-4">
<span className="tw-text-grey-muted">Owner:</span>{' '}
<span>
{entityData.owner ? (
<span className="tw-flex tw-ml-1">
<ProfilePicture
displayName={getEntityName(entityData.owner)}
id=""
name={getEntityName(entityData.owner)}
width="20"
/>
<span className="tw-ml-1">
{getEntityName(entityData.owner)}
</span>
</span>
) : (
<span className="tw-text-grey-muted tw-ml-1">No Owner</span>
)}
</span>
</div>
<p data-testid="tier">
{entityTier ? (
entityTier
) : (
<span className="tw-text-grey-muted">No Tier</span>
)}
</p>
<p data-testid="tags">{entityTags}</p>
{getColumnDetails()}
</div>
</div>
</TaskPageLayout>
);
};
export default observer(RequestTag);

View File

@ -13,14 +13,14 @@
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, Dropdown, Layout, Menu, Modal, Tabs } from 'antd';
import { Button, Card, Dropdown, Layout, Menu, Tabs } from 'antd';
import { AxiosError, AxiosResponse } from 'axios';
import classNames from 'classnames';
import { compare, Operation } from 'fast-json-patch';
import { isEmpty, isEqual, isUndefined, toLower } from 'lodash';
import { isEmpty, isEqual, toLower } from 'lodash';
import { observer } from 'mobx-react';
import { EditorContentRef, EntityReference, EntityTags } from 'Models';
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { EntityReference } from 'Models';
import React, { Fragment, useEffect, useMemo, useState } from 'react';
import { useHistory, useParams } from 'react-router-dom';
import AppState from '../../../AppState';
import { useAuthContext } from '../../../authentication/auth-provider/AuthProvider';
@ -37,17 +37,14 @@ import ActivityFeedEditor from '../../../components/ActivityFeed/ActivityFeedEdi
import FeedPanelBody from '../../../components/ActivityFeed/ActivityFeedPanel/FeedPanelBody';
import ActivityThreadPanelBody from '../../../components/ActivityFeed/ActivityThreadPanel/ActivityThreadPanelBody';
import AssigneeList from '../../../components/common/AssigneeList/AssigneeList';
import Ellipses from '../../../components/common/Ellipses/Ellipses';
import ErrorPlaceHolder from '../../../components/common/error-with-placeholder/ErrorPlaceHolder';
import UserPopOverCard from '../../../components/common/PopOverCard/UserPopOverCard';
import ProfilePicture from '../../../components/common/ProfilePicture/ProfilePicture';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import TitleBreadcrumb from '../../../components/common/title-breadcrumb/title-breadcrumb.component';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { TaskOperation } from '../../../constants/feed.constants';
import { PanelTab, TaskOperation } from '../../../constants/feed.constants';
import { EntityType } from '../../../enums/entity.enum';
import { CreateThread } from '../../../generated/api/feed/createThread';
import { Column } from '../../../generated/entity/data/table';
import {
TaskDetails,
TaskType,
@ -55,8 +52,8 @@ import {
ThreadTaskStatus,
ThreadType,
} from '../../../generated/entity/feed/thread';
import { TagLabel } from '../../../generated/type/tagLabel';
import { useAuth } from '../../../hooks/authHooks';
import { getEntityName } from '../../../utils/CommonUtils';
import { ENTITY_LINK_SEPARATOR } from '../../../utils/EntityUtils';
import {
deletePost,
@ -67,24 +64,26 @@ import {
} from '../../../utils/FeedUtils';
import { getEncodedFqn } from '../../../utils/StringsUtils';
import SVGIcons from '../../../utils/SvgUtils';
import {
getEntityLink,
getTagsWithoutTier,
getTierTags,
} from '../../../utils/TableUtils';
import { getEntityLink } from '../../../utils/TableUtils';
import {
fetchEntityDetail,
fetchOptions,
getBreadCrumbList,
getColumnObject,
getDescriptionDiff,
isDescriptionTask,
isTagsTask,
TASK_ACTION_LIST,
} from '../../../utils/TasksUtils';
import { getDayTimeByTimeStamp } from '../../../utils/TimeUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
import { DescriptionTabs } from '../shared/DescriptionTabs';
import { DiffView } from '../shared/DiffView';
import ClosedTask from '../shared/ClosedTask';
import ColumnDetail from '../shared/ColumnDetail';
import CommentModal from '../shared/CommentModal';
import DescriptionTask from '../shared/DescriptionTask';
import EntityDetail from '../shared/EntityDetail';
import TagsTask from '../shared/TagsTask';
import TaskStatus from '../shared/TaskStatus';
import { background, cardStyles, contentStyles } from '../TaskPage.styles';
import {
EntityData,
@ -102,8 +101,6 @@ const TaskDetailPage = () => {
const { taskId } = useParams<{ [key: string]: string }>();
const markdownRef = useRef<EditorContentRef>();
const [taskDetail, setTaskDetail] = useState<Thread>({} as Thread);
const [taskFeedDetail, setTaskFeedDetail] = useState<Thread>({} as Thread);
const [entityData, setEntityData] = useState<EntityData>({} as EntityData);
@ -116,6 +113,7 @@ const TaskDetailPage = () => {
const [taskAction, setTaskAction] = useState<TaskAction>(TASK_ACTION_LIST[0]);
const [modalVisible, setModalVisible] = useState<boolean>(false);
const [comment, setComment] = useState<string>('');
const [tagsSuggestion, setTagsSuggestion] = useState<TagLabel[]>([]);
// get current user details
const currentUser = useMemo(
@ -123,18 +121,6 @@ const TaskDetailPage = () => {
[AppState.userDetails, AppState.nonSecureUserDetails]
);
const entityTier = useMemo(() => {
const tierFQN = getTierTags(entityData.tags || [])?.tagFQN;
return tierFQN?.split(FQN_SEPARATOR_CHAR)[1];
}, [entityData.tags]);
const entityTags = useMemo(() => {
const tags: EntityTags[] = getTagsWithoutTier(entityData.tags || []) || [];
return tags.map((tag) => `#${tag.tagFQN}`).join(' ');
}, [entityData.tags]);
const entityType = useMemo(() => {
return getEntityType(taskDetail.about);
}, [taskDetail]);
@ -144,24 +130,25 @@ const TaskDetailPage = () => {
}, [taskDetail]);
const columnObject = useMemo(() => {
// prepare column from entityField
const column = entityField?.split(ENTITY_LINK_SEPARATOR)?.slice(-2)?.[0];
// prepare column value by replacing double quotes
const columnValue = column?.replaceAll(/^"|"$/g, '') || '';
/**
* Get column name by spliting columnValue with FQN Separator
*/
const columnName = columnValue.split(FQN_SEPARATOR_CHAR).pop();
return getColumnObject(columnName as string, entityData.columns || []);
}, [taskDetail, entityData]);
const isRequestDescription = isEqual(
taskDetail.task?.type,
TaskType.RequestDescription
);
const isUpdateDescription = isEqual(
taskDetail.task?.type,
TaskType.UpdateDescription
);
// const isRequestTag = isEqual(taskDetail.task?.type, TaskType.RequestTag);
// const isUpdateTag = isEqual(taskDetail.task?.type, TaskType.UpdateTag);
const isOwner = isEqual(entityData.owner?.id, currentUser?.id);
const isAssignee = taskDetail.task?.assignees?.some((assignee) =>
isEqual(assignee.id, currentUser?.id)
);
@ -175,6 +162,12 @@ const TaskDetailPage = () => {
const isTaskActionEdit = isEqual(taskAction.key, TaskActionMode.EDIT);
const isTaskDescription = isDescriptionTask(
taskDetail.task?.type as TaskType
);
const isTaskTags = isTagsTask(taskDetail.task?.type as TaskType);
const fetchTaskDetail = () => {
getTask(taskId)
.then((res: AxiosResponse) => {
@ -305,8 +298,7 @@ const TaskDetailPage = () => {
};
const onTaskResolve = () => {
if (suggestion) {
const data = { newValue: suggestion };
const updateTaskData = (data: Record<string, string>) => {
updateTask(TaskOperation.RESOLVE, taskDetail.task?.id, data)
.then(() => {
showSuccessToast('Task Resolved Successfully');
@ -318,10 +310,24 @@ const TaskDetailPage = () => {
);
})
.catch((err: AxiosError) => showErrorToast(err));
};
if (isTaskTags) {
if (!isEmpty(tagsSuggestion)) {
const data = { newValue: JSON.stringify(tagsSuggestion || '[]') };
updateTaskData(data);
} else {
showErrorToast('Cannot accept an empty tag list. Please add a tags.');
}
} else {
showErrorToast(
'Cannot accept an empty description. Please add a description.'
);
if (suggestion) {
const data = { newValue: suggestion };
updateTaskData(data);
} else {
showErrorToast(
'Cannot accept an empty description. Please add a description.'
);
}
}
};
@ -381,6 +387,7 @@ const TaskDetailPage = () => {
updateThreadData(threadId, postId, isThread, data, callback);
};
// prepare current description for update description task
const currentDescription = () => {
if (entityField && !isEmpty(columnObject)) {
return columnObject.description || '';
@ -389,24 +396,47 @@ const TaskDetailPage = () => {
}
};
// handle assignees search
const onSearch = (query: string) => {
fetchOptions(query, setOptions);
};
// handle sider tab change
const onTabChange = (key: string) => {
if (isEqual(key, '1')) {
if (isEqual(key, PanelTab.TASKS)) {
fetchTaskFeed(taskDetail.id);
}
};
// handle task action change
const onTaskActionChange = (key: string) => {
setTaskAction(
TASK_ACTION_LIST.find((action) => isEqual(action.key, key)) as TaskAction
);
};
/**
*
* @param taskSuggestion suggestion value
* Based on task type set's the suggestion for task
*/
const setTaskSuggestionOnRender = (taskSuggestion: string | undefined) => {
if (isTaskTags) {
const tagsSuggestion = JSON.parse(taskSuggestion || '[]');
isEmpty(tagsSuggestion) && setTaskAction(TASK_ACTION_LIST[1]);
setTagsSuggestion(tagsSuggestion);
} else {
if (!taskSuggestion) {
setTaskAction(TASK_ACTION_LIST[1]);
}
setSuggestion(taskSuggestion || '');
}
};
// handle task details change
const onTaskDetailChange = () => {
if (!isEmpty(taskDetail)) {
// get entityFQN and fetch entity data
const entityFQN = getEntityFQN(taskDetail.about);
entityFQN &&
@ -417,8 +447,8 @@ const TaskDetailPage = () => {
);
fetchTaskFeed(taskDetail.id);
// set task assignees
const taskAssignees = taskDetail.task?.assignees || [];
const taskSuggestion = taskDetail.task?.suggestion;
if (taskAssignees.length) {
const assigneesArr = taskAssignees.map((assignee) => ({
label: assignee.name as string,
@ -428,10 +458,9 @@ const TaskDetailPage = () => {
setAssignees(assigneesArr);
setOptions(assigneesArr);
}
if (!taskSuggestion) {
setTaskAction(TASK_ACTION_LIST[1]);
}
setSuggestion(taskSuggestion || '');
// set task suggestion on render
setTaskSuggestionOnRender(taskDetail.task?.suggestion);
}
};
@ -447,154 +476,18 @@ const TaskDetailPage = () => {
onTaskDetailChange();
}, [taskDetail]);
const TaskStatusElement = ({ status }: { status: ThreadTaskStatus }) => {
const openCheck = isEqual(status, ThreadTaskStatus.Open);
const closedCheck = isEqual(status, ThreadTaskStatus.Closed);
return (
<Fragment>
<div
className={classNames(
'tw-rounded-3xl tw-px-2 tw-p-0',
{
'tw-bg-task-status-bg': openCheck,
},
{ 'tw-bg-gray-100': closedCheck }
)}>
<span
className={classNames(
'tw-inline-block tw-w-2 tw-h-2 tw-rounded-full',
{
'tw-bg-task-status-fg': openCheck,
},
{
'tw-bg-gray-500': closedCheck,
}
)}
/>
<span
className={classNames(
'tw-ml-1',
{ 'tw-text-task-status-fg': openCheck },
{ 'tw-text-gray-500': closedCheck }
)}>
{status}
</span>
</div>
</Fragment>
);
};
const ColumnDetail = ({ column }: { column: Column }) => {
return !isEmpty(column) && !isUndefined(column) ? (
<div className="tw-mb-4" data-testid="column-details">
<div className="tw-flex">
<span className="tw-text-grey-muted tw-flex-none tw-mr-1">
Column type:
</span>{' '}
<Ellipses tooltip rows={1}>
{column.dataTypeDisplay}
</Ellipses>
</div>
{column.tags && column.tags.length ? (
<div className="tw-flex tw-mt-4">
<SVGIcons
alt="icon-tag"
className="tw-mr-1"
icon="icon-tag-grey"
width="12"
/>
<div>{column.tags.map((tag) => `#${tag.tagFQN}`)?.join(' ')}</div>
</div>
) : null}
</div>
) : null;
};
const EntityDetail = () => {
return (
<div data-testid="entityDetail">
<div className="tw-flex tw-ml-6">
<span className="tw-text-grey-muted">Owner:</span>{' '}
<span>
{entityData.owner ? (
<span className="tw-flex tw-ml-1">
<ProfilePicture
displayName={getEntityName(entityData.owner)}
id=""
name={getEntityName(entityData.owner)}
width="20"
/>
<span className="tw-ml-1">
{getEntityName(entityData.owner)}
</span>
</span>
) : (
<span className="tw-text-grey-muted tw-ml-1">No Owner</span>
)}
</span>
<span className="tw-mx-1.5 tw-inline-block tw-text-gray-400">|</span>
<p data-testid="tier">
{entityTier ? (
entityTier
) : (
<span className="tw-text-grey-muted">No Tier</span>
)}
</p>
</div>
<p className="tw-ml-6" data-testid="tags">
{entityTags}
</p>
</div>
);
};
const getDiffView = () => {
const oldValue = taskDetail.task?.oldValue;
const newValue = taskDetail.task?.newValue;
if (!oldValue && !newValue) {
return (
<div className="tw-border tw-border-main tw-p-2 tw-rounded tw-my-1 tw-mb-3">
<span className="tw-p-2 tw-text-grey-muted">No Description</span>
</div>
);
} else {
return (
<DiffView
className="tw-border tw-border-main tw-p-2 tw-rounded tw-my-1 tw-mb-3"
diffArr={getDescriptionDiff(
taskDetail?.task?.oldValue || '',
taskDetail?.task?.newValue || ''
)}
/>
);
}
};
const getCurrentDescription = () => {
const newDescription = taskDetail?.task?.suggestion;
const oldDescription = taskDetail?.task?.oldValue;
const diffs = getDescriptionDiff(
oldDescription || '',
newDescription || ''
);
return !newDescription && !oldDescription ? (
<span className="tw-p-2 tw-text-grey-muted">No Suggestion</span>
) : (
<DiffView className="tw-p-2" diffArr={diffs} />
);
};
const onModalClose = () => {
// handle comment modal close
const onCommentModalClose = () => {
setModalVisible(false);
setComment('');
};
const hasEditAccess = () => {
return isAdminUser || isAuthDisabled || isAssignee || isOwner;
};
/**
*
* @returns True if has access otherwise false
*/
const hasEditAccess = () =>
isAdminUser || isAuthDisabled || isAssignee || isOwner;
return (
<Layout style={{ ...background, height: '100vh' }}>
@ -613,7 +506,7 @@ const TaskDetailPage = () => {
},
]}
/>
<EntityDetail />
<EntityDetail entityData={entityData} />
<Card
data-testid="task-metadata"
@ -623,8 +516,8 @@ const TaskDetailPage = () => {
data-testid="task-title">
{`Task #${taskId}`} {taskDetail.message}
</p>
<p className="tw-flex tw-mb-4" data-testid="task-metadata">
<TaskStatusElement
<div className="tw-flex tw-mb-4" data-testid="task-metadata">
<TaskStatus
status={taskDetail.task?.status as ThreadTaskStatus}
/>
<span className="tw-mx-1.5 tw-inline-block tw-text-gray-400">
@ -651,7 +544,7 @@ const TaskDetailPage = () => {
)}
</span>
</span>
</p>
</div>
<ColumnDetail column={columnObject} />
<div className="tw-flex" data-testid="task-assignees">
@ -718,54 +611,24 @@ const TaskDetailPage = () => {
<Card
data-testid="task-data"
style={{ ...cardStyles, marginTop: '16px', marginLeft: '24px' }}>
<div data-testid="task-description-tabs">
<p className="tw-text-grey-muted">Description:</p>{' '}
{!isEmpty(taskDetail) && (
<Fragment>
{isTaskClosed ? (
getDiffView()
) : (
<div data-testid="description-task">
{isRequestDescription && (
<div data-testid="request-description">
{isTaskActionEdit && hasEditAccess() ? (
<RichTextEditor
height="208px"
initialValue={suggestion}
placeHolder="Add description"
style={{ marginTop: '0px' }}
onTextChange={onSuggestionChange}
/>
) : (
<div className="tw-flex tw-border tw-border-main tw-rounded tw-mb-4">
{getCurrentDescription()}
</div>
)}
</div>
)}
{isUpdateDescription && (
<div data-testid="update-description">
{isTaskActionEdit && hasEditAccess() ? (
<DescriptionTabs
description={currentDescription()}
markdownRef={markdownRef}
suggestion={suggestion}
onChange={onSuggestionChange}
/>
) : (
<div className="tw-flex tw-border tw-border-main tw-rounded tw-mb-4">
{getCurrentDescription()}
</div>
)}
</div>
)}
</div>
)}
</Fragment>
)}
</div>
{isTaskDescription && (
<DescriptionTask
currentDescription={currentDescription()}
hasEditAccess={hasEditAccess()}
isTaskActionEdit={isTaskActionEdit}
suggestion={suggestion}
taskDetail={taskDetail}
onSuggestionChange={onSuggestionChange}
/>
)}
{isTaskTags && (
<TagsTask
isTaskActionEdit={isTaskActionEdit}
setSuggestion={setTagsSuggestion}
suggestions={tagsSuggestion}
/>
)}
{hasEditAccess() && !isTaskClosed && (
<div
className="tw-flex tw-justify-end"
@ -811,57 +674,16 @@ const TaskDetailPage = () => {
</div>
)}
{isTaskClosed && (
<div className="tw-flex" data-testid="task-closed">
<UserPopOverCard userName={taskDetail?.task?.closedBy || ''}>
<span className="tw-flex">
<ProfilePicture
displayName={taskDetail?.task?.closedBy}
id=""
name={taskDetail?.task?.closedBy || ''}
width="20"
/>
<span className="tw-font-semibold tw-cursor-pointer hover:tw-underline tw-ml-1">
{taskDetail?.task?.closedBy}
</span>{' '}
</span>
</UserPopOverCard>
<span className="tw-ml-1"> closed this task </span>
<span className="tw-ml-1">
{toLower(
getDayTimeByTimeStamp(
taskDetail?.task?.closedAt as number
)
)}
</span>
</div>
)}
{isTaskClosed && <ClosedTask task={taskDetail.task} />}
</Card>
<Modal
centered
destroyOnClose
cancelButtonProps={{
type: 'link',
className: 'ant-btn-link-custom',
}}
okButtonProps={{
disabled: !comment,
className: 'ant-btn-primary-custom',
}}
okText="Close with comment"
title={`Close Task #${taskDetail.task?.id} ${taskDetail.message}`}
visible={modalVisible}
width={700}
onCancel={onModalClose}
onOk={onTaskReject}>
<RichTextEditor
height="208px"
initialValue={comment}
placeHolder="Add comment"
style={{ marginTop: '0px' }}
onTextChange={setComment}
/>
</Modal>
<CommentModal
comment={comment}
isVisible={modalVisible}
setComment={setComment}
taskDetail={taskDetail}
onClose={onCommentModalClose}
onConfirm={onTaskReject}
/>
</Content>
<Sider
@ -869,7 +691,7 @@ const TaskDetailPage = () => {
data-testid="task-right-sider"
width={600}>
<Tabs className="ant-tabs-custom-line" onChange={onTabChange}>
<TabPane key="1" tab="Task">
<TabPane key={PanelTab.TASKS} tab="Task">
{!isEmpty(taskFeedDetail) ? (
<div id="task-feed">
<FeedPanelBody
@ -886,7 +708,7 @@ const TaskDetailPage = () => {
) : null}
</TabPane>
<TabPane key="2" tab="Conversations">
<TabPane key={PanelTab.CONVERSATIONS} tab="Conversations">
{!isEmpty(taskFeedDetail) ? (
<ActivityThreadPanelBody
className="tw-p-0"

View File

@ -38,7 +38,7 @@ const Assignees: FC<Props> = ({ assignees, onSearch, onChange, options }) => {
return (
<Select
showSearch
className="ant-select-assignee"
className="ant-select-custom"
data-testid="select-assignee"
defaultActiveFirstOption={false}
filterOption={false}

View File

@ -0,0 +1,49 @@
/*
* 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 { toLower } from 'lodash';
import React, { FC } from 'react';
import UserPopOverCard from '../../../components/common/PopOverCard/UserPopOverCard';
import ProfilePicture from '../../../components/common/ProfilePicture/ProfilePicture';
import { Thread } from '../../../generated/entity/feed/thread';
import { getDayTimeByTimeStamp } from '../../../utils/TimeUtils';
interface ClosedTaskProps {
task: Thread['task'];
}
const ClosedTask: FC<ClosedTaskProps> = ({ task }) => {
return (
<div className="tw-flex" data-testid="task-closed">
<UserPopOverCard userName={task?.closedBy || ''}>
<span className="tw-flex">
<ProfilePicture
displayName={task?.closedBy}
id=""
name={task?.closedBy || ''}
width="20"
/>
<span className="tw-font-semibold tw-cursor-pointer hover:tw-underline tw-ml-1">
{task?.closedBy}
</span>{' '}
</span>
</UserPopOverCard>
<span className="tw-ml-1"> closed this task </span>
<span className="tw-ml-1">
{toLower(getDayTimeByTimeStamp(task?.closedAt as number))}
</span>
</div>
);
};
export default ClosedTask;

View File

@ -0,0 +1,46 @@
/*
* 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 { isEmpty, isUndefined } from 'lodash';
import React from 'react';
import Ellipses from '../../../components/common/Ellipses/Ellipses';
import { Column } from '../../../generated/entity/data/table';
import SVGIcons from '../../../utils/SvgUtils';
const ColumnDetail = ({ column }: { column: Column }) => {
return !isEmpty(column) && !isUndefined(column) ? (
<div className="tw-mb-4" data-testid="column-details">
<div className="tw-flex">
<span className="tw-text-grey-muted tw-flex-none tw-mr-1">
Column type:
</span>{' '}
<Ellipses tooltip rows={1}>
{column.dataTypeDisplay}
</Ellipses>
</div>
{column.tags && column.tags.length ? (
<div className="tw-flex tw-mt-4">
<SVGIcons
alt="icon-tag"
className="tw-mr-1"
icon="icon-tag-grey"
width="12"
/>
<div>{column.tags.map((tag) => `#${tag.tagFQN}`)?.join(' ')}</div>
</div>
) : null}
</div>
) : null;
};
export default ColumnDetail;

View File

@ -0,0 +1,65 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Modal } from 'antd';
import React, { FC } from 'react';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import { Thread } from '../../../generated/entity/feed/thread';
interface CommentModalProps {
taskDetail: Thread;
comment: string;
isVisible: boolean;
setComment: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
}
const CommentModal: FC<CommentModalProps> = ({
taskDetail,
comment,
isVisible,
setComment,
onClose,
onConfirm,
}) => {
return (
<Modal
centered
destroyOnClose
cancelButtonProps={{
type: 'link',
className: 'ant-btn-link-custom',
}}
okButtonProps={{
disabled: !comment,
className: 'ant-btn-primary-custom',
}}
okText="Close with comment"
title={`Close Task #${taskDetail.task?.id} ${taskDetail.message}`}
visible={isVisible}
width={700}
onCancel={onClose}
onOk={onConfirm}>
<RichTextEditor
height="208px"
initialValue={comment}
placeHolder="Add comment"
style={{ marginTop: '0px' }}
onTextChange={setComment}
/>
</Modal>
);
};
export default CommentModal;

View File

@ -0,0 +1,152 @@
/*
* 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 { isEqual } from 'lodash';
import { EditorContentRef } from 'Models';
import React, { FC, Fragment, useRef } from 'react';
import RichTextEditor from '../../../components/common/rich-text-editor/RichTextEditor';
import {
TaskType,
Thread,
ThreadTaskStatus,
} from '../../../generated/entity/feed/thread';
import { getDescriptionDiff } from '../../../utils/TasksUtils';
import { DescriptionTabs } from './DescriptionTabs';
import { DiffView } from './DiffView';
interface DescriptionTaskProps {
taskDetail: Thread;
isTaskActionEdit: boolean;
hasEditAccess: boolean;
suggestion: string;
currentDescription: string;
onSuggestionChange: (value: string) => void;
}
const DescriptionTask: FC<DescriptionTaskProps> = ({
taskDetail,
isTaskActionEdit,
hasEditAccess,
suggestion,
currentDescription,
onSuggestionChange,
}) => {
const markdownRef = useRef<EditorContentRef>();
const isRequestDescription = isEqual(
taskDetail.task?.type,
TaskType.RequestDescription
);
const isUpdateDescription = isEqual(
taskDetail.task?.type,
TaskType.UpdateDescription
);
const isTaskClosed = isEqual(
taskDetail.task?.status,
ThreadTaskStatus.Closed
);
const getDiffView = () => {
const oldValue = taskDetail.task?.oldValue;
const newValue = taskDetail.task?.newValue;
if (!oldValue && !newValue) {
return (
<div className="tw-border tw-border-main tw-p-2 tw-rounded tw-my-1 tw-mb-3">
<span className="tw-p-2 tw-text-grey-muted">No Description</span>
</div>
);
} else {
return (
<DiffView
className="tw-border tw-border-main tw-p-2 tw-rounded tw-my-1 tw-mb-3"
diffArr={getDescriptionDiff(
taskDetail?.task?.oldValue || '',
taskDetail?.task?.newValue || ''
)}
/>
);
}
};
/**
*
* @returns Suggested description diff
*/
const getSuggestedDescriptionDiff = () => {
const newDescription = taskDetail?.task?.suggestion;
const oldDescription = taskDetail?.task?.oldValue;
const diffs = getDescriptionDiff(
oldDescription || '',
newDescription || ''
);
return !newDescription && !oldDescription ? (
<span className="tw-p-2 tw-text-grey-muted">No Suggestion</span>
) : (
<DiffView className="tw-p-2" diffArr={diffs} />
);
};
return (
<div data-testid="task-description-tabs">
<p className="tw-text-grey-muted">Description:</p>{' '}
<Fragment>
{isTaskClosed ? (
getDiffView()
) : (
<div data-testid="description-task">
{isRequestDescription && (
<div data-testid="request-description">
{isTaskActionEdit && hasEditAccess ? (
<RichTextEditor
height="208px"
initialValue={suggestion}
placeHolder="Add description"
style={{ marginTop: '0px' }}
onTextChange={onSuggestionChange}
/>
) : (
<div className="tw-flex tw-border tw-border-main tw-rounded tw-mb-4">
{getSuggestedDescriptionDiff()}
</div>
)}
</div>
)}
{isUpdateDescription && (
<div data-testid="update-description">
{isTaskActionEdit && hasEditAccess ? (
<DescriptionTabs
description={currentDescription}
markdownRef={markdownRef}
suggestion={suggestion}
onChange={onSuggestionChange}
/>
) : (
<div className="tw-flex tw-border tw-border-main tw-rounded tw-mb-4">
{getSuggestedDescriptionDiff()}
</div>
)}
</div>
)}
</div>
)}
</Fragment>
</div>
);
};
export default DescriptionTask;

View File

@ -0,0 +1,74 @@
/*
* 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 { EntityTags } from 'Models';
import React, { useMemo } from 'react';
import ProfilePicture from '../../../components/common/ProfilePicture/ProfilePicture';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { getEntityName } from '../../../utils/CommonUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { EntityData } from '../TasksPage.interface';
interface EntityDetailProps {
entityData: EntityData;
}
const EntityDetail: React.FC<EntityDetailProps> = ({ entityData }) => {
const entityTier = useMemo(() => {
const tierFQN = getTierTags(entityData.tags || [])?.tagFQN;
return tierFQN?.split(FQN_SEPARATOR_CHAR)[1];
}, [entityData.tags]);
const entityTags = useMemo(() => {
const tags: EntityTags[] = getTagsWithoutTier(entityData.tags || []) || [];
return tags.map((tag) => `#${tag.tagFQN}`).join(' ');
}, [entityData.tags]);
return (
<div data-testid="entityDetail">
<div className="tw-flex tw-ml-6">
<span className="tw-text-grey-muted">Owner:</span>{' '}
<span>
{entityData.owner ? (
<span className="tw-flex tw-ml-1">
<ProfilePicture
displayName={getEntityName(entityData.owner)}
id=""
name={getEntityName(entityData.owner)}
width="20"
/>
<span className="tw-ml-1">{getEntityName(entityData.owner)}</span>
</span>
) : (
<span className="tw-text-grey-muted tw-ml-1">No Owner</span>
)}
</span>
<span className="tw-mx-1.5 tw-inline-block tw-text-gray-400">|</span>
<p data-testid="tier">
{entityTier ? (
entityTier
) : (
<span className="tw-text-grey-muted">No Tier</span>
)}
</p>
</div>
<p className="tw-ml-6" data-testid="tags">
{entityTags}
</p>
</div>
);
};
export default EntityDetail;

View File

@ -0,0 +1,124 @@
/*
* 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 { Select } from 'antd';
import { AxiosError, AxiosResponse } from 'axios';
import { isEmpty, isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { getTagSuggestions } from '../../../axiosAPIs/miscAPI';
import {
LabelType,
Source,
State,
TagLabel,
} from '../../../generated/type/tagLabel';
import { showErrorToast } from '../../../utils/ToastUtils';
const { Option } = Select;
interface SelectOption {
label: string;
value: string;
'data-sourceType': string;
}
interface Props {
onChange: (newTags: TagLabel[]) => void;
selectedTags: TagLabel[];
}
const TagSuggestion: React.FC<Props> = ({ onChange, selectedTags }) => {
const selectedOptions = () =>
selectedTags.map((tag) => ({
label: tag.tagFQN,
value: tag.tagFQN,
'data-sourceType': isEqual(tag.source, 'Tag') ? 'tag' : 'glossaryTerm',
}));
const [options, setOptions] = useState<SelectOption[]>([]);
const fetchOptions = (query: string) => {
getTagSuggestions(query)
.then((res: AxiosResponse) => {
const suggestOptions =
res.data.suggest['metadata-suggest'][0].options ?? [];
const uniqueOptions = [
...new Set(
// eslint-disable-next-line
suggestOptions.map((op: any) => op._source)
),
];
setOptions(
// eslint-disable-next-line
uniqueOptions.map((op: any) => ({
label: op.fullyQualifiedName as string,
value: op.fullyQualifiedName as string,
'data-sourceType': op.entityType,
}))
);
})
.catch((err: AxiosError) => showErrorToast(err));
};
const handleSearch = (newValue: string) => {
if (newValue) {
fetchOptions(newValue);
} else {
setOptions([]);
}
};
const handleOnChange = (
_values: SelectOption[],
option: SelectOption | SelectOption[]
) => {
const newTags = (option as SelectOption[]).map((value) => ({
labelType: LabelType.Manual,
state: State.Suggested,
source: isEqual(value['data-sourceType'], 'tag')
? Source.Tag
: Source.Glossary,
tagFQN: value.value,
}));
onChange(newTags);
};
useEffect(() => {
setOptions(selectedOptions());
}, [selectedTags]);
return (
<Select
showSearch
className="ant-select-custom"
data-testid="select-tags"
defaultActiveFirstOption={false}
filterOption={false}
mode="multiple"
notFoundContent={null}
placeholder="Search to Select"
showArrow={false}
value={!isEmpty(selectedOptions()) ? selectedOptions() : undefined}
onChange={handleOnChange}
onSearch={handleSearch}>
{options.map((d) => (
<Option
data-sourceType={d['data-sourceType']}
data-testid="tag-option"
key={d.value}>
{d.label}
</Option>
))}
</Select>
);
};
export default TagSuggestion;

View File

@ -0,0 +1,47 @@
/*
* 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 { Tag } from 'antd';
import { uniqueId } from 'lodash';
import React, { FC } from 'react';
import { TagLabel } from '../../../generated/type/tagLabel';
import TagSuggestion from './TagSuggestion';
interface TagsTaskProps {
isTaskActionEdit: boolean;
suggestions: TagLabel[];
setSuggestion: (value: TagLabel[]) => void;
}
const TagsTask: FC<TagsTaskProps> = ({
suggestions,
setSuggestion,
isTaskActionEdit,
}) => {
return (
<div data-testid="task-tags-tabs">
<p className="tw-text-grey-muted">Tags:</p>{' '}
{isTaskActionEdit ? (
<TagSuggestion selectedTags={suggestions} onChange={setSuggestion} />
) : (
<div className="tw-flex tw-flex-wrap tw-mt-2">
{suggestions.map((suggestion) => (
<Tag key={uniqueId()}>{suggestion.tagFQN}</Tag>
))}
</div>
)}
</div>
);
};
export default TagsTask;

View File

@ -0,0 +1,57 @@
/*
* 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 classNames from 'classnames';
import { isEqual } from 'lodash';
import React, { Fragment } from 'react';
import { ThreadTaskStatus } from '../../../generated/entity/feed/thread';
const TaskStatus = ({ status }: { status: ThreadTaskStatus }) => {
const openCheck = isEqual(status, ThreadTaskStatus.Open);
const closedCheck = isEqual(status, ThreadTaskStatus.Closed);
return (
<Fragment>
<div
className={classNames(
'tw-rounded-3xl tw-px-2 tw-p-0',
{
'tw-bg-task-status-bg': openCheck,
},
{ 'tw-bg-gray-100': closedCheck }
)}>
<span
className={classNames(
'tw-inline-block tw-w-2 tw-h-2 tw-rounded-full',
{
'tw-bg-task-status-fg': openCheck,
},
{
'tw-bg-gray-500': closedCheck,
}
)}
/>
<span
className={classNames(
'tw-ml-1',
{ 'tw-text-task-status-fg': openCheck },
{ 'tw-text-gray-500': closedCheck }
)}>
{status}
</span>
</div>
</Fragment>
);
};
export default TaskStatus;

View File

@ -155,6 +155,10 @@ const RequestDescriptionPage = withSuspenseFallback(
)
);
const RequestTagsPage = withSuspenseFallback(
React.lazy(() => import('../pages/TasksPage/RequestTagPage/RequestTagPage'))
);
const UpdateDescriptionPage = withSuspenseFallback(
React.lazy(
() =>
@ -332,6 +336,7 @@ const AuthenticatedAppRouter: FunctionComponent = () => {
/>
<Route exact component={TaskDetailPage} path={ROUTES.TASK_DETAIL} />
<Route exact component={RequestTagsPage} path={ROUTES.REQUEST_TAGS} />
<Redirect to={ROUTES.NOT_FOUND} />
</Switch>

View File

@ -1183,12 +1183,12 @@ code {
}
/* Antd custom button CSS */
.ant-select-assignee {
.ant-select-custom {
width: 100%;
margin: 8px 0px;
outline: none;
}
.ant-select-assignee:not(.ant-select-disabled):hover .ant-select-selector {
.ant-select-custom:not(.ant-select-disabled):hover .ant-select-selector {
border-color: #7147e8;
}
.ant-select-focused {

View File

@ -31,6 +31,7 @@ import {
import { EntityType, FqnPart } from '../enums/entity.enum';
import { ServiceCategory } from '../enums/service.enum';
import { Column, Table } from '../generated/entity/data/table';
import { TaskType } from '../generated/entity/feed/thread';
import { EntityReference } from '../generated/type/entityReference';
import {
EntityData,
@ -65,6 +66,26 @@ export const getRequestDescriptionPath = (
return { pathname, search: searchParams.toString() };
};
export const getRequestTagsPath = (
entityType: string,
entityFQN: string,
field?: string,
value?: string
) => {
let pathname = ROUTES.REQUEST_TAGS;
pathname = pathname
.replace(PLACEHOLDER_ROUTE_ENTITY_TYPE, entityType)
.replace(PLACEHOLDER_ROUTE_ENTITY_FQN, entityFQN);
const searchParams = new URLSearchParams();
if (!isUndefined(field) && !isUndefined(value)) {
searchParams.append('field', field);
searchParams.append('value', value);
}
return { pathname, search: searchParams.toString() };
};
export const getUpdateDescriptionPath = (
entityType: string,
entityFQN: string,
@ -263,3 +284,9 @@ export const TASK_ACTION_LIST = [
key: TaskActionMode.EDIT,
},
];
export const isDescriptionTask = (taskType: TaskType) =>
[TaskType.RequestDescription, TaskType.UpdateDescription].includes(taskType);
export const isTagsTask = (taskType: TaskType) =>
[TaskType.RequestTag, TaskType.UpdateTag].includes(taskType);