#13719 : Change task title in activity feeds (#14207)

* Change task title in activity feeds

* added link to entity and make label same as feed in user profile page for task

* fix entity link redirection, code smell

* added unit test for tasks

* fix unit test and styling issue
This commit is contained in:
Ashish Gupta 2023-12-05 10:38:43 +05:30 committed by GitHub
parent 357888af87
commit 5fbce50c05
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 877 additions and 142 deletions

View File

@ -68,6 +68,7 @@ export const ActivityFeedTab = ({
owner,
columns,
entityType,
isForFeedTab = true,
onUpdateEntityDetails,
}: ActivityFeedTabProps) => {
const history = useHistory();
@ -379,10 +380,10 @@ export const ActivityFeedTab = ({
)}
<ActivityFeedListV1
hidePopover
isForFeedTab
activeFeedId={selectedThread?.id}
emptyPlaceholderText={placeholderText}
feedList={threads}
isForFeedTab={isForFeedTab}
isLoading={false}
showThread={false}
tab={activeTab}
@ -413,11 +414,11 @@ export const ActivityFeedTab = ({
/>
</div>
<FeedPanelBodyV1
isForFeedTab
isOpenInDrawer
showThread
feed={selectedThread}
hidePopover={false}
isForFeedTab={isForFeedTab}
/>
<ActivityFeedEditor className="m-md" onSave={onSave} />
</div>
@ -427,6 +428,7 @@ export const ActivityFeedTab = ({
<TaskTab
columns={columns}
entityType={EntityType.TABLE}
isForFeedTab={isForFeedTab}
owner={owner}
taskThread={selectedThread}
onAfterClose={handleAfterTaskClose}
@ -435,6 +437,7 @@ export const ActivityFeedTab = ({
) : (
<TaskTab
entityType={isUserEntity ? entityTypeTask : entityType}
isForFeedTab={isForFeedTab}
owner={owner}
taskThread={selectedThread}
onAfterClose={handleAfterTaskClose}

View File

@ -25,6 +25,7 @@ export enum ActivityFeedTabs {
export interface ActivityFeedTabBasicProps {
fqn: string;
isForFeedTab?: boolean;
onFeedUpdate: () => void;
onUpdateEntityDetails?: () => void;
owner?: EntityReference;

View File

@ -0,0 +1,100 @@
/*
* 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.
*/
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { TASK_FEED, TASK_POST } from '../../../mocks/Task.mock';
import TaskFeedCard from './TaskFeedCard.component';
jest.mock('react-router-dom', () => ({
Link: jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => (
<p data-testid="link">{children}</p>
)),
useHistory: jest.fn(),
}));
jest.mock('../ActivityFeedProvider/ActivityFeedProvider', () => ({
useActivityFeedProvider: jest.fn().mockImplementation(() => ({
showDrawer: jest.fn(),
setActiveThread: jest.fn(),
})),
__esModule: true,
default: 'ActivityFeedProvider',
}));
jest.mock('../../../components/common/AssigneeList/AssigneeList', () => {
return jest.fn().mockImplementation(() => <p>AssigneeList</p>);
});
jest.mock('../../../components/common/PopOverCard/EntityPopOverCard', () => {
return jest.fn().mockImplementation(() => <p>EntityPopOverCard</p>);
});
jest.mock('../../../components/common/PopOverCard/UserPopOverCard', () => {
return jest.fn().mockImplementation(() => <p>UserPopOverCard</p>);
});
jest.mock('../../../components/common/ProfilePicture/ProfilePicture', () => {
return jest.fn().mockImplementation(() => <p>ProfilePicture</p>);
});
jest.mock('../Shared/ActivityFeedActions', () => {
return jest.fn().mockImplementation(() => <p>ActivityFeedActions</p>);
});
jest.mock('../../../utils/TasksUtils', () => ({
getTaskDetailPath: jest.fn().mockReturnValue('/'),
}));
jest.mock('../../../utils/TableUtils', () => ({
getEntityLink: jest.fn().mockReturnValue('/'),
}));
jest.mock('../../../utils/FeedUtils', () => ({
getEntityFQN: jest.fn().mockReturnValue('entityFQN'),
getEntityType: jest.fn().mockReturnValue('entityType'),
}));
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
formatDateTime: jest.fn().mockReturnValue('formatDateTime'),
getRelativeTime: jest.fn().mockReturnValue('getRelativeTime'),
}));
jest.mock('../../../utils/CommonUtils', () => ({
getNameFromFQN: jest.fn().mockReturnValue('formatDateTime'),
}));
const mockProps = {
post: TASK_POST,
feed: TASK_FEED,
showThread: false,
isActive: true,
hidePopover: true,
isForFeedTab: true,
};
describe('Test TaskFeedCard Component', () => {
it('Should render TaskFeedCard component', async () => {
await act(async () => {
render(<TaskFeedCard {...mockProps} />, {
wrapper: MemoryRouter,
});
});
expect(screen.getByTestId('task-feed-card')).toBeInTheDocument();
expect(screen.getByTestId('task-status-icon-open')).toBeInTheDocument();
expect(screen.getByTestId('redirect-task-button-link')).toBeInTheDocument();
});
});

View File

@ -13,7 +13,7 @@
import Icon from '@ant-design/icons';
import { Button, Col, Row, Tooltip, Typography } from 'antd';
import classNames from 'classnames';
import { isEmpty, isUndefined, noop } from 'lodash';
import { isEmpty, isUndefined, lowerCase, noop } from 'lodash';
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom';
@ -35,11 +35,8 @@ import {
getRelativeTime,
} from '../../../utils/date-time/DateTimeUtils';
import EntityLink from '../../../utils/EntityLink';
import {
getEntityFQN,
getEntityType,
prepareFeedLink,
} from '../../../utils/FeedUtils';
import { getEntityFQN, getEntityType } from '../../../utils/FeedUtils';
import { getEntityLink } from '../../../utils/TableUtils';
import { getTaskDetailPath } from '../../../utils/TasksUtils';
import { useActivityFeedProvider } from '../ActivityFeedProvider/ActivityFeedProvider';
import ActivityFeedActions from '../Shared/ActivityFeedActions';
@ -110,8 +107,11 @@ const TaskFeedCard = ({
<Typography.Text>
<Button
className="p-0"
data-testid="redirect-task-button-link"
type="link"
onClick={handleTaskLinkClick}>{`#${taskDetails?.id} `}</Button>
onClick={handleTaskLinkClick}>
{`#${taskDetails?.id} `}
</Button>
<Typography.Text className="p-l-xss">{taskDetails?.type}</Typography.Text>
<span className="m-x-xss">{t('label.for-lowercase')}</span>
@ -122,8 +122,8 @@ const TaskFeedCard = ({
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
<Link
className="break-all"
data-testid="entitylink"
to={prepareFeedLink(entityType, entityFQN)}
data-testid="entity-link"
to={getEntityLink(entityType, entityFQN)}
onClick={(e) => e.stopPropagation()}>
{getNameFromFQN(entityFQN)}
</Link>
@ -140,104 +140,106 @@ const TaskFeedCard = ({
);
return (
<>
<div
className={classNames(
className,
'task-feed-card-v1 activity-feed-card activity-feed-card-v1',
{ active: isActive }
)}>
<Row gutter={[0, 8]}>
<Col className="d-flex items-center" span={24}>
<Icon
className="m-r-xs"
component={
taskDetails?.status === ThreadTaskStatus.Open
? TaskOpenIcon
: TaskCloseIcon
}
style={{ fontSize: '18px' }}
/>
{getTaskLinkElement}
</Col>
<Col span={24}>
<Typography.Text className="task-feed-body text-xs text-grey-muted">
<UserPopOverCard
key={feed.createdBy}
userName={feed.createdBy ?? ''}>
<span className="p-r-xss">{feed.createdBy}</span>
</UserPopOverCard>
{t('message.created-this-task-lowercase')}
{timeStamp && (
<Tooltip title={formatDateTime(timeStamp)}>
<span className="p-l-xss" data-testid="timestamp">
{getRelativeTime(timeStamp)}
</span>
</Tooltip>
)}
</Typography.Text>
</Col>
{!showThread ? (
<Col span={24}>
<div className="d-flex items-center p-l-lg gap-2">
{postLength > 0 && (
<>
<div className="thread-users-profile-pic">
{repliedUniqueUsersList.map((user) => (
<UserPopOverCard key={user} userName={user}>
<span
className="profile-image-span cursor-pointer"
data-testid="authorAvatar">
<ProfilePicture
id=""
name={user}
type="circle"
width="24"
/>
</span>
</UserPopOverCard>
))}
</div>
<div
className="d-flex items-center thread-count cursor-pointer m-l-xs"
onClick={!hidePopover ? showReplies : noop}>
<ThreadIcon width={20} />{' '}
<span className="text-xs p-l-xss">{postLength}</span>
</div>
</>
)}
<Typography.Text
className={
postLength > 0
? 'm-l-sm text-sm text-grey-muted'
: 'text-sm text-grey-muted'
}>
{`${t('label.assignee-plural')}: `}
</Typography.Text>
<AssigneeList
assignees={feed?.task?.assignees || []}
className="d-flex gap-1"
profilePicType="circle"
profileWidth="24"
showUserName={false}
/>
</div>
</Col>
) : null}
</Row>
{!hidePopover && (
<ActivityFeedActions
feed={feed}
isPost={false}
post={post}
onEditPost={onEditPost}
<div
className={classNames(
className,
'task-feed-card-v1 activity-feed-card activity-feed-card-v1',
{ active: isActive }
)}
data-testid="task-feed-card">
<Row gutter={[0, 8]}>
<Col className="d-flex items-center" span={24}>
<Icon
className="m-r-xs"
component={
taskDetails?.status === ThreadTaskStatus.Open
? TaskOpenIcon
: TaskCloseIcon
}
data-testid={`task-status-icon-${lowerCase(taskDetails?.status)}`}
style={{ fontSize: '18px' }}
/>
)}
</div>
</>
{getTaskLinkElement}
</Col>
<Col span={24}>
<Typography.Text className="task-feed-body text-xs text-grey-muted">
<UserPopOverCard
key={feed.createdBy}
userName={feed.createdBy ?? ''}>
<span className="p-r-xss" data-testid="task-created-by">
{feed.createdBy}
</span>
</UserPopOverCard>
{t('message.created-this-task-lowercase')}
{timeStamp && (
<Tooltip title={formatDateTime(timeStamp)}>
<span className="p-l-xss" data-testid="timestamp">
{getRelativeTime(timeStamp)}
</span>
</Tooltip>
)}
</Typography.Text>
</Col>
{!showThread ? (
<Col span={24}>
<div className="d-flex items-center p-l-lg gap-2">
{postLength > 0 && (
<>
<div className="thread-users-profile-pic">
{repliedUniqueUsersList.map((user) => (
<UserPopOverCard key={user} userName={user}>
<span
className="profile-image-span cursor-pointer"
data-testid="authorAvatar">
<ProfilePicture
id=""
name={user}
type="circle"
width="24"
/>
</span>
</UserPopOverCard>
))}
</div>
<div
className="d-flex items-center thread-count cursor-pointer m-l-xs"
onClick={!hidePopover ? showReplies : noop}>
<ThreadIcon width={20} />{' '}
<span className="text-xs p-l-xss">{postLength}</span>
</div>
</>
)}
<Typography.Text
className={
postLength > 0
? 'm-l-sm text-sm text-grey-muted'
: 'text-sm text-grey-muted'
}>
{`${t('label.assignee-plural')}: `}
</Typography.Text>
<AssigneeList
assignees={feed?.task?.assignees || []}
className="d-flex gap-1"
profilePicType="circle"
profileWidth="24"
showUserName={false}
/>
</div>
</Col>
) : null}
</Row>
{!hidePopover && (
<ActivityFeedActions
feed={feed}
isPost={false}
post={post}
onEditPost={onEditPost}
/>
)}
</div>
);
};

View File

@ -0,0 +1,161 @@
/*
* 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.
*/
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { EntityType } from '../../../enums/entity.enum';
import { TASK_COLUMNS, TASK_FEED } from '../../../mocks/Task.mock';
import { mockUserData } from '../../Users/mocks/User.mocks';
import { TaskTab } from './TaskTab.component';
import { TaskTabProps } from './TaskTab.interface';
jest.mock('../../../rest/feedsAPI', () => ({
updateTask: jest.fn().mockImplementation(() => Promise.resolve()),
updateThread: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('react-router-dom', () => ({
Link: jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => (
<p data-testid="link">{children}</p>
)),
useHistory: jest.fn(),
}));
jest.mock(
'../../../components/ActivityFeed/ActivityFeedCard/ActivityFeedCardV1',
() => {
return jest.fn().mockImplementation(() => <p>ActivityFeedCardV1</p>);
}
);
jest.mock(
'../../../components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditor',
() => {
return jest.fn().mockImplementation(() => <p>ActivityFeedEditor</p>);
}
);
jest.mock('../../../components/common/AssigneeList/AssigneeList', () => {
return jest.fn().mockImplementation(() => <p>AssigneeList</p>);
});
jest.mock('../../../components/common/OwnerLabel/OwnerLabel.component', () => ({
OwnerLabel: jest.fn().mockImplementation(() => <p>OwnerLabel</p>),
}));
jest.mock('../../../components/InlineEdit/InlineEdit.component', () => {
return jest.fn().mockImplementation(() => <p>InlineEdit</p>);
});
jest.mock('../../../pages/TasksPage/shared/Assignees', () => {
return jest.fn().mockImplementation(() => <p>Assignees</p>);
});
jest.mock('../../../pages/TasksPage/shared/DescriptionTask', () => {
return jest.fn().mockImplementation(() => <p>DescriptionTask</p>);
});
jest.mock('../../../pages/TasksPage/shared/TagsTask', () => {
return jest.fn().mockImplementation(() => <p>TagsTask</p>);
});
jest.mock('../../common/PopOverCard/EntityPopOverCard', () => {
return jest.fn().mockImplementation(() => <p>EntityPopOverCard</p>);
});
jest.mock('../../../utils/CommonUtils', () => ({
getNameFromFQN: jest.fn().mockReturnValue('getNameFromFQN'),
}));
jest.mock('../../../utils/EntityUtils', () => ({
getEntityName: jest.fn().mockReturnValue('getEntityName'),
}));
jest.mock('../../../utils/FeedUtils', () => ({
getEntityFQN: jest.fn().mockReturnValue('getEntityFQN'),
}));
jest.mock('../../../utils/TableUtils', () => ({
getEntityLink: jest.fn().mockReturnValue('getEntityLink'),
}));
jest.mock('../../../utils/TasksUtils', () => ({
fetchOptions: jest.fn().mockReturnValue('getEntityLink'),
getTaskDetailPath: jest.fn().mockReturnValue('/'),
isDescriptionTask: jest.fn().mockReturnValue(false),
isTagsTask: jest.fn().mockReturnValue(true),
TASK_ACTION_LIST: jest.fn().mockReturnValue([]),
}));
jest.mock('../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
showSuccessToast: jest.fn(),
}));
jest.mock('../../Auth/AuthProviders/AuthProvider', () => ({
useAuthContext: jest.fn(() => ({
currentUser: mockUserData,
})),
}));
jest.mock('../../../rest/feedsAPI', () => ({
updateTask: jest.fn(),
updateThread: jest.fn(),
}));
jest.mock(
'../../../components/ActivityFeed/ActivityFeedProvider/ActivityFeedProvider',
() => ({
useActivityFeedProvider: jest.fn().mockImplementation(() => ({
postFeed: jest.fn(),
setActiveThread: jest.fn(),
})),
__esModule: true,
default: 'ActivityFeedProvider',
})
);
jest.mock('../../../hooks/authHooks', () => ({
useAuth: () => {
return {
isAdminUser: false,
};
},
}));
const mockOnAfterClose = jest.fn();
const mockOnUpdateEntityDetails = jest.fn();
const mockProps: TaskTabProps = {
taskThread: TASK_FEED,
entityType: EntityType.TABLE,
isForFeedTab: true,
columns: TASK_COLUMNS,
onAfterClose: mockOnAfterClose,
onUpdateEntityDetails: mockOnUpdateEntityDetails,
};
describe('Test TaskFeedCard component', () => {
it('Should render the component', async () => {
render(<TaskTab {...mockProps} />, {
wrapper: MemoryRouter,
});
const activityFeedCard = screen.getByTestId('task-tab');
expect(activityFeedCard).toBeInTheDocument();
});
});

View File

@ -31,6 +31,7 @@ import { isEmpty, isEqual, isUndefined, noop } from 'lodash';
import { MenuInfo } from 'rc-menu/lib/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom';
import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg';
import { ReactComponent as TaskCloseIcon } from '../../../assets/svg/ic-close-task.svg';
import { ReactComponent as TaskOpenIcon } from '../../../assets/svg/ic-open-task.svg';
@ -58,17 +59,21 @@ import {
TaskActionMode,
} from '../../../pages/TasksPage/TasksPage.interface';
import { updateTask, updateThread } from '../../../rest/feedsAPI';
import { getNameFromFQN } from '../../../utils/CommonUtils';
import EntityLink from '../../../utils/EntityLink';
import { getEntityName } from '../../../utils/EntityUtils';
import { getEntityFQN } from '../../../utils/FeedUtils';
import { getEntityLink } from '../../../utils/TableUtils';
import {
fetchOptions,
getTaskDetailPath,
isDescriptionTask,
isTagsTask,
TASK_ACTION_LIST,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { useAuthContext } from '../../Auth/AuthProviders/AuthProvider';
import EntityPopOverCard from '../../common/PopOverCard/EntityPopOverCard';
import './task-tab.less';
import { TaskTabProps } from './TaskTab.interface';
@ -76,8 +81,10 @@ export const TaskTab = ({
taskThread,
owner,
entityType,
isForFeedTab,
...rest
}: TaskTabProps) => {
const history = useHistory();
const [assigneesForm] = useForm();
const { currentUser } = useAuthContext();
const updatedAssignees = Form.useWatch('assignees', assigneesForm);
@ -142,14 +149,44 @@ export const TaskTab = ({
const isTaskGlossaryApproval = taskDetails?.type === TaskType.RequestApproval;
const handleTaskLinkClick = () => {
history.push({
pathname: getTaskDetailPath(taskThread),
});
};
const getTaskLinkElement = entityCheck && (
<Typography.Text className="font-medium text-md" data-testid="task-title">
<span>{`#${taskDetails?.id} `}</span>
<Button
className="p-r-xss text-md font-medium"
type="link"
onClick={handleTaskLinkClick}>
{`#${taskDetails?.id} `}
</Button>
<Typography.Text>{taskDetails?.type}</Typography.Text>
<span className="m-x-xss">{t('label.for-lowercase')}</span>
{!isEmpty(taskField) ? <span>{taskField}</span> : null}
{!isForFeedTab && (
<>
<span className="p-r-xss">{entityType}</span>
<EntityPopOverCard entityFQN={entityFQN} entityType={entityType}>
<Link
className="break-all p-r-xss"
data-testid="entitylink"
to={getEntityLink(entityType, entityFQN)}
onClick={(e) => e.stopPropagation()}>
<Typography.Text className="text-md font-medium text-color-inherit">
{' '}
{getNameFromFQN(entityFQN)}
</Typography.Text>
</Link>
</EntityPopOverCard>
</>
)}
{!isEmpty(taskField) ? (
<span className="break-all">{taskField}</span>
) : null}
</Typography.Text>
);
@ -418,7 +455,7 @@ export const TaskTab = ({
}, [initialAssignees]);
return (
<Row className="p-y-sm p-x-md" gutter={[0, 24]}>
<Row className="p-y-sm p-x-md" data-testid="task-tab" gutter={[0, 24]}>
<Col className="d-flex items-center" span={24}>
<Icon
className="m-r-xs"

View File

@ -18,6 +18,7 @@ import { EntityReference } from '../../../generated/entity/type';
export type TaskTabProps = {
taskThread: Thread;
owner?: EntityReference;
isForFeedTab?: boolean;
onUpdateEntityDetails?: () => void;
onAfterClose?: () => void;
} & (

View File

@ -159,6 +159,7 @@ const Users = ({
<ActivityFeedTab
entityType={EntityType.USER}
fqn={username}
isForFeedTab={false}
onFeedUpdate={noop}
/>
</ActivityFeedProvider>

View File

@ -46,3 +46,5 @@ export const ENDS_WITH_NUMBER_REGEX = /\d+$/;
export const VALID_OBJECT_KEY_REGEX = /^[_$a-zA-Z][_$a-zA-Z0-9]*$/;
export const HEX_COLOR_CODE_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
export const TASK_SANITIZE_VALUE_REGEX = /^"|"$/g;

View File

@ -12,7 +12,13 @@
*/
import { TableVersionProp } from '../components/TableVersion/TableVersion.interface';
import { DatabaseServiceType, TableType } from '../generated/entity/data/table';
import {
Constraint,
DatabaseServiceType,
DataType,
Table,
TableType,
} from '../generated/entity/data/table';
import { ENTITY_PERMISSIONS } from '../mocks/Permissions.mock';
import {
mockBackHandler,
@ -23,7 +29,7 @@ import {
mockVersionList,
} from '../mocks/VersionCommon.mock';
export const mockTableData = {
export const mockTableData: Table = {
id: 'ab4f893b-c303-43d9-9375-3e620a670b02',
name: 'raw_product_catalog',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify.raw_product_catalog',
@ -33,7 +39,20 @@ export const mockTableData = {
updatedAt: 1688442727895,
updatedBy: 'admin',
tableType: TableType.Regular,
columns: [],
columns: [
{
name: 'shop_id',
displayName: 'Shop Id Customer',
dataType: DataType.Number,
dataTypeDisplay: 'numeric',
description:
'Unique identifier for the store. This column is the primary key for this table.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify."dim.shop".shop_id',
tags: [],
constraint: Constraint.PrimaryKey,
ordinalPosition: 1,
},
],
owner: {
id: '38be030f-f817-4712-bc3b-ff7b9b9b805e',
type: 'user',

View File

@ -0,0 +1,103 @@
/*
* 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.
*/
import {
Column,
Constraint,
DataType,
} from '../generated/entity/data/container';
import {
Post,
TaskType,
Thread,
ThreadTaskStatus,
ThreadType,
} from '../generated/entity/feed/thread';
/* eslint-disable max-len */
export const TASK_FEED: Thread = {
id: '8b5076bb-8284-46b0-b00d-5e43a184ba9b',
type: ThreadType.Task,
href: 'http://localhost:8585/api/v1/feed/8b5076bb-8284-46b0-b00d-5e43a184ba9b',
threadTs: 1701686127533,
about:
'<#E::table::sample_data.ecommerce_db.shopify."dim.shop"::columns::shop_id::tags>',
entityId: 'defcff8c-0823-40e6-9c1e-9b0458ba0fa5',
createdBy: 'admin',
updatedAt: 1701686127534,
updatedBy: 'admin',
resolved: false,
message: 'Request tags for table dim.shop columns/shop_id',
postsCount: 0,
posts: [],
reactions: [],
task: {
id: 2,
type: TaskType.RequestTag,
assignees: [
{
id: '31d072f8-7873-4976-88ea-ac0d2f51f632',
type: 'team',
name: 'Sales',
fullyQualifiedName: 'Sales',
deleted: false,
},
],
status: ThreadTaskStatus.Open,
oldValue: '[]',
suggestion:
'[{"tagFQN":"PersonalData.SpecialCategory","source":"Classification","name":"SpecialCategory","description":"GDPR special category data is personal information of data subjects that is especially sensitive, the exposure of which could significantly impact the rights and freedoms of data subjects and potentially be used against them for unlawful discrimination."}]',
},
};
export const TASK_POST: Post = {
message: 'Request tags for table dim.shop columns/shop_id',
postTs: 1701686127533,
from: 'admin',
id: '8b5076bb-8284-46b0-b00d-5e43a184ba9b',
reactions: [],
};
export const TASK_COLUMNS: Column[] = [
{
name: 'shop_id',
dataType: DataType.Number,
dataTypeDisplay: 'numeric',
description:
'Unique identifier for the store. This column is the primary key for this table.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify."dim.shop".shop_id',
tags: [],
constraint: Constraint.PrimaryKey,
ordinalPosition: 1,
},
{
name: 'name',
dataType: DataType.Varchar,
dataLength: 100,
dataTypeDisplay: 'varchar',
description: 'Name of your store.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify."dim.shop".name',
tags: [],
ordinalPosition: 2,
},
{
name: 'domain',
dataType: DataType.Varchar,
dataLength: 1000,
dataTypeDisplay: 'varchar',
description:
'Primary domain specified for your online store. Your primary domain is the one that your customers and search engines see. For example, www.mycompany.com.',
fullyQualifiedName: 'sample_data.ecommerce_db.shopify."dim.shop".domain',
tags: [],
ordinalPosition: 3,
},
];

View File

@ -46,6 +46,7 @@ import {
fetchEntityDetail,
fetchOptions,
getBreadCrumbList,
getTaskMessage,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
@ -72,11 +73,17 @@ const RequestDescription = () => {
const [assignees, setAssignees] = useState<Array<Option>>([]);
const [suggestion, setSuggestion] = useState<string>('');
const getSanitizeValue = value?.replaceAll(/^"|"$/g, '') || '';
const message = `Request description for ${getSanitizeValue || entityType} ${
field !== EntityField.COLUMNS ? getEntityName(entityData) : ''
}`;
const taskMessage = useMemo(
() =>
getTaskMessage({
value,
entityType,
entityData,
field,
startMessage: 'Request description',
}),
[value, entityType, field, entityData]
);
const decodedEntityFQN = useMemo(
() => getDecodedFqn(entityFQN),
@ -105,7 +112,7 @@ const RequestDescription = () => {
if (assignees.length) {
const data: CreateThread = {
from: currentUser?.name as string,
message: value.title || message,
message: value.title || taskMessage,
about: getEntityFeedLink(entityType, decodedEntityFQN, getTaskAbout()),
taskDetails: {
assignees: assignees.map((assignee) => ({
@ -159,7 +166,7 @@ const RequestDescription = () => {
setOptions(defaultAssignee);
}
form.setFieldsValue({
title: message.trimEnd(),
title: taskMessage.trimEnd(),
assignees: defaultAssignee,
});
}, [entityData]);

View File

@ -45,6 +45,7 @@ import {
fetchEntityDetail,
fetchOptions,
getBreadCrumbList,
getTaskMessage,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
@ -70,11 +71,17 @@ const RequestTag = () => {
const [assignees, setAssignees] = useState<Option[]>([]);
const [suggestion] = useState<TagLabel[]>([]);
const getSanitizeValue = value?.replaceAll(/^"|"$/g, '') || '';
const message = `Request tags for ${getSanitizeValue || entityType} ${
field !== EntityField.COLUMNS ? getEntityName(entityData) : ''
}`;
const taskMessage = useMemo(
() =>
getTaskMessage({
value,
entityType,
entityData,
field,
startMessage: 'Request tags',
}),
[value, entityType, field, entityData]
);
const decodedEntityFQN = useMemo(
() => getDecodedFqn(entityFQN),
@ -98,7 +105,7 @@ const RequestTag = () => {
const onCreateTask: FormProps['onFinish'] = (value) => {
const data: CreateThread = {
from: currentUser?.name as string,
message: value.title || message,
message: value.title || taskMessage,
about: getEntityFeedLink(entityType, decodedEntityFQN, getTaskAbout()),
taskDetails: {
assignees: assignees.map((assignee) => ({
@ -149,7 +156,7 @@ const RequestTag = () => {
setOptions((prev) => [...defaultAssignee, ...prev]);
}
form.setFieldsValue({
title: message.trimEnd(),
title: taskMessage.trimEnd(),
assignees: defaultAssignee,
});
}, [entityData]);

View File

@ -27,6 +27,7 @@ import Loader from '../../../components/Loader/Loader';
import { SearchedDataProps } from '../../../components/SearchedData/SearchedData.interface';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { EntityField } from '../../../constants/Feeds.constants';
import { TASK_SANITIZE_VALUE_REGEX } from '../../../constants/regex.constants';
import { EntityTabs, EntityType } from '../../../enums/entity.enum';
import {
CreateThread,
@ -47,6 +48,7 @@ import {
getBreadCrumbList,
getColumnObject,
getEntityColumnsDetails,
getTaskMessage,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
@ -73,18 +75,29 @@ const UpdateDescription = () => {
const [assignees, setAssignees] = useState<Array<Option>>([]);
const [currentDescription, setCurrentDescription] = useState<string>('');
const getSanitizeValue = value?.replaceAll(/^"|"$/g, '') || '';
const sanitizeValue = useMemo(
() => value?.replaceAll(TASK_SANITIZE_VALUE_REGEX, '') ?? '',
[value]
);
const decodedEntityFQN = useMemo(() => getDecodedFqn(entityFQN), [entityFQN]);
const message = `Update description for ${getSanitizeValue || entityType} ${
field !== EntityField.COLUMNS ? getEntityName(entityData) : ''
}`;
const taskMessage = useMemo(
() =>
getTaskMessage({
value,
entityType,
entityData,
field,
startMessage: 'Update description',
}),
[value, entityType, field, entityData]
);
const back = () => history.goBack();
const columnObject = useMemo(() => {
const column = getSanitizeValue.split(FQN_SEPARATOR_CHAR).slice(-1);
const column = sanitizeValue.split(FQN_SEPARATOR_CHAR).slice(-1);
return getColumnObject(
column[0],
@ -116,7 +129,7 @@ const UpdateDescription = () => {
const onCreateTask: FormProps['onFinish'] = (value) => {
const data: CreateThread = {
from: currentUser?.name as string,
message: value.title || message,
message: value.title || taskMessage,
about: getEntityFeedLink(entityType, decodedEntityFQN, getTaskAbout()),
taskDetails: {
assignees: assignees.map((assignee) => ({
@ -167,7 +180,7 @@ const UpdateDescription = () => {
setOptions(defaultAssignee);
}
form.setFieldsValue({
title: message.trimEnd(),
title: taskMessage.trimEnd(),
assignees: defaultAssignee,
description: getDescription(),
});

View File

@ -27,6 +27,7 @@ import Loader from '../../../components/Loader/Loader';
import { SearchedDataProps } from '../../../components/SearchedData/SearchedData.interface';
import { FQN_SEPARATOR_CHAR } from '../../../constants/char.constants';
import { EntityField } from '../../../constants/Feeds.constants';
import { TASK_SANITIZE_VALUE_REGEX } from '../../../constants/regex.constants';
import { EntityTabs, EntityType } from '../../../enums/entity.enum';
import {
CreateThread,
@ -49,6 +50,7 @@ import {
getBreadCrumbList,
getColumnObject,
getEntityColumnsDetails,
getTaskMessage,
} from '../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import Assignees from '../shared/Assignees';
@ -78,17 +80,29 @@ const UpdateTag = () => {
const [currentTags, setCurrentTags] = useState<TagLabel[]>([]);
const [suggestion, setSuggestion] = useState<TagLabel[]>([]);
const getSanitizeValue = value?.replaceAll(/^"|"$/g, '') || '';
const sanitizeValue = useMemo(
() => value?.replaceAll(TASK_SANITIZE_VALUE_REGEX, '') ?? '',
[value]
);
const taskMessage = useMemo(
() =>
getTaskMessage({
value,
entityType,
entityData,
field,
startMessage: 'Update tags',
}),
[value, entityType, field, entityData]
);
const message = `Update tags for ${getSanitizeValue || entityType} ${
field !== EntityField.COLUMNS ? getEntityName(entityData) : ''
}`;
const decodedEntityFQN = useMemo(() => getDecodedFqn(entityFQN), [entityFQN]);
const back = () => history.goBack();
const columnObject = useMemo(() => {
const column = getSanitizeValue.split(FQN_SEPARATOR_CHAR).slice(-1);
const column = sanitizeValue.split(FQN_SEPARATOR_CHAR).slice(-1);
return getColumnObject(
column[0],
@ -121,7 +135,7 @@ const UpdateTag = () => {
const onCreateTask: FormProps['onFinish'] = (value) => {
const data: CreateThread = {
from: currentUser?.name as string,
message: value.title || message,
message: value.title || taskMessage,
about: getEntityFeedLink(entityType, decodedEntityFQN, getTaskAbout()),
taskDetails: {
assignees: assignees.map((assignee) => ({
@ -177,7 +191,7 @@ const UpdateTag = () => {
setOptions(defaultAssignee);
}
form.setFieldsValue({
title: message.trimEnd(),
title: taskMessage.trimEnd(),
updatedTags: getTags(),
assignees: defaultAssignee,
});

View File

@ -0,0 +1,159 @@
/*
* 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.
*/
import { EntityType } from '../enums/entity.enum';
import { mockTableData } from '../mocks/TableVersion.mock';
import { getEntityTableName, getTaskMessage } from './TasksUtils';
describe('Tests for DataAssetsHeaderUtils', () => {
it('function getEntityTableName should return name if no data found', () => {
const entityName = getEntityTableName(
EntityType.TABLE,
'data_test_id',
mockTableData
);
expect(entityName).toEqual('data_test_id');
});
it('function getEntityTableName should return name if it contains dot in it name', () => {
const entityName = getEntityTableName(
EntityType.TABLE,
'data.test_id',
mockTableData
);
expect(entityName).toEqual('data.test_id');
});
it('function getEntityTableName should return name if entity type not found', () => {
const entityName = getEntityTableName(
EntityType.DATABASE_SERVICE,
'cyber_test',
mockTableData
);
expect(entityName).toEqual('cyber_test');
});
it('function getEntityTableName should return entity display name for all entities', () => {
const entityTableName = getEntityTableName(
EntityType.TABLE,
'shop_id',
mockTableData
);
expect(entityTableName).toEqual('Shop Id Customer');
});
});
const taskTagMessage = {
value: null,
entityType: EntityType.TABLE,
entityData: mockTableData,
field: null,
startMessage: 'Request Tag',
};
const taskDescriptionMessage = {
...taskTagMessage,
startMessage: 'Request Description',
};
describe('Tests for getTaskMessage', () => {
it('function getTaskMessage should return task message for tags', () => {
// entity request task message
const requestTagsEntityMessage = getTaskMessage(taskTagMessage);
expect(requestTagsEntityMessage).toEqual(
'Request Tag for table raw_product_catalog '
);
// entity request column message
const requestTagsEntityColumnMessage = getTaskMessage({
...taskTagMessage,
value: 'order_id',
field: 'columns',
});
expect(requestTagsEntityColumnMessage).toEqual(
'Request Tag for table raw_product_catalog columns/order_id'
);
// entity update task message
const updateTagsEntityMessage = getTaskMessage({
...taskTagMessage,
startMessage: 'Update Tag',
});
expect(updateTagsEntityMessage).toEqual(
'Update Tag for table raw_product_catalog '
);
// entity update column message
const updateTagsEntityColumnMessage = getTaskMessage({
...taskTagMessage,
value: 'order_id',
field: 'columns',
startMessage: 'Update Tag',
});
expect(updateTagsEntityColumnMessage).toEqual(
'Update Tag for table raw_product_catalog columns/order_id'
);
});
it('function getTaskMessage should return task message for description', () => {
// entity request task message
const requestDescriptionEntityMessage = getTaskMessage(
taskDescriptionMessage
);
expect(requestDescriptionEntityMessage).toEqual(
'Request Description for table raw_product_catalog '
);
// entity request column message
const requestDescriptionEntityColumnMessage = getTaskMessage({
...taskDescriptionMessage,
value: 'order_id',
field: 'columns',
});
expect(requestDescriptionEntityColumnMessage).toEqual(
'Request Description for table raw_product_catalog columns/order_id'
);
// entity update task message
const updateDescriptionEntityMessage = getTaskMessage({
...taskDescriptionMessage,
startMessage: 'Update Description',
});
expect(updateDescriptionEntityMessage).toEqual(
'Update Description for table raw_product_catalog '
);
// entity update column message
const updateDescriptionEntityColumnMessage = getTaskMessage({
...taskDescriptionMessage,
value: 'order_id',
field: 'columns',
startMessage: 'Update Description',
});
expect(updateDescriptionEntityColumnMessage).toEqual(
'Update Description for table raw_product_catalog columns/order_id'
);
});
});

View File

@ -25,6 +25,7 @@ import {
ROUTES,
} from '../constants/constants';
import { EntityField } from '../constants/Feeds.constants';
import { TASK_SANITIZE_VALUE_REGEX } from '../constants/regex.constants';
import {
EntityTabs,
EntityType,
@ -35,8 +36,10 @@ import { ServiceCategory } from '../enums/service.enum';
import { Chart } from '../generated/entity/data/chart';
import { Container } from '../generated/entity/data/container';
import { Dashboard } from '../generated/entity/data/dashboard';
import { DashboardDataModel } from '../generated/entity/data/dashboardDataModel';
import { MlFeature, Mlmodel } from '../generated/entity/data/mlmodel';
import { Pipeline, Task } from '../generated/entity/data/pipeline';
import { SearchIndex } from '../generated/entity/data/searchIndex';
import { Column, Table } from '../generated/entity/data/table';
import { Field, Topic } from '../generated/entity/data/topic';
import { TaskType, Thread } from '../generated/entity/feed/thread';
@ -590,3 +593,105 @@ export const getEntityTaskDetails = (
return { fqnPart: [fqnPartTypes], entityField };
};
export const getEntityTableName = (
entityType: EntityType,
name: string,
entityData: EntityData
): string => {
if (name.includes('.')) {
return name;
}
let entityReference;
switch (entityType) {
case EntityType.TABLE:
entityReference = (entityData as Table).columns?.find(
(item) => item.name === name
);
break;
case EntityType.TOPIC:
entityReference = (entityData as Topic).messageSchema?.schemaFields?.find(
(item) => item.name === name
);
break;
case EntityType.DASHBOARD:
entityReference = (entityData as Dashboard).charts?.find(
(item) => item.name === name
);
break;
case EntityType.PIPELINE:
entityReference = (entityData as Pipeline).tasks?.find(
(item) => item.name === name
);
break;
case EntityType.MLMODEL:
entityReference = (entityData as Mlmodel).mlFeatures?.find(
(item) => item.name === name
);
break;
case EntityType.CONTAINER:
entityReference = (entityData as Container).dataModel?.columns?.find(
(item) => item.name === name
);
break;
case EntityType.SEARCH_INDEX:
entityReference = (entityData as SearchIndex).fields?.find(
(item) => item.name === name
);
break;
case EntityType.DASHBOARD_DATA_MODEL:
entityReference = (entityData as DashboardDataModel).columns?.find(
(item) => item.name === name
);
break;
default:
return name;
}
if (isUndefined(entityReference)) {
return name;
}
return getEntityName(entityReference);
};
export const getTaskMessage = ({
value,
entityType,
entityData,
field,
startMessage,
}: {
value: string | null;
entityType: EntityType;
entityData: EntityData;
field: string | null;
startMessage: string;
}) => {
const sanitizeValue = value?.replaceAll(TASK_SANITIZE_VALUE_REGEX, '') ?? '';
const entityColumnsName = field
? `${field}/${getEntityTableName(entityType, sanitizeValue, entityData)}`
: '';
return `${startMessage} for ${entityType} ${getEntityName(
entityData
)} ${entityColumnsName}`;
};