Task improvements (#12361)

* chore(#6741): use antlr parser for fqn and entityLink

* chore: use this for class context

* chore: address comments

* chore: convert js to ts

* fix: typescript errors

* fix: entity link split method issue

* fix: split listener

* chore(ui): task UI improvements

* feat: add support for edit task assignees

* fix: validation issue

* minor improvements

* chore: address comments

* chore: show dropdown button on task action button

---------

Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
Sachin Chaurasiya 2023-07-13 15:34:21 +05:30 committed by GitHub
parent e200a42926
commit a47e80baf0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 231 additions and 50 deletions

View File

@ -27,6 +27,7 @@ interface ActivityFeedListV1Props {
onFeedClick?: (feed: Thread) => void;
activeFeedId?: string;
hidePopover: boolean;
isForFeedTab?: boolean;
emptyPlaceholderText: string;
}
@ -37,6 +38,7 @@ const ActivityFeedListV1 = ({
onFeedClick,
activeFeedId,
hidePopover = false,
isForFeedTab = false,
emptyPlaceholderText,
}: ActivityFeedListV1Props) => {
const [entityThread, setEntityThread] = useState<Thread[]>([]);
@ -76,6 +78,7 @@ const ActivityFeedListV1 = ({
feed={feed}
hidePopover={hidePopover}
isActive={activeFeedId === feed.id}
isForFeedTab={isForFeedTab}
key={feed.id}
showThread={showThread}
onFeedClick={onFeedClick}

View File

@ -26,6 +26,7 @@ interface FeedPanelBodyPropV1 {
isOpenInDrawer?: boolean;
onFeedClick?: (feed: Thread) => void;
isActive?: boolean;
isForFeedTab?: boolean;
hidePopover: boolean;
}
@ -37,6 +38,7 @@ const FeedPanelBodyV1: FC<FeedPanelBodyPropV1> = ({
onFeedClick,
isActive,
hidePopover = false,
isForFeedTab = false,
}) => {
const { t } = useTranslation();
const mainFeed = {
@ -64,6 +66,7 @@ const FeedPanelBodyV1: FC<FeedPanelBodyPropV1> = ({
feed={feed}
hidePopover={hidePopover}
isActive={isActive}
isForFeedTab={isForFeedTab}
isOpenInDrawer={isOpenInDrawer}
key={feed.id}
post={mainFeed}

View File

@ -350,6 +350,7 @@ export const ActivityFeedTab = ({
)}
<ActivityFeedListV1
hidePopover
isForFeedTab
activeFeedId={selectedThread?.id}
emptyPlaceholderText={placeholderText}
feedList={threads}
@ -383,6 +384,7 @@ export const ActivityFeedTab = ({
/>
</div>
<FeedPanelBodyV1
isForFeedTab
isOpenInDrawer
showThread
feed={selectedThread}

View File

@ -11,24 +11,26 @@
* limitations under the License.
*/
import Icon from '@ant-design/icons';
import { Col, Row, Tooltip, Typography } from 'antd';
import { Button, Col, Row, Tooltip, Typography } from 'antd';
import classNames from 'classnames';
import AssigneeList from 'components/common/AssigneeList/AssigneeList';
import EntityPopOverCard from 'components/common/PopOverCard/EntityPopOverCard';
import UserPopOverCard from 'components/common/PopOverCard/UserPopOverCard';
import ProfilePicture from 'components/common/ProfilePicture/ProfilePicture';
import { Post, Thread, ThreadTaskStatus } from 'generated/entity/feed/thread';
import { isUndefined, noop } from 'lodash';
import React, { useState } from 'react';
import { isEmpty, isUndefined, noop } from 'lodash';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Link, useHistory } from 'react-router-dom';
import { getNameFromFQN } from 'utils/CommonUtils';
import EntityLink from 'utils/EntityLink';
import {
getEntityFieldDisplay,
getEntityFQN,
getEntityType,
prepareFeedLink,
} from 'utils/FeedUtils';
import { getTaskDetailPath } from 'utils/TasksUtils';
import {
getDateTimeFromMilliSeconds,
getDayTimeByTimeStamp,
@ -48,6 +50,7 @@ interface TaskFeedCardProps {
isEntityFeed?: boolean;
isOpenInDrawer?: boolean;
isActive?: boolean;
isForFeedTab?: boolean;
hidePopover: boolean;
}
@ -59,19 +62,33 @@ const TaskFeedCard = ({
showThread = true,
isActive,
hidePopover = false,
isForFeedTab = false,
}: TaskFeedCardProps) => {
const history = useHistory();
const { t } = useTranslation();
const timeStamp = feed.threadTs;
const taskDetails = feed.task;
const postLength = feed?.postsCount ?? 0;
const entityType = getEntityType(feed.about) ?? '';
const entityFQN = getEntityFQN(feed.about) ?? '';
const entityCheck = !isUndefined(entityFQN) && !isUndefined(entityType);
const [isEditPost, setIsEditPost] = useState(false);
const repliedUsers = [...new Set((feed?.posts ?? []).map((f) => f.from))];
const repliedUniqueUsersList = repliedUsers.slice(0, postLength >= 3 ? 2 : 1);
const { showDrawer } = useActivityFeedProvider();
const { showDrawer, setActiveThread } = useActivityFeedProvider();
const taskField = useMemo(() => {
const entityField = EntityLink.getEntityField(feed.about) ?? '';
const columnName = EntityLink.getTableColumnName(feed.about) ?? '';
if (columnName) {
return `${entityField}/${columnName}`;
}
return entityField;
}, [feed]);
const showReplies = () => {
showDrawer?.(feed);
@ -81,11 +98,21 @@ const TaskFeedCard = ({
setIsEditPost(!isEditPost);
};
const handleTaskLinkClick = () => {
history.push({
pathname: getTaskDetailPath(feed),
});
setActiveThread(feed);
};
const getTaskLinkElement = entityCheck && (
<Typography.Text>
<span>{`#${taskDetails?.id} `}</span>
<Button
className="p-0"
type="link"
onClick={handleTaskLinkClick}>{`#${taskDetails?.id} `}</Button>
<Typography.Text>{taskDetails?.type}</Typography.Text>
<Typography.Text className="p-l-xss">{taskDetails?.type}</Typography.Text>
<span className="m-x-xss">{t('label.for-lowercase')}</span>
{isEntityFeed ? (
<span className="tw-heading" data-testid="headerText-entityField">
@ -93,16 +120,26 @@ const TaskFeedCard = ({
</span>
) : (
<>
<span className="p-r-xss">{entityType}</span>
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
<Link
className="break-all"
data-testid="entitylink"
to={prepareFeedLink(entityType, entityFQN)}
onClick={(e) => e.stopPropagation()}>
{getNameFromFQN(entityFQN)}
</Link>
</EntityPopOverCard>
{isForFeedTab ? null : (
<>
<span className="p-r-xss">{entityType}</span>
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
<Link
className="break-all"
data-testid="entitylink"
to={prepareFeedLink(entityType, entityFQN)}
onClick={(e) => e.stopPropagation()}>
{getNameFromFQN(entityFQN)}
</Link>
</EntityPopOverCard>
</>
)}
{!isEmpty(taskField) ? (
<span className={classNames({ 'p-l-xss': !isForFeedTab })}>
{taskField}
</span>
) : null}
</>
)}
</Typography.Text>

View File

@ -12,6 +12,7 @@
*/
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Space } from 'antd';
import classNames from 'classnames';
import React from 'react';
import { InlineEditProps } from './InlineEdit.interface';
@ -20,10 +21,11 @@ const InlineEdit = ({
onCancel,
onSave,
direction,
className,
}: InlineEditProps) => {
return (
<Space
className="w-full"
className={classNames(className, 'w-full')}
data-testid="inline-edit-container"
direction={direction}>
{children}

View File

@ -14,6 +14,7 @@ import { SpaceProps } from 'antd';
import { ReactNode } from 'react';
export interface InlineEditProps {
className?: string;
children: ReactNode;
onCancel: () => void;
// onSave it can be API call or normal function

View File

@ -10,7 +10,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Icon from '@ant-design/icons';
import Icon, { DownOutlined } from '@ant-design/icons';
import {
Button,
Col,
@ -21,40 +21,51 @@ import {
Space,
Typography,
} from 'antd';
import { useForm } from 'antd/lib/form/Form';
import Modal from 'antd/lib/modal/Modal';
import AppState from 'AppState';
import { ReactComponent as EditIcon } from 'assets/svg/edit-new.svg';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import ActivityFeedCardV1 from 'components/ActivityFeed/ActivityFeedCard/ActivityFeedCardV1';
import ActivityFeedEditor from 'components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor';
import { useActivityFeedProvider } from 'components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import AssigneeList from 'components/common/AssigneeList/AssigneeList';
import { OwnerLabel } from 'components/common/OwnerLabel/OwnerLabel.component';
import EntityPopOverCard from 'components/common/PopOverCard/EntityPopOverCard';
import InlineEdit from 'components/InlineEdit/InlineEdit.component';
import { DE_ACTIVE_COLOR } from 'constants/constants';
import { TaskOperation } from 'constants/Feeds.constants';
import { compare } from 'fast-json-patch';
import { TaskType } from 'generated/api/feed/createThread';
import { TaskDetails, ThreadTaskStatus } from 'generated/entity/feed/thread';
import { TagLabel } from 'generated/type/tagLabel';
import { useAuth } from 'hooks/authHooks';
import { isEmpty, isEqual, isUndefined, noop } from 'lodash';
import Assignees from 'pages/TasksPage/shared/Assignees';
import DescriptionTask from 'pages/TasksPage/shared/DescriptionTask';
import TagsTask from 'pages/TasksPage/shared/TagsTask';
import {
Option,
TaskAction,
TaskActionMode,
} from 'pages/TasksPage/TasksPage.interface';
import { MenuInfo } from 'rc-menu/lib/interface';
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom';
import { updateTask } from 'rest/feedsAPI';
import { getNameFromFQN } from 'utils/CommonUtils';
import { getEntityFQN, prepareFeedLink } from 'utils/FeedUtils';
import { useHistory } from 'react-router-dom';
import { updateTask, updateThread } from 'rest/feedsAPI';
import EntityLink from 'utils/EntityLink';
import { getEntityName } from 'utils/EntityUtils';
import { getEntityFQN } from 'utils/FeedUtils';
import { getEntityLink } from 'utils/TableUtils';
import {
fetchOptions,
isDescriptionTask,
isTagsTask,
TASK_ACTION_LIST,
} from 'utils/TasksUtils';
import { showErrorToast, showSuccessToast } from 'utils/ToastUtils';
import './task-tab.less';
import { TaskTabProps } from './TaskTab.interface';
import { ReactComponent as TaskCloseIcon } from '/assets/svg/ic-close-task.svg';
import { ReactComponent as TaskOpenIcon } from '/assets/svg/ic-open-task.svg';
@ -65,6 +76,9 @@ export const TaskTab = ({
entityType,
...rest
}: TaskTabProps) => {
const [assigneesForm] = useForm();
const updatedAssignees = Form.useWatch('assignees', assigneesForm);
const { task: taskDetails } = taskThread;
const entityFQN = getEntityFQN(taskThread.about) ?? '';
const entityCheck = !isUndefined(entityFQN) && !isUndefined(entityType);
@ -72,12 +86,24 @@ export const TaskTab = ({
const [form] = Form.useForm();
const history = useHistory();
const { isAdminUser } = useAuth();
const { postFeed } = useActivityFeedProvider();
const { postFeed, setActiveThread } = useActivityFeedProvider();
const [taskAction, setTaskAction] = useState<TaskAction>(TASK_ACTION_LIST[0]);
const isTaskClosed = isEqual(taskDetails?.status, ThreadTaskStatus.Closed);
const [showEditTaskModel, setShowEditTaskModel] = useState(false);
const [comment, setComment] = useState('');
const [isEditAssignee, setIsEditAssignee] = useState<boolean>(false);
const [options, setOptions] = useState<Option[]>([]);
const initialAssignees = useMemo(
() =>
taskDetails?.assignees.map((assignee) => ({
label: getEntityName(assignee),
value: assignee.id || '',
type: assignee.type,
})) ?? [],
[taskDetails]
);
// get current user details
const currentUser = useMemo(
@ -85,6 +111,17 @@ export const TaskTab = ({
[AppState.userDetails, AppState.nonSecureUserDetails]
);
const taskField = useMemo(() => {
const entityField = EntityLink.getEntityField(taskThread.about) ?? '';
const columnName = EntityLink.getTableColumnName(taskThread.about) ?? '';
if (columnName) {
return `${entityField}/${columnName}`;
}
return entityField;
}, [taskThread]);
const isOwner = isEqual(owner?.id, currentUser?.id);
const isCreator = isEqual(taskThread.createdBy, currentUser?.name);
@ -113,18 +150,8 @@ export const TaskTab = ({
<Typography.Text>{taskDetails?.type}</Typography.Text>
<span className="m-x-xss">{t('label.for-lowercase')}</span>
<>
<span className="p-r-xss">{entityType}</span>
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
<Link
className="break-all"
data-testid="entitylink"
to={prepareFeedLink(entityType, entityFQN)}
onClick={(e) => e.stopPropagation()}>
{getNameFromFQN(entityFQN)}
</Link>
</EntityPopOverCard>
</>
{!isEmpty(taskField) ? <span>{taskField}</span> : null}
</Typography.Text>
);
@ -259,6 +286,7 @@ export const TaskTab = ({
</Button>
) : (
<Dropdown.Button
icon={<DownOutlined />}
menu={{
items: TASK_ACTION_LIST,
selectable: true,
@ -304,6 +332,32 @@ export const TaskTab = ({
}
}, [taskDetails, isTaskDescription]);
const handleAssigneeUpdate = async () => {
const updatedTaskThread = {
...taskThread,
task: {
...taskThread.task,
assignees: updatedAssignees.map((assignee: Option) => ({
id: assignee.value,
type: assignee.type,
})),
},
};
try {
const patch = compare(taskThread, updatedTaskThread);
const data = await updateThread(taskThread.id, patch);
setIsEditAssignee(false);
setActiveThread(data);
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
assigneesForm.setFieldValue('assignees', initialAssignees);
setOptions(initialAssignees);
}, [initialAssignees]);
return (
<Row className="p-y-sm p-x-md" gutter={[0, 24]}>
<Col className="d-flex items-center" span={24}>
@ -320,19 +374,78 @@ export const TaskTab = ({
{getTaskLinkElement}
</Col>
<Col span={24}>
<div className="d-flex justify-between">
<div className="flex-center gap-2">
<Typography.Text className="text-grey-muted">
{t('label.assignee-plural')}:{' '}
</Typography.Text>
<OwnerLabel
hasPermission={false}
owner={taskDetails?.assignees[0]}
onUpdate={noop}
/>
<div
className={classNames('d-flex justify-between', {
'flex-column': isEditAssignee,
})}>
<div
className={classNames('gap-2', { 'flex-center': !isEditAssignee })}>
{isEditAssignee ? (
<Form
form={assigneesForm}
layout="vertical"
onFinish={handleAssigneeUpdate}>
<Form.Item
data-testid="assignees"
label={`${t('label.assignee-plural')}:`}
name="assignees"
rules={[
{
required: true,
message: t('message.field-text-is-required', {
fieldText: t('label.assignee-plural'),
}),
},
]}>
<InlineEdit
className="assignees-edit-input"
direction="horizontal"
onCancel={() => {
setIsEditAssignee(false);
assigneesForm.setFieldValue(
'assignees',
initialAssignees
);
}}
onSave={() => assigneesForm.submit()}>
<Assignees
options={options}
value={updatedAssignees}
onChange={(values) =>
assigneesForm.setFieldValue('assignees', values)
}
onSearch={(query) => fetchOptions(query, setOptions)}
/>
</InlineEdit>
</Form.Item>
</Form>
) : (
<>
<Typography.Text className="text-grey-muted">
{t('label.assignee-plural')}:{' '}
</Typography.Text>
<AssigneeList
assignees={taskDetails?.assignees ?? []}
className="d-flex gap-1"
profilePicType="circle"
profileWidth="24"
showUserName={false}
/>
{isCreator || hasTaskUpdateAccess() ? (
<Button
className="flex-center p-0"
data-testid="edit-assignees"
icon={<EditIcon color={DE_ACTIVE_COLOR} width="14px" />}
size="small"
type="text"
onClick={() => setIsEditAssignee(true)}
/>
) : null}
</>
)}
</div>
<div className="flex-center gap-2">
<div
className={classNames('gap-2', { 'flex-center': !isEditAssignee })}>
<Typography.Text className="text-grey-muted">
{t('label.created-by')}:{' '}
</Typography.Text>

View File

@ -0,0 +1,17 @@
/*
* Copyright 2023 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.
*/
.assignees-edit-input {
.ant-space-item:first-child {
width: 100%;
}
}

View File

@ -142,6 +142,9 @@
.flex-row {
flex-direction: row;
}
.flex-column {
flex-direction: column;
}
// Justify Items
.justify-center {