From 6f7d5bf2841e276e46bcca54a5d9013f56b2196e Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 10:38:39 +0530 Subject: [PATCH 01/23] Fixes #33112: restore delete, hover affordance and expand in task comments Incident task comments had no way to delete a comment, no visible affordance for the action, and long comments were clamped with no way to expand them. - Drop enableSeeMoreVariant={false} on the comment previewer so it falls back to its default of true and long comments get a working view-more toggle. - Reveal a delete action on hover for the comment author or an admin, wired to deleteTaskComment through the shared DeleteModal. It is positioned out of flow so showing it cannot reflow the comment body. - Pass currentUser down from TaskTabNew for the permission check, and refetch the thread via onCommentDeleted so the list reflects the delete. Adds TaskCommentCard.test.tsx covering rendering, both regression guards, the four permission combinations, hover behaviour and the delete flow including the failure path. Extends the TaskTabNew suite to confirm the two new props are wired through. --- .../TaskCommentCard.component.tsx | 73 ++++- .../TaskCommentCard.test.tsx | 279 ++++++++++++++++++ .../TaskTab/TaskTabNew.component.test.tsx | 61 +++- .../Task/TaskTab/TaskTabNew.component.tsx | 4 +- 4 files changed, 409 insertions(+), 8 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index 77089f40d6f8..8dc975dd3cfc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -11,16 +11,22 @@ * limitations under the License. */ +import Icon from '@ant-design/icons/lib/components/Icon'; import { Space, Tooltip, Typography } from 'antd'; -import { FC, useMemo } from 'react'; +import { AxiosError } from 'axios'; +import { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import { useUserProfile } from '../../../hooks/user-profile/useUserProfile'; -import { Task, TaskComment } from '../../../rest/tasksAPI'; +import { deleteTaskComment, Task, TaskComment } from '../../../rest/tasksAPI'; import { formatDateTime, getRelativeTime, } from '../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { getFrontEndFormat } from '../../../utils/FeedUtilsPure'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import DeleteModal from '../../common/DeleteModal/DeleteModal'; import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; interface TaskCommentCardProps { @@ -28,12 +34,18 @@ interface TaskCommentCardProps { task: Task; isLastReply?: boolean; closeFeedEditor?: () => void; + currentUser?: { name?: string; isAdmin?: boolean }; + onCommentDeleted?: () => void; } const TaskCommentCard: FC = ({ comment, + task, isLastReply = false, + currentUser, + onCommentDeleted, }) => { + const { t } = useTranslation(); const [, , user] = useUserProfile({ permission: true, name: comment.author?.name ?? '', @@ -44,10 +56,40 @@ const TaskCommentCard: FC = ({ [user, comment.author] ); + const [isHovered, setIsHovered] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const canDelete = useMemo( + () => + (Boolean(currentUser?.name) && + comment.author?.name === currentUser?.name) || + Boolean(currentUser?.isAdmin), + [currentUser, comment.author] + ); + + const handleDelete = async () => { + setIsDeleting(true); + try { + await deleteTaskComment(task.id, comment.id); + setShowDeleteDialog(false); + onCommentDeleted?.(); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsDeleting(false); + } + }; + return (
+ className={`p-y-md p-x-sm relative ${ + !isLastReply ? 'border-bottom' : '' + }`} + data-testid="task-comment-card" + role="presentation" + onMouseEnter={() => setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> = ({
+ {isHovered && canDelete && ( + setShowDeleteDialog(true)} + /> + )} + setShowDeleteDialog(false)} + onDelete={handleDelete} + /> ); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx new file mode 100644 index 000000000000..3018d9fbdcdd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -0,0 +1,279 @@ +/* + * Copyright 2025 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, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { + Task, + TaskCategory, + TaskComment, + TaskStatus, + TaskType, +} from '../../../generated/entity/tasks/task'; +import { deleteTaskComment } from '../../../rest/tasksAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import TaskCommentCard from './TaskCommentCard.component'; + +jest.mock('../../../rest/tasksAPI', () => ({ + deleteTaskComment: jest.fn().mockResolvedValue({}), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../../hooks/user-profile/useUserProfile', () => ({ + useUserProfile: () => [ + false, + false, + { name: 'alice', displayName: 'Alice Author' }, + ], +})); + +jest.mock('../../common/ProfilePicture/ProfilePicture', () => { + return jest.fn(({ name }) => ( +
Avatar
+ )); +}); + +const mockRichTextPreview = jest.fn(); +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewNew', () => { + return jest.fn((props) => { + mockRichTextPreview(props); + + return
{props.markdown}
; + }); +}); + +jest.mock('../../common/DeleteModal/DeleteModal', () => ({ + __esModule: true, + default: jest.fn(({ open, isDeleting, onDelete, onCancel }) => + open ? ( +
+ {String(isDeleting)} + + +
+ ) : null + ), +})); + +jest.mock('../../../utils/FeedUtilsPure', () => ({ + getFrontEndFormat: jest.fn((text) => text), +})); + +jest.mock('../../../utils/date-time/DateTimeUtils', () => ({ + formatDateTime: jest.fn(() => 'Jan 01, 2025, 12:00 PM'), + getRelativeTime: jest.fn(() => '2 hours ago'), +})); + +const mockComment: TaskComment = { + id: 'comment-1', + message: 'This is the incident comment body', + createdAt: 1735732800000, + author: { id: 'user-1', type: 'user', name: 'alice' }, +}; + +const mockTask = { + id: 'task-1', + name: 'incident-task', + category: TaskCategory.Incident, + type: TaskType.IncidentResolution, + status: TaskStatus.InProgress, + createdBy: { id: 'user-1', type: 'user', name: 'alice' }, +} as Task; + +const renderCard = ( + props: Partial> = {} +) => + render(); + +const hoverCard = () => + fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); + +describe('TaskCommentCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + (deleteTaskComment as jest.Mock).mockResolvedValue({}); + }); + + describe('rendering', () => { + it('should render the author name, relative timestamp and comment body', () => { + renderCard(); + + expect(screen.getByTestId('author-name')).toHaveTextContent( + 'Alice Author' + ); + expect(screen.getByTestId('comment-time')).toHaveTextContent( + '2 hours ago' + ); + expect(screen.getByTestId('rich-text-preview')).toHaveTextContent( + 'This is the incident comment body' + ); + }); + + // Regression guard for #33112: passing enableSeeMoreVariant={false} clamped long + // comments with no way to expand them. The previewer defaults it to true, so the + // prop must stay unset rather than be re-added as false. + it('should not disable the see-more variant on the previewer', () => { + renderCard(); + + expect(mockRichTextPreview).toHaveBeenCalled(); + // Every render, not just one of them - toHaveBeenCalledWith would pass as long + // as a single call happened to omit the prop. + mockRichTextPreview.mock.calls.forEach(([props]) => { + expect(props.enableSeeMoreVariant).toBeUndefined(); + }); + }); + + // Regression guard for #33112: the delete affordance overlays the card rather than + // sharing its flow, so revealing it on hover cannot reflow the comment body. jsdom + // runs no layout, so this asserts the positioning contract that keeps it out of flow. + it('should overlay the delete action instead of placing it in the flow', () => { + renderCard({ currentUser: { name: 'alice' } }); + hoverCard(); + + expect(screen.getByTestId('task-comment-card')).toHaveClass('relative'); + expect(screen.getByTestId('delete-task-comment')).toHaveStyle({ + position: 'absolute', + }); + }); + }); + + describe('delete affordance permissions', () => { + it('should not show delete when there is no current user', () => { + renderCard(); + hoverCard(); + + expect( + screen.queryByTestId('delete-task-comment') + ).not.toBeInTheDocument(); + }); + + it('should show delete to the comment author', () => { + renderCard({ currentUser: { name: 'alice' } }); + hoverCard(); + + expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + }); + + it('should show delete to an admin who is not the author', () => { + renderCard({ currentUser: { name: 'bob', isAdmin: true } }); + hoverCard(); + + expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + }); + + it('should not show delete to a non-admin who is not the author', () => { + renderCard({ currentUser: { name: 'bob', isAdmin: false } }); + hoverCard(); + + expect( + screen.queryByTestId('delete-task-comment') + ).not.toBeInTheDocument(); + }); + }); + + describe('hover behaviour', () => { + it('should only reveal the delete affordance while hovered', () => { + renderCard({ currentUser: { name: 'alice' } }); + + expect( + screen.queryByTestId('delete-task-comment') + ).not.toBeInTheDocument(); + + hoverCard(); + + expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + + fireEvent.mouseLeave(screen.getByTestId('task-comment-card')); + + expect( + screen.queryByTestId('delete-task-comment') + ).not.toBeInTheDocument(); + }); + }); + + describe('delete flow', () => { + const openDeleteModal = (props = { currentUser: { name: 'alice' } }) => { + renderCard(props); + hoverCard(); + fireEvent.click(screen.getByTestId('delete-task-comment')); + }; + + it('should open the confirmation modal from the delete affordance', () => { + openDeleteModal(); + + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + expect(deleteTaskComment).not.toHaveBeenCalled(); + }); + + it('should delete the comment and notify the parent on confirm', async () => { + const onCommentDeleted = jest.fn(); + renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); + hoverCard(); + fireEvent.click(screen.getByTestId('delete-task-comment')); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete')); + }); + + expect(deleteTaskComment).toHaveBeenCalledWith('task-1', 'comment-1'); + + await waitFor(() => { + expect(onCommentDeleted).toHaveBeenCalledTimes(1); + }); + + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); + + it('should keep the modal open and toast when the delete fails', async () => { + const error = new Error('delete failed'); + (deleteTaskComment as jest.Mock).mockRejectedValueOnce(error); + const onCommentDeleted = jest.fn(); + renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); + hoverCard(); + fireEvent.click(screen.getByTestId('delete-task-comment')); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete')); + }); + + await waitFor(() => { + expect(showErrorToast).toHaveBeenCalledWith(error); + }); + + expect(onCommentDeleted).not.toHaveBeenCalled(); + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + expect(screen.getByTestId('is-deleting')).toHaveTextContent('false'); + }); + + it('should not delete anything when the modal is cancelled', () => { + openDeleteModal(); + + fireEvent.click(screen.getByTestId('cancel-delete')); + + expect(deleteTaskComment).not.toHaveBeenCalled(); + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx index 21bb20727a18..ae55d459437a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx @@ -303,13 +303,14 @@ jest.mock('../../../../context/PermissionProvider/PermissionProvider', () => ({ })), })); +const mockFetchUpdatedThread = jest.fn().mockResolvedValue({}); jest.mock( '../../../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider', () => ({ useActivityFeedProvider: jest.fn().mockImplementation(() => ({ postFeed: jest.fn().mockResolvedValue({}), updateTask: jest.fn(), - fetchUpdatedThread: jest.fn().mockResolvedValue({}), + fetchUpdatedThread: mockFetchUpdatedThread, updateTestCaseIncidentStatus: jest.fn(), testCaseResolutionStatus: [], isPostsLoading: false, @@ -423,10 +424,15 @@ jest.mock( } ); +const mockTaskCommentCardProps: Record[] = []; jest.mock( '../../../ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component', () => { - return jest.fn().mockImplementation(() =>

TaskCommentCard

); + return jest.fn().mockImplementation((props) => { + mockTaskCommentCardProps.push(props); + + return

TaskCommentCard

; + }); } ); @@ -452,6 +458,7 @@ const mockProps = { describe('TaskTabNew Component', () => { beforeEach(() => { jest.clearAllMocks(); + mockTaskCommentCardProps.length = 0; const { useAuth } = require('../../../../hooks/authHooks'); const { useApplicationStore, @@ -1269,4 +1276,54 @@ describe('TaskTabNew Component', () => { }); }); }); + + describe('task comments', () => { + // The card re-renders, so assert against the props it was last handed rather + // than a render count. + const lastCommentCardProps = () => + mockTaskCommentCardProps[mockTaskCommentCardProps.length - 1]; + + const MOCK_TASK_WITH_COMMENT: Task = { + ...MOCK_TASK, + comments: [ + { + id: 'comment-1', + message: 'A comment on the incident', + createdAt: 1735732800000, + author: { id: 'user-1', type: 'user', name: 'alice' }, + }, + ], + }; + + it('should pass the current user down so the card can resolve delete permission', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + expect(lastCommentCardProps().currentUser).toEqual( + expect.objectContaining({ name: 'test-user' }) + ); + }); + + it('should refetch the thread when a comment is deleted', async () => { + await act(async () => { + render(, { + wrapper: MemoryRouter, + }); + }); + + mockFetchUpdatedThread.mockClear(); + + await act(async () => { + (lastCommentCardProps().onCommentDeleted as () => void)(); + }); + + expect(mockFetchUpdatedThread).toHaveBeenCalledWith( + MOCK_TASK_WITH_COMMENT.id, + true + ); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx index 9d2f6c572740..1a24fe5d75a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx @@ -1837,14 +1837,16 @@ export const TaskTabNew = ({ fetchUpdatedThread(task.id, true)} /> ))} ); - }, [task, closeFeedEditor, isPostsLoading]); + }, [task, closeFeedEditor, isPostsLoading, currentUser, fetchUpdatedThread]); useEffect(() => { closeFeedEditor(); From 76d66174db1d3c62577b94bcf72d40de684213f5 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 10:54:19 +0530 Subject: [PATCH 02/23] Make the comment delete action keyboard and screen-reader accessible The delete affordance was an Ant Design Icon with an onClick, mounted only while the mouse was over the card. It had no button role, no tab stop and no accessible name, so keyboard and screen-reader users could never reach it. - Replace it with ButtonUtility from ui-core-components, which renders a real button via react-aria and takes an accessible name from its tooltip. - Keep it mounted whenever the user may delete, and reveal it with CSS on card hover or on its own focus instead of a mouse-only hover state. This drops the isHovered state and the wrapper's role="presentation". - Move the inline positioning styles onto Tailwind utilities. Covered by tests asserting the button role and accessible name, that it is focusable without a hover, and that Tab followed by Enter opens the modal. --- .../TaskCommentCard.component.tsx | 29 +++---- .../TaskCommentCard.test.tsx | 78 ++++++++++++------- 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index 8dc975dd3cfc..179b03c41c50 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import Icon from '@ant-design/icons/lib/components/Icon'; +import { ButtonUtility } from '@openmetadata/ui-core-components'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import { FC, useMemo, useState } from 'react'; @@ -56,7 +56,6 @@ const TaskCommentCard: FC = ({ [user, comment.author] ); - const [isHovered, setIsHovered] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -83,13 +82,10 @@ const TaskCommentCard: FC = ({ return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + data-testid="task-comment-card"> = ({
- {isHovered && canDelete && ( - setShowDeleteDialog(true)} /> )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index 3018d9fbdcdd..a1879952718f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -18,6 +18,7 @@ import { screen, waitFor, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { Task, TaskCategory, @@ -107,9 +108,6 @@ const renderCard = ( ) => render(); -const hoverCard = () => - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - describe('TaskCommentCard', () => { beforeEach(() => { jest.clearAllMocks(); @@ -138,6 +136,7 @@ describe('TaskCommentCard', () => { renderCard(); expect(mockRichTextPreview).toHaveBeenCalled(); + // Every render, not just one of them - toHaveBeenCalledWith would pass as long // as a single call happened to omit the prop. mockRichTextPreview.mock.calls.forEach(([props]) => { @@ -146,23 +145,24 @@ describe('TaskCommentCard', () => { }); // Regression guard for #33112: the delete affordance overlays the card rather than - // sharing its flow, so revealing it on hover cannot reflow the comment body. jsdom - // runs no layout, so this asserts the positioning contract that keeps it out of flow. + // sharing its flow, so revealing it cannot reflow the comment body. jsdom runs no + // layout, so this asserts the positioning contract that keeps it out of flow. it('should overlay the delete action instead of placing it in the flow', () => { renderCard({ currentUser: { name: 'alice' } }); - hoverCard(); - expect(screen.getByTestId('task-comment-card')).toHaveClass('relative'); - expect(screen.getByTestId('delete-task-comment')).toHaveStyle({ - position: 'absolute', - }); + expect(screen.getByTestId('task-comment-card')).toHaveClass( + 'relative', + 'tw:group' + ); + expect(screen.getByTestId('delete-task-comment')).toHaveClass( + 'tw:absolute' + ); }); }); describe('delete affordance permissions', () => { it('should not show delete when there is no current user', () => { renderCard(); - hoverCard(); expect( screen.queryByTestId('delete-task-comment') @@ -171,21 +171,18 @@ describe('TaskCommentCard', () => { it('should show delete to the comment author', () => { renderCard({ currentUser: { name: 'alice' } }); - hoverCard(); expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); }); it('should show delete to an admin who is not the author', () => { renderCard({ currentUser: { name: 'bob', isAdmin: true } }); - hoverCard(); expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); }); it('should not show delete to a non-admin who is not the author', () => { renderCard({ currentUser: { name: 'bob', isAdmin: false } }); - hoverCard(); expect( screen.queryByTestId('delete-task-comment') @@ -193,30 +190,57 @@ describe('TaskCommentCard', () => { }); }); - describe('hover behaviour', () => { - it('should only reveal the delete affordance while hovered', () => { + describe('accessibility', () => { + it('should expose the delete action as a button with an accessible name', () => { renderCard({ currentUser: { name: 'alice' } }); - expect( - screen.queryByTestId('delete-task-comment') - ).not.toBeInTheDocument(); + const deleteButton = screen.getByRole('button', { name: 'label.delete' }); - hoverCard(); + expect(deleteButton).toHaveAttribute( + 'data-testid', + 'delete-task-comment' + ); + }); - expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + // The affordance used to be mounted only while the mouse was over the card, which + // put it permanently out of reach of the keyboard. It now stays in the DOM and is + // revealed by CSS on hover or focus. + it('should keep the delete action focusable without a mouse hover', async () => { + renderCard({ currentUser: { name: 'alice' } }); - fireEvent.mouseLeave(screen.getByTestId('task-comment-card')); + const deleteButton = screen.getByTestId('delete-task-comment'); + deleteButton.focus(); - expect( - screen.queryByTestId('delete-task-comment') - ).not.toBeInTheDocument(); + expect(deleteButton).toHaveFocus(); + expect(deleteButton).toHaveClass('tw:focus-visible:opacity-100'); + }); + + it('should reveal the delete action on card hover via CSS, not conditional mount', () => { + renderCard({ currentUser: { name: 'alice' } }); + + expect(screen.getByTestId('delete-task-comment')).toHaveClass( + 'tw:opacity-0', + 'tw:group-hover:opacity-100' + ); + }); + + it('should open the confirmation modal from the keyboard alone', async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + renderCard({ currentUser: { name: 'alice' } }); + + await user.tab(); + + expect(screen.getByTestId('delete-task-comment')).toHaveFocus(); + + await user.keyboard('{Enter}'); + + expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); }); }); describe('delete flow', () => { const openDeleteModal = (props = { currentUser: { name: 'alice' } }) => { renderCard(props); - hoverCard(); fireEvent.click(screen.getByTestId('delete-task-comment')); }; @@ -230,7 +254,6 @@ describe('TaskCommentCard', () => { it('should delete the comment and notify the parent on confirm', async () => { const onCommentDeleted = jest.fn(); renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - hoverCard(); fireEvent.click(screen.getByTestId('delete-task-comment')); await act(async () => { @@ -251,7 +274,6 @@ describe('TaskCommentCard', () => { (deleteTaskComment as jest.Mock).mockRejectedValueOnce(error); const onCommentDeleted = jest.fn(); renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - hoverCard(); fireEvent.click(screen.getByTestId('delete-task-comment')); await act(async () => { From 08af180d6fd6fc4e8a6d20ebb28fc7957c089a82 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 11:02:34 +0530 Subject: [PATCH 03/23] Import the comment delete icon from the design-system layer Main added a rule that icons come from @openmetadata/ui-core-components/icons and must not be imported directly from assets/ paths, since bypassing the re-export can diverge on version upgrades. Swap the raw ic-delete.svg import for the Delete icon the package exports. --- .../ActivityFeedCardNew/TaskCommentCard.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index 179b03c41c50..40fe28625074 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -12,11 +12,11 @@ */ import { ButtonUtility } from '@openmetadata/ui-core-components'; +import { Delete as DeleteIcon } from '@openmetadata/ui-core-components/icons'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import { FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; import { useUserProfile } from '../../../hooks/user-profile/useUserProfile'; import { deleteTaskComment, Task, TaskComment } from '../../../rest/tasksAPI'; import { From 78858a32eb66f4f310d155f38082eb327fff3831 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 09:14:48 +0000 Subject: [PATCH 04/23] fix: address review feedback on task comment delete - Fix DeleteModal rendering behind antd Drawer by bumping its z-index above the Drawer's, so the confirm button is reachable when deleting a comment from inside the activity-feed drawer. - Make the comment author's name and avatar clickable, linking to their profile, matching CommentCard's existing pattern. - Add tw:motion-safe: to the delete button's opacity transition per frontend-a11y.md. - Replace the fake Playwright delete/edit spec (isVisible()-gated no-ops against nonexistent testids) with real coverage: author deleting their own comment, admin deleting a comment they didn't author, a non-author/non-admin not seeing the delete option, and deleting from inside the activity-feed drawer. - Extend TaskCommentCard unit tests for the above and prune the now-stale eslint-suppressions.json entry for this spec file. --- .../resources/ui/eslint-suppressions.json | 2 +- .../e2e/Features/Tasks/TaskComments.spec.ts | 221 +++++++++++++----- .../TaskCommentCard.component.tsx | 58 +++-- .../TaskCommentCard.test.tsx | 43 +++- .../common/DeleteModal/DeleteModal.tsx | 5 +- 5 files changed, 249 insertions(+), 80 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 625f29270b5a..4f2458ebeb25 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -475,7 +475,7 @@ }, "playwright/e2e/Features/Tasks/TaskComments.spec.ts": { "om-playwright/no-positional-locator": { - "count": 24 + "count": 17 } }, "playwright/e2e/Features/Tasks/TaskCreation.spec.ts": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 5ea5b922e3c2..5823b82e62f7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -520,10 +520,17 @@ test.describe('Task Comments - Edit/Delete', () => { } }); - test('should be able to delete own comment', async ({ page }) => { - await adminUser.login(page); + /** + * Shared by the two real delete tests below: opens the task's activity-feed + * drawer as `user` and posts one comment from there, returning the task's id + * (needed to match the DELETE response) and the comment's text (needed to + * find the right `task-comment-card`). + */ + const postCommentAsUser = async ( + page: import('@playwright/test').Page, + message: string + ) => { await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); await waitForPageLoaded(page); @@ -534,81 +541,171 @@ test.describe('Task Comments - Edit/Delete', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); + await expect(drawer).toBeVisible(); + + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); + await expect(commentInput).toBeVisible(); + await commentInput.fill(message); + + const sendBtn = drawer.getByTestId('send-comment'); + const commentResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/v1/tasks/') && + response.url().includes('/comments') && + response.request().method() === 'POST' + ); + await sendBtn.click(); + const commentResponse = await commentResponsePromise; + const comment = await commentResponse.json(); + + await expect(drawer.getByText(message)).toBeVisible(); + + return { drawer, taskCommentId: comment.id as string }; + }; + + /** + * Deletes the comment identified by `message` from an already-open drawer, + * waiting for the real DELETE response and asserting the comment is gone + * from the DOM afterwards. Runs the button through a real hover first, + * matching how a person actually finds it (the button is reachable by + * keyboard/tab without hovering, but hover is the primary discovery path). + */ + const deleteCommentViaUi = async ( + page: import('@playwright/test').Page, + drawer: ReturnType, + message: string, + taskCommentId: string + ) => { + const commentCard = drawer + .getByTestId('task-comment-card') + .filter({ hasText: message }); + await expect(commentCard).toBeVisible(); + + await commentCard.hover(); + await commentCard.getByTestId('delete-task-comment').click(); + + await expect(page.getByTestId('delete-modal')).toBeVisible(); + + const deleteResponsePromise = page.waitForResponse( + (response) => + response.url().includes(`/comments/${taskCommentId}`) && + response.request().method() === 'DELETE' + ); + await page.getByTestId('confirm-button').click(); + const deleteResponse = await deleteResponsePromise; + + expect(deleteResponse.ok()).toBe(true); + await expect(commentCard).not.toBeVisible(); + await expect(drawer.getByText(message)).not.toBeVisible(); + }; - if (await drawer.isVisible()) { - const comments = drawer.locator( - '[data-testid="comment-item"], .task-comment' - ); - const initialCount = await comments.count(); - - if (initialCount > 0) { - await comments.first().hover(); + test('should be able to delete own comment', async ({ page }) => { + // assigneeUser is a regular (non-admin) user, so a successful delete here + // exercises the author-match branch of canDelete, not the admin override. + await assigneeUser.login(page); - const deleteBtn = comments.first().getByTestId('delete-comment'); + const message = `Author-deletable comment ${Date.now()}`; + const { drawer, taskCommentId } = await postCommentAsUser(page, message); - if (await deleteBtn.isVisible()) { - await deleteBtn.click(); + await deleteCommentViaUi(page, drawer, message, taskCommentId); + }); - // Confirm deletion - const confirmBtn = page.getByRole('button', { - name: /confirm|yes|delete/i, - }); - if (await confirmBtn.isVisible()) { - await confirmBtn.click(); - await waitForPageLoaded(page); + test('admin should be able to delete a comment they did not author', async ({ + page, + browser, + }) => { + // Post as the non-admin assignee first, in a separate browser context so + // this test doesn't depend on execution order relative to the one above. + const authorContext = await browser.newContext(); + const authorPage = await authorContext.newPage(); + await assigneeUser.login(authorPage); - // Comment count should decrease - const newCount = await comments.count(); - expect(newCount).toBeLessThan(initialCount); - } - } - } - } - } - }); + const message = `Admin-deletable comment ${Date.now()}`; + const { taskCommentId } = await postCommentAsUser(authorPage, message); + await authorContext.close(); - test('non-author should not see edit/delete options', async ({ page }) => { - await assigneeUser.login(page); - await table.visitEntityPage(page); + await adminUser.login(page); + const { drawer } = await postCommentAsUser(page, `unused-${Date.now()}`); + // postCommentAsUser leaves an extra comment behind as a side effect of + // reusing it purely for its navigation-to-the-open-drawer behavior; that + // extra comment isn't asserted on and is left for afterAll to clean up + // along with the rest of the table. - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await deleteCommentViaUi(page, drawer, message, taskCommentId); + }); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); + test('non-author, non-admin should not see the delete option', async ({ + page, + browser, + }) => { + const otherUser = new UserClass(); + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + await otherUser.create(apiContext); + } finally { + await afterAction(); } - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + try { + const authorContext = await browser.newContext(); + const authorPage = await authorContext.newPage(); + await assigneeUser.login(authorPage); + + const message = `Not-my-comment ${Date.now()}`; + await postCommentAsUser(authorPage, message); + await authorContext.close(); + + await otherUser.login(page); + const { drawer } = await postCommentAsUser( + page, + `viewer-comment-${Date.now()}` + ); - const drawer = page.locator('.ant-drawer-content'); + const commentCard = drawer + .getByTestId('task-comment-card') + .filter({ hasText: message }); + await expect(commentCard).toBeVisible(); + await commentCard.hover(); - if (await drawer.isVisible()) { - // Find comment from admin (not assignee) - const comment = drawer.locator( - '[data-testid="comment-item"], .task-comment' - ); + await expect( + commentCard.getByTestId('delete-task-comment') + ).not.toBeVisible(); + } finally { + const { apiContext, afterAction } = await performAdminLogin(browser); + try { + await otherUser.delete(apiContext); + } finally { + await afterAction(); + } + } + }); - if (await comment.first().isVisible()) { - await comment.first().hover(); + test('should be able to delete a comment from inside the activity-feed drawer', async ({ + page, + }) => { + // Regression coverage for the DeleteModal/antd-Drawer z-index conflict: + // TaskTabNew (and therefore TaskCommentCard's DeleteModal) is rendered + // inside an antd Drawer here, unlike the standalone task page used by the + // other delete tests above. If the confirmation dialog's overlay ever + // sits below the Drawer's own mask again, this click lands on the mask + // (which closes the drawer) instead of the dialog's confirm button, and + // this test will hang/time out waiting for the DELETE response instead + // of silently passing. + await assigneeUser.login(page); - // Non-author should NOT see edit/delete buttons for others' comments - const editBtn = comment.first().getByTestId('edit-comment'); - const deleteBtn = comment.first().getByTestId('delete-comment'); + const message = `Drawer-delete comment ${Date.now()}`; + const { drawer, taskCommentId } = await postCommentAsUser(page, message); - // These should not be visible (or should be for own comments only) - } - } - } + await expect(page.locator('.activity-feed-drawer, .feed-drawer')).toBeVisible(); + + await deleteCommentViaUi(page, drawer, message, taskCommentId); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index 40fe28625074..b5dd98df7c7a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -15,8 +15,11 @@ import { ButtonUtility } from '@openmetadata/ui-core-components'; import { Delete as DeleteIcon } from '@openmetadata/ui-core-components/icons'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; +import classNames from 'classnames'; import { FC, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { User } from '../../../generated/entity/teams/user'; import { useUserProfile } from '../../../hooks/user-profile/useUserProfile'; import { deleteTaskComment, Task, TaskComment } from '../../../rest/tasksAPI'; import { @@ -25,8 +28,10 @@ import { } from '../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../utils/EntityNameUtils'; import { getFrontEndFormat } from '../../../utils/FeedUtilsPure'; +import { getUserPath } from '../../../utils/RouterUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import DeleteModal from '../../common/DeleteModal/DeleteModal'; +import UserPopOverCard from '../../common/PopOverCard/UserPopOverCard'; import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; interface TaskCommentCardProps { @@ -34,7 +39,7 @@ interface TaskCommentCardProps { task: Task; isLastReply?: boolean; closeFeedEditor?: () => void; - currentUser?: { name?: string; isAdmin?: boolean }; + currentUser?: Pick; onCommentDeleted?: () => void; } @@ -80,23 +85,50 @@ const TaskCommentCard: FC = ({ } }; + const authorUserName = comment.author?.name; + + const profilePicture = ( + + ); + + const authorNameText = ( + + {authorName} + + ); + return (
- + {authorUserName ? ( + + {profilePicture} + + ) : ( + profilePicture + )}
- - {authorName} - + {authorUserName ? ( + + + {authorName} + + + ) : ( + authorNameText + )} {comment.createdAt && ( = ({ // Stays mounted so it is reachable by Tab, and is revealed on card hover or // on its own focus rather than on a mouse-only hover state. { )); }); +jest.mock('../../common/PopOverCard/UserPopOverCard', () => { + return jest.fn(({ children }) => children); +}); + const mockRichTextPreview = jest.fn(); jest.mock('../../common/RichTextEditor/RichTextEditorPreviewNew', () => { return jest.fn((props) => { @@ -106,7 +111,11 @@ const mockTask = { const renderCard = ( props: Partial> = {} ) => - render(); + render( + + + + ); describe('TaskCommentCard', () => { beforeEach(() => { @@ -151,13 +160,35 @@ describe('TaskCommentCard', () => { renderCard({ currentUser: { name: 'alice' } }); expect(screen.getByTestId('task-comment-card')).toHaveClass( - 'relative', + 'tw:relative', 'tw:group' ); expect(screen.getByTestId('delete-task-comment')).toHaveClass( 'tw:absolute' ); }); + + it('should link the author name and avatar to their profile', () => { + renderCard(); + + const authorLink = screen.getByTestId('author-name'); + + expect(authorLink).toHaveAttribute('href', '/users/alice'); + expect(screen.getByTestId('profile-alice')).toBeInTheDocument(); + }); + + it('should fall back to plain text when the comment has no author name', () => { + renderCard({ + comment: { + ...mockComment, + author: { id: 'user-1', type: 'user' }, + }, + }); + + const authorName = screen.getByTestId('author-name'); + + expect(authorName).not.toHaveAttribute('href'); + }); }); describe('delete affordance permissions', () => { @@ -228,6 +259,12 @@ describe('TaskCommentCard', () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); renderCard({ currentUser: { name: 'alice' } }); + // Tab order: author-name link (now focusable, see the profile-link tests + // above) comes before the delete affordance. + await user.tab(); + + expect(screen.getByTestId('author-name')).toHaveFocus(); + await user.tab(); expect(screen.getByTestId('delete-task-comment')).toHaveFocus(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx index bba5defd33a5..2010c80895be 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx @@ -39,7 +39,10 @@ export const DeleteModal = ({ data-testid="delete-modal" isDismissable={!isDeleting} isOpen={open} - style={{ zIndex: 999 }} + // Delete confirmation must win over any ancestor antd Drawer/Modal + // (@zindex-modal / @zindex-modal-mask are both 1000), otherwise a click + // here lands on the drawer's mask instead of this dialog. + style={{ zIndex: 1001 }} onOpenChange={(isOpen) => !isOpen && !isDeleting && onCancel()}> From cda2de68f3053790b38386c402f2186960dfce9f Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 09:29:44 +0000 Subject: [PATCH 05/23] fix: address remaining review nits on task comment delete - Move keyboard focus to a sibling comment (or the replies container as a last resort) when a comment is deleted, instead of letting it fall to , per frontend-a11y.md's focus-management rule. Implemented as a useLayoutEffect cleanup so it fires on the actual unmount rather than being tied to the async refetch that removes the card. - Add a real Playwright test proving a long comment gets a working View More/View Less toggle in a real browser, since the existing Jest test only proves enableSeeMoreVariant isn't passed to a mocked previewer - jsdom has no layout, so it can't exercise the actual overflow check. --- .../e2e/Features/Tasks/TaskComments.spec.ts | 108 ++++++++++++++++++ .../TaskCommentCard.component.tsx | 39 ++++++- .../TaskCommentCard.test.tsx | 57 +++++++++ 3 files changed, 202 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 5823b82e62f7..1afa04308c02 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -709,6 +709,114 @@ test.describe('Task Comments - Edit/Delete', () => { }); }); +test.describe('Task Comments - Long Comment Overflow', () => { + const assigneeUser = new UserClass(); + const table = new TableClass(); + + test.beforeAll('Setup test data', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + try { + await assigneeUser.create(apiContext); + + await table.create(apiContext); + await table.setOwner(apiContext, { + id: assigneeUser.responseData.id, + type: 'user', + }); + + await apiContext.post('/api/v1/tasks', { + data: { + about: { + type: 'table', + id: table.entityResponseData?.id, + fullyQualifiedName: table.entityResponseData?.fullyQualifiedName, + }, + type: 'RequestDescription', + assignees: [{ id: assigneeUser.responseData.id, type: 'user' }], + }, + }); + } finally { + await afterAction(); + } + }); + + test.afterAll('Cleanup test data', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + try { + await table.delete(apiContext); + await assigneeUser.delete(apiContext); + } finally { + await afterAction(); + } + }); + + test('a long comment shows a working View More / View Less toggle instead of being silently clamped', async ({ + page, + }) => { + // Regression coverage for TaskCommentCard's RichTextEditorPreviewNew + // usage: the ~2-line clamp applies independent of `enableSeeMoreVariant`, + // so a comment that overflows needs the toggle rendered to stay + // readable. This can't be covered in Jest - jsdom has no real layout, so + // the scrollHeight-vs-clientHeight overflow check that decides whether + // to render the toggle never actually fires there. + await assigneeUser.login(page); + await table.visitEntityPage(page); + + await page.getByTestId('activity_feed').click(); + await waitForPageLoaded(page); + + const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); + if (await tasksTab.isVisible()) { + await tasksTab.click(); + await waitForPageLoaded(page); + } + + const taskCard = page.getByTestId('task-feed-card'); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); + + const drawer = page.locator('.ant-drawer-content'); + await expect(drawer).toBeVisible(); + + const uniqueMarker = `overflow-marker-${Date.now()}`; + const longMessage = `${'This comment is written to overflow the two line clamp on the task comment preview. '.repeat(8)}${uniqueMarker}`; + + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); + await expect(commentInput).toBeVisible(); + await commentInput.fill(longMessage); + + const sendBtn = drawer.getByTestId('send-comment'); + const commentResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/api/v1/tasks/') && + response.url().includes('/comments') && + response.request().method() === 'POST' + ); + await sendBtn.click(); + await commentResponsePromise; + + const commentCard = drawer + .getByTestId('task-comment-card') + .filter({ hasText: uniqueMarker }); + await expect(commentCard).toBeVisible(); + + // The toggle only renders when the browser's real layout measurement + // (scrollHeight vs clientHeight against the clamp) finds an overflow - + // its presence here is the actual signal Jest can't produce. + const readMoreButton = commentCard.getByTestId('read-more-button'); + await expect(readMoreButton).toBeVisible(); + await readMoreButton.click(); + + await expect(commentCard.getByTestId('read-less-button')).toBeVisible(); + await expect(commentCard.getByText(uniqueMarker)).toBeVisible(); + }); +}); + test.describe('Task Comments - API Validation', () => { const adminUser = new UserClass(); const table = new TableClass(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index b5dd98df7c7a..f6a97d29b5fa 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -16,7 +16,7 @@ import { Delete as DeleteIcon } from '@openmetadata/ui-core-components/icons'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; -import { FC, useMemo, useState } from 'react'; +import { FC, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { User } from '../../../generated/entity/teams/user'; @@ -72,6 +72,39 @@ const TaskCommentCard: FC = ({ [currentUser, comment.author] ); + const cardRef = useRef(null); + + // Removing the focused node (deleting this comment) must not let keyboard + // focus fall through to - move it to a sensible neighbour first. + // This runs on unmount rather than inside handleDelete because the card + // doesn't disappear until the parent's refetch resolves and re-renders; + // by then react-aria has already restored focus to our own (about to be + // removed) delete button, so redirecting it earlier would just get + // overwritten. See frontend-a11y.md's focus-management rule. + useLayoutEffect( + () => () => { + const card = cardRef.current; + if (!card || !card.contains(document.activeElement)) { + return; + } + + const nextFocusTarget = + (card.nextElementSibling as HTMLElement | null) ?? + (card.previousElementSibling as HTMLElement | null); + + if (nextFocusTarget) { + nextFocusTarget.focus(); + } else if (card.parentElement) { + // No sibling comments left - fall back to the still-mounted + // replies container itself rather than leaving focus on a node + // that's about to be removed. + card.parentElement.setAttribute('tabindex', '-1'); + card.parentElement.focus(); + } + }, + [] + ); + const handleDelete = async () => { setIsDeleting(true); try { @@ -106,7 +139,9 @@ const TaskCommentCard: FC = ({ className={classNames('p-y-md p-x-sm tw:relative tw:group', { 'border-bottom': !isLastReply, })} - data-testid="task-comment-card"> + data-testid="task-comment-card" + ref={cardRef} + tabIndex={-1}> {authorUserName ? ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index 782c511f0845..cff76c6a212b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -335,4 +335,61 @@ describe('TaskCommentCard', () => { expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); }); }); + + describe('focus management on delete', () => { + it('should move focus to a sibling comment instead of letting it fall to ', async () => { + const secondComment: TaskComment = { + ...mockComment, + id: 'comment-2', + message: 'A second comment', + }; + + const rerenderRef: { + current?: (ui: React.ReactElement) => void; + } = {}; + + const TwoComments = ({ showFirst }: { showFirst: boolean }) => ( + +
+ {showFirst && ( + + rerenderRef.current?.() + } + /> + )} + +
+
+ ); + + const { rerender } = render(); + rerenderRef.current = rerender; + + const deleteButtons = screen.getAllByTestId('delete-task-comment'); + // Simulate react-aria restoring focus to the trigger as the confirm + // dialog closes, which is what actually happens right before the + // parent's refetch removes this card in the real app. + deleteButtons[0].focus(); + fireEvent.click(deleteButtons[0]); + + await act(async () => { + fireEvent.click(screen.getByTestId('confirm-delete')); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('task-comment-card')).toHaveLength(1); + }); + + expect(document.body).not.toHaveFocus(); + expect(screen.getByTestId('task-comment-card')).toHaveFocus(); + }); + }); }); From 19f765c27e0026b0e885a70cd7f874ce6896492c Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 09:45:35 +0000 Subject: [PATCH 06/23] fix: scope DeleteModal z-index bump and stop mounting/mutating unnecessarily - Make DeleteModal's elevated z-index opt-in via an `elevated` prop (default false, back to 999) instead of raising it for all ~75 consumers; only TaskCommentCard passes elevated, scoping the drawer fix to where it's actually needed. - Move DeleteModal inside TaskCommentCard's canDelete guard so it's not mounted at all for comments the current user can't delete. - Replace the parentElement.setAttribute('tabindex', ...) fallback in TaskCommentCard's focus-restoration effect with a repliesContainerRef passed down from TaskTabNew, which already carries a stable tabIndex={-1} - the card no longer mutates a foreign parent node. --- .../TaskCommentCard.component.tsx | 73 ++++++++++++------- .../TaskCommentCard.test.tsx | 35 +++++++++ .../Task/TaskTab/TaskTabNew.component.tsx | 11 ++- .../DeleteModal/DeleteModal.interface.ts | 8 ++ .../common/DeleteModal/DeleteModal.tsx | 11 ++- 5 files changed, 106 insertions(+), 32 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index f6a97d29b5fa..18aba5ac57f2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -16,7 +16,14 @@ import { Delete as DeleteIcon } from '@openmetadata/ui-core-components/icons'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; -import { FC, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + FC, + RefObject, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { User } from '../../../generated/entity/teams/user'; @@ -41,6 +48,13 @@ interface TaskCommentCardProps { closeFeedEditor?: () => void; currentUser?: Pick; onCommentDeleted?: () => void; + /** + * Focus fallback for when a deleted comment has no sibling comment left to + * hand focus to. Must already carry a stable `tabIndex={-1}` - this + * component only ever calls `.focus()` on it, it never mutates a foreign + * parent node's attributes. + */ + repliesContainerRef?: RefObject; } const TaskCommentCard: FC = ({ @@ -49,6 +63,7 @@ const TaskCommentCard: FC = ({ isLastReply = false, currentUser, onCommentDeleted, + repliesContainerRef, }) => { const { t } = useTranslation(); const [, , user] = useUserProfile({ @@ -94,15 +109,16 @@ const TaskCommentCard: FC = ({ if (nextFocusTarget) { nextFocusTarget.focus(); - } else if (card.parentElement) { - // No sibling comments left - fall back to the still-mounted - // replies container itself rather than leaving focus on a node - // that's about to be removed. - card.parentElement.setAttribute('tabindex', '-1'); - card.parentElement.focus(); + } else { + // No sibling comments left - fall back to the replies container, + // which the parent already keeps focusable (tabIndex={-1}) for + // exactly this case, rather than leaving focus on a node that's + // about to be removed. Never mutate it ourselves - it's foreign, + // shared DOM we don't own. + repliesContainerRef?.current?.focus(); } }, - [] + [repliesContainerRef] ); const handleDelete = async () => { @@ -182,26 +198,29 @@ const TaskCommentCard: FC = ({
{canDelete && ( - // Stays mounted so it is reachable by Tab, and is revealed on card hover or - // on its own focus rather than on a mouse-only hover state. - setShowDeleteDialog(true)} - /> + <> + {/* Stays mounted so it is reachable by Tab, and is revealed on card + hover or on its own focus rather than on a mouse-only hover state. */} + setShowDeleteDialog(true)} + /> + setShowDeleteDialog(false)} + onDelete={handleDelete} + /> + )} - setShowDeleteDialog(false)} - onDelete={handleDelete} - />
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index cff76c6a212b..d9705cfd65a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -27,6 +27,7 @@ import { TaskStatus, TaskType, } from '../../../generated/entity/tasks/task'; +import DeleteModal from '../../common/DeleteModal/DeleteModal'; import { deleteTaskComment } from '../../../rest/tasksAPI'; import { showErrorToast } from '../../../utils/ToastUtils'; import TaskCommentCard from './TaskCommentCard.component'; @@ -219,6 +220,19 @@ describe('TaskCommentCard', () => { screen.queryByTestId('delete-task-comment') ).not.toBeInTheDocument(); }); + + it('should not mount DeleteModal at all when the current user cannot delete', () => { + renderCard({ currentUser: { name: 'bob', isAdmin: false } }); + + expect(DeleteModal as jest.Mock).not.toHaveBeenCalled(); + }); + + it('should mount DeleteModal (closed) when the current user can delete', () => { + renderCard({ currentUser: { name: 'alice' } }); + + expect(DeleteModal as jest.Mock).toHaveBeenCalled(); + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + }); }); describe('accessibility', () => { @@ -391,5 +405,26 @@ describe('TaskCommentCard', () => { expect(document.body).not.toHaveFocus(); expect(screen.getByTestId('task-comment-card')).toHaveFocus(); }); + + it('should fall back to the replies container when the deleted comment has no sibling to focus', () => { + // A plain object, not the parent's own DOM node - TaskCommentCard must + // only ever call .focus() on it, matching what the fix is actually + // for: never touching a foreign parent's attributes. + const repliesContainerRef = { current: document.createElement('div') }; + repliesContainerRef.current.tabIndex = -1; + document.body.appendChild(repliesContainerRef.current); + + const { unmount } = renderCard({ + currentUser: { name: 'alice' }, + repliesContainerRef, + }); + + screen.getByTestId('delete-task-comment').focus(); + unmount(); + + expect(repliesContainerRef.current).toHaveFocus(); + + document.body.removeChild(repliesContainerRef.current); + }); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx index 1a24fe5d75a4..c91f8d38901b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx @@ -420,6 +420,10 @@ export const TaskTabNew = ({ ...rest }: TaskTabProps) => { const editorRef = useRef(); + // Stable, always-focusable (tabIndex={-1}) fallback target the comment + // cards can hand focus to when a deleted comment has no sibling left - + // see TaskCommentCard's unmount focus-management effect. + const repliesContainerRef = useRef(null); const navigate = useNavigate(); const [assigneesForm] = useForm(); const { currentUser } = useApplicationStore(); @@ -1832,7 +1836,11 @@ export const TaskTabNew = ({ ); return ( - + {sortedComments.map((comment, index, arr) => ( fetchUpdatedThread(task.id, true)} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.interface.ts index 462d33c541de..1f33eefc1331 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.interface.ts @@ -25,4 +25,12 @@ export interface DeleteModalProps { onCancel: () => void; /** Callback when delete is confirmed */ onDelete: () => void; + /** + * Raises the overlay's z-index above antd's Drawer/Modal stacking + * context (@zindex-modal / @zindex-modal-mask, both 1000). Opt-in and + * off by default so this only affects consumers that actually open the + * dialog from inside a Drawer - DeleteModal is used across ~75 other + * call sites that don't need (or want) their stacking changed. + */ + elevated?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx index 2010c80895be..9ace2369c366 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx @@ -24,11 +24,17 @@ import { Trash01 } from '@untitledui/icons'; import { useTranslation } from 'react-i18next'; import { DeleteModalProps } from './DeleteModal.interface'; +// antd's @zindex-modal / @zindex-modal-mask are both 1000; this only needs +// to beat that when a consumer opts in via `elevated` (see DeleteModalProps). +const BASE_Z_INDEX = 999; +const ELEVATED_Z_INDEX = 1001; + export const DeleteModal = ({ open, entityTitle, message, isDeleting = false, + elevated = false, onCancel, onDelete, }: DeleteModalProps) => { @@ -39,10 +45,7 @@ export const DeleteModal = ({ data-testid="delete-modal" isDismissable={!isDeleting} isOpen={open} - // Delete confirmation must win over any ancestor antd Drawer/Modal - // (@zindex-modal / @zindex-modal-mask are both 1000), otherwise a click - // here lands on the drawer's mask instead of this dialog. - style={{ zIndex: 1001 }} + style={{ zIndex: elevated ? ELEVATED_Z_INDEX : BASE_Z_INDEX }} onOpenChange={(isOpen) => !isOpen && !isDeleting && onCancel()}> From ca3601de634666f10e6c17714800632d9b5cf207 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 11 Sep 2026 16:20:25 +0530 Subject: [PATCH 07/23] style(ui): apply checkstyle formatting to task comment tests Output of `make ui-checkstyle-changed`: Prettier line-wraps in the task comments Playwright spec and an import-order fix in TaskCommentCard.test.tsx. Formatting only, no behaviour change. Keeps the branch green on CI's UI Checkstyle job. --- .../ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts | 8 ++++++-- .../ActivityFeedCardNew/TaskCommentCard.test.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 1afa04308c02..71e1dfbfc91f 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -703,7 +703,9 @@ test.describe('Task Comments - Edit/Delete', () => { const message = `Drawer-delete comment ${Date.now()}`; const { drawer, taskCommentId } = await postCommentAsUser(page, message); - await expect(page.locator('.activity-feed-drawer, .feed-drawer')).toBeVisible(); + await expect( + page.locator('.activity-feed-drawer, .feed-drawer') + ).toBeVisible(); await deleteCommentViaUi(page, drawer, message, taskCommentId); }); @@ -782,7 +784,9 @@ test.describe('Task Comments - Long Comment Overflow', () => { await expect(drawer).toBeVisible(); const uniqueMarker = `overflow-marker-${Date.now()}`; - const longMessage = `${'This comment is written to overflow the two line clamp on the task comment preview. '.repeat(8)}${uniqueMarker}`; + const longMessage = `${'This comment is written to overflow the two line clamp on the task comment preview. '.repeat( + 8 + )}${uniqueMarker}`; const commentInput = drawer.locator( '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index d9705cfd65a8..e725653a9ad4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -27,9 +27,9 @@ import { TaskStatus, TaskType, } from '../../../generated/entity/tasks/task'; -import DeleteModal from '../../common/DeleteModal/DeleteModal'; import { deleteTaskComment } from '../../../rest/tasksAPI'; import { showErrorToast } from '../../../utils/ToastUtils'; +import DeleteModal from '../../common/DeleteModal/DeleteModal'; import TaskCommentCard from './TaskCommentCard.component'; jest.mock('../../../rest/tasksAPI', () => ({ From 4fb93898843b40697e837a26ea556b802595ba7a Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Wed, 16 Sep 2026 01:08:00 +0530 Subject: [PATCH 08/23] fix(playwright): sync suppressions baseline total in corpus.test.mjs Commit 78858a32eb lowered TaskComments.spec.ts from 24 to 17 suppressed no-positional-locator violations when the dead tests were removed, but the repo-wide total recorded in corpus.test.mjs still read 1298. The test sums every entry in eslint-suppressions.json and asserts exact equality, so it failed with 1291 actual against 1298 expected and turned ui-checkstyle red. Lower the recorded total by the same 7. The suppressions file itself was already correct: lint:playwright:suppressions prunes nothing further. --- .../resources/ui/playwright/eslint-rules/tests/corpus.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index 24a9f4492826..89e5db1724b1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -41,7 +41,7 @@ test('the suppressions baseline matches its recorded state exactly', () => { // of the same rule in the same file stays invisible here. const EXPECTED = { 'om-playwright/justified-rule-disable': 12, - 'om-playwright/no-positional-locator': 1298, + 'om-playwright/no-positional-locator': 1291, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 4, 'playwright/no-wait-for-selector': 35, From 63c1de1d2389d29bcf664c4d7fad8d5d34ab1fa7 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 10:35:22 +0530 Subject: [PATCH 09/23] fix: correct comment-id lookup and de-mock TaskCommentCard tests Two review-comment fixes on this branch: - TaskComments.spec.ts: the comment-creation POST returns the updated Task, not the new Comment, so reading comment.id was actually reading the task's id. This made the delete-comment test helper wait on a network response URL that could never match the UI's actual DELETE request. Now reads the new comment's id from the last entry of the returned task's comments array. Verified against the backend: TaskResource.addComment declares Task.class as its response schema, loads the comments field, and TaskRepository.addComment appends, so the new comment is last. - TaskCommentCard.test.tsx: replaced mocks of ProfilePicture, UserPopOverCard, RichTextEditorPreviewNew, DeleteModal, FeedUtilsPure, DateTimeUtils, ToastUtils and useUserProfile with the real implementations, keeping only the two REST client boundaries mocked (tasksAPI, and userAPI which backs useUserProfile), per this repo's testing philosophy of asserting observable behavior rather than internal wiring. Determinism for time-based output now comes from jest.setSystemTime() instead of a mock. Dropped the enableSeeMoreVariant assertion, which cannot be observed under jsdom's lack of real layout -- it is already covered by the existing Playwright long-comment overflow test. --- .../e2e/Features/Tasks/TaskComments.spec.ts | 9 +- .../TaskCommentCard.test.tsx | 368 +++++------------- 2 files changed, 105 insertions(+), 272 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 71e1dfbfc91f..01e4621680f6 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -563,11 +563,16 @@ test.describe('Task Comments - Edit/Delete', () => { ); await sendBtn.click(); const commentResponse = await commentResponsePromise; - const comment = await commentResponse.json(); + // POST /tasks/{id}/comments returns the updated Task, not the new comment, so + // the comment's own id has to come from the task's comments array. The server + // appends, so the new comment is the last entry. + const task = await commentResponse.json(); + const comments = task.comments ?? []; + const taskCommentId = comments[comments.length - 1]?.id as string; await expect(drawer.getByText(message)).toBeVisible(); - return { drawer, taskCommentId: comment.id as string }; + return { drawer, taskCommentId }; }; /** diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index e725653a9ad4..af700936fb6c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2026 Collate. + * Copyright 2025 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 @@ -11,13 +11,7 @@ * limitations under the License. */ -import { - act, - fireEvent, - render, - screen, - waitFor, -} from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { @@ -28,75 +22,32 @@ import { TaskType, } from '../../../generated/entity/tasks/task'; import { deleteTaskComment } from '../../../rest/tasksAPI'; -import { showErrorToast } from '../../../utils/ToastUtils'; -import DeleteModal from '../../common/DeleteModal/DeleteModal'; import TaskCommentCard from './TaskCommentCard.component'; +// Only the REST boundary is mocked. Every component, hook and utility below the +// card renders for real, so the assertions describe what a user actually sees. jest.mock('../../../rest/tasksAPI', () => ({ deleteTaskComment: jest.fn().mockResolvedValue({}), })); -jest.mock('../../../utils/ToastUtils', () => ({ - showErrorToast: jest.fn(), -})); - -jest.mock('../../../hooks/user-profile/useUserProfile', () => ({ - useUserProfile: () => [ - false, - false, - { name: 'alice', displayName: 'Alice Author' }, - ], +// The other half of that boundary: useUserProfile resolves the comment author +// through this REST module. Stubbing the request rather than the hook keeps the +// real hook and its consumers in the test. +jest.mock('../../../rest/userAPI', () => ({ + getUserByName: jest.fn().mockResolvedValue({ + id: 'user-1', + name: 'alice', + displayName: 'Alice Author', + }), })); -jest.mock('../../common/ProfilePicture/ProfilePicture', () => { - return jest.fn(({ name }) => ( -
Avatar
- )); -}); - -jest.mock('../../common/PopOverCard/UserPopOverCard', () => { - return jest.fn(({ children }) => children); -}); - -const mockRichTextPreview = jest.fn(); -jest.mock('../../common/RichTextEditor/RichTextEditorPreviewNew', () => { - return jest.fn((props) => { - mockRichTextPreview(props); - - return
{props.markdown}
; - }); -}); - -jest.mock('../../common/DeleteModal/DeleteModal', () => ({ - __esModule: true, - default: jest.fn(({ open, isDeleting, onDelete, onCancel }) => - open ? ( -
- {String(isDeleting)} - - -
- ) : null - ), -})); - -jest.mock('../../../utils/FeedUtilsPure', () => ({ - getFrontEndFormat: jest.fn((text) => text), -})); - -jest.mock('../../../utils/date-time/DateTimeUtils', () => ({ - formatDateTime: jest.fn(() => 'Jan 01, 2025, 12:00 PM'), - getRelativeTime: jest.fn(() => '2 hours ago'), -})); +const NOW = new Date('2025-01-01T12:00:00.000Z').getTime(); +const TWO_HOURS_AGO = NOW - 2 * 60 * 60 * 1000; const mockComment: TaskComment = { id: 'comment-1', message: 'This is the incident comment body', - createdAt: 1735732800000, + createdAt: TWO_HOURS_AGO, author: { id: 'user-1', type: 'user', name: 'alice' }, }; @@ -118,45 +69,47 @@ const renderCard = ( ); +const setup = () => + userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + describe('TaskCommentCard', () => { beforeEach(() => { jest.clearAllMocks(); + jest.setSystemTime(NOW); (deleteTaskComment as jest.Mock).mockResolvedValue({}); }); describe('rendering', () => { - it('should render the author name, relative timestamp and comment body', () => { + it('should show the author, a relative timestamp and the comment body', async () => { renderCard(); - expect(screen.getByTestId('author-name')).toHaveTextContent( - 'Alice Author' - ); - expect(screen.getByTestId('comment-time')).toHaveTextContent( - '2 hours ago' - ); - expect(screen.getByTestId('rich-text-preview')).toHaveTextContent( - 'This is the incident comment body' - ); + expect( + await screen.findByText('This is the incident comment body') + ).toBeInTheDocument(); + expect(screen.getByTestId('comment-time')).toHaveTextContent(/ago/i); + expect(screen.getByTestId('author-name')).toHaveTextContent(/alice/i); }); - // Regression guard for #33112: passing enableSeeMoreVariant={false} clamped long - // comments with no way to expand them. The previewer defaults it to true, so the - // prop must stay unset rather than be re-added as false. - it('should not disable the see-more variant on the previewer', () => { + it('should link the author to their profile page', () => { renderCard(); - expect(mockRichTextPreview).toHaveBeenCalled(); + expect(screen.getByTestId('author-name')).toHaveAttribute( + 'href', + '/users/alice' + ); + }); - // Every render, not just one of them - toHaveBeenCalledWith would pass as long - // as a single call happened to omit the prop. - mockRichTextPreview.mock.calls.forEach(([props]) => { - expect(props.enableSeeMoreVariant).toBeUndefined(); + it('should render the author as plain text when there is no author name', () => { + renderCard({ + comment: { ...mockComment, author: { id: 'user-1', type: 'user' } }, }); + + expect(screen.getByTestId('author-name')).not.toHaveAttribute('href'); }); - // Regression guard for #33112: the delete affordance overlays the card rather than - // sharing its flow, so revealing it cannot reflow the comment body. jsdom runs no - // layout, so this asserts the positioning contract that keeps it out of flow. + // Regression guard for #33112: the delete affordance overlays the card rather + // than sharing its flow, so revealing it cannot reflow the comment body. jsdom + // performs no layout, so the positioning contract is the observable proxy. it('should overlay the delete action instead of placing it in the flow', () => { renderCard({ currentUser: { name: 'alice' } }); @@ -168,32 +121,10 @@ describe('TaskCommentCard', () => { 'tw:absolute' ); }); - - it('should link the author name and avatar to their profile', () => { - renderCard(); - - const authorLink = screen.getByTestId('author-name'); - - expect(authorLink).toHaveAttribute('href', '/users/alice'); - expect(screen.getByTestId('profile-alice')).toBeInTheDocument(); - }); - - it('should fall back to plain text when the comment has no author name', () => { - renderCard({ - comment: { - ...mockComment, - author: { id: 'user-1', type: 'user' }, - }, - }); - - const authorName = screen.getByTestId('author-name'); - - expect(authorName).not.toHaveAttribute('href'); - }); }); describe('delete affordance permissions', () => { - it('should not show delete when there is no current user', () => { + it('should not offer delete when there is no current user', () => { renderCard(); expect( @@ -201,80 +132,45 @@ describe('TaskCommentCard', () => { ).not.toBeInTheDocument(); }); - it('should show delete to the comment author', () => { + it('should offer delete to the comment author', () => { renderCard({ currentUser: { name: 'alice' } }); expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); }); - it('should show delete to an admin who is not the author', () => { + it('should offer delete to an admin who is not the author', () => { renderCard({ currentUser: { name: 'bob', isAdmin: true } }); expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); }); - it('should not show delete to a non-admin who is not the author', () => { + it('should not offer delete to a non-admin who is not the author', () => { renderCard({ currentUser: { name: 'bob', isAdmin: false } }); expect( screen.queryByTestId('delete-task-comment') ).not.toBeInTheDocument(); }); - - it('should not mount DeleteModal at all when the current user cannot delete', () => { - renderCard({ currentUser: { name: 'bob', isAdmin: false } }); - - expect(DeleteModal as jest.Mock).not.toHaveBeenCalled(); - }); - - it('should mount DeleteModal (closed) when the current user can delete', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect(DeleteModal as jest.Mock).toHaveBeenCalled(); - expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); - }); }); describe('accessibility', () => { it('should expose the delete action as a button with an accessible name', () => { renderCard({ currentUser: { name: 'alice' } }); - const deleteButton = screen.getByRole('button', { name: 'label.delete' }); - - expect(deleteButton).toHaveAttribute( - 'data-testid', - 'delete-task-comment' - ); - }); - - // The affordance used to be mounted only while the mouse was over the card, which - // put it permanently out of reach of the keyboard. It now stays in the DOM and is - // revealed by CSS on hover or focus. - it('should keep the delete action focusable without a mouse hover', async () => { - renderCard({ currentUser: { name: 'alice' } }); - - const deleteButton = screen.getByTestId('delete-task-comment'); - deleteButton.focus(); - - expect(deleteButton).toHaveFocus(); - expect(deleteButton).toHaveClass('tw:focus-visible:opacity-100'); - }); - - it('should reveal the delete action on card hover via CSS, not conditional mount', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect(screen.getByTestId('delete-task-comment')).toHaveClass( - 'tw:opacity-0', - 'tw:group-hover:opacity-100' - ); + expect( + screen.getByRole('button', { name: 'label.delete' }) + ).toHaveAttribute('data-testid', 'delete-task-comment'); }); - it('should open the confirmation modal from the keyboard alone', async () => { - const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + // The affordance used to be mounted only while the mouse was over the card, + // which put it permanently out of reach of the keyboard. It now stays mounted + // and is revealed by CSS on hover or focus. + it('should reach and trigger the delete action from the keyboard alone', async () => { + const user = setup(); renderCard({ currentUser: { name: 'alice' } }); - // Tab order: author-name link (now focusable, see the profile-link tests - // above) comes before the delete affordance. + // Real tab order: the author's profile link comes first, the delete action + // second. Both are reachable without a pointer. await user.tab(); expect(screen.getByTestId('author-name')).toHaveFocus(); @@ -285,146 +181,78 @@ describe('TaskCommentCard', () => { await user.keyboard('{Enter}'); - expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('delete-modal')).toBeInTheDocument(); + }); + + it('should reveal the delete action on card hover via CSS, not by unmounting it', () => { + renderCard({ currentUser: { name: 'alice' } }); + + expect(screen.getByTestId('delete-task-comment')).toHaveClass( + 'tw:opacity-0', + 'tw:group-hover:opacity-100' + ); }); }); describe('delete flow', () => { - const openDeleteModal = (props = { currentUser: { name: 'alice' } }) => { - renderCard(props); - fireEvent.click(screen.getByTestId('delete-task-comment')); - }; + it('should open a confirmation dialog before deleting anything', async () => { + const user = setup(); + renderCard({ currentUser: { name: 'alice' } }); - it('should open the confirmation modal from the delete affordance', () => { - openDeleteModal(); + await user.click(screen.getByTestId('delete-task-comment')); - expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); + expect(await screen.findByTestId('delete-modal')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-button')).toBeInTheDocument(); + expect(screen.getByTestId('cancel-button')).toBeInTheDocument(); expect(deleteTaskComment).not.toHaveBeenCalled(); }); - it('should delete the comment and notify the parent on confirm', async () => { + it('should delete the comment and notify the parent when confirmed', async () => { + const user = setup(); const onCommentDeleted = jest.fn(); renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - fireEvent.click(screen.getByTestId('delete-task-comment')); - await act(async () => { - fireEvent.click(screen.getByTestId('confirm-delete')); - }); - - expect(deleteTaskComment).toHaveBeenCalledWith('task-1', 'comment-1'); + await user.click(screen.getByTestId('delete-task-comment')); + await user.click(await screen.findByTestId('confirm-button')); - await waitFor(() => { - expect(onCommentDeleted).toHaveBeenCalledTimes(1); - }); - - expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); + await waitFor(() => + expect(deleteTaskComment).toHaveBeenCalledWith('task-1', 'comment-1') + ); + await waitFor(() => expect(onCommentDeleted).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() + ); }); - it('should keep the modal open and toast when the delete fails', async () => { - const error = new Error('delete failed'); - (deleteTaskComment as jest.Mock).mockRejectedValueOnce(error); + it('should keep the dialog open and not notify the parent when the delete fails', async () => { + (deleteTaskComment as jest.Mock).mockRejectedValueOnce( + new Error('delete failed') + ); + const user = setup(); const onCommentDeleted = jest.fn(); renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - fireEvent.click(screen.getByTestId('delete-task-comment')); - await act(async () => { - fireEvent.click(screen.getByTestId('confirm-delete')); - }); + await user.click(screen.getByTestId('delete-task-comment')); + await user.click(await screen.findByTestId('confirm-button')); - await waitFor(() => { - expect(showErrorToast).toHaveBeenCalledWith(error); - }); + await waitFor(() => expect(deleteTaskComment).toHaveBeenCalled()); expect(onCommentDeleted).not.toHaveBeenCalled(); expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); - expect(screen.getByTestId('is-deleting')).toHaveTextContent('false'); }); - it('should not delete anything when the modal is cancelled', () => { - openDeleteModal(); - - fireEvent.click(screen.getByTestId('cancel-delete')); + it('should delete nothing when the dialog is cancelled', async () => { + const user = setup(); + renderCard({ currentUser: { name: 'alice' } }); - expect(deleteTaskComment).not.toHaveBeenCalled(); - expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument(); - }); - }); + await user.click(screen.getByTestId('delete-task-comment')); + await user.click(await screen.findByTestId('cancel-button')); - describe('focus management on delete', () => { - it('should move focus to a sibling comment instead of letting it fall to ', async () => { - const secondComment: TaskComment = { - ...mockComment, - id: 'comment-2', - message: 'A second comment', - }; - - const rerenderRef: { - current?: (ui: React.ReactElement) => void; - } = {}; - - const TwoComments = ({ showFirst }: { showFirst: boolean }) => ( - -
- {showFirst && ( - - rerenderRef.current?.() - } - /> - )} - -
-
+ await waitFor(() => + expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() ); - const { rerender } = render(); - rerenderRef.current = rerender; - - const deleteButtons = screen.getAllByTestId('delete-task-comment'); - // Simulate react-aria restoring focus to the trigger as the confirm - // dialog closes, which is what actually happens right before the - // parent's refetch removes this card in the real app. - deleteButtons[0].focus(); - fireEvent.click(deleteButtons[0]); - - await act(async () => { - fireEvent.click(screen.getByTestId('confirm-delete')); - }); - - await waitFor(() => { - expect(screen.getAllByTestId('task-comment-card')).toHaveLength(1); - }); - - expect(document.body).not.toHaveFocus(); - expect(screen.getByTestId('task-comment-card')).toHaveFocus(); - }); - - it('should fall back to the replies container when the deleted comment has no sibling to focus', () => { - // A plain object, not the parent's own DOM node - TaskCommentCard must - // only ever call .focus() on it, matching what the fix is actually - // for: never touching a foreign parent's attributes. - const repliesContainerRef = { current: document.createElement('div') }; - repliesContainerRef.current.tabIndex = -1; - document.body.appendChild(repliesContainerRef.current); - - const { unmount } = renderCard({ - currentUser: { name: 'alice' }, - repliesContainerRef, - }); - - screen.getByTestId('delete-task-comment').focus(); - unmount(); - - expect(repliesContainerRef.current).toHaveFocus(); - - document.body.removeChild(repliesContainerRef.current); + expect(deleteTaskComment).not.toHaveBeenCalled(); }); }); }); From c193da5ded2f6c7ecbe4ad8511c3383f95b67d89 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 10:51:14 +0530 Subject: [PATCH 10/23] test(ui): restore focus-management coverage for comment delete The de-mock commit (63c1de1d23) dropped the "focus management on delete" describe along with the mocks it depended on, leaving the useLayoutEffect unmount cleanup in TaskCommentCard.component.tsx untested -- including the repliesContainerRef fallback. That cleanup is what stops keyboard focus falling to when the focused comment is removed, so it should not go uncovered. Both cases are back, written to the de-mocked file's discipline: real components throughout, still only the two REST boundaries mocked. The parent harness mirrors TaskTabNew's wiring -- a tabIndex={-1} replies container holding sibling cards, each handed the same ref -- and unmounts a card by re-rendering without that comment, which is what TaskTabNew does once its post-delete refetch resolves. The cleanup only fires when focus is inside the card at unmount. In a browser react-aria supplies that by restoring focus to the trigger as the dialog closes; jsdom does not reproduce it, so the tests establish that precondition directly rather than mocking the real dialog away. The confirm-button delete path stays covered by the existing dialog tests. Verified by mutation: disabling the cleanup fails both tests, and disabling either branch alone fails exactly its own test. --- .../TaskCommentCard.test.tsx | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx index af700936fb6c..7ec81c8ec246 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx @@ -11,8 +11,9 @@ * limitations under the License. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { useRef } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { Task, @@ -255,4 +256,85 @@ describe('TaskCommentCard', () => { expect(deleteTaskComment).not.toHaveBeenCalled(); }); }); + + // Covers the useLayoutEffect unmount cleanup in TaskCommentCard.component.tsx: + // when the card that holds focus is removed, focus must move to a sibling card + // or fall back to the replies container, never to . + // + // The parent is modelled on TaskTabNew's real wiring: a tabIndex={-1} replies + // container holding sibling cards, each handed the same ref. Removal is driven + // by re-rendering the parent with the comment gone, which is exactly what + // TaskTabNew does once its post-delete refetch resolves. + // + // Deliberately not routed through the delete dialog: in a browser react-aria + // restores focus to the trigger as the dialog closes, but jsdom does not + // reproduce that, so the precondition (focus inside the card at unmount) is + // established directly instead of mocking the real dialog away. + describe('focus management on delete', () => { + const secondComment: TaskComment = { + ...mockComment, + id: 'comment-2', + message: 'A sibling comment', + }; + + const CommentList = ({ comments }: { comments: TaskComment[] }) => { + const repliesContainerRef = useRef(null); + + return ( + +
+ {comments.map((entry, index, arr) => ( + + ))} +
+
+ ); + }; + + const focusFirstCardsDeleteButton = () => { + const card = screen.getAllByTestId('task-comment-card')[0]; + const button = within(card).getByTestId('delete-task-comment'); + + act(() => button.focus()); + + expect(card.contains(document.activeElement)).toBe(true); + }; + + it('should move focus to a sibling comment instead of letting it fall to ', () => { + const { rerender } = render( + + ); + + focusFirstCardsDeleteButton(); + + rerender(); + + const survivor = screen.getByTestId('task-comment-card'); + + expect(survivor).toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + }); + + it('should fall back to the replies container when the deleted comment has no sibling to focus', () => { + const { rerender } = render(); + + focusFirstCardsDeleteButton(); + + rerender(); + + expect(screen.queryByTestId('task-comment-card')).not.toBeInTheDocument(); + expect(screen.getByTestId('feed-replies')).toHaveFocus(); + expect(document.activeElement).not.toBe(document.body); + }); + }); }); From 18b8c9e518320e5a145d17c7e1b4a0948d075155 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 12:18:11 +0530 Subject: [PATCH 11/23] fix(ui): address review feedback on task comment delete Revert the scoped DeleteModal z-index, per review. DeleteModal.tsx and DeleteModal.interface.ts are back to their state on main: the BASE_Z_INDEX / ELEVATED_Z_INDEX constants and the opt-in `elevated` prop are gone, and the overlay is a plain zIndex: 999 again. TaskCommentCard stops passing `elevated`. Nothing else from that commit is touched -- the canDelete guard and the repliesContainerRef focus fallback stay as they are. Note this restores the original stacking problem: at 999 the confirmation dialog sits below antd's Drawer and mask (both 1000), so a click inside it lands on the mask. That is the case the drawer-delete Playwright test covers, and it will need a different fix -- likely at the design-system layer rather than per-consumer. Also drop the inline import() type annotations in TaskComments.spec.ts in favour of a normal top-level `import type { Locator, Page }`, and replace the ReturnType construction with Playwright's own Locator. Three call sites in total. Finally, record why the feed-replies container carries tabIndex={-1}: it is what makes repliesContainerRef.current.focus() work in TaskCommentCard's unmount fallback, since a plain div is not focusable and focus would otherwise fall to . --- .../playwright/e2e/Features/Tasks/TaskComments.spec.ts | 10 ++++------ .../ActivityFeedCardNew/TaskCommentCard.component.tsx | 1 - .../Entity/Task/TaskTab/TaskTabNew.component.tsx | 3 +++ .../common/DeleteModal/DeleteModal.interface.ts | 8 -------- .../src/components/common/DeleteModal/DeleteModal.tsx | 8 +------- 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 01e4621680f6..0c0249f8b613 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import type { Locator, Page } from '@playwright/test'; import { TableClass } from '../../../support/entity/TableClass'; import { expect, test } from '../../../support/fixtures/base'; import { UserClass } from '../../../support/user/UserClass'; @@ -526,10 +527,7 @@ test.describe('Task Comments - Edit/Delete', () => { * (needed to match the DELETE response) and the comment's text (needed to * find the right `task-comment-card`). */ - const postCommentAsUser = async ( - page: import('@playwright/test').Page, - message: string - ) => { + const postCommentAsUser = async (page: Page, message: string) => { await table.visitEntityPage(page); await page.getByTestId('activity_feed').click(); await waitForPageLoaded(page); @@ -583,8 +581,8 @@ test.describe('Task Comments - Edit/Delete', () => { * keyboard/tab without hovering, but hover is the primary discovery path). */ const deleteCommentViaUi = async ( - page: import('@playwright/test').Page, - drawer: ReturnType, + page: Page, + drawer: Locator, message: string, taskCommentId: string ) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index 18aba5ac57f2..f08980143a21 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -211,7 +211,6 @@ const TaskCommentCard: FC = ({ onClick={() => setShowDeleteDialog(true)} /> . -1 keeps it out of the normal tab order. tabIndex={-1}> {sortedComments.map((comment, index, arr) => ( void; /** Callback when delete is confirmed */ onDelete: () => void; - /** - * Raises the overlay's z-index above antd's Drawer/Modal stacking - * context (@zindex-modal / @zindex-modal-mask, both 1000). Opt-in and - * off by default so this only affects consumers that actually open the - * dialog from inside a Drawer - DeleteModal is used across ~75 other - * call sites that don't need (or want) their stacking changed. - */ - elevated?: boolean; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx index 9ace2369c366..bba5defd33a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx @@ -24,17 +24,11 @@ import { Trash01 } from '@untitledui/icons'; import { useTranslation } from 'react-i18next'; import { DeleteModalProps } from './DeleteModal.interface'; -// antd's @zindex-modal / @zindex-modal-mask are both 1000; this only needs -// to beat that when a consumer opts in via `elevated` (see DeleteModalProps). -const BASE_Z_INDEX = 999; -const ELEVATED_Z_INDEX = 1001; - export const DeleteModal = ({ open, entityTitle, message, isDeleting = false, - elevated = false, onCancel, onDelete, }: DeleteModalProps) => { @@ -45,7 +39,7 @@ export const DeleteModal = ({ data-testid="delete-modal" isDismissable={!isDeleting} isOpen={open} - style={{ zIndex: elevated ? ELEVATED_Z_INDEX : BASE_Z_INDEX }} + style={{ zIndex: 999 }} onOpenChange={(isOpen) => !isOpen && !isDeleting && onCancel()}> From 91d890582e7bd27a6d7bcf4d6c9076861d5f3c29 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 12:56:12 +0530 Subject: [PATCH 12/23] feat(ui): share task comment permissions/actions and add comment editing Extract the comment permission rules and the edit/delete affordances that were living module-private inside the Inbox task panel, so the activity-feed card and the Inbox cannot drift apart from each other or from the server. - utils/TaskCommentUtils.ts holds resolveCommentPermissions: the author may edit or delete their own comment, an admin may additionally delete anyone's. This mirrors TaskRepository's editComment/deleteComment rules. - components/common/TaskComment/ holds TaskCommentActions and TaskCommentBody. Actions are real ButtonUtility buttons rather than clickable SVGs, so they are reachable by Tab, carry an accessible name and activate on Enter. Consumers reveal them with CSS on a group ancestor; unmounting them until hover is what put them out of the keyboard's reach. TaskCommentBody shares only the inline editor and takes each consumer's own read view as children, because the two layouts differ by design. TaskCommentCard now uses the shared resolver and gains editing, which it did not have before. Editing is deliberately narrower than deleting: an admin who is not the author may remove a comment but not rewrite it. TaskDetailPanel drops its private copies for the shared ones and keeps its own layout. Its actions are no longer mounted only while hovered, so its tests move off the mouseEnter-then-click pattern to the interaction the component actually ships. Also in this change: - DeleteModal takes its overlay z-index from the --om-z-modal design token instead of a hardcoded value, matching EntityNameModal. At 1500 the confirmation dialog clears antd's Drawer and mask, which a flat 999 did not, so a click inside it no longer lands on the drawer's mask. - The overlay-positioning assertion moves out of Jest, where it could only compare Tailwind class names under a DOM with no layout engine, and into Playwright, where it measures that revealing the delete affordance does not shift the comment body's bounding box. - Corrects the copyright year on the card's test file. --- .../e2e/Features/Tasks/TaskComments.spec.ts | 61 ++++++++ .../TaskCommentCard.component.tsx | 68 ++++++--- .../TaskCommentCard.test.tsx | 90 +++++++++--- .../common/DeleteModal/DeleteModal.tsx | 2 +- .../common/TaskComment/TaskCommentActions.tsx | 78 ++++++++++ .../common/TaskComment/TaskCommentBody.tsx | 73 ++++++++++ .../components/TaskDetailPanel.test.tsx | 59 ++++---- .../InboxPage/components/TaskDetailPanel.tsx | 135 +++--------------- .../ui/src/utils/TaskCommentUtils.test.ts | 63 ++++++++ .../ui/src/utils/TaskCommentUtils.ts | 40 ++++++ 10 files changed, 483 insertions(+), 186 deletions(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.ts diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 0c0249f8b613..135cf32b481c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -134,6 +134,67 @@ test.describe('Task Comments - Add Comment', () => { } }); + // Replaces a Jest assertion that could only check Tailwind class names: jsdom has + // no layout engine, so it could not have caught an actual reflow. Here the delete + // affordance is positioned out of flow, so revealing it on hover must not shift + // the comment body by a single pixel. + test('revealing the delete affordance on hover must not reflow the comment body', async ({ + page, + }) => { + await assigneeUser.login(page); + await table.visitEntityPage(page); + + await page.getByTestId('activity_feed').click(); + await waitForPageLoaded(page); + + const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); + if (await tasksTab.isVisible()) { + await tasksTab.click(); + await waitForPageLoaded(page); + } + + const taskCard = page.locator('[data-testid="task-feed-card"]').first(); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); + + const drawer = page.locator('.ant-drawer-content'); + await expect(drawer).toBeVisible(); + + const message = `Layout probe ${Date.now()}`; + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); + await expect(commentInput).toBeVisible(); + await commentInput.fill(message); + + const commentResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/tasks/') && + response.url().includes('/comments') && + response.request().method() === 'POST' + ); + await drawer.getByTestId('send-comment').click(); + await commentResponse; + + const card = drawer + .locator('[data-testid="task-comment-card"]') + .filter({ hasText: message }); + await expect(card).toBeVisible(); + + const body = card.getByTestId('viewer-container'); + const before = await body.boundingBox(); + + await card.hover(); + + const deleteAction = card.getByTestId('delete-task-comment'); + await expect(deleteAction).toBeVisible(); + + const after = await body.boundingBox(); + + expect(after).toEqual(before); + }); + test('non-assignee should be able to add comment', async ({ page }) => { await commentingUser.login(page); await table.visitEntityPage(page); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx index f08980143a21..269cebbfff56 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx @@ -11,8 +11,6 @@ * limitations under the License. */ -import { ButtonUtility } from '@openmetadata/ui-core-components'; -import { Delete as DeleteIcon } from '@openmetadata/ui-core-components/icons'; import { Space, Tooltip, Typography } from 'antd'; import { AxiosError } from 'axios'; import classNames from 'classnames'; @@ -28,7 +26,12 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { User } from '../../../generated/entity/teams/user'; import { useUserProfile } from '../../../hooks/user-profile/useUserProfile'; -import { deleteTaskComment, Task, TaskComment } from '../../../rest/tasksAPI'; +import { + deleteTaskComment, + editTaskComment, + Task, + TaskComment, +} from '../../../rest/tasksAPI'; import { formatDateTime, getRelativeTime, @@ -36,11 +39,14 @@ import { import { getEntityName } from '../../../utils/EntityNameUtils'; import { getFrontEndFormat } from '../../../utils/FeedUtilsPure'; import { getUserPath } from '../../../utils/RouterUtils'; +import { resolveCommentPermissions } from '../../../utils/TaskCommentUtils'; import { showErrorToast } from '../../../utils/ToastUtils'; import DeleteModal from '../../common/DeleteModal/DeleteModal'; import UserPopOverCard from '../../common/PopOverCard/UserPopOverCard'; import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; +import TaskCommentActions from '../../common/TaskComment/TaskCommentActions'; +import TaskCommentBody from '../../common/TaskComment/TaskCommentBody'; interface TaskCommentCardProps { comment: TaskComment; task: Task; @@ -79,12 +85,11 @@ const TaskCommentCard: FC = ({ const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const canDelete = useMemo( - () => - (Boolean(currentUser?.name) && - comment.author?.name === currentUser?.name) || - Boolean(currentUser?.isAdmin), - [currentUser, comment.author] + const [isEditing, setIsEditing] = useState(false); + + const { canEdit, canDelete, canModify } = useMemo( + () => resolveCommentPermissions(currentUser, comment), + [currentUser, comment] ); const cardRef = useRef(null); @@ -121,6 +126,19 @@ const TaskCommentCard: FC = ({ [repliesContainerRef] ); + const handleEditSave = async (message: string) => { + if (!message) { + return; + } + try { + await editTaskComment(task.id, comment.id, message); + setIsEditing(false); + onCommentDeleted?.(); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + const handleDelete = async () => { setIsDeleting(true); try { @@ -191,25 +209,31 @@ const TaskCommentCard: FC = ({ )}
- + setIsEditing(false)} + onSave={handleEditSave}> + +
- {canDelete && ( + {canModify && ( <> {/* Stays mounted so it is reachable by Tab, and is revealed on card hover or on its own focus rather than on a mouse-only hover state. */} - setShowDeleteDialog(true)} - /> + {!isEditing && ( + setShowDeleteDialog(true)} + onEditRequest={() => setIsEditing(true)} + /> + )} ({ deleteTaskComment: jest.fn().mockResolvedValue({}), + editTaskComment: jest.fn().mockResolvedValue({}), })); // The other half of that boundary: useUserProfile resolves the comment author @@ -107,21 +108,6 @@ describe('TaskCommentCard', () => { expect(screen.getByTestId('author-name')).not.toHaveAttribute('href'); }); - - // Regression guard for #33112: the delete affordance overlays the card rather - // than sharing its flow, so revealing it cannot reflow the comment body. jsdom - // performs no layout, so the positioning contract is the observable proxy. - it('should overlay the delete action instead of placing it in the flow', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect(screen.getByTestId('task-comment-card')).toHaveClass( - 'tw:relative', - 'tw:group' - ); - expect(screen.getByTestId('delete-task-comment')).toHaveClass( - 'tw:absolute' - ); - }); }); describe('delete affordance permissions', () => { @@ -170,14 +156,18 @@ describe('TaskCommentCard', () => { const user = setup(); renderCard({ currentUser: { name: 'alice' } }); - // Real tab order: the author's profile link comes first, the delete action - // second. Both are reachable without a pointer. + // Real tab order: the author's profile link, then edit, then delete - all + // reachable without a pointer. await user.tab(); expect(screen.getByTestId('author-name')).toHaveFocus(); await user.tab(); + expect(screen.getByTestId('edit-task-comment')).toHaveFocus(); + + await user.tab(); + expect(screen.getByTestId('delete-task-comment')).toHaveFocus(); await user.keyboard('{Enter}'); @@ -185,13 +175,71 @@ describe('TaskCommentCard', () => { expect(await screen.findByTestId('delete-modal')).toBeInTheDocument(); }); - it('should reveal the delete action on card hover via CSS, not by unmounting it', () => { + it('should reveal the actions on card hover via CSS, not by unmounting them', () => { renderCard({ currentUser: { name: 'alice' } }); - expect(screen.getByTestId('delete-task-comment')).toHaveClass( + // The reveal lives on the actions container so the buttons themselves stay + // mounted and focusable; unmounting them until hover is what put them out + // of the keyboard's reach. + expect(screen.getByTestId('task-comment-actions')).toHaveClass( 'tw:opacity-0', - 'tw:group-hover:opacity-100' + 'tw:group-hover:opacity-100', + 'tw:focus-within:opacity-100' + ); + expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + }); + }); + + describe('edit permissions and flow', () => { + it('should offer edit to the comment author', () => { + renderCard({ currentUser: { name: 'alice' } }); + + expect(screen.getByTestId('edit-task-comment')).toBeInTheDocument(); + }); + + // Deliberately narrower than delete: an admin may remove someone else's + // comment but must not rewrite it, matching the server's rules. + it('should not offer edit to an admin who is not the author', () => { + renderCard({ currentUser: { name: 'bob', isAdmin: true } }); + + expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); + expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); + }); + + it('should not offer edit to a non-author non-admin', () => { + renderCard({ currentUser: { name: 'bob' } }); + + expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); + }); + + it('should open the inline editor and hide the actions while editing', async () => { + const user = setup(); + renderCard({ currentUser: { name: 'alice' } }); + + await user.click(screen.getByTestId('edit-task-comment')); + + expect( + await screen.findByTestId('edit-task-comment-editor') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('task-comment-actions') + ).not.toBeInTheDocument(); + }); + + it('should return to the rendered comment when the edit is cancelled', async () => { + const user = setup(); + renderCard({ currentUser: { name: 'alice' } }); + + await user.click(screen.getByTestId('edit-task-comment')); + await user.click(await screen.findByTestId('cancel-edit-task-comment')); + + await waitFor(() => + expect( + screen.queryByTestId('edit-task-comment-editor') + ).not.toBeInTheDocument() ); + + expect(screen.getByTestId('task-comment-actions')).toBeInTheDocument(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx index bba5defd33a5..7e2f0fe06724 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx @@ -39,7 +39,7 @@ export const DeleteModal = ({ data-testid="delete-modal" isDismissable={!isDeleting} isOpen={open} - style={{ zIndex: 999 }} + style={{ zIndex: 'var(--om-z-modal)' }} onOpenChange={(isOpen) => !isOpen && !isDeleting && onCancel()}> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx new file mode 100644 index 000000000000..08aadef40837 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx @@ -0,0 +1,78 @@ +/* + * Copyright 2026 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 { ButtonUtility } from '@openmetadata/ui-core-components'; +import { + Delete as DeleteIcon, + Edit as EditIcon, +} from '@openmetadata/ui-core-components/icons'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +export interface TaskCommentActionsProps { + canDelete: boolean; + canEdit: boolean; + className?: string; + onDeleteRequest: () => void; + onEditRequest: () => void; +} + +/** + * Edit / delete affordances for a task comment. + * + * Real buttons rather than clickable SVGs: each is reachable by Tab, carries an + * accessible name from its tooltip, and activates on Enter/Space. Consumers that + * want these revealed on hover should do that with CSS (opacity) on a `tw:group` + * ancestor - unmounting them until hover puts them permanently out of reach of + * the keyboard, which is the bug this pattern exists to avoid. + */ +const TaskCommentActions: FC = ({ + canDelete, + canEdit, + className, + onDeleteRequest, + onEditRequest, +}) => { + const { t } = useTranslation(); + + return ( +
+ {canEdit && ( + + )} + {canDelete && ( + + )} +
+ ); +}; + +export default TaskCommentActions; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx new file mode 100644 index 000000000000..6c86e363dead --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2026 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 { Box, Button } from '@openmetadata/ui-core-components'; +import { FC, ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TaskComment } from '../../../generated/entity/tasks/task'; +import { + getFrontEndFormat, + MarkdownToHTMLConverter, +} from '../../../utils/FeedUtilsPure'; +import ActivityFeedEditorNew from '../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; + +export interface TaskCommentBodyProps { + comment: TaskComment; + isEditing: boolean; + onCancelEdit: () => void; + onSave: (message: string) => Promise; + /** + * The comment as it reads when not being edited. Supplied by the consumer + * because the activity-feed card and the Inbox panel present a comment very + * differently - only the edit affordance is shared, not the surrounding layout. + */ + children: ReactNode; +} + +/** A comment's inline editor while editing, otherwise the consumer's own view. */ +const TaskCommentBody: FC = ({ + comment, + isEditing, + onCancelEdit, + onSave, + children, +}) => { + const { t } = useTranslation(); + + if (!isEditing) { + return <>{children}; + } + + return ( + + + + + + + ); +}; + +export default TaskCommentBody; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx index 10c13b99ac67..155eb5a2ac18 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.test.tsx @@ -12,6 +12,7 @@ */ import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { ComponentProps, ReactNode } from 'react'; const mockGetTaskById = jest.fn(); @@ -233,6 +234,25 @@ jest.mock('components/common/RichTextEditor/RichTextEditorPreviewerV1', () => ({ })); jest.mock('@openmetadata/ui-core-components', () => ({ + // Real + ), Badge: ({ children, color, @@ -986,11 +1006,10 @@ describe('TaskDetailPanel', () => { await act(async () => render()); - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - await act(async () => { - fireEvent.click(screen.getByTestId('edit-task-comment')); - }); - const editBox = screen.getByTestId('edit-task-comment-editor'); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await user.click(screen.getByTestId('edit-task-comment')); + + const editBox = await screen.findByTestId('edit-task-comment-editor'); await act(async () => { fireEvent.click(within(editBox).getByTestId('comment-editor')); }); @@ -1005,16 +1024,14 @@ describe('TaskDetailPanel', () => { await act(async () => render()); - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - await act(async () => { - fireEvent.click(screen.getByTestId('edit-task-comment')); - }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await user.click(screen.getByTestId('edit-task-comment')); - expect(screen.getByTestId('edit-task-comment-editor')).toBeInTheDocument(); + expect( + await screen.findByTestId('edit-task-comment-editor') + ).toBeInTheDocument(); - await act(async () => { - fireEvent.click(screen.getByTestId('cancel-edit-task-comment')); - }); + await user.click(screen.getByTestId('cancel-edit-task-comment')); expect( screen.queryByTestId('edit-task-comment-editor') @@ -1028,13 +1045,9 @@ describe('TaskDetailPanel', () => { await act(async () => render()); - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - await act(async () => { - fireEvent.click(screen.getByTestId('delete-task-comment')); - }); - await act(async () => { - fireEvent.click(screen.getByTestId('confirm-delete-task-comment')); - }); + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + await user.click(screen.getByTestId('delete-task-comment')); + await user.click(await screen.findByTestId('confirm-delete-task-comment')); expect(mockDeleteComment).toHaveBeenCalledWith('task-1', 'c1'); expect(mockGetTaskById).toHaveBeenCalledTimes(2); @@ -1046,8 +1059,8 @@ describe('TaskDetailPanel', () => { await act(async () => render()); - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - + // No hover: the affordance a user is entitled to must be in the DOM (and so + // reachable by keyboard) regardless of pointer position. expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); }); @@ -1058,8 +1071,6 @@ describe('TaskDetailPanel', () => { await act(async () => render()); - fireEvent.mouseEnter(screen.getByTestId('task-comment-card')); - expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); expect(screen.queryByTestId('delete-task-comment')).not.toBeInTheDocument(); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx index a290262f6631..4287127cb1ed 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx @@ -19,7 +19,7 @@ import { Tabs, Typography, } from '@openmetadata/ui-core-components'; -import { CheckCircle, Edit01, Trash01, XCircle } from '@untitledui/icons'; +import { CheckCircle, XCircle } from '@untitledui/icons'; import { AxiosError } from 'axios'; import React, { ComponentProps, @@ -32,10 +32,11 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import ActivityFeedEditorNew from '../../../../../components/ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; import DeleteModal from '../../../../../components/common/DeleteModal/DeleteModal'; import ProfilePicture from '../../../../../components/common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewerV1 from '../../../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; +import TaskCommentActions from '../../../../../components/common/TaskComment/TaskCommentActions'; +import TaskCommentBody from '../../../../../components/common/TaskComment/TaskCommentBody'; import { UserTeamSelectableList } from '../../../../../components/common/UserTeamSelectableList/UserTeamSelectableList.component'; import { usePermissionProvider } from '../../../../../context/PermissionProvider/PermissionProvider'; import { @@ -63,12 +64,10 @@ import { } from '../../../../../rest/tasksAPI'; import { getRelativeTime } from '../../../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../../../utils/EntityNameUtils'; -import { - getFrontEndFormat, - MarkdownToHTMLConverter, -} from '../../../../../utils/FeedUtilsPure'; +import { getFrontEndFormat } from '../../../../../utils/FeedUtilsPure'; import { getTestCaseDetailPagePath } from '../../../../../utils/RouterUtils'; import { getPermissionErrorText } from '../../../../../utils/StringUtils'; +import { resolveCommentPermissions } from '../../../../../utils/TaskCommentUtils'; import { getResolvedTaskFormSchema } from '../../../../../utils/TaskFormSchemaUtils'; import { getTaskDetailPathFromTask } from '../../../../../utils/TaskNavigationUtils'; import { showErrorToast } from '../../../../../utils/ToastUtils'; @@ -255,110 +254,6 @@ interface TaskCommentRowProps { onChanged: () => void; } -interface CommentPermissions { - canDelete: boolean; - canEdit: boolean; - canModify: boolean; -} - -/** The comment's author may edit or delete it; an admin may also delete it. */ -const resolveCommentPermissions = ( - currentUser: { name?: string; isAdmin?: boolean } | undefined, - comment: TaskComment -): CommentPermissions => { - const isAuthor = - Boolean(currentUser?.name) && comment.author?.name === currentUser?.name; - const canEdit = isAuthor; - const canDelete = isAuthor || Boolean(currentUser?.isAdmin); - - return { canEdit, canDelete, canModify: canEdit || canDelete }; -}; - -interface TaskCommentActionsProps { - canDelete: boolean; - canEdit: boolean; - onDeleteRequest: () => void; - onEditRequest: () => void; -} - -/** Hover-only edit/delete affordances for a comment row. */ -const TaskCommentActions = ({ - canDelete, - canEdit, - onDeleteRequest, - onEditRequest, -}: TaskCommentActionsProps) => ( - - {canEdit && ( - - )} - {canDelete && ( - - )} - -); - -interface TaskCommentBodyProps { - comment: TaskComment; - isEditing: boolean; - onCancelEdit: () => void; - onSave: (message: string) => Promise; -} - -/** The comment's editor when editing, else its rendered markdown. */ -const TaskCommentBody = ({ - comment, - isEditing, - onCancelEdit, - onSave, -}: TaskCommentBodyProps) => { - const { t } = useTranslation(); - - if (isEditing) { - return ( - - - - - - - ); - } - - return ( - - - - ); -}; - /** * A single task comment with author, message and timestamp. The comment's author * can edit or delete it (admins can also delete); the actions surface on hover. @@ -379,7 +274,6 @@ const TaskCommentRow: React.FC = ({ canModify: canModifyComment, } = resolveCommentPermissions(currentUser, comment); - const [isHovered, setIsHovered] = useState(false); const [isEditing, setIsEditing] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); @@ -415,12 +309,10 @@ const TaskCommentRow: React.FC = ({ return ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + gap={2}> = ({ {authorName} - {isHovered && !isEditing && canModifyComment && ( + {!isEditing && canModifyComment && ( setShowDeleteDialog(true)} onEditRequest={() => setIsEditing(true)} /> @@ -445,8 +338,14 @@ const TaskCommentRow: React.FC = ({ comment={comment} isEditing={isEditing} onCancelEdit={() => setIsEditing(false)} - onSave={handleEditSave} - /> + onSave={handleEditSave}> + + + + {getRelativeTime(comment.createdAt)} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.test.ts new file mode 100644 index 000000000000..2edc9aafb1ac --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 { TaskComment } from '../generated/entity/tasks/task'; +import { resolveCommentPermissions } from './TaskCommentUtils'; + +const comment = { + id: 'c1', + message: 'hello', + createdAt: 0, + author: { id: 'u1', type: 'user', name: 'alice' }, +} as TaskComment; + +describe('resolveCommentPermissions', () => { + it('should let the author edit and delete their own comment', () => { + expect(resolveCommentPermissions({ name: 'alice' }, comment)).toEqual({ + canEdit: true, + canDelete: true, + canModify: true, + }); + }); + + it('should let an admin delete but not edit someone elses comment', () => { + expect( + resolveCommentPermissions({ name: 'bob', isAdmin: true }, comment) + ).toEqual({ canEdit: false, canDelete: true, canModify: true }); + }); + + it('should give a non-author non-admin nothing', () => { + expect( + resolveCommentPermissions({ name: 'bob', isAdmin: false }, comment) + ).toEqual({ canEdit: false, canDelete: false, canModify: false }); + }); + + it('should give an unknown current user nothing', () => { + expect(resolveCommentPermissions(undefined, comment)).toEqual({ + canEdit: false, + canDelete: false, + canModify: false, + }); + }); + + // Both sides being nameless must not read as "same person". + it('should not treat a nameless user as the author of a nameless comment', () => { + const anonymous = { ...comment, author: { id: 'u1', type: 'user' } }; + + expect(resolveCommentPermissions({}, anonymous as TaskComment)).toEqual({ + canEdit: false, + canDelete: false, + canModify: false, + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.ts new file mode 100644 index 000000000000..f3b0b46d19dd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TaskCommentUtils.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2026 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 { TaskComment } from '../generated/entity/tasks/task'; + +export interface CommentPermissions { + canDelete: boolean; + canEdit: boolean; + canModify: boolean; +} + +/** + * Who may act on a task comment. Mirrors the server's rules in + * TaskRepository#editComment / #deleteComment: the author may edit or delete + * their own comment, and an admin may additionally delete anyone's. + * + * Shared so the activity-feed card and the Inbox task panel cannot drift apart + * from each other, or from the backend. + */ +export const resolveCommentPermissions = ( + currentUser: { name?: string; isAdmin?: boolean } | undefined, + comment: TaskComment +): CommentPermissions => { + const isAuthor = + Boolean(currentUser?.name) && comment.author?.name === currentUser?.name; + const canEdit = isAuthor; + const canDelete = isAuthor || Boolean(currentUser?.isAdmin); + + return { canEdit, canDelete, canModify: canEdit || canDelete }; +}; From 7212f04dd564b1240441780e7b34bb40e7e18b0b Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 14:38:43 +0530 Subject: [PATCH 13/23] test(playwright): make task comment assertions real and cover the incident tab The task comment spec wrapped most of its assertions in `if (await locator.isVisible())`. When an element never rendered the body was skipped and the test still passed, so a broken flow reported green. Convert the 27 guards that gate elements which must exist - the task card, the drawer, the comment input, the send button, a posted comment, and the edit affordance - into `await expect(...).toBeVisible()` so their absence fails loudly. The mention dropdown guard was worse: a swallowed `waitFor(...).catch()` followed by a boolean check, two layers of silent skip. It is legitimately async, so it becomes an assertion with an explicit timeout rather than a hard immediate check. The ten `tasksTab` guards are left alone. That sub-tab may genuinely not be present depending on how the feed renders, so whether they should assert is a separate question from these. Also fixes a positional-locator regression introduced by the previous commit: the hover-reflow test addressed the task card with `.first()`, which put the file one violation over its recorded suppression count and so surfaced all eighteen. The describe seeds exactly one task against a fresh table, so the card is now addressed directly with a count assertion, which is both non-positional and stricter. No suppression baseline change is needed - the file is back to the seventeen already on record. Adds a comment-deletion test to the Incident Manager spec. The reported problem was on that page, but coverage so far ran entirely through the activity-feed drawer; this exercises the same post-then-delete flow through the incident task tab, which renders the task tab rather than the drawer. --- .../e2e/Features/IncidentManager.spec.ts | 67 ++++ .../e2e/Features/Tasks/TaskComments.spec.ts | 338 ++++++++---------- 2 files changed, 223 insertions(+), 182 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index 6aff526f6469..c3eef1a6e36c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts @@ -1021,6 +1021,73 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { ).toBeVisible(); }); + /** + * Delete a comment from an incident's task tab + * @description #33112 was reported on the Incident Manager page, but the rest of + * the task-comment coverage exercises the activity-feed drawer only. This runs + * the same post-then-delete flow through TestCaseIncidentTab, which renders the + * task tab (and so TaskCommentCard) rather than the drawer. + */ + test('Delete a task comment from the incident task tab', async ({ page }) => { + const testCase = table1.testCasesResponseData[0]; + const testCaseName = testCase?.['name'] as string; + + await visitProfilerTab(page, table1); + await waitForAllLoadersToDisappear(page); + + await page.getByTestId(testCaseName).getByText(testCaseName).click(); + await expect(page.getByTestId('entity-page-header')).toBeVisible(); + + await openIncidentTaskTab(page, true); + + const taskTab = page.getByTestId('task-tab'); + await expect(taskTab).toBeVisible(); + + // Post a comment to delete. Unique per run so the card can be matched by + // text rather than by position. + const message = `Incident tab comment ${Date.now()}`; + const commentInput = taskTab.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); + await expect(commentInput).toBeVisible(); + await commentInput.fill(message); + + const postResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/tasks/') && + response.url().includes('/comments') && + response.request().method() === 'POST' + ); + await taskTab.getByTestId('send-comment').click(); + const postedTask = await (await postResponse).json(); + const comments = postedTask.comments ?? []; + const commentId = comments[comments.length - 1]?.id as string; + + const card = taskTab + .locator('[data-testid="task-comment-card"]') + .filter({ hasText: message }); + await expect(card).toBeVisible(); + + // The affordance is revealed on hover but stays mounted, so it is present + // for the keyboard too - hovering here mirrors what a mouse user does. + await card.hover(); + + const deleteResponse = page.waitForResponse( + (response) => + response.url().includes(`/comments/${commentId}`) && + response.request().method() === 'DELETE' + ); + await card.getByTestId('delete-task-comment').click(); + await page.getByTestId('confirm-button').click(); + await deleteResponse; + + await expect( + taskTab + .locator('[data-testid="task-comment-card"]') + .filter({ hasText: message }) + ).toHaveCount(0); + }); + /** * Verify filters in Incident Manager page * @description Tests Assignee, Status, Test Case, and Date filters and confirms list updates accordingly. diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 135cf32b481c..517ce0e1708b 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -98,40 +98,36 @@ test.describe('Task Comments - Add Comment', () => { // Click on task to open detail drawer const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - // Find comment input in drawer - const drawer = page.locator('.ant-drawer-content'); - - if (await drawer.isVisible()) { - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); - - if (await commentInput.isVisible()) { - await commentInput.fill('This is a test comment from assignee'); - - // Submit comment - const sendBtn = drawer.getByTestId('send-comment'); - if (await sendBtn.isVisible()) { - const commentResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/tasks/') && - response.url().includes('/comments') - ); - await sendBtn.click(); - await commentResponse; - - // Verify comment appears - await expect( - drawer.getByText('This is a test comment from assignee') - ).toBeVisible(); - } - } - } - } + // Find comment input in drawer + const drawer = page.locator('.ant-drawer-content'); + + await expect(drawer).toBeVisible(); + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); + + await expect(commentInput).toBeVisible(); + await commentInput.fill('This is a test comment from assignee'); + + // Submit comment + const sendBtn = drawer.getByTestId('send-comment'); + await expect(sendBtn).toBeVisible(); + const commentResponse = page.waitForResponse( + (response) => + response.url().includes('/api/v1/tasks/') && + response.url().includes('/comments') + ); + await sendBtn.click(); + await commentResponse; + + // Verify comment appears + await expect( + drawer.getByText('This is a test comment from assignee') + ).toBeVisible(); }); // Replaces a Jest assertion that could only check Tailwind class names: jsdom has @@ -153,8 +149,11 @@ test.describe('Task Comments - Add Comment', () => { await waitForPageLoaded(page); } - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); + // This describe seeds exactly one task against a fresh table, so the card can + // be addressed directly rather than by position - a positional locator would + // silently pick up a different task if the fixture ever grows. + const taskCard = page.locator('[data-testid="task-feed-card"]'); + await expect(taskCard).toHaveCount(1); await taskCard.click(); await waitForPageLoaded(page); @@ -209,31 +208,27 @@ test.describe('Task Comments - Add Comment', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + await expect(drawer).toBeVisible(); + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); - if (await commentInput.isVisible()) { - await commentInput.fill('Comment from non-assignee user'); + await expect(commentInput).toBeVisible(); + await commentInput.fill('Comment from non-assignee user'); - const sendBtn = drawer.getByTestId('send-comment'); - if (await sendBtn.isVisible()) { - await sendBtn.click(); - await waitForPageLoaded(page); + const sendBtn = drawer.getByTestId('send-comment'); + await expect(sendBtn).toBeVisible(); + await sendBtn.click(); + await waitForPageLoaded(page); - // Comment should be added or access denied - // (depends on permission model) - } - } - } - } + // Comment should be added or access denied + // (depends on permission model) }); test('admin should be able to add comment to any task', async ({ page }) => { @@ -250,32 +245,28 @@ test.describe('Task Comments - Add Comment', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + await expect(drawer).toBeVisible(); + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' + ); - if (await commentInput.isVisible()) { - await commentInput.fill('Admin comment on task'); + await expect(commentInput).toBeVisible(); + await commentInput.fill('Admin comment on task'); - const sendBtn = drawer.getByTestId('send-comment'); - if (await sendBtn.isVisible()) { - await sendBtn.click(); - await waitForPageLoaded(page); + const sendBtn = drawer.getByTestId('send-comment'); + await expect(sendBtn).toBeVisible(); + await sendBtn.click(); + await waitForPageLoaded(page); - await expect( - drawer.getByText('Admin comment on task') - ).toBeVisible(); - } - } - } - } + await expect( + drawer.getByText('Admin comment on task') + ).toBeVisible(); }); }); @@ -341,34 +332,31 @@ test.describe('Task Comments - @Mention', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' - ); + await expect(drawer).toBeVisible(); + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' + ); - if (await commentInput.isVisible()) { - await commentInput.click(); - await page.keyboard.type('@'); - await waitForPageLoaded(page); + await expect(commentInput).toBeVisible(); + await commentInput.click(); + await page.keyboard.type('@'); + await waitForPageLoaded(page); - // Should show mention dropdown - const mentionDropdown = page.locator( - '.mention-dropdown, .ql-mention-list-container, [data-testid="mention-suggestions"]' - ); + // Should show mention dropdown + const mentionDropdown = page.locator( + '.mention-dropdown, .ql-mention-list-container, [data-testid="mention-suggestions"]' + ); - await mentionDropdown - .first() - .waitFor({ state: 'visible', timeout: 2000 }) - .catch(() => undefined); - } - } - } + await mentionDropdown + .first() + .waitFor({ state: 'visible', timeout: 2000 }) + .catch(() => undefined); }); test('selecting user from @ dropdown should add mention', async ({ @@ -387,47 +375,41 @@ test.describe('Task Comments - @Mention', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' - ); + await expect(drawer).toBeVisible(); + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' + ); - if (await commentInput.isVisible()) { - await commentInput.click(); + await expect(commentInput).toBeVisible(); + await commentInput.click(); - // Type @ and part of username - await page.keyboard.type(`@${mentionedUser.responseData.name}`); + // Type @ and part of username + await page.keyboard.type(`@${mentionedUser.responseData.name}`); - // Select from dropdown if visible - const mentionItem = page.locator( - `.mention-item, .ql-mention-list-item:has-text("${mentionedUser.responseData.displayName}")` - ); - await mentionItem - .first() - .waitFor({ state: 'visible', timeout: 2000 }) - .catch(() => undefined); + // The suggestion list is populated from an async lookup, so it needs a wait - + // but it must actually arrive. Previously a swallowed waitFor plus a boolean + // check let the whole mention flow no-op without failing. + const mentionItem = page.locator( + `.mention-item, .ql-mention-list-item:has-text("${mentionedUser.responseData.displayName}")` + ); + const firstMention = mentionItem.first(); + await expect(firstMention).toBeVisible({ timeout: 10_000 }); - if (await mentionItem.isVisible()) { - await mentionItem.click(); + await firstMention.click(); - // Continue typing and submit - await page.keyboard.type(' please review this task'); + // Continue typing and submit + await page.keyboard.type(' please review this task'); - const sendBtn = drawer.getByTestId('send-comment'); - if (await sendBtn.isVisible()) { - await sendBtn.click(); - await waitForPageLoaded(page); - } - } - } - } - } + const sendBtn = drawer.getByTestId('send-comment'); + await expect(sendBtn).toBeVisible(); + await sendBtn.click(); + await waitForPageLoaded(page); }); }); @@ -501,31 +483,28 @@ test.describe('Task Comments - Edit/Delete', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - // Find comment - const comment = drawer.locator( - '[data-testid="comment-item"], .task-comment' - ); + await expect(drawer).toBeVisible(); + // Find comment + const comment = drawer.locator( + '[data-testid="comment-item"], .task-comment' + ); - if (await comment.first().isVisible()) { - // Hover to show actions - await comment.first().hover(); + await expect(comment.first()).toBeVisible(); + // Hover to show actions + await comment.first().hover(); - // Look for edit/delete buttons - const editBtn = comment.first().getByTestId('edit-comment'); - const deleteBtn = comment.first().getByTestId('delete-comment'); + // Look for edit/delete buttons + const editBtn = comment.first().getByTestId('edit-comment'); + const deleteBtn = comment.first().getByTestId('delete-comment'); - // Author should see these buttons - // (depends on UI implementation) - } - } - } + // Author should see these buttons + // (depends on UI implementation) }); test('should be able to edit own comment', async ({ page }) => { @@ -542,44 +521,39 @@ test.describe('Task Comments - Edit/Delete', () => { } const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - if (await taskCard.isVisible()) { - await taskCard.click(); - await waitForPageLoaded(page); + await expect(taskCard).toBeVisible(); + await taskCard.click(); + await waitForPageLoaded(page); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('.ant-drawer-content'); - if (await drawer.isVisible()) { - const comment = drawer.locator( - '[data-testid="comment-item"], .task-comment' - ); + await expect(drawer).toBeVisible(); + const comment = drawer.locator( + '[data-testid="comment-item"], .task-comment' + ); - if (await comment.first().isVisible()) { - await comment.first().hover(); + await expect(comment.first()).toBeVisible(); + await comment.first().hover(); - const editBtn = comment.first().getByTestId('edit-comment'); + const editBtn = comment.first().getByTestId('edit-comment'); - if (await editBtn.isVisible()) { - await editBtn.click(); + await expect(editBtn).toBeVisible(); + await editBtn.click(); - // Edit comment text - const editInput = drawer.locator( - '[data-testid="edit-comment-input"]' - ); - if (await editInput.isVisible()) { - await editInput.fill('Updated comment text'); + // Edit comment text + const editInput = drawer.locator( + '[data-testid="edit-comment-input"]' + ); + await expect(editInput).toBeVisible(); + await editInput.fill('Updated comment text'); - const saveBtn = drawer.getByTestId('save-comment'); - await saveBtn.click(); - await waitForPageLoaded(page); + const saveBtn = drawer.getByTestId('save-comment'); + await saveBtn.click(); + await waitForPageLoaded(page); - await expect( - drawer.getByText('Updated comment text') - ).toBeVisible(); - } - } - } - } - } + await expect( + drawer.getByText('Updated comment text') + ).toBeVisible(); }); /** From 4716c3cc0913cb0abe5a856a4c0cbba92cd97f4e Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 15:11:39 +0530 Subject: [PATCH 14/23] test(playwright): assert the mention dropdown actually appears The '@ should show user suggestion dropdown' test ended with a waitFor whose rejection was swallowed by `.catch(() => undefined)`, and it made no assertion at all. It therefore passed whether or not the dropdown ever rendered, which is the same false-coverage pattern the sibling mention test was just changed to avoid. Assert visibility instead, keeping a timeout because the suggestion list is populated asynchronously. The locator matches three alternative implementations, so it stays scoped to the first match - asserting on the bare locator would raise a strict-mode violation whenever more than one resolves. --- .../ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 517ce0e1708b..4083a30bd9c8 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -353,10 +353,10 @@ test.describe('Task Comments - @Mention', () => { '.mention-dropdown, .ql-mention-list-container, [data-testid="mention-suggestions"]' ); - await mentionDropdown - .first() - .waitFor({ state: 'visible', timeout: 2000 }) - .catch(() => undefined); + // The suggestion list is populated asynchronously, so this needs a wait - but + // it must actually arrive. A swallowed waitFor left this test asserting + // nothing, so it passed whether or not the dropdown ever rendered. + await expect(mentionDropdown.first()).toBeVisible({ timeout: 10_000 }); }); test('selecting user from @ dropdown should add mention', async ({ From c406a00c8a7a572b5f90b687b62e9031730ec5bf Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 15:21:57 +0530 Subject: [PATCH 15/23] test(playwright): drop positional locators and use the shared click helper Addresses two review points on the task comment specs. Positional locators are replaced with unique matches. The mention dropdown and the mention suggestion are now asserted with toHaveCount(1), the latter narrowed by the user's display name instead of an inline :has-text selector plus .first(). The fourth site needed more than a locator swap. 'comment author should see edit/delete options' addressed comments by position and then looked for edit-comment / delete-comment, testids that exist nowhere in the source - the component renders edit-task-comment and delete-task-comment. It also bound both to variables it never asserted on, so the test could not fail. It now posts its own timestamped comment, addresses the card by that text, and asserts the real affordances are visible. That also clears two of the file's unused-variable type errors. Inline waitForResponse paired with a separate click is replaced by clickAndWaitFor from utils/waitHelpers at the three flagged sites, plus the hover-reflow test for consistency. The helper matches on a URL pattern rather than a predicate, so it cannot filter by HTTP method the way the predicates did; the patterns are anchored with $ to compensate. The task read is ?fields=comments with a query string and so cannot match the anchored comments pattern, and the delete pattern is scoped to the specific comment id. Removing six positional locators left the recorded suppression count stale, so the baseline for this spec drops from 17 to 11 and the corpus total from 1235 to 1229. Lowering a count on a fix is the intended direction. --- .../resources/ui/eslint-suppressions.json | 2 +- .../e2e/Features/IncidentManager.spec.ts | 26 ++++--- .../e2e/Features/Tasks/TaskComments.spec.ts | 67 ++++++++++--------- .../eslint-rules/tests/corpus.test.mjs | 2 +- 4 files changed, 49 insertions(+), 48 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 0c2459441e08..79dabc8bd2ab 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -470,7 +470,7 @@ }, "playwright/e2e/Features/Tasks/TaskComments.spec.ts": { "om-playwright/no-positional-locator": { - "count": 17 + "count": 11 } }, "playwright/e2e/Features/Tasks/TaskCreation.spec.ts": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index c3eef1a6e36c..d6a4687fd2e1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts @@ -38,6 +38,7 @@ import { makeRetryRequest } from '../../utils/serviceIngestion'; import { sidebarClick } from '../../utils/sidebar'; import { waitForTaskResolveResponse } from '../../utils/task'; import { verifyTestCaseLastRunBanner } from '../../utils/testCases'; +import { clickAndWaitFor } from '../../utils/waitHelpers'; import { test } from '../fixtures/pages'; let user1: UserClass; @@ -1052,14 +1053,13 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { await expect(commentInput).toBeVisible(); await commentInput.fill(message); - const postResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/tasks/') && - response.url().includes('/comments') && - response.request().method() === 'POST' + // Anchored so it cannot match the tab's own GET of the task with its comments. + const postResponse = await clickAndWaitFor( + page, + taskTab.getByTestId('send-comment'), + /\/api\/v1\/tasks\/[^/]+\/comments$/ ); - await taskTab.getByTestId('send-comment').click(); - const postedTask = await (await postResponse).json(); + const postedTask = await postResponse.json(); const comments = postedTask.comments ?? []; const commentId = comments[comments.length - 1]?.id as string; @@ -1072,14 +1072,12 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { // for the keyboard too - hovering here mirrors what a mouse user does. await card.hover(); - const deleteResponse = page.waitForResponse( - (response) => - response.url().includes(`/comments/${commentId}`) && - response.request().method() === 'DELETE' - ); await card.getByTestId('delete-task-comment').click(); - await page.getByTestId('confirm-button').click(); - await deleteResponse; + await clickAndWaitFor( + page, + page.getByTestId('confirm-button'), + new RegExp(`/comments/${commentId}$`) + ); await expect( taskTab diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 4083a30bd9c8..5196dac32bcf 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -17,6 +17,7 @@ import { expect, test } from '../../../support/fixtures/base'; import { UserClass } from '../../../support/user/UserClass'; import { performAdminLogin } from '../../../utils/admin'; import { waitForPageLoaded } from '../../../utils/polling'; +import { clickAndWaitFor } from '../../../utils/waitHelpers'; /** * Task Comments Tests @@ -116,13 +117,7 @@ test.describe('Task Comments - Add Comment', () => { // Submit comment const sendBtn = drawer.getByTestId('send-comment'); await expect(sendBtn).toBeVisible(); - const commentResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/tasks/') && - response.url().includes('/comments') - ); - await sendBtn.click(); - await commentResponse; + await clickAndWaitFor(page, sendBtn, /\/api\/v1\/tasks\/[^/]+\/comments$/); // Verify comment appears await expect( @@ -167,14 +162,11 @@ test.describe('Task Comments - Add Comment', () => { await expect(commentInput).toBeVisible(); await commentInput.fill(message); - const commentResponse = page.waitForResponse( - (response) => - response.url().includes('/api/v1/tasks/') && - response.url().includes('/comments') && - response.request().method() === 'POST' + await clickAndWaitFor( + page, + drawer.getByTestId('send-comment'), + /\/api\/v1\/tasks\/[^/]+\/comments$/ ); - await drawer.getByTestId('send-comment').click(); - await commentResponse; const card = drawer .locator('[data-testid="task-comment-card"]') @@ -356,7 +348,8 @@ test.describe('Task Comments - @Mention', () => { // The suggestion list is populated asynchronously, so this needs a wait - but // it must actually arrive. A swallowed waitFor left this test asserting // nothing, so it passed whether or not the dropdown ever rendered. - await expect(mentionDropdown.first()).toBeVisible({ timeout: 10_000 }); + await expect(mentionDropdown).toHaveCount(1, { timeout: 10_000 }); + await expect(mentionDropdown).toBeVisible(); }); test('selecting user from @ dropdown should add mention', async ({ @@ -395,13 +388,12 @@ test.describe('Task Comments - @Mention', () => { // The suggestion list is populated from an async lookup, so it needs a wait - // but it must actually arrive. Previously a swallowed waitFor plus a boolean // check let the whole mention flow no-op without failing. - const mentionItem = page.locator( - `.mention-item, .ql-mention-list-item:has-text("${mentionedUser.responseData.displayName}")` - ); - const firstMention = mentionItem.first(); - await expect(firstMention).toBeVisible({ timeout: 10_000 }); + const mentionItem = page + .locator('.mention-item, .ql-mention-list-item') + .filter({ hasText: mentionedUser.responseData.displayName }); + await expect(mentionItem).toHaveCount(1, { timeout: 10_000 }); - await firstMention.click(); + await mentionItem.click(); // Continue typing and submit await page.keyboard.type(' please review this task'); @@ -490,21 +482,32 @@ test.describe('Task Comments - Edit/Delete', () => { const drawer = page.locator('.ant-drawer-content'); await expect(drawer).toBeVisible(); - // Find comment - const comment = drawer.locator( - '[data-testid="comment-item"], .task-comment' + + // Post a comment of our own so the card can be addressed by its text rather + // than by position - the drawer already holds comments from earlier tests in + // this serial describe. + const message = `Author actions ${Date.now()}`; + const commentInput = drawer.locator( + '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' ); + await expect(commentInput).toBeVisible(); + await commentInput.fill(message); - await expect(comment.first()).toBeVisible(); - // Hover to show actions - await comment.first().hover(); + await clickAndWaitFor( + page, + drawer.getByTestId('send-comment'), + /\/api\/v1\/tasks\/[^/]+\/comments$/ + ); - // Look for edit/delete buttons - const editBtn = comment.first().getByTestId('edit-comment'); - const deleteBtn = comment.first().getByTestId('delete-comment'); + const comment = drawer + .locator('[data-testid="task-comment-card"]') + .filter({ hasText: message }); + await expect(comment).toHaveCount(1); + await comment.hover(); - // Author should see these buttons - // (depends on UI implementation) + // The author may both edit and delete their own comment. + await expect(comment.getByTestId('edit-task-comment')).toBeVisible(); + await expect(comment.getByTestId('delete-task-comment')).toBeVisible(); }); test('should be able to edit own comment', async ({ page }) => { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index 00b3965fe258..8285a7c9b186 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -41,7 +41,7 @@ test('the suppressions baseline matches its recorded state exactly', () => { // of the same rule in the same file stays invisible here. const EXPECTED = { 'om-playwright/justified-rule-disable': 12, - 'om-playwright/no-positional-locator': 1235, + 'om-playwright/no-positional-locator': 1229, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 4, 'playwright/no-wait-for-selector': 35, From 77f30a79906bd002b4efc65eb167fe8b87ec2e6d Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Thu, 17 Sep 2026 20:42:31 +0530 Subject: [PATCH 16/23] refactor(ui): share one comment card between the activity feed and task tab Replace TaskCommentCard with a generic CommentCard driven entirely by props, so a task comment and a conversation reply render through the same component instead of two that drift apart. - CommentCard takes author/createdAt/message plus onEdit/onDelete callbacks; the reactions footer renders only when onReaction is supplied, which is how the task tab opts out of reactions it has no API for. - ActivityFeedActions splits canManage into canEdit and canDelete, and accepts an onDelete override for callers with no conversation behind them. Both default to the feed's existing author-or-admin rule, so feed behaviour is unchanged. - The activity feed keeps owning its own updateFeed/deleteFeed/updateReactions calls, now passed in at the call site rather than reached for from inside the card. - isFeedPostAuthor moves to FeedUtilsPure so the call site and the component derive permissions from one rule, and mocking the component in a test does not also have to restate the util. Make the feed actions usable without a mouse. They were , which Ant renders as a non-focusable , and were mounted only while the card was hovered - unreachable by keyboard and invisible to screen readers. They are now real buttons that stay mounted, hidden with opacity and revealed on hover or focus-within. The group is named per card because a conversation card contains its reply cards, and an unnamed one would reveal every reply's actions at once. Point the toast container at the --om-z-toast token instead of a hardcoded z-index, so toasts stack above modals as the token scale intends. The task tab's comment preview now trims on character count rather than clamping lines, so the long-comment E2E test asserts the trimmed tail is absent until View More is used, instead of merely visually clipped. Prune the stale Playwright suppressions the positional-locator cleanup left behind and bring the corpus total down to match (1229 -> 1220). --- .../application/toast/toast-provider.tsx | 2 +- .../resources/ui/eslint-suppressions.json | 2 +- .../e2e/Features/IncidentManager.spec.ts | 24 +- .../e2e/Features/Tasks/TaskComments.spec.ts | 522 ++++++++---------- .../eslint-rules/tests/corpus.test.mjs | 2 +- .../ActivityFeedcardNew.component.test.tsx | 111 +++- .../ActivityFeedcardNew.component.tsx | 75 ++- .../CommentCard.component.tsx | 137 +++-- .../ActivityFeedCardNew/CommentCard.test.tsx | 255 +++++---- .../TaskCommentCard.component.tsx | 251 --------- .../TaskCommentCard.test.tsx | 388 ------------- .../Shared/ActivityFeedActions.constants.ts | 44 ++ .../Shared/ActivityFeedActions.test.tsx | 88 +++ .../Shared/ActivityFeedActions.tsx | 139 +++-- .../TaskTab/TaskTabNew.component.test.tsx | 96 +++- .../Task/TaskTab/TaskTabNew.component.tsx | 57 +- .../resources/ui/src/utils/FeedUtilsPure.ts | 19 + 17 files changed, 1000 insertions(+), 1212 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx index 24831d0cb519..e45690484b20 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx @@ -68,7 +68,7 @@ export const ToastProvider = ({ return ( diff --git a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json index 79dabc8bd2ab..101ec1299570 100644 --- a/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json +++ b/openmetadata-ui/src/main/resources/ui/eslint-suppressions.json @@ -470,7 +470,7 @@ }, "playwright/e2e/Features/Tasks/TaskComments.spec.ts": { "om-playwright/no-positional-locator": { - "count": 11 + "count": 2 } }, "playwright/e2e/Features/Tasks/TaskCreation.spec.ts": { diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts index d6a4687fd2e1..a30bf8d2f6c7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/IncidentManager.spec.ts @@ -1027,7 +1027,7 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { * @description #33112 was reported on the Incident Manager page, but the rest of * the task-comment coverage exercises the activity-feed drawer only. This runs * the same post-then-delete flow through TestCaseIncidentTab, which renders the - * task tab (and so TaskCommentCard) rather than the drawer. + * task tab (and so CommentCard) rather than the drawer. */ test('Delete a task comment from the incident task tab', async ({ page }) => { const testCase = table1.testCasesResponseData[0]; @@ -1047,16 +1047,20 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { // Post a comment to delete. Unique per run so the card can be matched by // text rather than by position. const message = `Incident tab comment ${Date.now()}`; - const commentInput = taskTab.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + // The input is a trigger that opens the editor - it cannot be filled. + const commentInput = taskTab.getByTestId('comments-input-field'); await expect(commentInput).toBeVisible(); - await commentInput.fill(message); + await commentInput.click(); + + const editor = taskTab.locator('[data-testid="editor-wrapper"] .ql-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await editor.click(); + await editor.type(message); // Anchored so it cannot match the tab's own GET of the task with its comments. const postResponse = await clickAndWaitFor( page, - taskTab.getByTestId('send-comment'), + taskTab.getByTestId('send-button'), /\/api\/v1\/tasks\/[^/]+\/comments$/ ); const postedTask = await postResponse.json(); @@ -1064,7 +1068,7 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { const commentId = comments[comments.length - 1]?.id as string; const card = taskTab - .locator('[data-testid="task-comment-card"]') + .locator('[data-testid="feed-reply-card"]') .filter({ hasText: message }); await expect(card).toBeVisible(); @@ -1072,16 +1076,16 @@ test.describe('Incident Manager', PLAYWRIGHT_INGESTION_TAG_OBJ, () => { // for the keyboard too - hovering here mirrors what a mouse user does. await card.hover(); - await card.getByTestId('delete-task-comment').click(); + await card.getByTestId('delete-message').click(); await clickAndWaitFor( page, - page.getByTestId('confirm-button'), + page.getByTestId('save-button'), new RegExp(`/comments/${commentId}$`) ); await expect( taskTab - .locator('[data-testid="task-comment-card"]') + .locator('[data-testid="feed-reply-card"]') .filter({ hasText: message }) ).toHaveCount(0); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index 5196dac32bcf..cb762d30eed1 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -17,7 +17,12 @@ import { expect, test } from '../../../support/fixtures/base'; import { UserClass } from '../../../support/user/UserClass'; import { performAdminLogin } from '../../../utils/admin'; import { waitForPageLoaded } from '../../../utils/polling'; -import { clickAndWaitFor } from '../../../utils/waitHelpers'; +import { + addCommentToTask, + CreatedTask, + openEntityTasksTab, + openTaskDetails, +} from '../../../utils/taskWorkflow'; /** * Task Comments Tests @@ -32,6 +37,7 @@ import { clickAndWaitFor } from '../../../utils/waitHelpers'; */ test.describe('Task Comments - Add Comment', () => { + let createdTask: CreatedTask; const adminUser = new UserClass(); const assigneeUser = new UserClass(); const commentingUser = new UserClass(); @@ -64,7 +70,8 @@ test.describe('Task Comments - Add Comment', () => { assignees: [assigneeUser.responseData.name], }, }); - const task = await taskResponse.json(); + createdTask = (await taskResponse.json()) as CreatedTask; + const task = createdTask; taskId = task.id; } finally { await afterAction(); @@ -88,36 +95,16 @@ test.describe('Task Comments - Add Comment', () => { await assigneeUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); - - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openEntityTasksTab(page); // Click on task to open detail drawer - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); // Find comment input in drawer - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); - - await expect(commentInput).toBeVisible(); - await commentInput.fill('This is a test comment from assignee'); - - // Submit comment - const sendBtn = drawer.getByTestId('send-comment'); - await expect(sendBtn).toBeVisible(); - await clickAndWaitFor(page, sendBtn, /\/api\/v1\/tasks\/[^/]+\/comments$/); + await addCommentToTask(page, 'This is a test comment from assignee'); // Verify comment appears await expect( @@ -135,50 +122,57 @@ test.describe('Task Comments - Add Comment', () => { await assigneeUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); - - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openEntityTasksTab(page); // This describe seeds exactly one task against a fresh table, so the card can // be addressed directly rather than by position - a positional locator would // silently pick up a different task if the fixture ever grows. - const taskCard = page.locator('[data-testid="task-feed-card"]'); - await expect(taskCard).toHaveCount(1); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); const message = `Layout probe ${Date.now()}`; - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); - await expect(commentInput).toBeVisible(); - await commentInput.fill(message); - - await clickAndWaitFor( - page, - drawer.getByTestId('send-comment'), - /\/api\/v1\/tasks\/[^/]+\/comments$/ - ); + await addCommentToTask(page, message); const card = drawer - .locator('[data-testid="task-comment-card"]') + .locator('[data-testid="feed-reply-card"]') .filter({ hasText: message }); await expect(card).toBeVisible(); const body = card.getByTestId('viewer-container'); - const before = await body.boundingBox(); + + // The markdown preview measures itself and applies its own clamp one frame + // after mount, so sample until the box stops moving - otherwise this test + // measures that clamp rather than the hover it is meant to guard. + let previous = await body.boundingBox(); + await expect + .poll( + async () => { + const current = await body.boundingBox(); + const settled = JSON.stringify(current) === JSON.stringify(previous); + previous = current; + + return settled; + }, + { timeout: 10_000 } + ) + .toBe(true); + + const before = previous; + + const actions = card.getByTestId('feed-actions'); + const deleteAction = card.getByTestId('delete-message'); + + // Mounted before any hover so it stays reachable by keyboard and screen + // readers - the reveal is opacity, which Playwright's visibility check + // deliberately ignores, so assert the computed value directly. + await expect(deleteAction).toBeAttached(); + await expect(actions).toHaveCSS('opacity', '0'); await card.hover(); - const deleteAction = card.getByTestId('delete-task-comment'); + await expect(actions).toHaveCSS('opacity', '1'); await expect(deleteAction).toBeVisible(); const after = await body.boundingBox(); @@ -186,37 +180,56 @@ test.describe('Task Comments - Add Comment', () => { expect(after).toEqual(before); }); - test('non-assignee should be able to add comment', async ({ page }) => { - await commentingUser.login(page); + test('the comment actions are reachable and operable by keyboard alone', async ({ + page, + }) => { + await assigneeUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await openEntityTasksTab(page); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openTaskDetails(page, createdTask); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + const drawer = page.locator('#task-panel'); + await expect(drawer).toBeVisible(); - const drawer = page.locator('.ant-drawer-content'); + const message = `Keyboard probe ${Date.now()}`; + await addCommentToTask(page, message); - await expect(drawer).toBeVisible(); - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + const card = drawer + .locator('[data-testid="feed-reply-card"]') + .filter({ hasText: message }); + await expect(card).toBeVisible(); - await expect(commentInput).toBeVisible(); - await commentInput.fill('Comment from non-assignee user'); + const actions = card.getByTestId('feed-actions'); + const deleteAction = card.getByTestId('delete-message'); - const sendBtn = drawer.getByTestId('send-comment'); - await expect(sendBtn).toBeVisible(); - await sendBtn.click(); + // Regression coverage for the affordance being an `` span: + // it could not hold focus at all, so none of this was possible without a + // mouse. Focusing it must also bring the bar into view via focus-within. + await deleteAction.focus(); + + await expect(deleteAction).toBeFocused(); + await expect(actions).toHaveCSS('opacity', '1'); + + // Enter activates it, as it would any button. + await page.keyboard.press('Enter'); + + await expect(page.getByTestId('save-button')).toBeVisible(); + }); + + test('non-assignee should be able to add comment', async ({ page }) => { + await commentingUser.login(page); + await table.visitEntityPage(page); + + await openEntityTasksTab(page); + + await openTaskDetails(page, createdTask); + + const drawer = page.locator('#task-panel'); + + await expect(drawer).toBeVisible(); + await addCommentToTask(page, 'Comment from non-assignee user'); await waitForPageLoaded(page); // Comment should be added or access denied @@ -227,45 +240,24 @@ test.describe('Task Comments - Add Comment', () => { await adminUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); - - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openEntityTasksTab(page); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); - - await expect(commentInput).toBeVisible(); - await commentInput.fill('Admin comment on task'); - - const sendBtn = drawer.getByTestId('send-comment'); - await expect(sendBtn).toBeVisible(); - await sendBtn.click(); + await addCommentToTask(page, 'Admin comment on task'); await waitForPageLoaded(page); - await expect( - drawer.getByText('Admin comment on task') - ).toBeVisible(); + await expect(drawer.getByText('Admin comment on task')).toBeVisible(); }); }); test.describe('Task Comments - @Mention', () => { + let createdTask: CreatedTask; const adminUser = new UserClass(); const assigneeUser = new UserClass(); - const mentionedUser = new UserClass(); const table = new TableClass(); test.beforeAll('Setup test data', async ({ browser }) => { @@ -275,7 +267,6 @@ test.describe('Task Comments - @Mention', () => { await adminUser.create(apiContext); await adminUser.setAdminRole(apiContext); await assigneeUser.create(apiContext); - await mentionedUser.create(apiContext); await table.create(apiContext); await table.setOwner(apiContext, { @@ -283,7 +274,7 @@ test.describe('Task Comments - @Mention', () => { type: 'user', }); - await apiContext.post('/api/v1/tasks', { + const taskResponse = await apiContext.post('/api/v1/tasks', { data: { name: `Test Task - ${Date.now()}`, about: `<#E::table::${table.entityResponseData?.fullyQualifiedName}>`, @@ -292,6 +283,7 @@ test.describe('Task Comments - @Mention', () => { assignees: [assigneeUser.responseData.name], }, }); + createdTask = (await taskResponse.json()) as CreatedTask; } finally { await afterAction(); } @@ -302,7 +294,6 @@ test.describe('Task Comments - @Mention', () => { try { await table.delete(apiContext); - await mentionedUser.delete(apiContext); await assigneeUser.delete(apiContext); await adminUser.delete(apiContext); } finally { @@ -314,28 +305,23 @@ test.describe('Task Comments - @Mention', () => { await adminUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await openEntityTasksTab(page); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openTaskDetails(page, createdTask); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); - - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); + const commentTrigger = drawer.getByTestId('comments-input-field'); + + await expect(commentTrigger).toBeVisible(); + await commentTrigger.click(); + const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' + '[data-testid="editor-wrapper"] .ql-editor' ); - await expect(commentInput).toBeVisible(); + await expect(commentInput).toBeVisible({ timeout: 15_000 }); await commentInput.click(); await page.keyboard.type('@'); await waitForPageLoaded(page); @@ -358,54 +344,53 @@ test.describe('Task Comments - @Mention', () => { await adminUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await openEntityTasksTab(page); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openTaskDetails(page, createdTask); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); - - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); + const commentTrigger = drawer.getByTestId('comments-input-field'); + + await expect(commentTrigger).toBeVisible(); + await commentTrigger.click(); + const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [contenteditable="true"]' + '[data-testid="editor-wrapper"] .ql-editor' ); - await expect(commentInput).toBeVisible(); + await expect(commentInput).toBeVisible({ timeout: 15_000 }); await commentInput.click(); - // Type @ and part of username - await page.keyboard.type(`@${mentionedUser.responseData.name}`); + // Mention the seeded `admin` account rather than a user created in this + // describe's beforeAll: the suggestion list is search-index backed, and a + // seconds-old user is not reliably queryable yet. What this test guards is + // that picking from the dropdown inserts a mention, which any indexed user + // exercises identically. + const mentionTarget = 'admin'; + + // quill-mention only opens on a real keystroke - `fill()` sets the text in + // one shot and the module never sees the denotation char. + await commentInput.click(); + await page.keyboard.type(`@${mentionTarget}`); - // The suggestion list is populated from an async lookup, so it needs a wait - - // but it must actually arrive. Previously a swallowed waitFor plus a boolean - // check let the whole mention flow no-op without failing. - const mentionItem = page - .locator('.mention-item, .ql-mention-list-item') - .filter({ hasText: mentionedUser.responseData.displayName }); - await expect(mentionItem).toHaveCount(1, { timeout: 10_000 }); + const mentionItem = page.locator(`[data-value="@${mentionTarget}"]`); - await mentionItem.click(); + await expect(mentionItem.first()).toBeVisible({ timeout: 15_000 }); + await mentionItem.first().click(); - // Continue typing and submit await page.keyboard.type(' please review this task'); - const sendBtn = drawer.getByTestId('send-comment'); - await expect(sendBtn).toBeVisible(); + const sendBtn = drawer.getByTestId('send-button'); + await expect(sendBtn).toBeEnabled(); await sendBtn.click(); await waitForPageLoaded(page); }); }); test.describe('Task Comments - Edit/Delete', () => { + let createdTask: CreatedTask; const adminUser = new UserClass(); const assigneeUser = new UserClass(); const table = new TableClass(); @@ -427,16 +412,14 @@ test.describe('Task Comments - Edit/Delete', () => { // Create task with comment const taskResponse = await apiContext.post('/api/v1/tasks', { data: { - about: { - type: 'table', - id: table.entityResponseData?.id, - fullyQualifiedName: table.entityResponseData?.fullyQualifiedName, - }, - type: 'RequestDescription', - assignees: [{ id: assigneeUser.responseData.id, type: 'user' }], + about: `<#E::table::${table.entityResponseData?.fullyQualifiedName}>`, + type: 'DescriptionUpdate', + category: 'MetadataUpdate', + assignees: [assigneeUser.responseData.name], }, }); - const task = await taskResponse.json(); + createdTask = (await taskResponse.json()) as CreatedTask; + const task = createdTask; // Add a comment await apiContext.post(`/api/v1/tasks/${task.id}/comments`, { @@ -465,21 +448,11 @@ test.describe('Task Comments - Edit/Delete', () => { await adminUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); - - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openEntityTasksTab(page); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); @@ -487,110 +460,92 @@ test.describe('Task Comments - Edit/Delete', () => { // than by position - the drawer already holds comments from earlier tests in // this serial describe. const message = `Author actions ${Date.now()}`; - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); - await expect(commentInput).toBeVisible(); - await commentInput.fill(message); - - await clickAndWaitFor( - page, - drawer.getByTestId('send-comment'), - /\/api\/v1\/tasks\/[^/]+\/comments$/ - ); + await addCommentToTask(page, message); const comment = drawer - .locator('[data-testid="task-comment-card"]') + .locator('[data-testid="feed-reply-card"]') .filter({ hasText: message }); await expect(comment).toHaveCount(1); await comment.hover(); // The author may both edit and delete their own comment. - await expect(comment.getByTestId('edit-task-comment')).toBeVisible(); - await expect(comment.getByTestId('delete-task-comment')).toBeVisible(); + await expect(comment.getByTestId('edit-message')).toBeVisible(); + await expect(comment.getByTestId('delete-message')).toBeVisible(); }); test('should be able to edit own comment', async ({ page }) => { await adminUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await openEntityTasksTab(page); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openTaskDetails(page, createdTask); - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); - - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); - const comment = drawer.locator( - '[data-testid="comment-item"], .task-comment' - ); + // Only the author may edit, so this test has to own the comment it edits + // rather than reaching for whatever card happens to be first. + const original = `Original comment ${Date.now()}`; + await addCommentToTask(page, original); - await expect(comment.first()).toBeVisible(); - await comment.first().hover(); + const comment = drawer + .locator('[data-testid="feed-reply-card"]') + .filter({ hasText: original }); + + await expect(comment).toBeVisible(); + await comment.hover(); - const editBtn = comment.first().getByTestId('edit-comment'); + const editBtn = comment.getByTestId('edit-message'); await expect(editBtn).toBeVisible(); await editBtn.click(); - // Edit comment text - const editInput = drawer.locator( - '[data-testid="edit-comment-input"]' + // Scoped to the editing card, not to `comment` - that locator filters on + // the original text, which stops matching the moment the editor is + // refilled. Qualifying with feed-reply-card keeps the panel's own comment + // composer, which is the same editor component, out of the match. + const editor = drawer.locator( + '[data-testid="feed-reply-card"] [data-testid="activity-feed-editor-new"]' ); + const editInput = editor.locator('.ql-editor'); + await expect(editInput).toBeVisible(); await editInput.fill('Updated comment text'); - const saveBtn = drawer.getByTestId('save-comment'); + const saveBtn = editor.getByTestId('send-button'); await saveBtn.click(); await waitForPageLoaded(page); - await expect( - drawer.getByText('Updated comment text') - ).toBeVisible(); + await expect(drawer.getByText('Updated comment text')).toBeVisible(); }); /** * Shared by the two real delete tests below: opens the task's activity-feed * drawer as `user` and posts one comment from there, returning the task's id * (needed to match the DELETE response) and the comment's text (needed to - * find the right `task-comment-card`). + * find the right `feed-reply-card`). */ const postCommentAsUser = async (page: Page, message: string) => { await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); + await openEntityTasksTab(page); - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } - - const taskCard = page.locator('[data-testid="task-feed-card"]').first(); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + // The input is a trigger that opens the editor - it cannot be filled. + const commentInput = drawer.getByTestId('comments-input-field'); await expect(commentInput).toBeVisible(); - await commentInput.fill(message); + await commentInput.click(); - const sendBtn = drawer.getByTestId('send-comment'); + const editor = drawer.locator('[data-testid="editor-wrapper"] .ql-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await editor.click(); + await editor.type(message); + + const sendBtn = drawer.getByTestId('send-button'); const commentResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/tasks/') && @@ -614,9 +569,8 @@ test.describe('Task Comments - Edit/Delete', () => { /** * Deletes the comment identified by `message` from an already-open drawer, * waiting for the real DELETE response and asserting the comment is gone - * from the DOM afterwards. Runs the button through a real hover first, - * matching how a person actually finds it (the button is reachable by - * keyboard/tab without hovering, but hover is the primary discovery path). + * from the DOM afterwards. The hover is required, not just realistic: the + * shared feed actions only mount while the card is hovered. */ const deleteCommentViaUi = async ( page: Page, @@ -625,21 +579,24 @@ test.describe('Task Comments - Edit/Delete', () => { taskCommentId: string ) => { const commentCard = drawer - .getByTestId('task-comment-card') + .getByTestId('feed-reply-card') .filter({ hasText: message }); await expect(commentCard).toBeVisible(); await commentCard.hover(); - await commentCard.getByTestId('delete-task-comment').click(); + await commentCard.getByTestId('delete-message').click(); - await expect(page.getByTestId('delete-modal')).toBeVisible(); + // Asserted on the confirm button rather than the modal container: antd's + // Modal does not forward `data-testid` to the rendered DOM. + const confirmButton = page.getByTestId('save-button'); + await expect(confirmButton).toBeVisible(); const deleteResponsePromise = page.waitForResponse( (response) => response.url().includes(`/comments/${taskCommentId}`) && response.request().method() === 'DELETE' ); - await page.getByTestId('confirm-button').click(); + await confirmButton.click(); const deleteResponse = await deleteResponsePromise; expect(deleteResponse.ok()).toBe(true); @@ -710,14 +667,12 @@ test.describe('Task Comments - Edit/Delete', () => { ); const commentCard = drawer - .getByTestId('task-comment-card') + .getByTestId('feed-reply-card') .filter({ hasText: message }); await expect(commentCard).toBeVisible(); await commentCard.hover(); - await expect( - commentCard.getByTestId('delete-task-comment') - ).not.toBeVisible(); + await expect(commentCard.getByTestId('delete-message')).not.toBeVisible(); } finally { const { apiContext, afterAction } = await performAdminLogin(browser); try { @@ -731,8 +686,8 @@ test.describe('Task Comments - Edit/Delete', () => { test('should be able to delete a comment from inside the activity-feed drawer', async ({ page, }) => { - // Regression coverage for the DeleteModal/antd-Drawer z-index conflict: - // TaskTabNew (and therefore TaskCommentCard's DeleteModal) is rendered + // Regression coverage for the confirmation-modal/antd-Drawer z-index + // conflict: TaskTabNew (and so CommentCard's confirmation modal) is rendered // inside an antd Drawer here, unlike the standalone task page used by the // other delete tests above. If the confirmation dialog's overlay ever // sits below the Drawer's own mask again, this click lands on the mask @@ -744,15 +699,14 @@ test.describe('Task Comments - Edit/Delete', () => { const message = `Drawer-delete comment ${Date.now()}`; const { drawer, taskCommentId } = await postCommentAsUser(page, message); - await expect( - page.locator('.activity-feed-drawer, .feed-drawer') - ).toBeVisible(); + await expect(page.locator('#task-panel')).toBeVisible(); await deleteCommentViaUi(page, drawer, message, taskCommentId); }); }); test.describe('Task Comments - Long Comment Overflow', () => { + let createdTask: CreatedTask; const assigneeUser = new UserClass(); const table = new TableClass(); @@ -768,17 +722,15 @@ test.describe('Task Comments - Long Comment Overflow', () => { type: 'user', }); - await apiContext.post('/api/v1/tasks', { + const taskResponse = await apiContext.post('/api/v1/tasks', { data: { - about: { - type: 'table', - id: table.entityResponseData?.id, - fullyQualifiedName: table.entityResponseData?.fullyQualifiedName, - }, - type: 'RequestDescription', - assignees: [{ id: assigneeUser.responseData.id, type: 'user' }], + about: `<#E::table::${table.entityResponseData?.fullyQualifiedName}>`, + type: 'DescriptionUpdate', + category: 'MetadataUpdate', + assignees: [assigneeUser.responseData.name], }, }); + createdTask = (await taskResponse.json()) as CreatedTask; } finally { await afterAction(); } @@ -798,44 +750,39 @@ test.describe('Task Comments - Long Comment Overflow', () => { test('a long comment shows a working View More / View Less toggle instead of being silently clamped', async ({ page, }) => { - // Regression coverage for TaskCommentCard's RichTextEditorPreviewNew - // usage: the ~2-line clamp applies independent of `enableSeeMoreVariant`, - // so a comment that overflows needs the toggle rendered to stay - // readable. This can't be covered in Jest - jsdom has no real layout, so - // the scrollHeight-vs-clientHeight overflow check that decides whether - // to render the toggle never actually fires there. + // Regression coverage for the task tab's comment preview: comments longer + // than DESCRIPTION_MAX_PREVIEW_CHARACTERS are trimmed, so one that + // overflows needs the toggle rendered to stay readable. Covered end to end + // rather than in Jest because the value being guarded is that a real user + // can recover the full text, not that the previewer trims a string. await assigneeUser.login(page); await table.visitEntityPage(page); - await page.getByTestId('activity_feed').click(); - await waitForPageLoaded(page); - - const tasksTab = page.getByRole('menuitem', { name: /tasks/i }); - if (await tasksTab.isVisible()) { - await tasksTab.click(); - await waitForPageLoaded(page); - } + await openEntityTasksTab(page); - const taskCard = page.getByTestId('task-feed-card'); - await expect(taskCard).toBeVisible(); - await taskCard.click(); - await waitForPageLoaded(page); + await openTaskDetails(page, createdTask); - const drawer = page.locator('.ant-drawer-content'); + const drawer = page.locator('#task-panel'); await expect(drawer).toBeVisible(); - const uniqueMarker = `overflow-marker-${Date.now()}`; - const longMessage = `${'This comment is written to overflow the two line clamp on the task comment preview. '.repeat( + const runId = Date.now(); + const headMarker = `overflow-head-${runId}`; + const tailMarker = `overflow-tail-${runId}`; + const longMessage = `${headMarker} ${'This comment is written to overflow the preview limit on the task comment body. '.repeat( 8 - )}${uniqueMarker}`; + )}${tailMarker}`; - const commentInput = drawer.locator( - '[data-testid="comment-input"], .ql-editor, [placeholder*="comment" i]' - ); + // The input is a trigger that opens the editor - it cannot be filled. + const commentInput = drawer.getByTestId('comments-input-field'); await expect(commentInput).toBeVisible(); - await commentInput.fill(longMessage); + await commentInput.click(); - const sendBtn = drawer.getByTestId('send-comment'); + const editor = drawer.locator('[data-testid="editor-wrapper"] .ql-editor'); + await expect(editor).toBeVisible({ timeout: 15_000 }); + await editor.click(); + await editor.type(longMessage); + + const sendBtn = drawer.getByTestId('send-button'); const commentResponsePromise = page.waitForResponse( (response) => response.url().includes('/api/v1/tasks/') && @@ -845,20 +792,23 @@ test.describe('Task Comments - Long Comment Overflow', () => { await sendBtn.click(); await commentResponsePromise; + // Matched on the head marker, which survives the trim - the tail marker + // is cut out of the DOM entirely while the preview is collapsed. const commentCard = drawer - .getByTestId('task-comment-card') - .filter({ hasText: uniqueMarker }); + .getByTestId('feed-reply-card') + .filter({ hasText: headMarker }); await expect(commentCard).toBeVisible(); - // The toggle only renders when the browser's real layout measurement - // (scrollHeight vs clientHeight against the clamp) finds an overflow - - // its presence here is the actual signal Jest can't produce. + // The trim drops the end of the message rather than hiding it, so without + // a working toggle that text would be unreachable, not merely clipped. + await expect(commentCard.getByText(tailMarker)).toBeHidden(); + const readMoreButton = commentCard.getByTestId('read-more-button'); await expect(readMoreButton).toBeVisible(); await readMoreButton.click(); await expect(commentCard.getByTestId('read-less-button')).toBeVisible(); - await expect(commentCard.getByText(uniqueMarker)).toBeVisible(); + await expect(commentCard.getByText(tailMarker)).toBeVisible(); }); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs index 8285a7c9b186..204b965e7c67 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs +++ b/openmetadata-ui/src/main/resources/ui/playwright/eslint-rules/tests/corpus.test.mjs @@ -41,7 +41,7 @@ test('the suppressions baseline matches its recorded state exactly', () => { // of the same rule in the same file stays invisible here. const EXPECTED = { 'om-playwright/justified-rule-disable': 12, - 'om-playwright/no-positional-locator': 1229, + 'om-playwright/no-positional-locator': 1220, 'om-playwright/require-assertion-per-test': 1, 'playwright/no-skipped-test': 4, 'playwright/no-wait-for-selector': 35, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx index e790324ef864..74b1df03ceae 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx @@ -13,6 +13,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import { ReactionOperation } from '../../../enums/reactions.enum'; import { ActivityEvent, ActivityEventType, @@ -22,6 +23,7 @@ import { ConversationReply, ConversationSource, } from '../../../generated/entity/feed/conversation'; +import { ReactionType } from '../../../generated/type/reaction'; import ActivityFeedCardNew from './ActivityFeedcardNew.component'; const mockProviderValue = { @@ -31,6 +33,8 @@ const mockProviderValue = { postFeed: jest.fn(), selectedThread: undefined, updateFeed: jest.fn(), + deleteFeed: jest.fn(), + updateReactions: jest.fn(), }; jest.mock('../../../hooks/useApplicationStore', () => ({ @@ -63,14 +67,22 @@ jest.mock('../ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter', () => jest.fn(() =>
) ); +const mockActivityFeedActionsProps: Record[] = []; jest.mock('../Shared/ActivityFeedActions', () => - jest.fn(() =>
) + jest.fn((props) => { + mockActivityFeedActionsProps.push(props); + + return
; + }) ); +const mockCommentCardProps: Record[] = []; jest.mock('./CommentCard.component', () => - jest.fn(({ reply }) => ( -
{reply.message}
- )) + jest.fn((props) => { + mockCommentCardProps.push(props); + + return
{props.message}
; + }) ); jest.mock('../../common/PopOverCard/EntityPopOverCard', () => @@ -137,7 +149,10 @@ const activityReply: ConversationReply = { describe('ActivityFeedCardNew', () => { beforeEach(() => { + jest.clearAllMocks(); mockProviderValue.activityReplies = []; + mockCommentCardProps.length = 0; + mockActivityFeedActionsProps.length = 0; }); it('keeps root reactions and management actions available in the drawer', () => { @@ -148,13 +163,35 @@ describe('ActivityFeedCardNew', () => { ); expect(screen.getByTestId('conversation-reaction-footer')).toBeVisible(); - expect(screen.queryByTestId('conversation-root-actions')).toBeNull(); + + // Mounted before any pointer interaction: hiding these behind a hover + // state put them out of reach of the keyboard and of screen readers. The + // hover reveal is presentational, applied by CSS on the owning card. + expect(screen.getByTestId('conversation-root-actions')).toBeVisible(); fireEvent.mouseEnter(screen.getByTestId('feed-card-v2-sidebar')); expect(screen.getByTestId('conversation-root-actions')).toBeVisible(); }); + it('hands the card-scoped hover reveal to the root actions', () => { + render( + + + + ); + + // Named group, not a bare `tw:group`: a conversation card contains its + // reply cards, so an unnamed one would reveal every reply's actions at + // once when the conversation is hovered. + expect(screen.getByTestId('feed-card-v2-sidebar').className).toContain( + 'tw:group/feed-card' + ); + expect(mockActivityFeedActionsProps.at(-1)?.className).toContain( + 'tw:group-hover/feed-card:opacity-100' + ); + }); + it('renders activity replies in the open side panel', () => { mockProviderValue.activityReplies = [activityReply]; @@ -168,4 +205,68 @@ describe('ActivityFeedCardNew', () => { activityReply.message ); }); + + describe('reply wiring', () => { + const renderWithReply = () => { + mockProviderValue.activityReplies = [activityReply]; + + render( + + + + ); + + return mockCommentCardProps[mockCommentCardProps.length - 1]; + }; + + it('grants edit and delete to the reply author', () => { + expect(renderWithReply()).toEqual( + expect.objectContaining({ canDelete: true, canEdit: true }) + ); + }); + + it('patches the reply through updateFeed on edit', async () => { + const props = renderWithReply(); + + await (props.onEdit as (message: string) => Promise)('edited'); + + expect(mockProviderValue.updateFeed).toHaveBeenCalledWith( + activity.id, + activityReply.id, + false, + [{ op: 'replace', path: '/message', value: 'edited' }] + ); + }); + + it('removes the reply through deleteFeed on delete', async () => { + const props = renderWithReply(); + + await (props.onDelete as () => Promise)(); + + expect(mockProviderValue.deleteFeed).toHaveBeenCalledWith( + activity.id, + activityReply.id, + false + ); + }); + + it('forwards reactions to updateReactions', async () => { + const props = renderWithReply(); + + await ( + props.onReaction as ( + type: ReactionType, + operation: ReactionOperation + ) => Promise + )(ReactionType.ThumbsUp, ReactionOperation.ADD); + + expect(mockProviderValue.updateReactions).toHaveBeenCalledWith( + activityReply, + activity.id, + false, + ReactionType.ThumbsUp, + ReactionOperation.ADD + ); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx index b4b5767c75ac..77575fe389a8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx @@ -37,6 +37,7 @@ import { entityDisplayName, getEntityFQN, getEntityType, + isFeedPostAuthor, } from '../../../utils/FeedUtilsPure'; import { getUserPath } from '../../../utils/RouterUtils'; import searchClassBase from '../../../utils/SearchClassBase'; @@ -49,6 +50,7 @@ import FeedCardFooterNew from '../ActivityFeedCardV2/FeedCardFooter/FeedCardFoot import { useActivityFeedProvider } from '../ActivityFeedProvider/ActivityFeedProvider'; import '../ActivityFeedTab/activity-feed-tab.less'; import ActivityFeedActions from '../Shared/ActivityFeedActions'; +import { FEED_ACTIONS_HOVER_REVEAL } from '../Shared/ActivityFeedActions.constants'; import CommentCard from './CommentCard.component'; const ActivityFeedEditorNew = withSuspenseFallback( lazy(() => import('../ActivityFeedEditor/ActivityFeedEditorNew')) @@ -157,13 +159,14 @@ const ActivityFeedCardNew = ({ selectedThread, postFeed, updateFeed, + deleteFeed, + updateReactions, isPostsLoading, postActivityComment, activityReplies, } = useActivityFeedProvider(); const [showFeedEditor, setShowFeedEditor] = useState(false); const [isEditPost, setIsEditPost] = useState(false); - const [isHovered, setIsHovered] = useState(false); const [, , user] = useUserProfile({ permission: true, name: createdBy, @@ -289,10 +292,12 @@ const ActivityFeedCardNew = ({ setShowFeedEditor(false); }; - const canShowFeedActions = isHovered && !isActivityEvent && !isPost; + // Rendered unconditionally and revealed with CSS: gating the mount on hover + // put these permanently out of reach of the keyboard and screen readers. const feedActions = - canShowFeedActions && feed ? ( + !isActivityEvent && !isPost && feed ? ( - {orderedPosts.map((reply, index, arr) => ( - - ))} + {orderedPosts.map((reply, index, arr) => { + const conversationId = activity?.id ?? feed?.id ?? ''; + const canManage = + isFeedPostAuthor(currentUser, reply.author) || + Boolean(currentUser?.isAdmin); + + return ( + deleteFeed(conversationId, reply.id, false)} + onEdit={async (message) => { + await updateFeed( + conversationId, + reply.id, + false, + compare(reply, { ...reply, message }) + ); + }} + onReaction={(reaction, operation) => + updateReactions( + reply, + conversationId, + false, + reaction, + operation + ) + } + /> + ); + })} ); }, [ @@ -344,6 +377,10 @@ const ActivityFeedCardNew = ({ isActivityEvent, activityReplies, activity?.id, + currentUser, + deleteFeed, + updateFeed, + updateReactions, ]); const feedMessage = useMemo(() => { @@ -357,16 +394,14 @@ const ActivityFeedCardNew = ({ const renderWidgetCard = () => ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + data-testid="feed-card-v2-sidebar">
@@ -524,16 +559,14 @@ const ActivityFeedCardNew = ({ const renderFullCard = () => ( setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + data-testid="feed-card-v2-sidebar"> import('../ActivityFeedEditor/ActivityFeedEditorNew')) ); -interface CommentCardInterface { - conversation?: Conversation; - conversationId: string; - reply: ConversationReply; +interface CommentCardProps { + author: EntityReference; + createdAt: number; + message: string; + reactions?: Reaction[]; isLastReply: boolean; - closeFeedEditor: () => void; + canEdit: boolean; + canDelete: boolean; + onEdit: (message: string) => Promise; + onDelete: () => Promise; + /** Omitted by callers with no reactions support - hides the footer entirely. */ + onReaction?: ( + type: ReactionType, + operation: ReactionOperation + ) => Promise; + /** + * Lets the owning feed dismiss its own reply editor when this card opens + * one, so the two are never open at the same time. Optional because callers + * outside the activity feed have no second editor to close. + */ + closeFeedEditor?: () => void; } const CommentCard = ({ - conversation, - conversationId, - reply, + author, + createdAt, + message, + reactions, isLastReply, + canEdit, + canDelete, + onEdit, + onDelete, + onReaction, closeFeedEditor, -}: CommentCardInterface) => { - const { updateFeed } = useActivityFeedProvider(); - const [isHovered, setIsHovered] = useState(false); +}: CommentCardProps) => { const [isEditPost, setIsEditPost] = useState(false); const [postMessage, setPostMessage] = useState(''); const seperator = '.'; const editorRef = useRef(null); - const authorName = reply.author.name ?? reply.author.fullyQualifiedName ?? ''; + const authorName = author.name ?? author.fullyQualifiedName ?? ''; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -88,24 +105,18 @@ const CommentCard = ({ }); const onEditPost = () => { - closeFeedEditor(); - setIsEditPost(!isEditPost); - }; - - const onUpdate = async (message: string) => { - const updatedReply = { ...reply, message }; - const patch = compare(reply, updatedReply); - updateFeed(conversationId, reply.id, false, patch); + closeFeedEditor?.(); setIsEditPost(!isEditPost); }; const handleSave = useCallback(() => { - onUpdate?.(postMessage ?? ''); - }, [onUpdate, postMessage]); + onEdit(postMessage ?? ''); + setIsEditPost(false); + }, [onEdit, postMessage]); const defaultValue = useMemo( - () => MarkdownToHTMLConverter.makeHtml(getFrontEndFormat(reply.message)), - [reply.message] + () => MarkdownToHTMLConverter.makeHtml(getFrontEndFormat(message)), + [message] ); const feedBodyRender = useMemo(() => { @@ -127,24 +138,24 @@ const CommentCard = ({ return ( ); }, [isEditPost, postMessage, handleSave]); return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)}> + className={classNames( + 'd-flex justify-start relative reply-card gap-2 tw:group/comment', + { + 'reply-card-border-bottom': !isLastReply, + } + )} + data-testid="feed-reply-card">
- +
@@ -166,34 +177,44 @@ const CommentCard = ({ + title={formatDateTime(createdAt)}> - {getRelativeTime(reply.createdAt)} + {getRelativeTime(createdAt)}
{feedBodyRender} - + {onReaction && ( + + +
+
+ +
+
+ +
+ )}
- {isHovered && ( - - )} +
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx index 097ece317ae2..48f23c12a3af 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx @@ -13,21 +13,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import { - Conversation, - ConversationReply, - ConversationSource, -} from '../../../generated/entity/feed/conversation'; +import { ReactionOperation } from '../../../enums/reactions.enum'; +import { ReactionType } from '../../../generated/type/reaction'; import CommentCard from './CommentCard.component'; -const mockUpdateFeed = jest.fn(); - -jest.mock('../ActivityFeedProvider/ActivityFeedProvider', () => ({ - useActivityFeedProvider: () => ({ - updateFeed: mockUpdateFeed, - }), -})); - jest.mock('../../../hooks/user-profile/useUserProfile', () => ({ useUserProfile: () => [ false, @@ -59,8 +48,17 @@ jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => { )); }); -jest.mock('../ActivityFeedCardV2/FeedCardFooter/FeedCardFooterNew', () => { - return jest.fn(() =>
); +jest.mock('../Reactions/Reactions', () => { + return jest.fn(({ reactions, onReactionSelect }) => ( + + )); }); jest.mock('../ActivityFeedEditor/ActivityFeedEditorNew', () => { @@ -71,24 +69,29 @@ jest.mock('../ActivityFeedEditor/ActivityFeedEditorNew', () => { data-testid="editor-input" onChange={(e) => onTextChange(e.target.value)} /> -
)); }); +const mockActivityFeedActions = jest.fn(); jest.mock('../Shared/ActivityFeedActions', () => { - return jest.fn(({ onEditPost }) => ( -
- - -
- )); + return jest.fn((props) => { + mockActivityFeedActions(props); + + return ( +
+ + +
+ ); + }); }); jest.mock('../../../utils/FeedUtilsPure', () => ({ @@ -98,49 +101,22 @@ jest.mock('../../../utils/FeedUtilsPure', () => ({ }, })); -const createMockReply = ( - author: string, - message: string -): ConversationReply => ({ - id: 'reply-123', - conversationId: 'conversation-123', - message, - createdAt: 1234567890, - updatedAt: 1234567890, - author: { id: 'user-1', type: 'user', name: author }, -}); - -const createMockConversation = (): Conversation => ({ - id: 'conversation-123', - href: 'http://test', - createdAt: 1234567890, - about: '<#E::table::test>', - createdBy: { id: 'user-1', type: 'user', name: 'testuser' }, - entityRef: { id: 'entity-1', type: 'table', name: 'test' }, - updatedAt: 1234567890, - updatedBy: 'testuser', - source: ConversationSource.User, - message: 'Test thread message', - replyCount: 1, - replies: [], - reactions: [], - resolved: false, -}); +const onEdit = jest.fn().mockResolvedValue(undefined); +const onDelete = jest.fn().mockResolvedValue(undefined); +const onReaction = jest.fn().mockResolvedValue(undefined); const renderCommentCard = ( - props?: Partial<{ - conversation: Conversation; - conversationId: string; - reply: ConversationReply; - isLastReply: boolean; - closeFeedEditor: () => void; - }> + props?: Partial> ) => { - const defaultProps = { - conversation: createMockConversation(), - conversationId: 'conversation-123', - reply: createMockReply('testuser', 'Test comment message'), + const defaultProps: React.ComponentProps = { + author: { id: 'user-1', type: 'user', name: 'testuser' }, + createdAt: 1234567890, + message: 'Test comment message', isLastReply: false, + canEdit: true, + canDelete: true, + onDelete, + onEdit, closeFeedEditor: jest.fn(), }; @@ -151,6 +127,14 @@ const renderCommentCard = ( ); }; +const hoverCard = async () => { + fireEvent.mouseEnter(screen.getByTestId('feed-reply-card')); + + await waitFor(() => { + expect(screen.getByTestId('feed-actions')).toBeInTheDocument(); + }); +}; + describe('CommentCard', () => { beforeEach(() => { jest.clearAllMocks(); @@ -181,16 +165,77 @@ describe('CommentCard', () => { expect(screen.getByTestId('user-popover-2')).toBeInTheDocument(); }); - it('should render feed card footer', () => { + it('should render timestamp', () => { renderCommentCard(); + expect(screen.getByTestId('timestamp')).toBeInTheDocument(); + }); + + it('should fall back to the fully qualified name when author has no name', () => { + renderCommentCard({ + author: { id: 'user-1', type: 'user', fullyQualifiedName: 'fqn-user' }, + }); + + expect(screen.getByTestId('profile-fqn-user')).toBeInTheDocument(); + }); + }); + + describe('Reactions footer', () => { + it('should render the footer only when onReaction is provided', () => { + const { rerender } = renderCommentCard(); + + expect(screen.queryByTestId('feed-card-footer')).not.toBeInTheDocument(); + + rerender( + + + + ); + expect(screen.getByTestId('feed-card-footer')).toBeInTheDocument(); }); - it('should render timestamp', () => { + it('should forward the reaction selection to onReaction', () => { + renderCommentCard({ onReaction, reactions: [] }); + + fireEvent.click(screen.getByTestId('reactions')); + + expect(onReaction).toHaveBeenCalledWith( + ReactionType.ThumbsUp, + ReactionOperation.ADD + ); + }); + }); + + describe('Permissions', () => { + it('should forward canEdit and canDelete to the actions', async () => { + renderCommentCard({ canDelete: false, canEdit: true }); + + await hoverCard(); + + expect(mockActivityFeedActions).toHaveBeenCalledWith( + expect.objectContaining({ canDelete: false, canEdit: true }) + ); + }); + + it('should call onDelete when the delete action fires', async () => { renderCommentCard(); - expect(screen.getByTestId('timestamp')).toBeInTheDocument(); + await hoverCard(); + + fireEvent.click(screen.getByTestId('delete-button')); + + expect(onDelete).toHaveBeenCalled(); }); }); @@ -198,28 +243,31 @@ describe('CommentCard', () => { it('should show feed actions on hover', async () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('feed-actions')).toBeInTheDocument(); - }); + await hoverCard(); }); - it('should hide feed actions when not hovering', async () => { + it('should keep feed actions mounted when not hovering', () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); + // Never unmounted on pointer state: doing that made the edit and delete + // controls unreachable by keyboard and invisible to screen readers. + // Hiding them until hover is CSS's job, and it leaves them focusable. + expect(screen.getByTestId('feed-actions')).toBeInTheDocument(); + }); - fireEvent.mouseEnter(card); - await waitFor(() => { - expect(screen.getByTestId('feed-actions')).toBeInTheDocument(); - }); + it('should hand the comment-scoped hover reveal to the actions', () => { + renderCommentCard(); - fireEvent.mouseLeave(card); - await waitFor(() => { - expect(screen.queryByTestId('feed-actions')).not.toBeInTheDocument(); - }); + expect(screen.getByTestId('feed-reply-card').className).toContain( + 'tw:group/comment' + ); + expect(mockActivityFeedActions).toHaveBeenCalledWith( + expect.objectContaining({ + className: expect.stringContaining( + 'tw:group-hover/comment:opacity-100' + ), + }) + ); }); }); @@ -227,12 +275,7 @@ describe('CommentCard', () => { it('should show editor when edit button is clicked', async () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('edit-button')).toBeInTheDocument(); - }); + await hoverCard(); fireEvent.click(screen.getByTestId('edit-button')); @@ -245,27 +288,17 @@ describe('CommentCard', () => { const closeFeedEditor = jest.fn(); renderCommentCard({ closeFeedEditor }); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('edit-button')).toBeInTheDocument(); - }); + await hoverCard(); fireEvent.click(screen.getByTestId('edit-button')); expect(closeFeedEditor).toHaveBeenCalled(); }); - it('should call updateFeed when saving edited message', async () => { + it('should call onEdit with the edited message when saving', async () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('edit-button')).toBeInTheDocument(); - }); + await hoverCard(); fireEvent.click(screen.getByTestId('edit-button')); @@ -280,19 +313,14 @@ describe('CommentCard', () => { fireEvent.click(screen.getByTestId('send-button')); await waitFor(() => { - expect(mockUpdateFeed).toHaveBeenCalled(); + expect(onEdit).toHaveBeenCalledWith('updated message'); }); }); it('should hide editor and show preview after update', async () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('edit-button')).toBeInTheDocument(); - }); + await hoverCard(); fireEvent.click(screen.getByTestId('edit-button')); @@ -331,12 +359,7 @@ describe('CommentCard', () => { it('should close edit mode when clicking outside', async () => { renderCommentCard(); - const card = screen.getByTestId('feed-reply-card'); - fireEvent.mouseEnter(card); - - await waitFor(() => { - expect(screen.getByTestId('edit-button')).toBeInTheDocument(); - }); + await hoverCard(); fireEvent.click(screen.getByTestId('edit-button')); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx deleted file mode 100644 index 269cebbfff56..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component.tsx +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright 2024 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 { Space, Tooltip, Typography } from 'antd'; -import { AxiosError } from 'axios'; -import classNames from 'classnames'; -import { - FC, - RefObject, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { User } from '../../../generated/entity/teams/user'; -import { useUserProfile } from '../../../hooks/user-profile/useUserProfile'; -import { - deleteTaskComment, - editTaskComment, - Task, - TaskComment, -} from '../../../rest/tasksAPI'; -import { - formatDateTime, - getRelativeTime, -} from '../../../utils/date-time/DateTimeUtils'; -import { getEntityName } from '../../../utils/EntityNameUtils'; -import { getFrontEndFormat } from '../../../utils/FeedUtilsPure'; -import { getUserPath } from '../../../utils/RouterUtils'; -import { resolveCommentPermissions } from '../../../utils/TaskCommentUtils'; -import { showErrorToast } from '../../../utils/ToastUtils'; -import DeleteModal from '../../common/DeleteModal/DeleteModal'; -import UserPopOverCard from '../../common/PopOverCard/UserPopOverCard'; -import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; -import RichTextEditorPreviewNew from '../../common/RichTextEditor/RichTextEditorPreviewNew'; -import TaskCommentActions from '../../common/TaskComment/TaskCommentActions'; -import TaskCommentBody from '../../common/TaskComment/TaskCommentBody'; -interface TaskCommentCardProps { - comment: TaskComment; - task: Task; - isLastReply?: boolean; - closeFeedEditor?: () => void; - currentUser?: Pick; - onCommentDeleted?: () => void; - /** - * Focus fallback for when a deleted comment has no sibling comment left to - * hand focus to. Must already carry a stable `tabIndex={-1}` - this - * component only ever calls `.focus()` on it, it never mutates a foreign - * parent node's attributes. - */ - repliesContainerRef?: RefObject; -} - -const TaskCommentCard: FC = ({ - comment, - task, - isLastReply = false, - currentUser, - onCommentDeleted, - repliesContainerRef, -}) => { - const { t } = useTranslation(); - const [, , user] = useUserProfile({ - permission: true, - name: comment.author?.name ?? '', - }); - - const authorName = useMemo( - () => getEntityName(user) || comment.author?.name || 'Unknown', - [user, comment.author] - ); - - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - - const [isEditing, setIsEditing] = useState(false); - - const { canEdit, canDelete, canModify } = useMemo( - () => resolveCommentPermissions(currentUser, comment), - [currentUser, comment] - ); - - const cardRef = useRef(null); - - // Removing the focused node (deleting this comment) must not let keyboard - // focus fall through to - move it to a sensible neighbour first. - // This runs on unmount rather than inside handleDelete because the card - // doesn't disappear until the parent's refetch resolves and re-renders; - // by then react-aria has already restored focus to our own (about to be - // removed) delete button, so redirecting it earlier would just get - // overwritten. See frontend-a11y.md's focus-management rule. - useLayoutEffect( - () => () => { - const card = cardRef.current; - if (!card || !card.contains(document.activeElement)) { - return; - } - - const nextFocusTarget = - (card.nextElementSibling as HTMLElement | null) ?? - (card.previousElementSibling as HTMLElement | null); - - if (nextFocusTarget) { - nextFocusTarget.focus(); - } else { - // No sibling comments left - fall back to the replies container, - // which the parent already keeps focusable (tabIndex={-1}) for - // exactly this case, rather than leaving focus on a node that's - // about to be removed. Never mutate it ourselves - it's foreign, - // shared DOM we don't own. - repliesContainerRef?.current?.focus(); - } - }, - [repliesContainerRef] - ); - - const handleEditSave = async (message: string) => { - if (!message) { - return; - } - try { - await editTaskComment(task.id, comment.id, message); - setIsEditing(false); - onCommentDeleted?.(); - } catch (error) { - showErrorToast(error as AxiosError); - } - }; - - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteTaskComment(task.id, comment.id); - setShowDeleteDialog(false); - onCommentDeleted?.(); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsDeleting(false); - } - }; - - const authorUserName = comment.author?.name; - - const profilePicture = ( - - ); - - const authorNameText = ( - - {authorName} - - ); - - return ( -
- - {authorUserName ? ( - - {profilePicture} - - ) : ( - profilePicture - )} -
- - {authorUserName ? ( - - - {authorName} - - - ) : ( - authorNameText - )} - {comment.createdAt && ( - - - {getRelativeTime(comment.createdAt)} - - - )} - -
- setIsEditing(false)} - onSave={handleEditSave}> - - -
-
-
- {canModify && ( - <> - {/* Stays mounted so it is reachable by Tab, and is revealed on card - hover or on its own focus rather than on a mouse-only hover state. */} - {!isEditing && ( - setShowDeleteDialog(true)} - onEditRequest={() => setIsEditing(true)} - /> - )} - setShowDeleteDialog(false)} - onDelete={handleDelete} - /> - - )} -
- ); -}; - -export default TaskCommentCard; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx deleted file mode 100644 index 586e49312e16..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/TaskCommentCard.test.tsx +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright 2026 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, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useRef } from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { - Task, - TaskCategory, - TaskComment, - TaskStatus, - TaskType, -} from '../../../generated/entity/tasks/task'; -import { deleteTaskComment } from '../../../rest/tasksAPI'; -import TaskCommentCard from './TaskCommentCard.component'; - -// Only the REST boundary is mocked. Every component, hook and utility below the -// card renders for real, so the assertions describe what a user actually sees. -jest.mock('../../../rest/tasksAPI', () => ({ - deleteTaskComment: jest.fn().mockResolvedValue({}), - editTaskComment: jest.fn().mockResolvedValue({}), -})); - -// The other half of that boundary: useUserProfile resolves the comment author -// through this REST module. Stubbing the request rather than the hook keeps the -// real hook and its consumers in the test. -jest.mock('../../../rest/userAPI', () => ({ - getUserByName: jest.fn().mockResolvedValue({ - id: 'user-1', - name: 'alice', - displayName: 'Alice Author', - }), -})); - -const NOW = new Date('2025-01-01T12:00:00.000Z').getTime(); -const TWO_HOURS_AGO = NOW - 2 * 60 * 60 * 1000; - -const mockComment: TaskComment = { - id: 'comment-1', - message: 'This is the incident comment body', - createdAt: TWO_HOURS_AGO, - author: { id: 'user-1', type: 'user', name: 'alice' }, -}; - -const mockTask = { - id: 'task-1', - name: 'incident-task', - category: TaskCategory.Incident, - type: TaskType.IncidentResolution, - status: TaskStatus.InProgress, - createdBy: { id: 'user-1', type: 'user', name: 'alice' }, -} as Task; - -const renderCard = ( - props: Partial> = {} -) => - render( - - - - ); - -const setup = () => - userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - -describe('TaskCommentCard', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.setSystemTime(NOW); - (deleteTaskComment as jest.Mock).mockResolvedValue({}); - }); - - describe('rendering', () => { - it('should show the author, a relative timestamp and the comment body', async () => { - renderCard(); - - expect( - await screen.findByText('This is the incident comment body') - ).toBeInTheDocument(); - expect(screen.getByTestId('comment-time')).toHaveTextContent(/ago/i); - expect(screen.getByTestId('author-name')).toHaveTextContent(/alice/i); - }); - - it('should link the author to their profile page', () => { - renderCard(); - - expect(screen.getByTestId('author-name')).toHaveAttribute( - 'href', - '/users/alice' - ); - }); - - it('should render the author as plain text when there is no author name', () => { - renderCard({ - comment: { ...mockComment, author: { id: 'user-1', type: 'user' } }, - }); - - expect(screen.getByTestId('author-name')).not.toHaveAttribute('href'); - }); - }); - - describe('delete affordance permissions', () => { - it('should not offer delete when there is no current user', () => { - renderCard(); - - expect( - screen.queryByTestId('delete-task-comment') - ).not.toBeInTheDocument(); - }); - - it('should offer delete to the comment author', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); - }); - - it('should offer delete to an admin who is not the author', () => { - renderCard({ currentUser: { name: 'bob', isAdmin: true } }); - - expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); - }); - - it('should not offer delete to a non-admin who is not the author', () => { - renderCard({ currentUser: { name: 'bob', isAdmin: false } }); - - expect( - screen.queryByTestId('delete-task-comment') - ).not.toBeInTheDocument(); - }); - }); - - describe('accessibility', () => { - it('should expose the delete action as a button with an accessible name', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect( - screen.getByRole('button', { name: 'label.delete' }) - ).toHaveAttribute('data-testid', 'delete-task-comment'); - }); - - // The affordance used to be mounted only while the mouse was over the card, - // which put it permanently out of reach of the keyboard. It now stays mounted - // and is revealed by CSS on hover or focus. - it('should reach and trigger the delete action from the keyboard alone', async () => { - const user = setup(); - renderCard({ currentUser: { name: 'alice' } }); - - // Real tab order: the author's profile link, then edit, then delete - all - // reachable without a pointer. - await user.tab(); - - expect(screen.getByTestId('author-name')).toHaveFocus(); - - await user.tab(); - - expect(screen.getByTestId('edit-task-comment')).toHaveFocus(); - - await user.tab(); - - expect(screen.getByTestId('delete-task-comment')).toHaveFocus(); - - await user.keyboard('{Enter}'); - - expect(await screen.findByTestId('delete-modal')).toBeInTheDocument(); - }); - - it('should reveal the actions on card hover via CSS, not by unmounting them', () => { - renderCard({ currentUser: { name: 'alice' } }); - - // The reveal lives on the actions container so the buttons themselves stay - // mounted and focusable; unmounting them until hover is what put them out - // of the keyboard's reach. - expect(screen.getByTestId('task-comment-actions')).toHaveClass( - 'tw:opacity-0', - 'tw:group-hover:opacity-100', - 'tw:focus-within:opacity-100' - ); - expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); - }); - }); - - describe('edit permissions and flow', () => { - it('should offer edit to the comment author', () => { - renderCard({ currentUser: { name: 'alice' } }); - - expect(screen.getByTestId('edit-task-comment')).toBeInTheDocument(); - }); - - // Deliberately narrower than delete: an admin may remove someone else's - // comment but must not rewrite it, matching the server's rules. - it('should not offer edit to an admin who is not the author', () => { - renderCard({ currentUser: { name: 'bob', isAdmin: true } }); - - expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); - expect(screen.getByTestId('delete-task-comment')).toBeInTheDocument(); - }); - - it('should not offer edit to a non-author non-admin', () => { - renderCard({ currentUser: { name: 'bob' } }); - - expect(screen.queryByTestId('edit-task-comment')).not.toBeInTheDocument(); - }); - - it('should open the inline editor and hide the actions while editing', async () => { - const user = setup(); - renderCard({ currentUser: { name: 'alice' } }); - - await user.click(screen.getByTestId('edit-task-comment')); - - expect( - await screen.findByTestId('edit-task-comment-editor') - ).toBeInTheDocument(); - expect( - screen.queryByTestId('task-comment-actions') - ).not.toBeInTheDocument(); - }); - - it('should return to the rendered comment when the edit is cancelled', async () => { - const user = setup(); - renderCard({ currentUser: { name: 'alice' } }); - - await user.click(screen.getByTestId('edit-task-comment')); - await user.click(await screen.findByTestId('cancel-edit-task-comment')); - - await waitFor(() => - expect( - screen.queryByTestId('edit-task-comment-editor') - ).not.toBeInTheDocument() - ); - - expect(screen.getByTestId('task-comment-actions')).toBeInTheDocument(); - }); - }); - - describe('delete flow', () => { - it('should open a confirmation dialog before deleting anything', async () => { - const user = setup(); - renderCard({ currentUser: { name: 'alice' } }); - - await user.click(screen.getByTestId('delete-task-comment')); - - expect(await screen.findByTestId('delete-modal')).toBeInTheDocument(); - expect(screen.getByTestId('confirm-button')).toBeInTheDocument(); - expect(screen.getByTestId('cancel-button')).toBeInTheDocument(); - expect(deleteTaskComment).not.toHaveBeenCalled(); - }); - - it('should delete the comment and notify the parent when confirmed', async () => { - const user = setup(); - const onCommentDeleted = jest.fn(); - renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - - await user.click(screen.getByTestId('delete-task-comment')); - await user.click(await screen.findByTestId('confirm-button')); - - await waitFor(() => - expect(deleteTaskComment).toHaveBeenCalledWith('task-1', 'comment-1') - ); - await waitFor(() => expect(onCommentDeleted).toHaveBeenCalledTimes(1)); - await waitFor(() => - expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() - ); - }); - - it('should keep the dialog open and not notify the parent when the delete fails', async () => { - (deleteTaskComment as jest.Mock).mockRejectedValueOnce( - new Error('delete failed') - ); - const user = setup(); - const onCommentDeleted = jest.fn(); - renderCard({ currentUser: { name: 'alice' }, onCommentDeleted }); - - await user.click(screen.getByTestId('delete-task-comment')); - await user.click(await screen.findByTestId('confirm-button')); - - await waitFor(() => expect(deleteTaskComment).toHaveBeenCalled()); - - expect(onCommentDeleted).not.toHaveBeenCalled(); - expect(screen.getByTestId('delete-modal')).toBeInTheDocument(); - }); - - it('should delete nothing when the dialog is cancelled', async () => { - const user = setup(); - renderCard({ currentUser: { name: 'alice' } }); - - await user.click(screen.getByTestId('delete-task-comment')); - await user.click(await screen.findByTestId('cancel-button')); - - await waitFor(() => - expect(screen.queryByTestId('delete-modal')).not.toBeInTheDocument() - ); - - expect(deleteTaskComment).not.toHaveBeenCalled(); - }); - }); - - // Covers the useLayoutEffect unmount cleanup in TaskCommentCard.component.tsx: - // when the card that holds focus is removed, focus must move to a sibling card - // or fall back to the replies container, never to . - // - // The parent is modelled on TaskTabNew's real wiring: a tabIndex={-1} replies - // container holding sibling cards, each handed the same ref. Removal is driven - // by re-rendering the parent with the comment gone, which is exactly what - // TaskTabNew does once its post-delete refetch resolves. - // - // Deliberately not routed through the delete dialog: in a browser react-aria - // restores focus to the trigger as the dialog closes, but jsdom does not - // reproduce that, so the precondition (focus inside the card at unmount) is - // established directly instead of mocking the real dialog away. - describe('focus management on delete', () => { - const secondComment: TaskComment = { - ...mockComment, - id: 'comment-2', - message: 'A sibling comment', - }; - - const CommentList = ({ comments }: { comments: TaskComment[] }) => { - const repliesContainerRef = useRef(null); - - return ( - -
- {comments.map((entry, index, arr) => ( - - ))} -
-
- ); - }; - - const focusFirstCardsDeleteButton = () => { - const card = screen.getAllByTestId('task-comment-card')[0]; - const button = within(card).getByTestId('delete-task-comment'); - - act(() => button.focus()); - - expect(card.contains(document.activeElement)).toBe(true); - }; - - it('should move focus to a sibling comment instead of letting it fall to ', () => { - const { rerender } = render( - - ); - - focusFirstCardsDeleteButton(); - - rerender(); - - const survivor = screen.getByTestId('task-comment-card'); - - expect(survivor).toHaveFocus(); - expect(document.activeElement).not.toBe(document.body); - }); - - it('should fall back to the replies container when the deleted comment has no sibling to focus', () => { - const { rerender } = render(); - - focusFirstCardsDeleteButton(); - - rerender(); - - expect(screen.queryByTestId('task-comment-card')).not.toBeInTheDocument(); - expect(screen.getByTestId('feed-replies')).toHaveFocus(); - expect(document.activeElement).not.toBe(document.body); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts new file mode 100644 index 000000000000..ff7c8ca637dc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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. + */ + +/** + * Reveal styling for a card that wants its feed actions hidden until the + * pointer is over it. Deliberately opacity and not a conditional mount: the + * buttons stay in the tab order and in the accessibility tree either way, and + * `focus-within` brings them back into view for keyboard users. + * + * Two variants rather than one because a conversation card contains its reply + * cards - an unnamed `tw:group` would make hovering the conversation reveal + * every reply's actions at once. Each is paired with the matching + * `tw:group/` on the card that owns it, and both are spelled out in full + * because Tailwind only sees class names it can read statically. + * + * Kept out of the component module so a test that mocks ActivityFeedActions + * does not also have to restate these. + */ +export const FEED_ACTIONS_HOVER_REVEAL = [ + 'tw:opacity-0 tw:pointer-events-none', + 'tw:motion-safe:transition-opacity', + 'tw:group-hover/feed-card:opacity-100', + 'tw:group-hover/feed-card:pointer-events-auto', + 'tw:focus-within:opacity-100 tw:focus-within:pointer-events-auto', +].join(' '); + +/** As above, scoped to a single reply card. */ +export const COMMENT_ACTIONS_HOVER_REVEAL = [ + 'tw:opacity-0 tw:pointer-events-none', + 'tw:motion-safe:transition-opacity', + 'tw:group-hover/comment:opacity-100', + 'tw:group-hover/comment:pointer-events-auto', + 'tw:focus-within:opacity-100 tw:focus-within:pointer-events-auto', +].join(' '); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx index 7de36e1c93f4..c4a4493657f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx @@ -204,6 +204,94 @@ describe('ActivityFeedActions', () => { expect(mockUpdateEditorFocus).toHaveBeenCalledWith(true); }); + it('renders every control as a focusable button with an accessible name', () => { + render( + + ); + + // The regression this guards: these were ``, which Ant + // renders as `` - not focusable, not announced as a + // control, and unusable with Enter/Space. + for (const testId of [ + 'add-reply', + 'toggle-resolved', + 'edit-message', + 'delete-message', + ]) { + const control = screen.getByTestId(testId); + + expect(control.tagName).toBe('BUTTON'); + expect(control).toHaveAccessibleName(); + + control.focus(); + + expect(control).toHaveFocus(); + } + }); + + it('groups the actions and never unmounts them on pointer state', () => { + render( + + ); + + expect(screen.getByTestId('feed-actions')).toHaveAttribute('role', 'group'); + expect(screen.getByTestId('feed-actions')).toHaveAccessibleName(); + }); + + it('merges the reveal class handed down by the owning card', () => { + render( + + ); + + const actions = screen.getByTestId('feed-actions'); + + expect(actions).toHaveClass('feed-actions'); + expect(actions).toHaveClass('tw:opacity-0'); + }); + + it('honours canEdit and canDelete independently of the author rule', () => { + mockUseApplicationStore.mockReturnValue({ + currentUser: { id: 'other-id', name: 'bob', isAdmin: false }, + }); + + render( + + ); + + expect(screen.queryByTestId('edit-message')).not.toBeInTheDocument(); + expect(screen.getByTestId('delete-message')).toBeInTheDocument(); + }); + + it('calls onDelete instead of the feed provider when one is supplied', () => { + const onDelete = jest.fn(); + + render(); + fireEvent.click(screen.getByTestId('delete-message')); + fireEvent.click(screen.getByTestId('confirm-delete')); + + expect(onDelete).toHaveBeenCalled(); + expect(mockDeleteFeed).not.toHaveBeenCalled(); + }); + it('closes the confirmation without deleting', () => { render( void; + /** + * Replaces the provider-backed delete. Required by callers outside the + * activity feed, which have no conversation to delete a post from. + */ + onDelete?: () => void; + /** + * Reveal styling from the owning card. These actions stay mounted so they + * remain reachable by Tab and by a screen reader; a consumer that wants them + * revealed on pointer hover does that with opacity on a `tw:group` ancestor, + * never by unmounting them. + */ + className?: string; } +/** + * Fall back to the feed's own author-or-admin rule for whichever action the + * caller did not state a permission for. + */ +const resolveActionVisibility = ( + isAuthor: boolean, + isAdmin: boolean, + canEdit?: boolean, + canDelete?: boolean +) => { + const canManage = isAuthor || isAdmin; + + return { + canManage, + showDelete: canDelete ?? canManage, + showEdit: canEdit ?? canManage, + }; +}; + const getIsAuthor = ( isReply: boolean, currentUser: { id?: string; name?: string } | undefined, conversation?: Conversation, reply?: ConversationReply -): boolean => { - const author = isReply - ? reply?.author.name ?? reply?.author.fullyQualifiedName - : conversation?.createdBy?.name ?? - conversation?.createdBy?.fullyQualifiedName; - const authorId = isReply ? reply?.author.id : conversation?.createdBy?.id; - - return authorId ? authorId === currentUser?.id : author === currentUser?.name; -}; +): boolean => + isFeedPostAuthor( + currentUser, + isReply ? reply?.author : conversation?.createdBy + ); const ActivityFeedActions = ({ conversation, conversationId, reply, isReply, + canEdit, + canDelete, onEditPost, + onDelete, + className, }: ActivityFeedActionsProps) => { const { t, i18n } = useTranslation(); const dir = i18n.dir(); @@ -83,20 +125,38 @@ const ActivityFeedActions = ({ }; const handleDelete = () => { - const targetId = reply?.id ?? conversationId; - deleteFeed(conversationId, targetId, !isReply).catch(() => { - // ignore since error is displayed in toast in the parent promise. - }); setShowDeleteDialog(false); + + if (onDelete) { + onDelete(); + + return; + } + + if (!conversationId) { + return; + } + + deleteFeed(conversationId, reply?.id ?? conversationId, !isReply).catch( + () => { + // ignore since error is displayed in toast in the parent promise. + } + ); + if (!isReply) { hideDrawer(); } }; - const canManage = isAuthor || Boolean(currentUser?.isAdmin); + const { canManage, showEdit, showDelete } = resolveActionVisibility( + isAuthor, + Boolean(currentUser?.isAdmin), + canEdit, + canDelete + ); const handleResolvedChange = () => { - if (!conversation || isReply) { + if (!conversation || isReply || !conversationId) { return; } updateFeed(conversationId, conversationId, true, [ @@ -111,49 +171,58 @@ const ActivityFeedActions = ({ return ( <> {!isReply && conversation && ( - )} {!isReply && conversation && canManage && ( - )} - {canManage && ( - )} - {canManage && ( - setShowDeleteDialog(true)} /> )} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx index 6fddb61f096a..d518da24a15b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx @@ -323,6 +323,8 @@ jest.mock('../../../../rest/tasksAPI', () => ({ resolveTask: jest.fn().mockResolvedValue({}), closeTask: jest.fn().mockResolvedValue({}), patchTask: jest.fn().mockResolvedValue({}), + editTaskComment: jest.fn().mockResolvedValue({}), + deleteTaskComment: jest.fn().mockResolvedValue({}), })); jest.mock('../../../../rest/userAPI', () => ({ @@ -425,14 +427,14 @@ jest.mock( } ); -const mockTaskCommentCardProps: Record[] = []; +const mockCommentCardProps: Record[] = []; jest.mock( - '../../../ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component', + '../../../ActivityFeed/ActivityFeedCardNew/CommentCard.component', () => { return jest.fn().mockImplementation((props) => { - mockTaskCommentCardProps.push(props); + mockCommentCardProps.push(props); - return

TaskCommentCard

; + return

CommentCard

; }); } ); @@ -459,7 +461,7 @@ const mockProps = { describe('TaskTabNew Component', () => { beforeEach(() => { jest.clearAllMocks(); - mockTaskCommentCardProps.length = 0; + mockCommentCardProps.length = 0; const { useAuth } = require('../../../../hooks/authHooks'); const { useApplicationStore, @@ -1282,7 +1284,7 @@ describe('TaskTabNew Component', () => { // The card re-renders, so assert against the props it was last handed rather // than a render count. const lastCommentCardProps = () => - mockTaskCommentCardProps[mockTaskCommentCardProps.length - 1]; + mockCommentCardProps[mockCommentCardProps.length - 1]; const MOCK_TASK_WITH_COMMENT: Task = { ...MOCK_TASK, @@ -1296,31 +1298,97 @@ describe('TaskTabNew Component', () => { ], }; - it('should pass the current user down so the card can resolve delete permission', async () => { + const renderWithComment = async () => { await act(async () => { render(, { wrapper: MemoryRouter, }); }); + }; + + it('should pass the comment through to the card', async () => { + await renderWithComment(); + + expect(lastCommentCardProps()).toEqual( + expect.objectContaining({ + author: expect.objectContaining({ name: 'alice' }), + createdAt: 1735732800000, + message: 'A comment on the incident', + }) + ); + }); + + it('should deny edit and delete to a non-author, non-admin user', async () => { + await renderWithComment(); + + expect(lastCommentCardProps()).toEqual( + expect.objectContaining({ canDelete: false, canEdit: false }) + ); + }); + + it('should let an admin delete but not edit someone elses comment', async () => { + const { + useApplicationStore, + } = require('../../../../hooks/useApplicationStore'); + useApplicationStore.mockReturnValue({ + currentUser: { + id: 'admin-id', + name: 'an-admin', + isAdmin: true, + teams: [], + }, + }); - expect(lastCommentCardProps().currentUser).toEqual( - expect.objectContaining({ name: 'test-user' }) + await renderWithComment(); + + expect(lastCommentCardProps()).toEqual( + expect.objectContaining({ canDelete: true, canEdit: false }) ); }); - it('should refetch the thread when a comment is deleted', async () => { + it('should omit onReaction so the card hides its reactions footer', async () => { + await renderWithComment(); + + expect(lastCommentCardProps().onReaction).toBeUndefined(); + }); + + it('should delete the comment and refetch the thread', async () => { + const { deleteTaskComment } = require('../../../../rest/tasksAPI'); + await renderWithComment(); + + mockFetchUpdatedThread.mockClear(); + await act(async () => { - render(, { - wrapper: MemoryRouter, - }); + await (lastCommentCardProps().onDelete as () => Promise)(); }); + expect(deleteTaskComment).toHaveBeenCalledWith( + MOCK_TASK_WITH_COMMENT.id, + 'comment-1' + ); + expect(mockFetchUpdatedThread).toHaveBeenCalledWith( + MOCK_TASK_WITH_COMMENT.id, + true + ); + }); + + it('should edit the comment and refetch the thread', async () => { + const { editTaskComment } = require('../../../../rest/tasksAPI'); + await renderWithComment(); + mockFetchUpdatedThread.mockClear(); await act(async () => { - (lastCommentCardProps().onCommentDeleted as () => void)(); + await ( + lastCommentCardProps().onEdit as (message: string) => Promise + )('an edited comment'); }); + expect(editTaskComment).toHaveBeenCalledWith( + MOCK_TASK_WITH_COMMENT.id, + 'comment-1', + 'an edited comment' + ); expect(mockFetchUpdatedThread).toHaveBeenCalledWith( MOCK_TASK_WITH_COMMENT.id, true diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx index 2019c2140968..e21e48213c3f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx @@ -97,6 +97,8 @@ import { } from '../../../../rest/taskFormSchemasAPI'; import { closeTask as closeTaskAPI, + deleteTaskComment, + editTaskComment, patchTask, resolveTask as resolveTaskAPI, Task, @@ -130,6 +132,7 @@ import { fetchOptions, generateOptions, } from '../../../../utils/TaskAssigneeUtils'; +import { resolveCommentPermissions } from '../../../../utils/TaskCommentUtils'; import { applyTaskFormSchemaDefaults, getDefaultTaskFormSchema, @@ -150,7 +153,7 @@ import { } from '../../../../utils/TaskNavigationUtils'; import { getNormalizedTaskPayload } from '../../../../utils/TaskPayloadUtils'; import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils'; -import TaskCommentCard from '../../../ActivityFeed/ActivityFeedCardNew/TaskCommentCard.component'; +import CommentCard from '../../../ActivityFeed/ActivityFeedCardNew/CommentCard.component'; import ActivityFeedEditorNew from '../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; import { useActivityFeedProvider } from '../../../ActivityFeed/ActivityFeedProvider/ActivityFeedProvider'; import withSuspenseFallback from '../../../AppRouter/withSuspenseFallback'; @@ -421,10 +424,6 @@ export const TaskTabNew = ({ ...rest }: TaskTabProps) => { const editorRef = useRef(); - // Stable, always-focusable (tabIndex={-1}) fallback target the comment - // cards can hand focus to when a deleted comment has no sibling left - - // see TaskCommentCard's unmount focus-management effect. - const repliesContainerRef = useRef(null); const navigate = useNavigate(); const [assigneesForm] = useForm(); const { currentUser } = useApplicationStore(); @@ -1839,26 +1838,34 @@ export const TaskTabNew = ({ ); return ( - . -1 keeps it out of the normal tab order. - tabIndex={-1}> - {sortedComments.map((comment, index, arr) => ( - fetchUpdatedThread(task.id, true)} - /> - ))} + + {sortedComments.map((comment, index, arr) => { + const { canEdit, canDelete } = resolveCommentPermissions( + currentUser, + comment + ); + + return ( + { + await deleteTaskComment(task.id, comment.id); + await fetchUpdatedThread(task.id, true); + }} + onEdit={async (message) => { + await editTaskComment(task.id, comment.id, message); + await fetchUpdatedThread(task.id, true); + }} + /> + ); + })} ); }, [task, closeFeedEditor, isPostsLoading, currentUser, fetchUpdatedThread]); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtilsPure.ts b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtilsPure.ts index 701c50878c69..2ea4c6f90e6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtilsPure.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtilsPure.ts @@ -543,3 +543,22 @@ export const fetchEntityActivityCountInto = async ( showErrorToast(err as AxiosError, t('server.entity-feed-fetch-error')); } }; + +/** + * The activity feed's authorship test: match on id when the post carries one, + * otherwise on name. Shared so a call site that has to pass `canEdit` / + * `canDelete` into ActivityFeedActions derives them from the same rule the + * component itself falls back to, rather than a second copy of it. + */ +export const isFeedPostAuthor = ( + currentUser: { id?: string; name?: string } | undefined, + author: + | { id?: string; name?: string; fullyQualifiedName?: string } + | undefined +): boolean => { + const authorName = author?.name ?? author?.fullyQualifiedName; + + return author?.id + ? author.id === currentUser?.id + : authorName === currentUser?.name; +}; From 4a853892e3cd33d49fdc07e46fa8e5520258d67f Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 12:38:17 +0530 Subject: [PATCH 17/23] fix(ui): align task comment rows and inline the single-consumer comment parts Fix the comment thread's layout on every surface that renders one, and fold away a shared module that no longer has more than one consumer. Inbox / TaskDetailPanel: the message box and timestamp were siblings of the avatar row, so they started at the container's left edge instead of lining up under the author name. The row is now an avatar column plus a content column with the avatar pinned to the top, so the alignment holds regardless of how long the comment is. TaskCommentActions and TaskCommentBody are inlined into TaskDetailPanel and deleted. They were extracted to share with TaskCommentCard, which no longer exists, leaving TaskDetailPanel as their only caller. Activity feed replies: give .reply-card symmetric padding so the divider sits between two comments rather than flush against the text above it, and put a bordered box on the reply footer's add-reaction control, which otherwise reads as a loose glyph with none of the post toolbar's siblings to frame it. CommentCard now imports the stylesheet it depends on, the way ActivityFeedcardNew and FeedPanelBodyV1 already do. Its classes live in activity-feed-tab.less, which only loaded with the Activity Feed tab, so on a hard refresh of a surface that renders the card without that tab - the incident page's Issues tab - the replies came out unstyled. The comments container is styled from task-tab-new.less for the same reason, scoped to the task panel. The timestamp tooltip keeps antd's default white text on the white background its `color` prop sets. The rule fixing that lived in feed-widget.less, which only loads with the landing-page widget, so the tooltip rendered as an empty bubble anywhere that widget had not been visited. Move it next to the other feed styles and drop the chunk-scoped copy so the two cannot race on load order. Document why the task-comment and conversation permission rules differ rather than merging them: TaskRepository#editComment takes no isAdmin and permits the author only, while #deleteComment permits author-or-admin; conversation replies go through RBAC, which an admin passes for both. Collapsing them would break one surface or the other. --- .../e2e/Features/Tasks/TaskComments.spec.ts | 10 ++ .../CommentCard.component.tsx | 17 ++- .../ActivityFeedTab/activity-feed-tab.less | 27 ++++- .../Shared/ActivityFeedActions.tsx | 10 ++ .../Entity/Task/TaskTab/task-tab-new.less | 16 +++ .../MyData/FeedWidget/feed-widget.less | 6 - .../common/TaskComment/TaskCommentActions.tsx | 78 ------------ .../common/TaskComment/TaskCommentBody.tsx | 73 ----------- .../InboxPage/components/TaskDetailPanel.tsx | 114 ++++++++++++------ .../ui/src/utils/TaskCommentUtils.ts | 9 +- 10 files changed, 157 insertions(+), 203 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts index cb762d30eed1..8b60f12bcdaa 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/Tasks/TaskComments.spec.ts @@ -164,6 +164,10 @@ test.describe('Task Comments - Add Comment', () => { const actions = card.getByTestId('feed-actions'); const deleteAction = card.getByTestId('delete-message'); + // Posting the comment leaves the pointer over the card, which would hold + // the bar revealed - park it away first to sample the resting state. + await page.mouse.move(0, 0); + // Mounted before any hover so it stays reachable by keyboard and screen // readers - the reveal is opacity, which Playwright's visibility check // deliberately ignores, so assert the computed value directly. @@ -204,6 +208,12 @@ test.describe('Task Comments - Add Comment', () => { const actions = card.getByTestId('feed-actions'); const deleteAction = card.getByTestId('delete-message'); + // Park the pointer away from the card first, so the reveal asserted below + // is attributable to focus-within and not to a leftover hover. + await page.mouse.move(0, 0); + + await expect(actions).toHaveCSS('opacity', '0'); + // Regression coverage for the affordance being an `` span: // it could not hold focus at all, so none of this was possible without a // mouse. Focusing it must also bring the bar into view via focus-within. diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx index 3c6558ebf4ca..86178791d0e3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx @@ -34,6 +34,7 @@ import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; import Reactions from '../Reactions/Reactions'; import ActivityFeedActions from '../Shared/ActivityFeedActions'; +import '../ActivityFeedTab/activity-feed-tab.less'; import { COMMENT_ACTIONS_HOVER_REVEAL } from '../Shared/ActivityFeedActions.constants'; const ActivityFeedEditor = withSuspenseFallback( lazy(() => import('../ActivityFeedEditor/ActivityFeedEditorNew')) @@ -146,7 +147,7 @@ const CommentCard = ({ return (
-
-
- -
+
+
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less index 43f21195d6a8..b611b1c24f4c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less @@ -486,13 +486,38 @@ } .reply-card { - padding-top: @padding-lg; + // Symmetric, so the divider below a reply sits midway between it and the + // next one rather than flush against the text it follows. Matches the + // padding of the comments container these sit in. + padding-top: var(--om-space-16); + padding-bottom: var(--om-space-16); } .reply-card-border-bottom { border-bottom: 0.5px solid var(--om-legacy-color-e4e4e4); } +// A reply's footer carries the add-reaction control on its own, without the +// post toolbar's sibling controls to frame it. Give it the same bordered box +// the feed actions use so it reads as a control rather than a loose glyph. +.reply-card-footer { + [data-testid='add-reactions'] { + border: 0.5px solid var(--om-color-gray-blue-100); + background: var(--tw-color-bg-primary); + } +} + +// Tooltips render into a portal on document.body, so this has to sit in a +// stylesheet that every component rendering `timestamp-tooltip` pulls in - +// both ActivityFeedcardNew and CommentCard import this file. Without it the +// tooltip keeps antd's default white text on the white background its `color` +// prop sets, i.e. an empty bubble. +.timestamp-tooltip { + .ant-tooltip-inner { + color: var(--om-color-text-primary); + } +} + .reply-message { color: var(--om-legacy-color-4a4a4a); white-space: normal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx index c954ec68e561..43faef7abdc4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx @@ -69,6 +69,16 @@ interface ActivityFeedActionsProps { /** * Fall back to the feed's own author-or-admin rule for whichever action the * caller did not state a permission for. + * + * This default is the *conversation* rule, not the task-comment one. A + * conversation reply is authorized through RBAC + * (ConversationRepository#patchReply / #deleteReply ask the authorizer for + * EDIT_ALL / DELETE), which an admin passes for both. A task comment is not: + * TaskRepository#editComment takes no `isAdmin` at all and permits the author + * only, while #deleteComment takes one and permits author-or-admin. The two + * rules are deliberately different - do not collapse them. Callers rendering + * task comments pass `canEdit`/`canDelete` explicitly, derived from + * `resolveCommentPermissions` in utils/TaskCommentUtils. */ const resolveActionVisibility = ( isAuthor: boolean, diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/task-tab-new.less b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/task-tab-new.less index 2ed22d19c8e8..c255a9f70801 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/task-tab-new.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/task-tab-new.less @@ -13,6 +13,22 @@ @import (reference) '../../../../styles/variables.less'; +// TaskTabNew renders .activity-feed-comments-container, but that class is only +// styled in activity-feed-tab.less and feed-panel-body-v1.less - neither of +// which loads on a surface that mounts the task tab on its own, such as the +// incident page's Issues tab. Without this the box lost its background, border +// and padding on a hard refresh there. Scoped to the task panel so it cannot +// cross-talk with the activity feed's own copies of the class. +.task-details-panel { + .activity-feed-comments-container { + border-radius: var(--om-radius-xl); + padding: var(--om-space-16); + margin: var(--om-space-20) 0 0 0; + border: 0.5px solid var(--om-color-gray-blue-100); + background: @grey-9; + } +} + .assignees-edit-input { .ant-space-item:first-child { width: 100%; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/FeedWidget/feed-widget.less b/openmetadata-ui/src/main/resources/ui/src/components/MyData/FeedWidget/feed-widget.less index 19f653d773f6..b90397d5073d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/FeedWidget/feed-widget.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/FeedWidget/feed-widget.less @@ -57,9 +57,3 @@ color: @primary-7; } } - -.timestamp-tooltip { - .ant-tooltip-inner { - color: @grey-3; - } -} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx deleted file mode 100644 index 08aadef40837..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentActions.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright 2026 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 { ButtonUtility } from '@openmetadata/ui-core-components'; -import { - Delete as DeleteIcon, - Edit as EditIcon, -} from '@openmetadata/ui-core-components/icons'; -import { FC } from 'react'; -import { useTranslation } from 'react-i18next'; - -export interface TaskCommentActionsProps { - canDelete: boolean; - canEdit: boolean; - className?: string; - onDeleteRequest: () => void; - onEditRequest: () => void; -} - -/** - * Edit / delete affordances for a task comment. - * - * Real buttons rather than clickable SVGs: each is reachable by Tab, carries an - * accessible name from its tooltip, and activates on Enter/Space. Consumers that - * want these revealed on hover should do that with CSS (opacity) on a `tw:group` - * ancestor - unmounting them until hover puts them permanently out of reach of - * the keyboard, which is the bug this pattern exists to avoid. - */ -const TaskCommentActions: FC = ({ - canDelete, - canEdit, - className, - onDeleteRequest, - onEditRequest, -}) => { - const { t } = useTranslation(); - - return ( -
- {canEdit && ( - - )} - {canDelete && ( - - )} -
- ); -}; - -export default TaskCommentActions; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx deleted file mode 100644 index 6c86e363dead..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TaskComment/TaskCommentBody.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2026 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 { Box, Button } from '@openmetadata/ui-core-components'; -import { FC, ReactNode } from 'react'; -import { useTranslation } from 'react-i18next'; -import { TaskComment } from '../../../generated/entity/tasks/task'; -import { - getFrontEndFormat, - MarkdownToHTMLConverter, -} from '../../../utils/FeedUtilsPure'; -import ActivityFeedEditorNew from '../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; - -export interface TaskCommentBodyProps { - comment: TaskComment; - isEditing: boolean; - onCancelEdit: () => void; - onSave: (message: string) => Promise; - /** - * The comment as it reads when not being edited. Supplied by the consumer - * because the activity-feed card and the Inbox panel present a comment very - * differently - only the edit affordance is shared, not the surrounding layout. - */ - children: ReactNode; -} - -/** A comment's inline editor while editing, otherwise the consumer's own view. */ -const TaskCommentBody: FC = ({ - comment, - isEditing, - onCancelEdit, - onSave, - children, -}) => { - const { t } = useTranslation(); - - if (!isEditing) { - return <>{children}; - } - - return ( - - - - - - - ); -}; - -export default TaskCommentBody; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx index 4287127cb1ed..1ef6a7e95c69 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx @@ -15,10 +15,15 @@ import { Badge, Box, Button, + ButtonUtility, EmptyPlaceholder, Tabs, Typography, } from '@openmetadata/ui-core-components'; +import { + Delete as DeleteIcon, + Edit as EditIcon, +} from '@openmetadata/ui-core-components/icons'; import { CheckCircle, XCircle } from '@untitledui/icons'; import { AxiosError } from 'axios'; import React, { @@ -35,8 +40,6 @@ import { Link } from 'react-router-dom'; import DeleteModal from '../../../../../components/common/DeleteModal/DeleteModal'; import ProfilePicture from '../../../../../components/common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewerV1 from '../../../../../components/common/RichTextEditor/RichTextEditorPreviewerV1'; -import TaskCommentActions from '../../../../../components/common/TaskComment/TaskCommentActions'; -import TaskCommentBody from '../../../../../components/common/TaskComment/TaskCommentBody'; import { UserTeamSelectableList } from '../../../../../components/common/UserTeamSelectableList/UserTeamSelectableList.component'; import { usePermissionProvider } from '../../../../../context/PermissionProvider/PermissionProvider'; import { @@ -64,7 +67,10 @@ import { } from '../../../../../rest/tasksAPI'; import { getRelativeTime } from '../../../../../utils/date-time/DateTimeUtils'; import { getEntityName } from '../../../../../utils/EntityNameUtils'; -import { getFrontEndFormat } from '../../../../../utils/FeedUtilsPure'; +import { + getFrontEndFormat, + MarkdownToHTMLConverter, +} from '../../../../../utils/FeedUtilsPure'; import { getTestCaseDetailPagePath } from '../../../../../utils/RouterUtils'; import { getPermissionErrorText } from '../../../../../utils/StringUtils'; import { resolveCommentPermissions } from '../../../../../utils/TaskCommentUtils'; @@ -78,6 +84,7 @@ import { TaskResolveAction, } from '../taskResolve.utils'; import { getTaskTitle } from '../taskTitle.utils'; +import ActivityFeedEditorNew from '../../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; import InboxCommentComposer from './InboxCommentComposer'; import TaskActionCommentModal from './TaskActionCommentModal'; import TaskActivityTimeline from './TaskActivityTimeline'; @@ -308,47 +315,86 @@ const TaskCommentRow: React.FC = ({ }, [taskId, comment.id, onChanged]); return ( + // align="start" keeps the avatar pinned to the top of the row, and the + // content column holds everything else so the message and timestamp line + // up under the author name instead of under the avatar. - - - + + + {authorName} + {!isEditing && canModifyComment && ( + // Real buttons, kept mounted and revealed with opacity: unmounting + // them until hover puts them out of reach of the keyboard. +
+ {canEdit && ( + setIsEditing(true)} + /> + )} + {canDelete && ( + setShowDeleteDialog(true)} + /> + )} +
+ )}
- {!isEditing && canModifyComment && ( - setShowDeleteDialog(true)} - onEditRequest={() => setIsEditing(true)} - /> + {isEditing ? ( + + + + + + + ) : ( + + + )} + + {getRelativeTime(comment.createdAt)} +
- setIsEditing(false)} - onSave={handleEditSave}> - - - - - - {getRelativeTime(comment.createdAt)} - Date: Fri, 18 Sep 2026 13:00:26 +0530 Subject: [PATCH 18/23] fix(ui): surface task comment edit and delete failures A failed task-comment edit or delete looked like a success. The REST helpers deleteTaskComment and editTaskComment throw and report nothing of their own, TaskTabNew passed them straight through with no try/catch, and the two shared components dismissed their UI before the call had resolved: CommentCard closed the editor without awaiting onEdit, and ActivityFeedActions closed the confirmation before invoking onDelete. A permission error or a network failure therefore dropped the editor or the dialog, showed nothing, and left the rejection unhandled. TaskTabNew now toasts the error and rethrows, so the card can tell the action failed. CommentCard awaits onEdit and only then closes the editor, and ActivityFeedActions awaits onDelete and only then closes the confirmation, both keeping their UI open on rejection so the user can retry - the behaviour the Inbox panel already had. Each catches to avoid an unhandled rejection but leaves reporting to the caller, which owns the error message. Covered by three tests that fail without the change: a rejected save keeps the editor open, a rejected delete keeps the confirmation open, and the task callbacks toast and rethrow. --- .../CommentCard.component.tsx | 11 ++++-- .../ActivityFeedCardNew/CommentCard.test.tsx | 22 ++++++++++++ .../Shared/ActivityFeedActions.test.tsx | 17 +++++++++- .../Shared/ActivityFeedActions.tsx | 16 ++++++--- .../TaskTab/TaskTabNew.component.test.tsx | 34 +++++++++++++++++++ .../Task/TaskTab/TaskTabNew.component.tsx | 24 ++++++++++--- 6 files changed, 111 insertions(+), 13 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx index 86178791d0e3..be627cf9c1c1 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx @@ -110,9 +110,14 @@ const CommentCard = ({ setIsEditPost(!isEditPost); }; - const handleSave = useCallback(() => { - onEdit(postMessage ?? ''); - setIsEditPost(false); + const handleSave = useCallback(async () => { + try { + await onEdit(postMessage ?? ''); + setIsEditPost(false); + } catch { + // Keep the editor open and the draft intact so the edit can be retried. + // The caller owns reporting the failure. + } }, [onEdit, postMessage]); const defaultValue = useMemo( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx index 48f23c12a3af..5ad203f6740c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx @@ -317,6 +317,28 @@ describe('CommentCard', () => { }); }); + it('should keep the editor open when the save fails', async () => { + onEdit.mockRejectedValueOnce(new Error('boom')); + renderCommentCard(); + + await hoverCard(); + + fireEvent.click(screen.getByTestId('edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('feed-editor')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('send-button')); + + await waitFor(() => { + expect(onEdit).toHaveBeenCalled(); + }); + + // Dismissing here would look like the edit had been saved. + expect(screen.getByTestId('feed-editor')).toBeInTheDocument(); + }); + it('should hide editor and show preview after update', async () => { renderCommentCard(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx index c4a4493657f3..2117e5c3672e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Conversation, ConversationReply, @@ -292,6 +292,21 @@ describe('ActivityFeedActions', () => { expect(mockDeleteFeed).not.toHaveBeenCalled(); }); + it('keeps the confirmation open when onDelete rejects', async () => { + const onDelete = jest.fn().mockRejectedValue(new Error('boom')); + + render(); + fireEvent.click(screen.getByTestId('delete-message')); + fireEvent.click(screen.getByTestId('confirm-delete')); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalled(); + }); + + // Closing here would look like the delete had gone through. + expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument(); + }); + it('closes the confirmation without deleting', () => { render( void; + onDelete?: () => void | Promise; /** * Reveal styling from the owning card. These actions stay mounted so they * remain reachable by Tab and by a screen reader; a consumer that wants them @@ -134,15 +134,21 @@ const ActivityFeedActions = ({ updateEditorFocus(true); }; - const handleDelete = () => { - setShowDeleteDialog(false); - + const handleDelete = async () => { if (onDelete) { - onDelete(); + try { + await onDelete(); + setShowDeleteDialog(false); + } catch { + // Leave the confirmation open so the delete can be retried. The caller + // owns reporting the failure. + } return; } + setShowDeleteDialog(false); + if (!conversationId) { return; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx index d518da24a15b..7513970350d7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx @@ -318,6 +318,12 @@ jest.mock( }) ); +const mockShowErrorToast = jest.fn(); +jest.mock('../../../../utils/ToastUtils', () => ({ + ...jest.requireActual('../../../../utils/ToastUtils'), + showErrorToast: (...args: unknown[]) => mockShowErrorToast(...args), +})); + jest.mock('../../../../rest/tasksAPI', () => ({ ...jest.requireActual('../../../../rest/tasksAPI'), resolveTask: jest.fn().mockResolvedValue({}), @@ -1372,6 +1378,34 @@ describe('TaskTabNew Component', () => { ); }); + it('should toast and rethrow when deleting the comment fails', async () => { + const { deleteTaskComment } = require('../../../../rest/tasksAPI'); + const failure = new Error('nope'); + deleteTaskComment.mockRejectedValueOnce(failure); + await renderWithComment(); + + // Rethrown on purpose: the card keeps its confirmation open only if the + // callback it awaited actually rejects. + await expect( + (lastCommentCardProps().onDelete as () => Promise)() + ).rejects.toThrow('nope'); + + expect(mockShowErrorToast).toHaveBeenCalledWith(failure); + }); + + it('should toast and rethrow when editing the comment fails', async () => { + const { editTaskComment } = require('../../../../rest/tasksAPI'); + const failure = new Error('nope'); + editTaskComment.mockRejectedValueOnce(failure); + await renderWithComment(); + + await expect( + (lastCommentCardProps().onEdit as (m: string) => Promise)('x') + ).rejects.toThrow('nope'); + + expect(mockShowErrorToast).toHaveBeenCalledWith(failure); + }); + it('should edit the comment and refetch the thread', async () => { const { editTaskComment } = require('../../../../rest/tasksAPI'); await renderWithComment(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx index 9c8d958d1bc8..3f60c796e46f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx @@ -1854,12 +1854,28 @@ export const TaskTabNew = ({ key={comment.id} message={comment.message} onDelete={async () => { - await deleteTaskComment(task.id, comment.id); - await fetchUpdatedThread(task.id, true); + try { + await deleteTaskComment(task.id, comment.id); + await fetchUpdatedThread(task.id, true); + } catch (error) { + // The REST helpers throw without surfacing anything of their + // own. Rethrow after toasting so the card leaves the + // confirmation open for a retry instead of dismissing it as + // though the delete had succeeded. + showErrorToast(error as AxiosError); + + throw error; + } }} onEdit={async (message) => { - await editTaskComment(task.id, comment.id, message); - await fetchUpdatedThread(task.id, true); + try { + await editTaskComment(task.id, comment.id, message); + await fetchUpdatedThread(task.id, true); + } catch (error) { + showErrorToast(error as AxiosError); + + throw error; + } }} /> ); From 0570292c09d92f84a11644d41cb90355905a5ecb Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 13:05:29 +0530 Subject: [PATCH 19/23] test(ui): exercise the real comment card in the activity feed tests The reply tests replaced CommentCard and ActivityFeedActions - both components of this codebase, not boundaries - with prop-capturing mocks, then called the captured callbacks directly. They asserted the feed provider was reached, but nothing proved a user could reach those callbacks through the rendered UI, so they would have passed with the wiring severed. Render both components for real and drive the DOM instead: click the edit and delete buttons that ActivityFeedActions renders inside the reply card, save through the editor, and react, asserting what the provider is then asked to do. Only true boundaries stay mocked - the Quill editor, the tiptap previewer, the emoji element and the lazily imported confirmation modal - each because it wraps a third-party dependency rather than logic of ours. Two neighbouring tests moved off the removed mocks: they asserted a testid that only the mock rendered, and now assert the real actions element and the card-scoped reveal class it carries. Verified by mutation: severing the delete wiring fails the delete test, which the previous mock-based version passed. --- .../ActivityFeedcardNew.component.test.tsx | 166 +++++++++++------- 1 file changed, 105 insertions(+), 61 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx index 74b1df03ceae..e815c5130ceb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx @@ -11,7 +11,13 @@ * limitations under the License. */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import { ReactionOperation } from '../../../enums/reactions.enum'; import { @@ -67,22 +73,50 @@ jest.mock('../ActivityFeedCardV2/FeedCardFooter/ActivityEventFooter', () => jest.fn(() =>
) ); -const mockActivityFeedActionsProps: Record[] = []; -jest.mock('../Shared/ActivityFeedActions', () => - jest.fn((props) => { - mockActivityFeedActionsProps.push(props); +// CommentCard and ActivityFeedActions are deliberately NOT mocked: the point +// of these tests is that a user can actually edit, delete and react through +// the rendered reply. Only true boundaries are stubbed below - the Quill +// editor, the tiptap-backed previewer, the emoji element and the lazily +// imported confirmation modal. +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => + jest.fn(({ markdown }) =>
{markdown}
) +); - return
; - }) +jest.mock('../ActivityFeedEditor/ActivityFeedEditorNew', () => + jest.fn(({ onSave, onTextChange }) => ( +
+ onTextChange?.(e.target.value)} + /> + +
+ )) ); -const mockCommentCardProps: Record[] = []; -jest.mock('./CommentCard.component', () => - jest.fn((props) => { - mockCommentCardProps.push(props); +jest.mock('../Reactions/Reactions', () => + jest.fn(({ onReactionSelect }) => ( + + )) +); - return
{props.message}
; - }) +jest.mock('../../Modals/ConfirmationModal/ConfirmationModal', () => + jest.fn(({ visible, onConfirm }) => + visible ? ( + + ) : null + ) ); jest.mock('../../common/PopOverCard/EntityPopOverCard', () => @@ -151,8 +185,6 @@ describe('ActivityFeedCardNew', () => { beforeEach(() => { jest.clearAllMocks(); mockProviderValue.activityReplies = []; - mockCommentCardProps.length = 0; - mockActivityFeedActionsProps.length = 0; }); it('keeps root reactions and management actions available in the drawer', () => { @@ -167,11 +199,12 @@ describe('ActivityFeedCardNew', () => { // Mounted before any pointer interaction: hiding these behind a hover // state put them out of reach of the keyboard and of screen readers. The // hover reveal is presentational, applied by CSS on the owning card. - expect(screen.getByTestId('conversation-root-actions')).toBeVisible(); + expect(screen.getByTestId('feed-actions')).toBeVisible(); + expect(screen.getByTestId('edit-message')).toBeVisible(); fireEvent.mouseEnter(screen.getByTestId('feed-card-v2-sidebar')); - expect(screen.getByTestId('conversation-root-actions')).toBeVisible(); + expect(screen.getByTestId('feed-actions')).toBeVisible(); }); it('hands the card-scoped hover reveal to the root actions', () => { @@ -187,7 +220,7 @@ describe('ActivityFeedCardNew', () => { expect(screen.getByTestId('feed-card-v2-sidebar').className).toContain( 'tw:group/feed-card' ); - expect(mockActivityFeedActionsProps.at(-1)?.className).toContain( + expect(screen.getByTestId('feed-actions').className).toContain( 'tw:group-hover/feed-card:opacity-100' ); }); @@ -206,8 +239,10 @@ describe('ActivityFeedCardNew', () => { ); }); - describe('reply wiring', () => { - const renderWithReply = () => { + describe('reply actions', () => { + // Drives the real CommentCard / ActivityFeedActions the user sees, and + // asserts what the feed provider is asked to do as a result. + const renderReply = () => { mockProviderValue.activityReplies = [activityReply]; render( @@ -216,57 +251,66 @@ describe('ActivityFeedCardNew', () => { ); - return mockCommentCardProps[mockCommentCardProps.length - 1]; + return within(screen.getByTestId('feed-reply-card')); }; - it('grants edit and delete to the reply author', () => { - expect(renderWithReply()).toEqual( - expect.objectContaining({ canDelete: true, canEdit: true }) - ); - }); - - it('patches the reply through updateFeed on edit', async () => { - const props = renderWithReply(); + it('offers edit and delete on the authors own reply', () => { + const reply = renderReply(); - await (props.onEdit as (message: string) => Promise)('edited'); + expect(reply.getByTestId('edit-message')).toBeInTheDocument(); + expect(reply.getByTestId('delete-message')).toBeInTheDocument(); + }); - expect(mockProviderValue.updateFeed).toHaveBeenCalledWith( - activity.id, - activityReply.id, - false, - [{ op: 'replace', path: '/message', value: 'edited' }] - ); + it('patches the reply through updateFeed when the user saves an edit', async () => { + const reply = renderReply(); + + fireEvent.click(reply.getByTestId('edit-message')); + + const editor = await reply.findByTestId('reply-editor'); + fireEvent.change(within(editor).getByTestId('reply-editor-input'), { + target: { value: 'edited' }, + }); + fireEvent.click(within(editor).getByTestId('reply-editor-save')); + + await waitFor(() => { + expect(mockProviderValue.updateFeed).toHaveBeenCalledWith( + activity.id, + activityReply.id, + false, + [{ op: 'replace', path: '/message', value: 'edited' }] + ); + }); }); - it('removes the reply through deleteFeed on delete', async () => { - const props = renderWithReply(); + it('removes the reply through deleteFeed when the user confirms', async () => { + const reply = renderReply(); - await (props.onDelete as () => Promise)(); + fireEvent.click(reply.getByTestId('delete-message')); + fireEvent.click(await screen.findByTestId('confirm-delete')); - expect(mockProviderValue.deleteFeed).toHaveBeenCalledWith( - activity.id, - activityReply.id, - false - ); + await waitFor(() => { + expect(mockProviderValue.deleteFeed).toHaveBeenCalledWith( + activity.id, + activityReply.id, + false + ); + }); }); - it('forwards reactions to updateReactions', async () => { - const props = renderWithReply(); - - await ( - props.onReaction as ( - type: ReactionType, - operation: ReactionOperation - ) => Promise - )(ReactionType.ThumbsUp, ReactionOperation.ADD); - - expect(mockProviderValue.updateReactions).toHaveBeenCalledWith( - activityReply, - activity.id, - false, - ReactionType.ThumbsUp, - ReactionOperation.ADD - ); + it('forwards a reaction on the reply to updateReactions', async () => { + const reply = renderReply(); + + fireEvent.click(reply.getByTestId('reply-reactions')); + + await waitFor(() => { + expect(mockProviderValue.updateReactions).toHaveBeenCalledWith( + activityReply, + activity.id, + false, + ReactionType.ThumbsUp, + ReactionOperation.ADD + ); + }); }); }); }); From 1fa5af0d1721960e5366674e5da82ca582e10320 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 13:55:36 +0530 Subject: [PATCH 20/23] fix(ui): guard task comment delete and edit against a double submit Awaiting the callback before closing the UI left the confirmation dialog open and its button live for the length of the request, so a second click fired a concurrent delete. The first request removes the comment and the second fails against a comment that is no longer there, showing the user an error for a delete that actually succeeded and leaving the dialog open. Track the in-flight state in ActivityFeedActions and hand it to the confirmation modal's existing isLoading prop, which puts antd's loading state on the confirm button and stops the click reaching the handler; the state check is the backstop. This matches how TaskDetailPanel already drives DeleteModal, so both delete paths behave the same way. CommentCard's save has the same hazard from the same await - a second click while the edit is in flight sends it twice - so guard it as well. There is no spinner on that path: the send button belongs to ActivityFeedEditorNew, which takes no loading prop, so the guard is state only. Both tests click twice while the promise is still pending and assert a single call; removing either guard fails the matching test. --- .../CommentCard.component.tsx | 12 ++++++++- .../ActivityFeedCardNew/CommentCard.test.tsx | 27 +++++++++++++++++++ .../Shared/ActivityFeedActions.test.tsx | 23 ++++++++++++++++ .../Shared/ActivityFeedActions.tsx | 13 +++++++++ 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx index be627cf9c1c1..80c7cad3e330 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx @@ -78,6 +78,7 @@ const CommentCard = ({ }: CommentCardProps) => { const [isEditPost, setIsEditPost] = useState(false); const [postMessage, setPostMessage] = useState(''); + const [isSaving, setIsSaving] = useState(false); const seperator = '.'; const editorRef = useRef(null); const authorName = author.name ?? author.fullyQualifiedName ?? ''; @@ -111,14 +112,23 @@ const CommentCard = ({ }; const handleSave = useCallback(async () => { + // The editor stays open while the save is in flight, so guard against a + // second submit firing a concurrent edit. + if (isSaving) { + return; + } + + setIsSaving(true); try { await onEdit(postMessage ?? ''); setIsEditPost(false); } catch { // Keep the editor open and the draft intact so the edit can be retried. // The caller owns reporting the failure. + } finally { + setIsSaving(false); } - }, [onEdit, postMessage]); + }, [isSaving, onEdit, postMessage]); const defaultValue = useMemo( () => MarkdownToHTMLConverter.makeHtml(getFrontEndFormat(message)), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx index 5ad203f6740c..b266ed088072 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx @@ -317,6 +317,33 @@ describe('CommentCard', () => { }); }); + it('should not fire a second save while one is in flight', async () => { + let settle: () => void = () => undefined; + onEdit.mockReturnValueOnce( + new Promise((resolve) => { + settle = resolve; + }) + ); + renderCommentCard(); + + await hoverCard(); + + fireEvent.click(screen.getByTestId('edit-button')); + + await waitFor(() => { + expect(screen.getByTestId('feed-editor')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId('send-button')); + fireEvent.click(screen.getByTestId('send-button')); + + await waitFor(() => { + expect(onEdit).toHaveBeenCalledTimes(1); + }); + + settle(); + }); + it('should keep the editor open when the save fails', async () => { onEdit.mockRejectedValueOnce(new Error('boom')); renderCommentCard(); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx index 2117e5c3672e..566497154054 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.test.tsx @@ -292,6 +292,29 @@ describe('ActivityFeedActions', () => { expect(mockDeleteFeed).not.toHaveBeenCalled(); }); + it('does not fire a second delete while one is in flight', async () => { + let settle: () => void = () => undefined; + const onDelete = jest.fn().mockReturnValue( + new Promise((resolve) => { + settle = resolve; + }) + ); + + render(); + fireEvent.click(screen.getByTestId('delete-message')); + + // Double click while the request is still outstanding. Without the guard + // the second click deletes again and 404s against the missing comment. + fireEvent.click(screen.getByTestId('confirm-delete')); + fireEvent.click(screen.getByTestId('confirm-delete')); + + await waitFor(() => { + expect(onDelete).toHaveBeenCalledTimes(1); + }); + + settle(); + }); + it('keeps the confirmation open when onDelete rejects', async () => { const onDelete = jest.fn().mockRejectedValue(new Error('boom')); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx index 211c0b10ece9..d7bebb7dd89b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.tsx @@ -122,6 +122,7 @@ const ActivityFeedActions = ({ const { currentUser } = useApplicationStore(); const isAuthor = getIsAuthor(isReply, currentUser, conversation, reply); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const { deleteFeed, showDrawer, hideDrawer, updateEditorFocus, updateFeed } = useActivityFeedProvider(); @@ -136,12 +137,23 @@ const ActivityFeedActions = ({ const handleDelete = async () => { if (onDelete) { + // The confirmation stays open for the length of the request, so without + // this the button remains clickable and a second click fires a + // concurrent delete - the first succeeds, the second 404s, and the user + // is shown a failure for a delete that actually worked. + if (isDeleting) { + return; + } + + setIsDeleting(true); try { await onDelete(); setShowDeleteDialog(false); } catch { // Leave the confirmation open so the delete can be retried. The caller // owns reporting the failure. + } finally { + setIsDeleting(false); } return; @@ -248,6 +260,7 @@ const ActivityFeedActions = ({ cancelText={t('label.cancel')} confirmText={t('label.delete')} header={t('message.delete-message-question-mark')} + isLoading={isDeleting} visible={showDeleteDialog} onCancel={() => setShowDeleteDialog(false)} onConfirm={handleDelete} From dfc407ae1d887c18c22d868c4c2dd9e7e98896a9 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 16:06:16 +0530 Subject: [PATCH 21/23] fix(ui): put the comment actions in the author row and simplify the card props The edit and delete controls were absolutely positioned over the reply card and nudged into place with a fixed offset, which only ever lined up against one particular padding. Changing the card's spacing left them hanging below the author name, and trimming that left them overlapping the first line of the comment. Render them as the second child of the author row instead, so flexbox centres them against the name and timestamp and they cannot be mispositioned relative to a padding or line height they know nothing about. The shared absolute positioning is undone for this card only; the conversation card keeps its own. Collapse the card's four data props into a single `reply` object, unpacked once inside. Both a conversation reply and a task comment carry author, createdAt, message and reactions, so one structural type serves the activity feed and the task and incident tabs without either call site spreading the same fields. No `conversation` counterpart: a task comment has none, and the card no longer reads one. Drop the antd Col and Row added for the reactions footer. The file already imported antd, but tw-guard forbids new symbols from a deprecated stack, and plain divs render the same box. Trim the action buttons back to the height of the row they sit in and paint them with the bar's own text colour rather than ButtonUtility's grey, which is how these icons read before they became real buttons. Restore DeleteModal's and the toast region's original z-index values. They were raised so the delete confirmation cleared the activity feed drawer; reverting them together keeps the stack consistent, since the toast still sits above the modal. --- .../application/toast/toast-provider.tsx | 2 +- .../ActivityFeedcardNew.component.tsx | 5 +- .../CommentCard.component.tsx | 107 ++++++++++-------- .../ActivityFeedCardNew/CommentCard.test.tsx | 28 +++-- .../ActivityFeedTab/activity-feed-tab.less | 22 ++-- .../Shared/activity-feed-actions.less | 14 +++ .../TaskTab/TaskTabNew.component.test.tsx | 8 +- .../Task/TaskTab/TaskTabNew.component.tsx | 4 +- .../common/DeleteModal/DeleteModal.tsx | 2 +- .../InboxPage/components/TaskDetailPanel.tsx | 2 +- 10 files changed, 117 insertions(+), 77 deletions(-) diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx index e45690484b20..24831d0cb519 100644 --- a/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx +++ b/openmetadata-ui-core-components/src/main/resources/ui/src/components/application/toast/toast-provider.tsx @@ -68,7 +68,7 @@ export const ToastProvider = ({ return ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx index 77575fe389a8..df34c26eed40 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx @@ -336,15 +336,12 @@ const ActivityFeedCardNew = ({ return ( deleteFeed(conversationId, reply.id, false)} onEdit={async (message) => { await updateFeed( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx index 80c7cad3e330..742645039611 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.component.tsx @@ -10,7 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Col, Row, Tooltip, Typography } from 'antd'; +import { Tooltip, Typography } from 'antd'; import classNames from 'classnames'; import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; @@ -32,19 +32,32 @@ import { getUserPath } from '../../../utils/RouterUtils'; import UserPopOverCard from '../../common/PopOverCard/UserPopOverCard'; import ProfilePicture from '../../common/ProfilePicture/ProfilePicture'; import RichTextEditorPreviewerV1 from '../../common/RichTextEditor/RichTextEditorPreviewerV1'; +import '../ActivityFeedTab/activity-feed-tab.less'; import Reactions from '../Reactions/Reactions'; import ActivityFeedActions from '../Shared/ActivityFeedActions'; -import '../ActivityFeedTab/activity-feed-tab.less'; import { COMMENT_ACTIONS_HOVER_REVEAL } from '../Shared/ActivityFeedActions.constants'; const ActivityFeedEditor = withSuspenseFallback( lazy(() => import('../ActivityFeedEditor/ActivityFeedEditorNew')) ); -interface CommentCardProps { +/** + * The comment being rendered. Declared structurally rather than as + * ConversationReply or TaskComment so one card serves both: the activity feed + * passes a conversation reply, the task and incident tabs pass a task comment, + * and these four fields are all this component reads from either. There is no + * `conversation` counterpart because a task comment has none - anything that + * needs the surrounding thread is handled by the caller through the callbacks + * below. + */ +export interface CommentCardReply { author: EntityReference; createdAt: number; message: string; reactions?: Reaction[]; +} + +interface CommentCardProps { + reply: CommentCardReply; isLastReply: boolean; canEdit: boolean; canDelete: boolean; @@ -64,10 +77,7 @@ interface CommentCardProps { } const CommentCard = ({ - author, - createdAt, - message, - reactions, + reply, isLastReply, canEdit, canDelete, @@ -76,6 +86,9 @@ const CommentCard = ({ onReaction, closeFeedEditor, }: CommentCardProps) => { + // Unpacked once here so the rest of the component reads the same field names + // regardless of which comment shape the caller handed over. + const { author, createdAt, message, reactions } = reply; const [isEditPost, setIsEditPost] = useState(false); const [postMessage, setPostMessage] = useState(''); const [isSaving, setIsSaving] = useState(false); @@ -176,59 +189,57 @@ const CommentCard = ({
-
- - - - {getEntityName(user)} - - - - - {seperator} - - - - - {getRelativeTime(createdAt)} - - - +
+
+ + + + {getEntityName(user)} + + + + + {seperator} + + + + + {getRelativeTime(createdAt)} + + + +
+
{feedBodyRender} {onReaction && ( - - +
+
- - +
+
)}
- -
); }; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx index b266ed088072..2f8c605f4339 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx @@ -109,9 +109,11 @@ const renderCommentCard = ( props?: Partial> ) => { const defaultProps: React.ComponentProps = { - author: { id: 'user-1', type: 'user', name: 'testuser' }, - createdAt: 1234567890, - message: 'Test comment message', + reply: { + author: { id: 'user-1', type: 'user', name: 'testuser' }, + createdAt: 1234567890, + message: 'Test comment message', + }, isLastReply: false, canEdit: true, canDelete: true, @@ -173,7 +175,15 @@ describe('CommentCard', () => { it('should fall back to the fully qualified name when author has no name', () => { renderCommentCard({ - author: { id: 'user-1', type: 'user', fullyQualifiedName: 'fqn-user' }, + reply: { + author: { + id: 'user-1', + type: 'user', + fullyQualifiedName: 'fqn-user', + }, + createdAt: 1234567890, + message: 'Test comment message', + }, }); expect(screen.getByTestId('profile-fqn-user')).toBeInTheDocument(); @@ -191,10 +201,12 @@ describe('CommentCard', () => { { }); it('should forward the reaction selection to onReaction', () => { - renderCommentCard({ onReaction, reactions: [] }); + renderCommentCard({ onReaction }); fireEvent.click(screen.getByTestId('reactions')); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less index b611b1c24f4c..0133224bc1dd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedTab/activity-feed-tab.less @@ -497,14 +497,20 @@ border-bottom: 0.5px solid var(--om-legacy-color-e4e4e4); } -// A reply's footer carries the add-reaction control on its own, without the -// post toolbar's sibling controls to frame it. Give it the same bordered box -// the feed actions use so it reads as a control rather than a loose glyph. -.reply-card-footer { - [data-testid='add-reactions'] { - border: 0.5px solid var(--om-color-gray-blue-100); - background: var(--tw-color-bg-primary); - } +// .feed-actions is absolutely positioned at a fixed top: 24px, which only +// lined up while this card's padding was also 24px. It now uses 16px, so pull +// the bar up to match - otherwise it hangs below the author row and covers the +// first line of the comment. Scoped to the reply card so the conversation +// card, which keeps its own spacing, is unaffected. +// A reply renders its actions inside the author row rather than floating them +// over the card, so undo the shared absolute positioning here. Keeping them in +// normal flow is what stops the bar overlapping the comment text - it can no +// longer be mispositioned relative to a padding or line-height it doesn't know +// about. +.reply-card .feed-actions.ant-space { + position: static; + padding-top: var(--om-space-2); + padding-bottom: var(--om-space-2); } // Tooltips render into a portal on document.body, so this has to sit in a diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less index 64034d3c6066..17670f7ce718 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less @@ -35,3 +35,17 @@ align-items: center; justify-content: center; } + +// ButtonUtility ships tw:p-1.5 (6px), which makes each control 28px and the +// bar noticeably taller than the 16px glyphs it replaced. Trim it back so the +// bar keeps its original height while the controls stay real, focusable +// buttons. Written here rather than passed as a class because a Tailwind +// utility would tie on specificity with p-1.5 and win or lose on stylesheet +// order. +.feed-actions.ant-space .toolbar-button { + padding: var(--om-space-2); + // ButtonUtility's `tertiary` colour paints the glyph tw:text-quaternary, a + // mid grey. Fall back to the bar's own text colour, which is what these + // icons inherited before they became buttons. + color: @text-color; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx index 7513970350d7..f6d96a0660bb 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.test.tsx @@ -1317,9 +1317,11 @@ describe('TaskTabNew Component', () => { expect(lastCommentCardProps()).toEqual( expect.objectContaining({ - author: expect.objectContaining({ name: 'alice' }), - createdAt: 1735732800000, - message: 'A comment on the incident', + reply: expect.objectContaining({ + author: expect.objectContaining({ name: 'alice' }), + createdAt: 1735732800000, + message: 'A comment on the incident', + }), }) ); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx index 3f60c796e46f..20102cb52275 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/Task/TaskTab/TaskTabNew.component.tsx @@ -1845,14 +1845,12 @@ export const TaskTabNew = ({ return ( { try { await deleteTaskComment(task.id, comment.id); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx index 7e2f0fe06724..bba5defd33a5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DeleteModal/DeleteModal.tsx @@ -39,7 +39,7 @@ export const DeleteModal = ({ data-testid="delete-modal" isDismissable={!isDeleting} isOpen={open} - style={{ zIndex: 'var(--om-z-modal)' }} + style={{ zIndex: 999 }} onOpenChange={(isOpen) => !isOpen && !isDeleting && onCancel()}> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx index 1ef6a7e95c69..b8649abe62bd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/TaskDetailPanel.tsx @@ -77,6 +77,7 @@ import { resolveCommentPermissions } from '../../../../../utils/TaskCommentUtils import { getResolvedTaskFormSchema } from '../../../../../utils/TaskFormSchemaUtils'; import { getTaskDetailPathFromTask } from '../../../../../utils/TaskNavigationUtils'; import { showErrorToast } from '../../../../../utils/ToastUtils'; +import ActivityFeedEditorNew from '../../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; import { getTaskStatusBadge } from '../taskResolution.utils'; import { buildResolveBody, @@ -84,7 +85,6 @@ import { TaskResolveAction, } from '../taskResolve.utils'; import { getTaskTitle } from '../taskTitle.utils'; -import ActivityFeedEditorNew from '../../../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew'; import InboxCommentComposer from './InboxCommentComposer'; import TaskActionCommentModal from './TaskActionCommentModal'; import TaskActivityTimeline from './TaskActivityTimeline'; From fb83b95866c7e6e40bb6af6f1da5c30da75e48e5 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 16:46:13 +0530 Subject: [PATCH 22/23] refactor(ui): declare the feed action reveal once in CSS The hide-until-hover behaviour was built from Tailwind utility strings held in a constants module, threaded through a className prop into ActivityFeedActions, and paired with a tw:group marker on each card. That spelled the same behaviour out twice - once per card - because a named group is the only way to stop a conversation card's hover reaching the reply cards nested inside it. The actions element already carries a stable class, so state the behaviour against that instead. Hiding, the transition and the focus-within reveal are now one block. Only the hover trigger has to know which card it belongs to, and that is a single rule with two selectors, each anchored to its own card rather than to any ancestor. Removes the constants module, the class strings it exported, the className threading at both call sites and the group markers on three card roots. The className prop on ActivityFeedActions is left in place as an escape hatch but no longer has a caller. Drops the two tests that asserted the class strings, which described the old mechanism rather than any behaviour. What matters - that the controls stay mounted, and so reachable by keyboard, without any pointer interaction - is still covered in both files. --- .../ActivityFeedcardNew.component.test.tsx | 18 -------- .../ActivityFeedcardNew.component.tsx | 6 +-- .../CommentCard.component.tsx | 4 +- .../ActivityFeedCardNew/CommentCard.test.tsx | 15 ------- .../Shared/ActivityFeedActions.constants.ts | 44 ------------------- .../Shared/activity-feed-actions.less | 24 ++++++++++ 6 files changed, 27 insertions(+), 84 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx index e815c5130ceb..042cb422b34e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx @@ -207,24 +207,6 @@ describe('ActivityFeedCardNew', () => { expect(screen.getByTestId('feed-actions')).toBeVisible(); }); - it('hands the card-scoped hover reveal to the root actions', () => { - render( - - - - ); - - // Named group, not a bare `tw:group`: a conversation card contains its - // reply cards, so an unnamed one would reveal every reply's actions at - // once when the conversation is hovered. - expect(screen.getByTestId('feed-card-v2-sidebar').className).toContain( - 'tw:group/feed-card' - ); - expect(screen.getByTestId('feed-actions').className).toContain( - 'tw:group-hover/feed-card:opacity-100' - ); - }); - it('renders activity replies in the open side panel', () => { mockProviderValue.activityReplies = [activityReply]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx index df34c26eed40..6d228841cb0d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.tsx @@ -50,7 +50,6 @@ import FeedCardFooterNew from '../ActivityFeedCardV2/FeedCardFooter/FeedCardFoot import { useActivityFeedProvider } from '../ActivityFeedProvider/ActivityFeedProvider'; import '../ActivityFeedTab/activity-feed-tab.less'; import ActivityFeedActions from '../Shared/ActivityFeedActions'; -import { FEED_ACTIONS_HOVER_REVEAL } from '../Shared/ActivityFeedActions.constants'; import CommentCard from './CommentCard.component'; const ActivityFeedEditorNew = withSuspenseFallback( lazy(() => import('../ActivityFeedEditor/ActivityFeedEditorNew')) @@ -297,7 +296,6 @@ const ActivityFeedCardNew = ({ const feedActions = !isActivityEvent && !isPost && feed ? ( ( ( import('../ActivityFeedEditor/ActivityFeedEditorNew')) ); @@ -175,7 +174,7 @@ const CommentCard = ({ return (
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx index 2f8c605f4339..d4589b9b0824 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/CommentCard.test.tsx @@ -266,21 +266,6 @@ describe('CommentCard', () => { // Hiding them until hover is CSS's job, and it leaves them focusable. expect(screen.getByTestId('feed-actions')).toBeInTheDocument(); }); - - it('should hand the comment-scoped hover reveal to the actions', () => { - renderCommentCard(); - - expect(screen.getByTestId('feed-reply-card').className).toContain( - 'tw:group/comment' - ); - expect(mockActivityFeedActions).toHaveBeenCalledWith( - expect.objectContaining({ - className: expect.stringContaining( - 'tw:group-hover/comment:opacity-100' - ), - }) - ); - }); }); describe('Edit Mode', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts deleted file mode 100644 index ff7c8ca637dc..000000000000 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/ActivityFeedActions.constants.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2026 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. - */ - -/** - * Reveal styling for a card that wants its feed actions hidden until the - * pointer is over it. Deliberately opacity and not a conditional mount: the - * buttons stay in the tab order and in the accessibility tree either way, and - * `focus-within` brings them back into view for keyboard users. - * - * Two variants rather than one because a conversation card contains its reply - * cards - an unnamed `tw:group` would make hovering the conversation reveal - * every reply's actions at once. Each is paired with the matching - * `tw:group/` on the card that owns it, and both are spelled out in full - * because Tailwind only sees class names it can read statically. - * - * Kept out of the component module so a test that mocks ActivityFeedActions - * does not also have to restate these. - */ -export const FEED_ACTIONS_HOVER_REVEAL = [ - 'tw:opacity-0 tw:pointer-events-none', - 'tw:motion-safe:transition-opacity', - 'tw:group-hover/feed-card:opacity-100', - 'tw:group-hover/feed-card:pointer-events-auto', - 'tw:focus-within:opacity-100 tw:focus-within:pointer-events-auto', -].join(' '); - -/** As above, scoped to a single reply card. */ -export const COMMENT_ACTIONS_HOVER_REVEAL = [ - 'tw:opacity-0 tw:pointer-events-none', - 'tw:motion-safe:transition-opacity', - 'tw:group-hover/comment:opacity-100', - 'tw:group-hover/comment:pointer-events-auto', - 'tw:focus-within:opacity-100 tw:focus-within:pointer-events-auto', -].join(' '); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less index 17670f7ce718..28b787704d77 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less @@ -12,6 +12,30 @@ */ @import '../../../styles/variables.less'; +// Hidden until the card is hovered, or until focus lands inside. Declared once +// here against the actions' own class rather than handed down as utility +// classes: the behaviour is identical on every card, so only the hover trigger +// below has to know which card it belongs to. +.feed-actions.ant-space { + opacity: 0; + pointer-events: none; + transition: opacity var(--om-duration-300); + + &:focus-within { + opacity: 1; + pointer-events: auto; + } +} + +// The one part that cannot be shared: which card's hover reveals which bar. +// A conversation card contains reply cards, so each trigger is anchored to its +// own card rather than to any ancestor. +.activity-feed-card-new:hover > .feed-actions.ant-space, +.reply-card:hover .feed-actions.ant-space { + opacity: 1; + pointer-events: auto; +} + .feed-actions.ant-space { position: absolute; right: 10px; From 986e86729879acdc6ac59bdba19428200de98d82 Mon Sep 17 00:00:00 2001 From: Vansh0310 Date: Fri, 18 Sep 2026 16:56:07 +0530 Subject: [PATCH 23/23] fix(ui): match the conversation card's action bar through antd's card body The hover reveal was written as `.activity-feed-card-new:hover > .feed-actions`, but antd's Card renders its children inside an .ant-card-body wrapper, so the bar is a grandchild of the card and the direct-child combinator never matched. Hovering a conversation left the reply, resolve, edit and delete controls at opacity 0 with pointer events off; only keyboard focus still reached them. The Tailwind rule this replaced used a descendant selector and did match, so the regression arrived with that refactor. Add the .ant-card-body step. A plain descendant selector would match again but also reveal every nested reply's bar whenever the conversation is hovered, which is the reason the rule is anchored in the first place; reply cards sit several levels below the card body, so the child chain still excludes them. Pin the structure the selector depends on with a test asserting the bar's parent is .ant-card-body and its grandparent the card. jsdom does not evaluate the stylesheet, so nothing else in the suite would have caught this - an antd upgrade that moves the wrapper now fails loudly instead of quietly hiding the controls from mouse users again. --- .../ActivityFeedcardNew.component.test.tsx | 19 +++++++++++++++++++ .../Shared/activity-feed-actions.less | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx index 042cb422b34e..6fc194c810ef 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/ActivityFeedCardNew/ActivityFeedcardNew.component.test.tsx @@ -207,6 +207,25 @@ describe('ActivityFeedCardNew', () => { expect(screen.getByTestId('feed-actions')).toBeVisible(); }); + it('keeps the root actions a direct child of the card body', () => { + render( + + + + ); + + // The hover reveal in activity-feed-actions.less is written as + // `.activity-feed-card-new:hover > .ant-card-body > .feed-actions`. antd's + // Card puts its children inside .ant-card-body, so that middle step is + // load-bearing: drop it and the selector stops matching, leaving the + // reply/resolve/edit/delete bar hidden from mouse users. + const actions = screen.getByTestId('feed-actions'); + const body = actions.parentElement; + + expect(body).toHaveClass('ant-card-body'); + expect(body?.parentElement).toHaveClass('activity-feed-card-new'); + }); + it('renders activity replies in the open side panel', () => { mockProviderValue.activityReplies = [activityReply]; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less index 28b787704d77..08db382d455b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/Shared/activity-feed-actions.less @@ -29,8 +29,14 @@ // The one part that cannot be shared: which card's hover reveals which bar. // A conversation card contains reply cards, so each trigger is anchored to its -// own card rather than to any ancestor. -.activity-feed-card-new:hover > .feed-actions.ant-space, +// own card rather than to any ancestor - a plain descendant selector on the +// conversation would also reveal every reply's bar. +// +// The .ant-card-body step is antd's own wrapper: Card renders its children +// inside it, so the conversation's bar is a grandchild, not a child. A test in +// ActivityFeedcardNew.component.test.tsx pins that shape so an antd upgrade +// that changes it fails loudly rather than silently leaving these hidden. +.activity-feed-card-new:hover > .ant-card-body > .feed-actions.ant-space, .reply-card:hover .feed-actions.ant-space { opacity: 1; pointer-events: auto;