#13876: change placement of comment and close button in task approval workflow (#17044)

* change placment of comment and close button in task approval workflow

* minor change

* playwright test for the close and comment function

* supported ref in activityFeedEditor

* fix playwright test

* added playwright test for data steward

* fix the test for the data streward user

* fix the close button not showing if task has no suggestions and icon fixes

* fix sonar issue

* change glossary and add suggestion button to dropdown button

* fix the glossary failure due to button change

* icon change for add tag and description

* fix glossary cypress failure due to button chnages

* changes as per comments
This commit is contained in:
Ashish Gupta 2024-07-22 12:45:07 +05:30 committed by GitHub
parent b19b7f59a5
commit f500e70256
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 790 additions and 164 deletions

View File

@ -382,7 +382,10 @@ const approveGlossaryTermWorkflow = ({ glossary, glossaryTerm }) => {
interceptURL('PUT', '/api/v1/feed/tasks/*/resolve', 'resolveTask');
cy.get('[data-testid="approve-task"]').click();
// approve the task
cy.get(
'[data-testid="glossary-accept-reject-task-dropdown"] .ant-btn-compact-first-item > span'
).click();
verifyResponseStatusCode('@resolveTask', 200);

View File

@ -16,6 +16,8 @@ import { UserClass } from '../../support/user/UserClass';
import { checkDescriptionInEditModal } from '../../utils/activityFeed';
import {
createNewPage,
performAdminLogin,
performUserLogin,
redirectToHomePage,
toastNotification,
visitUserProfilePage,
@ -27,18 +29,21 @@ import {
TaskDetails,
} from '../../utils/task';
// use the admin user to login
test.use({ storageState: 'playwright/.auth/admin.json' });
const entity = new TableClass();
const user = new UserClass();
const entity2 = new TableClass();
const user1 = new UserClass();
const user2 = new UserClass();
test.describe('Activity feed', () => {
// use the admin user to login
test.use({ storageState: 'playwright/.auth/admin.json' });
test.beforeAll('Setup pre-requests', async ({ browser }) => {
const { apiContext, afterAction } = await createNewPage(browser);
await entity.create(apiContext);
await user.create(apiContext);
await entity2.create(apiContext);
await user1.create(apiContext);
await afterAction();
});
@ -50,7 +55,8 @@ test.describe('Activity feed', () => {
test.afterAll('Cleanup', async ({ browser }) => {
const { apiContext, afterAction } = await createNewPage(browser);
await entity.delete(apiContext);
await user.delete(apiContext);
await entity2.delete(apiContext);
await user1.delete(apiContext);
await afterAction();
});
@ -58,7 +64,7 @@ test.describe('Activity feed', () => {
test('Assigned task should appear to task tab', async ({ page }) => {
const value: TaskDetails = {
term: entity.entity.name,
assignee: user.responseData.name,
assignee: user1.responseData.name,
};
await entity.visitEntityPage(page);
@ -186,7 +192,7 @@ test.describe('Activity feed', () => {
test('Update Description Task on Columns', async ({ page }) => {
const firstTaskValue: TaskDetails = {
term: entity.entity.name,
assignee: user.responseData.name,
assignee: user1.responseData.name,
description: 'Column Description 1',
columnName: entity.entity.columns[0].name,
oldDescription: entity.entity.columns[0].description,
@ -246,4 +252,225 @@ test.describe('Activity feed', () => {
expect(closedTask).toContain('2 Closed');
});
test('Comment and Close Task should work in Task Flow', async ({ page }) => {
const value: TaskDetails = {
term: entity2.entity.name,
assignee: user1.responseData.name,
};
await entity2.visitEntityPage(page);
await page.getByTestId('request-description').click();
await createDescriptionTask(page, value);
// Task 1 - Update Description right panel check
const descriptionTask = await page.getByTestId('task-title').innerText();
expect(descriptionTask).toContain('Request to update description');
// Check the editor send button is not visible and comment button is disabled when no text is added
expect(page.locator('[data-testid="send-button"]')).not.toBeVisible();
expect(
await page.locator('[data-testid="comment-button"]').isDisabled()
).toBeTruthy();
await page.fill(
'[data-testid="editor-wrapper"] .ql-editor',
'Test comment added'
);
const addComment = page.waitForResponse('/api/v1/feed/*/posts');
await page.getByTestId('comment-button').click();
await addComment;
// Close the task from the Button.Group, should throw error when no comment is added.
await page.getByRole('button', { name: 'down' }).click();
await page.waitForSelector('.ant-dropdown', {
state: 'visible',
});
await page.getByRole('menuitem', { name: 'close' }).click();
await toastNotification(page, 'Task cannot be closed without a comment.');
// Close the task from the Button.Group, with comment is added.
await page.fill(
'[data-testid="editor-wrapper"] .ql-editor',
'Closing the task with comment'
);
const commentWithCloseTask = page.waitForResponse(
'/api/v1/feed/tasks/*/close'
);
await page.getByRole('button', { name: 'down' }).click();
await page.waitForSelector('.ant-dropdown', {
state: 'visible',
});
await page.getByRole('menuitem', { name: 'close' }).click();
await commentWithCloseTask;
await toastNotification(page, 'Task closed successfully.');
const openTask = await page.getByTestId('open-task').textContent();
expect(openTask).toContain('0 Open');
const closedTask = await page.getByTestId('closed-task').textContent();
expect(closedTask).toContain('1 Closed');
});
});
test.describe('Activity feed with Data Steward User', () => {
test.slow(true);
test.beforeAll('Setup pre-requests', async ({ browser }) => {
const { afterAction, apiContext } = await performAdminLogin(browser);
await entity.create(apiContext);
await user1.create(apiContext);
await user2.create(apiContext);
await afterAction();
});
test.afterAll('Cleanup', async ({ browser }) => {
const { afterAction, apiContext } = await performAdminLogin(browser);
await entity.delete(apiContext);
await user1.delete(apiContext);
await user2.delete(apiContext);
await afterAction();
});
test('Create and Assign Task', async ({ browser }) => {
const { page: page1, afterAction: afterActionUser1 } =
await performUserLogin(browser, user1);
const { page: page2, afterAction: afterActionUser2 } =
await performUserLogin(browser, user2);
const value: TaskDetails = {
term: entity.entity.name,
assignee: user2.responseData.name,
};
await test.step('Create, Close and Assign Task to User 2', async () => {
await redirectToHomePage(page1);
await entity.visitEntityPage(page1);
// Create 2 task for the same entity, one to close and 2nd for the user2 action
await page1.getByTestId('request-description').click();
await createDescriptionTask(page1, value);
await page1.getByTestId('schema').click();
await page1.getByTestId('request-entity-tags').click();
// create tag task
await createTagTask(page1, { ...value, tag: 'PII.None' });
// Should only see the close and comment button
expect(
await page1.locator('[data-testid="comment-button"]').isDisabled()
).toBeTruthy();
expect(page1.locator('[data-testid="close-button"]')).toBeVisible();
expect(
page1.locator('[data-testid="edit-accept-task-dropdown"]')
).not.toBeVisible();
// Close 1st task
await page1.fill(
'[data-testid="editor-wrapper"] .ql-editor',
'Closing the task with comment'
);
const commentWithCloseTask = page1.waitForResponse(
'/api/v1/feed/tasks/*/close'
);
page1.locator('[data-testid="close-button"]').click();
await commentWithCloseTask;
// TODO: Ashish - Fix the toast notification once issue is resolved from Backend https://github.com/open-metadata/OpenMetadata/issues/17059
// await toastNotification(page1, 'Task closed successfully.');
await toastNotification(
page1,
'An exception with message [Cannot invoke "org.openmetadata.schema.type.EntityReference.getName()" because "owner" is null] was thrown while processing request.'
);
// TODO: Ashish - Enable them once issue is resolved from Backend https://github.com/open-metadata/OpenMetadata/issues/17059
// const openTask = await page1.getByTestId('open-task').textContent();
// expect(openTask).toContain('1 Open');
// const closedTask = await page1.getByTestId('closed-task').textContent();
// expect(closedTask).toContain('1 Closed');
await afterActionUser1();
});
await test.step('Accept Task By User 2', async () => {
await redirectToHomePage(page2);
const taskResponse = page2.waitForResponse(
'/api/v1/feed?type=Task&filterType=OWNER&taskStatus=Open&userId=*'
);
await page2
.getByTestId('activity-feed-widget')
.getByText('Tasks')
.click();
await taskResponse;
await expect(
page2.locator(
'[data-testid="activity-feed-widget"] [data-testid="no-data-placeholder"]'
)
).not.toBeVisible();
const entityPageTaskTab = page2.waitForResponse(
'/api/v1/feed?*&type=Task'
);
const tagsTask = page2.getByTestId('redirect-task-button-link').first();
const tagsTaskContent = await tagsTask.innerText();
expect(tagsTaskContent).toContain('Request tags for');
await tagsTask.click();
await entityPageTaskTab;
// TODO: Ashish - Enable them once issue is resolved from Backend https://github.com/open-metadata/OpenMetadata/issues/17059
// Count for task should be 1 both open and closed
// const openTaskBefore = await page2.getByTestId('open-task').textContent();
// expect(openTaskBefore).toContain('1 Open');
// const closedTaskBefore = await page2
// .getByTestId('closed-task')
// .textContent();
// expect(closedTaskBefore).toContain('1 Closed');
// Should not see the close button
expect(page2.locator('[data-testid="close-button"]')).not.toBeVisible();
expect(
await page2.locator('[data-testid="comment-button"]').isDisabled()
).toBeTruthy();
expect(
page2.locator('[data-testid="edit-accept-task-dropdown"]')
).toBeVisible();
await page2.getByText('Accept Suggestion').click();
await toastNotification(page2, /Task resolved successfully/);
// TODO: Ashish - Enable them once issue is resolved from Backend https://github.com/open-metadata/OpenMetadata/issues/17059
// const openTask = await page2.getByTestId('open-task').textContent();
// expect(openTask).toContain('0 Open');
const closedTask = await page2.getByTestId('closed-task').textContent();
expect(closedTask).toContain('1 Closed');
await afterActionUser2();
});
});
});

View File

@ -150,7 +150,7 @@ export const clickOutside = async (page: Page) => {
export const visitUserProfilePage = async (page: Page) => {
await page.getByTestId('dropdown-profile').click();
await page.waitForSelector('.profile-dropdown', {
await page.waitForSelector('[role="menu"].profile-dropdown', {
state: 'visible',
});
const userResponse = page.waitForResponse(

View File

@ -553,7 +553,7 @@ export const approveGlossaryTermTask = async (
) => {
await validateGlossaryTermTask(page, term);
const taskResolve = page.waitForResponse('/api/v1/feed/tasks/*/resolve');
await page.click('[data-testid="approve-task"]');
await page.getByRole('button', { name: 'Approve' }).click();
await taskResolve;
// Display toast notification

View File

@ -0,0 +1,11 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_7784_244249)">
<path d="M10 0C4.48357 0 0 4.48357 0 10C0 15.5164 4.48357 20 10 20C15.5164 20 20 15.5164 20 10C20 4.48357 15.5164 0 10 0ZM10 18.9906C5.04695 18.9906 1.00939 14.9531 1.00939 10C1.00939 5.04695 5.04695 1.00939 10 1.00939C14.9531 1.00939 18.9906 5.04695 18.9906 10C18.9906 14.9531 14.9531 18.9906 10 18.9906Z" fill="#757575"/>
<path d="M10.7042 9.95317L13.6385 7.0189C13.8263 6.83111 13.8263 6.50247 13.6385 6.31467C13.4507 6.12688 13.1221 6.12688 12.9343 6.31467L10 9.24895L7.06573 6.31467C6.87793 6.12688 6.5493 6.12688 6.3615 6.31467C6.17371 6.50247 6.17371 6.83111 6.3615 7.0189L9.29577 9.95317L6.3615 12.864C6.17371 13.0518 6.17371 13.3804 6.3615 13.5682C6.4554 13.6621 6.59624 13.709 6.71362 13.709C6.83099 13.709 6.97183 13.6621 7.06573 13.5682L10 10.6339L12.9343 13.5682C13.0282 13.6621 13.169 13.709 13.2864 13.709C13.4038 13.709 13.5446 13.6621 13.6385 13.5682C13.8263 13.3804 13.8263 13.0518 13.6385 12.864L10.7042 9.95317Z" fill="#757575"/>
</g>
<defs>
<clipPath id="clip0_7784_244249">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@ -0,0 +1,11 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_8185_248203)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 5.58203C10.3853 5.58203 10.6977 5.89439 10.6977 6.27971V9.30296H13.7209C14.1062 9.30296 14.4186 9.61533 14.4186 10.0006C14.4186 10.3859 14.1062 10.6983 13.7209 10.6983H10.6977V13.7216C10.6977 14.1069 10.3853 14.4192 10 14.4192C9.61469 14.4192 9.30232 14.1069 9.30232 13.7216V10.6983H6.27906C5.89375 10.6983 5.58139 10.3859 5.58139 10.0006C5.58139 9.61533 5.89375 9.30296 6.27906 9.30296H9.30232V6.27971C9.30232 5.89439 9.61469 5.58203 10 5.58203Z" fill="#48CA9E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 18.6047C5.24779 18.6047 1.39535 14.7522 1.39535 10C1.39535 5.24779 5.24779 1.39535 10 1.39535C14.7522 1.39535 18.6047 5.24779 18.6047 10C18.6047 14.7522 14.7522 18.6047 10 18.6047ZM0 10C0 15.5229 4.47715 20 10 20C15.5229 20 20 15.5229 20 10C20 4.47715 15.5229 0 10 0C4.47715 0 0 4.47715 0 10Z" fill="#48CA9E"/>
</g>
<defs>
<clipPath id="clip0_8185_248203">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,4 @@
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9.5" fill="white" stroke="#48CA9E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6869 6.31246C15.8821 6.50772 15.8821 6.82431 15.6869 7.01957L10.0042 12.7023C9.45255 13.2539 8.57016 13.2906 7.97461 12.7867L4.3437 9.71438C4.13289 9.536 4.1066 9.22051 4.28497 9.00971C4.46335 8.79891 4.77883 8.77262 4.98964 8.95099L8.62056 12.0233C8.81907 12.1913 9.1132 12.179 9.29708 11.9952L14.9798 6.31246C15.175 6.1172 15.4916 6.1172 15.6869 6.31246Z" fill="#48CA9E"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

View File

@ -12,8 +12,15 @@
*/
import classNames from 'classnames';
import React, { FC, HTMLAttributes, useRef, useState } from 'react';
import React, {
forwardRef,
HTMLAttributes,
useImperativeHandle,
useRef,
useState,
} from 'react';
import { getBackendFormat, HTMLToMarkdown } from '../../../utils/FeedUtils';
import { editorRef } from '../../common/RichTextEditor/RichTextEditor.interface';
import { FeedEditor } from '../FeedEditor/FeedEditor';
import { KeyHelp } from './KeyHelp';
import { SendButton } from './SendButton';
@ -33,60 +40,73 @@ export type EditorContentRef = {
clearEditorValue: () => string;
};
const ActivityFeedEditor: FC<ActivityFeedEditorProp> = ({
className,
editorClass,
onSave,
placeHolder,
defaultValue,
onTextChange,
editAction,
focused = false,
}) => {
const editorRef = useRef<EditorContentRef>();
const [editorValue, setEditorValue] = useState<string>('');
const ActivityFeedEditor = forwardRef<editorRef, ActivityFeedEditorProp>(
(
{
className,
editorClass,
onSave,
placeHolder,
defaultValue,
onTextChange,
editAction,
focused = false,
},
ref
) => {
const editorRef = useRef<EditorContentRef>();
const [editorValue, setEditorValue] = useState<string>('');
const onChangeHandler = (value: string) => {
const markdown = HTMLToMarkdown.turndown(value);
const backendFormat = getBackendFormat(markdown);
setEditorValue(markdown);
onTextChange && onTextChange(backendFormat);
};
const onChangeHandler = (value: string) => {
const markdown = HTMLToMarkdown.turndown(value);
const backendFormat = getBackendFormat(markdown);
setEditorValue(markdown);
onTextChange && onTextChange(backendFormat);
};
const onSaveHandler = () => {
if (editorRef.current) {
if (editorRef.current?.getEditorValue()) {
setEditorValue('');
editorRef.current?.clearEditorValue();
const message = getBackendFormat(editorRef.current?.getEditorValue());
onSave && onSave(message);
const onSaveHandler = () => {
if (editorRef.current) {
if (editorRef.current?.getEditorValue()) {
setEditorValue('');
editorRef.current?.clearEditorValue();
const message = getBackendFormat(editorRef.current?.getEditorValue());
onSave && onSave(message);
}
}
}
};
};
return (
<div
className={classNames('relative', className)}
onClick={(e) => e.stopPropagation()}>
<FeedEditor
defaultValue={defaultValue}
editorClass={editorClass}
focused={focused}
placeHolder={placeHolder}
ref={editorRef}
onChangeHandler={onChangeHandler}
onSave={onSaveHandler}
/>
{editAction ? (
editAction
) : (
<>
<SendButton editorValue={editorValue} onSaveHandler={onSaveHandler} />
<KeyHelp editorValue={editorValue} />
</>
)}
</div>
);
};
/**
* Handle forward ref logic and provide method access to parent component
*/
useImperativeHandle(ref, () => ({
...editorRef.current,
}));
return (
<div
className={classNames('relative', className)}
onClick={(e) => e.stopPropagation()}>
<FeedEditor
defaultValue={defaultValue}
editorClass={editorClass}
focused={focused}
placeHolder={placeHolder}
ref={editorRef}
onChangeHandler={onChangeHandler}
onSave={onSaveHandler}
/>
{editAction ?? (
<>
<SendButton
editorValue={editorValue}
onSaveHandler={onSaveHandler}
/>
<KeyHelp editorValue={editorValue} />
</>
)}
</div>
);
}
);
export default ActivityFeedEditor;

View File

@ -420,6 +420,7 @@ export const ActivityFeedTab = ({
'font-medium': taskFilter === 'open',
}
)}
data-testid="open-task"
onClick={() => {
handleUpdateTaskFilter('open');
setActiveThread();

View File

@ -15,7 +15,14 @@ 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 { useAuth } from '../../../../hooks/authHooks';
import {
MOCK_TASK,
MOCK_TASK_2,
MOCK_TASK_3,
TASK_COLUMNS,
TASK_FEED,
} from '../../../../mocks/Task.mock';
import { mockUserData } from '../../../Settings/Users/mocks/User.mocks';
import { TaskTab } from './TaskTab.component';
import { TaskTabProps } from './TaskTab.interface';
@ -39,7 +46,12 @@ jest.mock('../../../ActivityFeed/ActivityFeedCardV2/ActivityFeedCardV2', () => {
});
jest.mock('../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor', () => {
return jest.fn().mockImplementation(() => <p>ActivityFeedEditor</p>);
return jest.fn().mockImplementation(({ editAction }) => (
<div>
<p>ActivityFeedEditor</p>
{editAction}
</div>
));
});
jest.mock('../../../common/AssigneeList/AssigneeList', () => {
@ -133,11 +145,7 @@ jest.mock(
);
jest.mock('../../../../hooks/authHooks', () => ({
useAuth: () => {
return {
isAdminUser: false,
};
},
useAuth: jest.fn().mockReturnValue({ isAdminUser: false }),
}));
const mockOnAfterClose = jest.fn();
@ -168,6 +176,108 @@ describe('Test TaskFeedCard component', () => {
wrapper: MemoryRouter,
});
expect(screen.getByTestId('task-cta-buttons')).toBeEmptyDOMElement();
expect(screen.getByTestId('task-cta-buttons')).toHaveTextContent(
'label.comment'
);
expect(screen.getByTestId('task-cta-buttons')).not.toHaveTextContent(
'label.accept-suggestion'
);
expect(screen.getByTestId('task-cta-buttons')).not.toHaveTextContent(
'label.add-entity'
);
expect(screen.getByTestId('task-cta-buttons')).not.toHaveTextContent(
'label.add-suggestion'
);
});
it('should render close button if the user is creator task', async () => {
render(
<TaskTab
{...mockProps}
taskThread={{
...TASK_FEED,
createdBy: 'xyz',
}}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.getByText('label.close')).toBeInTheDocument();
});
it('should not render close button if the user is not a creator of task', async () => {
render(<TaskTab {...mockProps} />, {
wrapper: MemoryRouter,
});
expect(screen.queryByText('label.close')).not.toBeInTheDocument();
});
it('should not render close button if the user is a creator and even have hasEditAccess of task', async () => {
(useAuth as jest.Mock).mockImplementation(() => ({
isAdminUser: true,
}));
render(
<TaskTab
{...mockProps}
taskThread={{ ...TASK_FEED, createdBy: 'xyz' }}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.queryByText('label.close')).not.toBeInTheDocument();
});
it('should not render close button if the user is a creator and assignee of task', async () => {
render(
<TaskTab
{...mockProps}
taskThread={{ ...TASK_FEED, createdBy: 'xyz', task: MOCK_TASK }}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.queryByText('label.close')).not.toBeInTheDocument();
});
it('should render dropdown button with add and close tag if task created with no tags', async () => {
render(
<TaskTab
{...mockProps}
taskThread={{ ...TASK_FEED, createdBy: 'xyz', task: MOCK_TASK_2 }}
/>,
{
wrapper: MemoryRouter,
}
);
expect(screen.getByTestId('add-close-task-dropdown')).toBeInTheDocument();
expect(screen.getByText('label.add-entity')).toBeInTheDocument();
expect(screen.getByText('label.comment')).toBeInTheDocument();
});
it('should render dropdown button with resolve and reject tag if task is Glossary approval', async () => {
render(
<TaskTab
{...mockProps}
taskThread={{ ...TASK_FEED, task: MOCK_TASK_3 }}
/>,
{
wrapper: MemoryRouter,
}
);
expect(
screen.getByTestId('glossary-accept-reject-task-dropdown')
).toBeInTheDocument();
expect(screen.getByText('label.approve')).toBeInTheDocument();
expect(screen.getByText('label.comment')).toBeInTheDocument();
});
});

View File

@ -49,6 +49,8 @@ import { 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';
import { ReactComponent as AddColored } from '../../../../assets/svg/plus-colored.svg';
import { DE_ACTIVE_COLOR } from '../../../../constants/constants';
import { TaskOperation } from '../../../../constants/Feeds.constants';
import { TASK_TYPES } from '../../../../constants/Task.constant';
@ -88,21 +90,25 @@ import {
fetchOptions,
generateOptions,
getTaskDetailPath,
GLOSSARY_TASK_ACTION_LIST,
INCIDENT_TASK_ACTION_LIST,
isDescriptionTask,
isTagsTask,
TASK_ACTION_COMMON_ITEM,
TASK_ACTION_LIST,
} from '../../../../utils/TasksUtils';
import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils';
import ActivityFeedCardV2 from '../../../ActivityFeed/ActivityFeedCardV2/ActivityFeedCardV2';
import ActivityFeedEditor from '../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor';
import ActivityFeedEditor, {
EditorContentRef,
} from '../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditor';
import { useActivityFeedProvider } from '../../../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider';
import AssigneeList from '../../../common/AssigneeList/AssigneeList';
import InlineEdit from '../../../common/InlineEdit/InlineEdit.component';
import { OwnerLabel } from '../../../common/OwnerLabel/OwnerLabel.component';
import EntityPopOverCard from '../../../common/PopOverCard/EntityPopOverCard';
import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor';
import { EditorContentRef } from '../../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.interface';
import { EditorContentRef as MarkdownEditorContentRef } from '../../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor.interface';
import TaskTabIncidentManagerHeader from '../TaskTabIncidentManagerHeader/TaskTabIncidentManagerHeader.component';
import './task-tab.less';
import { TaskTabProps } from './TaskTab.interface';
@ -114,10 +120,11 @@ export const TaskTab = ({
hasGlossaryReviewer,
...rest
}: TaskTabProps) => {
const editorRef = useRef<EditorContentRef>();
const history = useHistory();
const [assigneesForm] = useForm();
const { currentUser } = useApplicationStore();
const markdownRef = useRef<EditorContentRef>();
const markdownRef = useRef<MarkdownEditorContentRef>();
const updatedAssignees = Form.useWatch('assignees', assigneesForm);
const { permissions } = usePermissionProvider();
const { task: taskDetails } = taskThread;
@ -143,9 +150,56 @@ export const TaskTab = ({
testCaseResolutionStatus,
initialAssignees: usersList,
} = useActivityFeedProvider();
const isTaskDescription = isDescriptionTask(taskDetails?.type as TaskType);
const isTaskTags = isTagsTask(taskDetails?.type as TaskType);
const showAddSuggestionButton = useMemo(() => {
const taskType = taskDetails?.type ?? ('' as TaskType);
const parsedSuggestion = [
TaskType.UpdateDescription,
TaskType.RequestDescription,
].includes(taskType)
? taskDetails?.suggestion
: JSON.parse(taskDetails?.suggestion || '[]');
return (
[TaskType.RequestTag, TaskType.RequestDescription].includes(taskType) &&
isEmpty(parsedSuggestion)
);
}, [taskDetails]);
const noSuggestionTaskMenuOptions = useMemo(() => {
let label;
if (taskThread.task?.newValue) {
label = t('label.add-suggestion');
} else if (isTaskTags) {
label = t('label.add-entity', {
entity: t('label.tag-plural'),
});
} else {
label = t('label.add-entity', {
entity: t('label.description'),
});
}
return [
{
label,
key: TaskActionMode.EDIT,
icon: AddColored,
},
...TASK_ACTION_COMMON_ITEM,
];
}, [isTaskTags, taskThread.task?.newValue]);
const isTaskTestCaseResult =
taskDetails?.type === TaskType.RequestTestCaseFailureResolution;
const isTaskGlossaryApproval = taskDetails?.type === TaskType.RequestApproval;
const latestAction = useMemo(() => {
const resolutionStatus = last(testCaseResolutionStatus);
@ -157,10 +211,20 @@ export const TaskTab = ({
default:
return INCIDENT_TASK_ACTION_LIST[0];
}
} else if (isTaskGlossaryApproval) {
return GLOSSARY_TASK_ACTION_LIST[0];
} else if (showAddSuggestionButton) {
return noSuggestionTaskMenuOptions[0];
} else {
return TASK_ACTION_LIST[0];
}
}, [testCaseResolutionStatus, isTaskTestCaseResult]);
}, [
showAddSuggestionButton,
testCaseResolutionStatus,
isTaskGlossaryApproval,
isTaskTestCaseResult,
noSuggestionTaskMenuOptions,
]);
const [taskAction, setTaskAction] = useState<TaskAction>(latestAction);
const [isActionLoading, setIsActionLoading] = useState(false);
@ -212,11 +276,12 @@ export const TaskTab = ({
assignee.type === 'team' ? checkIfUserPartOfTeam(assignee.id) : false
);
const isTaskDescription = isDescriptionTask(taskDetails?.type as TaskType);
const isTaskTags = isTagsTask(taskDetails?.type as TaskType);
const isTaskGlossaryApproval = taskDetails?.type === TaskType.RequestApproval;
const getFormattedMenuOptions = (options: TaskAction[]) => {
return options.map((item) => ({
...item,
icon: <Icon component={item.icon} style={{ fontSize: '16px' }} />,
}));
};
const handleTaskLinkClick = () => {
history.push({
@ -341,16 +406,22 @@ export const TaskTab = ({
(!hasGlossaryReviewer && isOwner) ||
(Boolean(isPartOfAssigneeTeam) && !isCreator);
const onSave = (message: string) => {
postFeed(message, taskThread?.id ?? '').catch(() => {
// ignore since error is displayed in toast in the parent promise.
// Added block for sonar code smell
});
const onSave = () => {
postFeed(comment, taskThread?.id ?? '')
.catch(() => {
// ignore since error is displayed in toast in the parent promise.
// Added block for sonar code smell
})
.finally(() => {
editorRef.current?.clearEditorValue();
});
};
const handleMenuItemClick: MenuProps['onClick'] = (info) => {
if (info.key === TaskActionMode.EDIT) {
setShowEditTaskModel(true);
} else if (info.key === TaskActionMode.CLOSE) {
onTaskReject();
} else {
onTaskResolve();
}
@ -458,7 +529,7 @@ export const TaskTab = ({
}
};
const onTaskDropdownClick = () => {
const onTestCaseTaskDropdownClick = () => {
if (taskAction.key === TaskActionMode.RESOLVE) {
setShowEditTaskModel(true);
} else {
@ -466,13 +537,54 @@ export const TaskTab = ({
}
};
const handleGlossaryTaskMenuClick = (info: MenuInfo) => {
setTaskAction(
GLOSSARY_TASK_ACTION_LIST.find((action) => action.key === info.key) ??
GLOSSARY_TASK_ACTION_LIST[0]
);
switch (info.key) {
case TaskActionMode.RESOLVE:
onTaskResolve();
break;
case TaskActionMode.CLOSE:
onTaskReject();
break;
}
};
const onTaskDropdownClick = () => {
if (
taskAction.key === TaskActionMode.RESOLVE ||
taskAction.key === TaskActionMode.EDIT
) {
handleMenuItemClick({ key: taskAction.key } as MenuInfo);
} else {
onTaskReject();
}
};
const renderCommentButton = useMemo(() => {
return (
<Button
data-testid="comment-button"
disabled={isEmpty(comment)}
type="primary"
onClick={onSave}>
{t('label.comment')}
</Button>
);
}, [comment, onSave]);
const approvalWorkflowActions = useMemo(() => {
const hasApprovalAccess =
isAssignee || (Boolean(isPartOfAssigneeTeam) && !isCreator);
return (
<Space
className="m-t-sm items-end w-full"
className="m-t-sm items-end w-full justify-end"
data-testid="task-cta-buttons"
size="small">
<Tooltip
@ -481,31 +593,35 @@ export const TaskTab = ({
? t('message.only-reviewers-can-approve-or-reject')
: ''
}>
<Button
data-testid="reject-task"
<Dropdown.Button
className="task-action-button"
data-testid="glossary-accept-reject-task-dropdown"
disabled={!hasApprovalAccess}
onClick={onTaskReject}>
{t('label.reject')}
</Button>
icon={<DownOutlined />}
menu={{
items: getFormattedMenuOptions(GLOSSARY_TASK_ACTION_LIST),
selectable: true,
selectedKeys: [taskAction.key],
onClick: handleGlossaryTaskMenuClick,
}}
overlayClassName="task-action-dropdown"
onClick={onTaskDropdownClick}>
{taskAction.label}
</Dropdown.Button>
</Tooltip>
<Tooltip
title={
!hasApprovalAccess
? t('message.only-reviewers-can-approve-or-reject')
: ''
}>
<Button
data-testid="approve-task"
disabled={!hasApprovalAccess}
type="primary"
onClick={onTaskResolve}>
{t('label.approve')}
</Button>
</Tooltip>
{renderCommentButton}
</Space>
);
}, [taskDetails, onTaskResolve, isAssignee, isPartOfAssigneeTeam]);
}, [
taskAction,
isAssignee,
isCreator,
isPartOfAssigneeTeam,
renderCommentButton,
handleGlossaryTaskMenuClick,
onTaskDropdownClick,
]);
const testCaseResultFlow = useMemo(() => {
const editPermission = checkPermission(
@ -516,32 +632,34 @@ export const TaskTab = ({
const hasApprovalAccess = isAssignee || isCreator || editPermission;
return (
<Dropdown.Button
className="m-t-sm"
data-testid="task-cta-buttons"
icon={<DownOutlined />}
loading={isActionLoading}
menu={{
items: INCIDENT_TASK_ACTION_LIST,
selectable: true,
selectedKeys: [taskAction.key],
onClick: handleTaskMenuClick,
disabled: !hasApprovalAccess,
}}
type="primary"
onClick={onTaskDropdownClick}>
{taskAction.label}
</Dropdown.Button>
<div className="m-t-sm d-flex justify-end items-center gap-4">
<Dropdown.Button
className="w-auto task-action-button"
data-testid="task-cta-buttons"
icon={<DownOutlined />}
loading={isActionLoading}
menu={{
items: INCIDENT_TASK_ACTION_LIST,
selectable: true,
selectedKeys: [taskAction.key],
onClick: handleTaskMenuClick,
disabled: !hasApprovalAccess,
}}
onClick={onTestCaseTaskDropdownClick}>
{taskAction.label}
</Dropdown.Button>
{renderCommentButton}
</div>
);
}, [taskDetails, isAssignee, isPartOfAssigneeTeam, taskAction]);
}, [
taskDetails,
isAssignee,
isPartOfAssigneeTeam,
taskAction,
renderCommentButton,
]);
const actionButtons = useMemo(() => {
if (isTaskClosed) {
return null;
}
const taskType = taskDetails?.type ?? '';
if (isTaskGlossaryApproval) {
return approvalWorkflowActions;
}
@ -550,49 +668,47 @@ export const TaskTab = ({
return testCaseResultFlow;
}
const parsedSuggestion = [
'RequestDescription',
'UpdateDescription',
].includes(taskType)
? taskDetails?.suggestion
: JSON.parse(taskDetails?.suggestion || '[]');
return (
<Space
className="m-t-sm items-end w-full"
className="m-t-sm items-end w-full justify-end"
data-testid="task-cta-buttons"
size="small">
{(isCreator || hasEditAccess) && (
<Button onClick={onTaskReject}>{t('label.close')}</Button>
{isCreator && !hasEditAccess && (
<Button data-testid="close-button" onClick={onTaskReject}>
{t('label.close')}
</Button>
)}
{hasEditAccess ? (
{hasEditAccess && (
<>
{['RequestDescription', 'RequestTag'].includes(taskType) &&
isEmpty(parsedSuggestion) ? (
<Button
type="primary"
onClick={() =>
handleMenuItemClick({ key: TaskActionMode.EDIT } as MenuInfo)
}>
{taskThread.task?.newValue
? t('label.add-suggestion')
: t('label.add-entity', {
entity: isTaskTags
? t('label.tag-plural')
: t('label.description'),
})}
</Button>
{showAddSuggestionButton ? (
<div className="d-flex justify-end gap-2">
<Dropdown.Button
className="task-action-button"
data-testid="add-close-task-dropdown"
icon={<DownOutlined />}
menu={{
items: getFormattedMenuOptions(noSuggestionTaskMenuOptions),
selectable: true,
selectedKeys: [taskAction.key],
onClick: handleMenuItemClick,
}}
overlayClassName="task-action-dropdown"
onClick={onTaskDropdownClick}>
{taskAction.label}
</Dropdown.Button>
</div>
) : (
<Dropdown.Button
className="task-action-button"
data-testid="edit-accept-task-dropdown"
icon={<DownOutlined />}
menu={{
items: TASK_ACTION_LIST,
items: getFormattedMenuOptions(TASK_ACTION_LIST),
selectable: true,
selectedKeys: [taskAction.key],
onClick: handleMenuItemClick,
}}
type="primary"
overlayClassName="task-action-dropdown"
onClick={() =>
taskAction.key === TaskActionMode.EDIT
? handleMenuItemClick({ key: taskAction.key } as MenuInfo)
@ -602,22 +718,24 @@ export const TaskTab = ({
</Dropdown.Button>
)}
</>
) : (
<></>
)}
{renderCommentButton}
</Space>
);
}, [
onTaskReject,
taskDetails,
onTaskResolve,
handleMenuItemClick,
taskAction,
isTaskClosed,
isTaskGlossaryApproval,
showAddSuggestionButton,
isCreator,
approvalWorkflowActions,
testCaseResultFlow,
isTaskTestCaseResult,
renderCommentButton,
]);
const initialFormValue = useMemo(() => {
@ -806,10 +924,13 @@ export const TaskTab = ({
<Col span={24}>
{taskDetails?.status === ThreadTaskStatus.Open && (
<ActivityFeedEditor onSave={onSave} onTextChange={setComment} />
<ActivityFeedEditor
editAction={actionButtons}
ref={editorRef}
onSave={onSave}
onTextChange={setComment}
/>
)}
{actionButtons}
</Col>
{isTaskTestCaseResult ? (
<Modal

View File

@ -18,3 +18,33 @@
width: 100%;
}
}
.task-action-button {
button {
border: 1px solid @primary-color;
color: @primary-color;
&:first-child {
border-right: none;
padding-right: 0;
}
&:last-child {
border-left: none;
}
}
}
.task-action-dropdown {
ul {
padding: 4px 8px;
li {
padding: 8px;
border-bottom: @global-border;
&:last-child {
border-bottom: none;
}
}
}
}

View File

@ -285,3 +285,53 @@ export const MOCK_TASK_ASSIGNEE = [
value: 'id1',
},
];
export const MOCK_TASK = {
id: 1,
type: TaskType.RequestTag,
assignees: [
{
id: 'd6764107-e8b4-4748-b256-c86fecc66064',
type: 'User',
name: 'xyz',
fullyQualifiedName: 'xyz',
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 MOCK_TASK_2 = {
id: 1,
type: TaskType.RequestTag,
assignees: [
{
id: 'd6764107-e8b4-4748-b256-c86fecc66064',
type: 'User',
name: 'xyz',
fullyQualifiedName: 'xyz',
deleted: false,
},
],
status: ThreadTaskStatus.Open,
oldValue: '[]',
};
export const MOCK_TASK_3 = {
id: 1,
type: TaskType.RequestApproval,
assignees: [
{
id: 'd6764107-e8b4-4748-b256-c86fecc66064',
type: 'User',
name: 'xyz',
fullyQualifiedName: 'xyz',
deleted: false,
},
],
status: ThreadTaskStatus.Open,
oldValue: '[]',
};

View File

@ -55,6 +55,7 @@ export interface Option {
export interface TaskAction {
label: string;
key: string;
icon?: SvgComponent;
}
export enum TaskActionMode {
@ -62,6 +63,7 @@ export enum TaskActionMode {
EDIT = 'edit',
RE_ASSIGN = 're-assign',
RESOLVE = 'resolve',
CLOSE = 'close',
}
export enum TaskTabs {

View File

@ -10,11 +10,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AxiosError } from 'axios';
import { Change, diffWordsWithSpace } from 'diff';
import i18Next from 'i18next';
import { isEmpty, isEqual, isUndefined } from 'lodash';
import React from 'react';
import { ReactComponent as CancelColored } from '../assets/svg/cancel-colored.svg';
import { ReactComponent as EditColored } from '../assets/svg/edit-colored.svg';
import { ReactComponent as SuccessColored } from '../assets/svg/success-colored.svg';
import { ActivityFeedTabs } from '../components/ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface';
import {
getEntityDetailsPath,
@ -595,14 +598,38 @@ export const fetchEntityDetail = (
}
};
export const TASK_ACTION_COMMON_ITEM: TaskAction[] = [
{
label: i18Next.t('label.close'),
key: TaskActionMode.CLOSE,
icon: CancelColored,
},
];
export const TASK_ACTION_LIST: TaskAction[] = [
{
label: i18Next.t('label.accept-suggestion'),
key: TaskActionMode.VIEW,
icon: SuccessColored,
},
{
label: i18Next.t('label.edit-amp-accept-suggestion'),
key: TaskActionMode.EDIT,
icon: EditColored,
},
...TASK_ACTION_COMMON_ITEM,
];
export const GLOSSARY_TASK_ACTION_LIST: TaskAction[] = [
{
label: i18Next.t('label.approve'),
key: TaskActionMode.RESOLVE,
icon: SuccessColored,
},
{
label: i18Next.t('label.reject'),
key: TaskActionMode.CLOSE,
icon: CancelColored,
},
];