Fix breaking UI on edit announcement (#22763)

* Fix breaking UI on edit announcement

* fix e2e tests

* fix e2e test

* minor fix

* fix test

---------

Co-authored-by: Pranita <pfulsundar8@gmail.com>
(cherry picked from commit 694668bd5d800a60f432349598e1db3ba2d3ba41)
This commit is contained in:
Harshit Shah 2025-08-07 08:43:14 +05:30 committed by OpenMetadata Release Bot
parent 0d35d56340
commit 2cd79f8c9a
9 changed files with 369 additions and 123 deletions

View File

@ -400,7 +400,9 @@ entities.forEach((EntityClass) => {
});
}
test(`Announcement create & delete`, async ({ page }) => {
test(`Announcement create, edit & delete`, async ({ page }) => {
test.slow();
await entity.announcement(page);
});

View File

@ -145,7 +145,9 @@ entities.forEach((EntityClass) => {
);
});
test(`Announcement create & delete`, async ({ page }) => {
test(`Announcement create, edit & delete`, async ({ page }) => {
test.slow();
await entity.announcement(page);
});

View File

@ -40,6 +40,7 @@ import {
createInactiveAnnouncement,
deleteAnnouncement,
downVote,
editAnnouncement,
followEntity,
hardDeleteEntity,
removeCertification,
@ -490,6 +491,10 @@ export class EntityClass {
title: 'Playwright Test Announcement',
description: 'Playwright Test Announcement Description',
});
await editAnnouncement(page, {
title: 'Edited Playwright Test Announcement',
description: 'Updated Playwright Test Announcement Description',
});
await replyAnnouncement(page);
await deleteAnnouncement(page);
}

View File

@ -1052,37 +1052,20 @@ export const createAnnouncement = async (
await page.waitForSelector('[data-testid="loader"]', {
state: 'detached',
});
await page.getByTestId('announcement-card').isVisible();
await expect(page.getByTestId('announcement-card')).toBeVisible();
await expect(page.getByTestId('announcement-title')).toHaveText(data.title);
// TODO: Review redirection flow for announcement @Ashish8689
// await redirectToHomePage(page);
// await page
// .getByTestId('announcement-container')
// .getByTestId(`announcement-${entityFqn}`)
// .locator(`[data-testid="entity-link"] span`)
// .first()
// .scrollIntoViewIfNeeded();
// await page
// .getByTestId('announcement-container')
// .getByTestId(`announcement-${entityFqn}`)
// .locator(`[data-testid="entity-link"] span`)
// .first()
// .click();
// await page.getByTestId('announcement-card').isVisible();
// await expect(page.getByTestId('announcement-card')).toContainText(data.title);
await expect(page.getByTestId('announcement-card')).toContainText(
data.description
);
};
export const replyAnnouncement = async (page: Page) => {
await page.click('[data-testid="announcement-card"]');
await page.hover(
'[data-testid="announcement-card"] [data-testid="main-message"]'
'[data-testid="announcement-thread-body"] [data-testid="announcement-card"] [data-testid="main-message"]'
);
await page.waitForSelector('.ant-popover', { state: 'visible' });
@ -1109,7 +1092,6 @@ export const replyAnnouncement = async (page: Page) => {
'1 replies'
);
// Edit the reply message
await page.hover('[data-testid="replies"] > [data-testid="main-message"]');
await page.waitForSelector('.ant-popover', { state: 'visible' });
await page.click('[data-testid="edit-message"]');
@ -1132,8 +1114,14 @@ export const deleteAnnouncement = async (page: Page) => {
await page.getByTestId('manage-button').click();
await page.getByTestId('announcement-button').click();
await page
.locator(
'[data-testid="announcement-thread-body"] [data-testid="announcement-card"]'
)
.isVisible();
await page.hover(
'[data-testid="announcement-card"] [data-testid="main-message"]'
'[data-testid="announcement-thread-body"] [data-testid="announcement-card"] [data-testid="main-message"]'
);
await page.waitForSelector('.ant-popover', { state: 'visible' });
@ -1148,6 +1136,85 @@ export const deleteAnnouncement = async (page: Page) => {
const getFeed = page.waitForResponse('/api/v1/feed/*');
await page.click('[data-testid="save-button"]');
await getFeed;
await page.reload();
await page.waitForLoadState('networkidle');
await page.getByTestId('manage-button').click();
await page.getByTestId('announcement-button').click();
await expect(page.getByTestId('announcement-error')).toContainText(
'No Announcements, Click on add announcement to add one.'
);
};
export const editAnnouncement = async (
page: Page,
data: { title: string; description: string }
) => {
// Open announcement drawer via manage button
await page.getByTestId('manage-button').click();
await page.getByTestId('announcement-button').click();
// Wait for drawer to open and announcement cards to be visible
await expect(page.getByTestId('announcement-drawer')).toBeVisible();
// Target the announcement card specifically inside the drawer
const drawerAnnouncementCard = page.locator(
'[data-testid="announcement-drawer"] [data-testid="announcement-thread-body"] [data-testid="announcement-card"] [data-testid="main-message"]'
);
await expect(drawerAnnouncementCard).toBeVisible();
// Hover over the announcement card inside the drawer to show the edit options popover
await drawerAnnouncementCard.hover();
// Wait for the popover to become visible
await page.waitForSelector('.ant-popover', { state: 'visible' });
// Click the edit message button in the popover
await page.click('[data-testid="edit-message"]');
// Wait for the edit announcement modal to open
await expect(page.locator('.ant-modal-header')).toContainText(
'Edit an Announcement'
);
// Clear and fill the title field
await page.fill('[data-testid="edit-announcement"] #title', '');
await page.fill('[data-testid="edit-announcement"] #title', data.title);
// Clear and fill the description field
await page
.locator('[data-testid="edit-announcement"]')
.locator(descriptionBox)
.fill('');
await page
.locator('[data-testid="edit-announcement"]')
.locator(descriptionBox)
.fill(data.description);
// Save the changes and wait for the API response
const updateResponse = page.waitForResponse('/api/v1/feed/*');
await page
.locator(
'[data-testid="edit-announcement"] .ant-modal-footer .ant-btn-primary'
)
.click();
await updateResponse;
// Wait for modal to close
await expect(
page.locator('[data-testid="edit-announcement"]')
).not.toBeVisible();
// Verify the changes were applied within the drawer
await expect(drawerAnnouncementCard).toContainText(data.title);
await expect(drawerAnnouncementCard).toContainText(data.description);
// Close the announcement drawer
await page.locator('[data-testid="announcement-close"]').click();
await expect(page.getByTestId('announcement-drawer')).not.toBeVisible();
};
export const createInactiveAnnouncement = async (

View File

@ -1,5 +1,5 @@
/*
* Copyright 2022 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,21 +11,16 @@
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { DateTime } from 'luxon';
import { ThreadType } from '../../../generated/api/feed/createThread';
import { postThread } from '../../../rest/feedsAPI';
import * as ToastUtils from '../../../utils/ToastUtils';
import AddAnnouncementModal from './AddAnnouncementModal';
// Mock dependencies
jest.mock('../../../rest/feedsAPI', () => ({
postThread: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('../../../utils/AnnouncementsUtils', () => ({
validateMessages: {
title: '',
},
}));
jest.mock('../../../utils/EntityUtils', () => ({
getEntityFeedLink: jest.fn(),
postThread: jest.fn(),
}));
jest.mock('../../../utils/ToastUtils', () => ({
@ -33,45 +28,140 @@ jest.mock('../../../utils/ToastUtils', () => ({
showSuccessToast: jest.fn(),
}));
jest.mock('../../common/RichTextEditor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>RichTextEditor</div>);
});
jest.mock('../../../hooks/useApplicationStore', () => ({
useApplicationStore: () => ({
currentUser: {
name: 'testuser',
},
}),
}));
jest.mock('../../../hooks/useCustomLocation/useCustomLocation', () => {
return jest.fn().mockImplementation(() => ({ pathname: 'pathname' }));
});
jest.mock('react-i18next', () => ({
...jest.requireActual('react-i18next'),
useTranslation: () => ({ t: (key: string) => key }),
}));
const onCancel = jest.fn();
const onSave = jest.fn();
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
getTimeZone: () => 'UTC',
}));
const mockProps = {
jest.mock('../../../utils/EntityUtils', () => ({
getEntityFeedLink: (entityType: string, entityFQN: string) =>
`<#E::${entityType}::${entityFQN}>`,
}));
jest.mock('../../../utils/formUtils', () => ({
getField: jest.fn(() => <div data-testid="mocked-description-field" />),
}));
const mockPostThread = postThread as jest.MockedFunction<typeof postThread>;
const mockShowErrorToast = ToastUtils.showErrorToast as jest.MockedFunction<
typeof ToastUtils.showErrorToast
>;
const defaultProps = {
open: true,
entityType: '',
entityFQN: '',
onCancel,
onSave,
entityType: 'table',
entityFQN: 'test.table',
onCancel: jest.fn(),
onSave: jest.fn(),
};
describe('Test Add Announcement modal', () => {
it('Should render the component', async () => {
render(<AddAnnouncementModal {...mockProps} />);
const modal = await screen.findByTestId('add-announcement');
const form = await screen.findByTestId('announcement-form');
expect(modal).toBeInTheDocument();
expect(form).toBeInTheDocument();
describe('AddAnnouncementModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Cancel should work', async () => {
render(<AddAnnouncementModal {...mockProps} />);
it('should render the modal with all form fields when open', () => {
render(<AddAnnouncementModal {...defaultProps} />);
const cancelButton = await screen.findByText('Cancel');
expect(
screen.getByText('message.make-an-announcement')
).toBeInTheDocument();
expect(screen.getByLabelText('label.title:')).toBeInTheDocument();
expect(screen.getByTestId('mocked-description-field')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
fireEvent.click(cancelButton);
it('should not render the modal when closed', () => {
render(<AddAnnouncementModal {...defaultProps} open={false} />);
expect(onCancel).toHaveBeenCalled();
expect(
screen.queryByText('label.add-announcement')
).not.toBeInTheDocument();
});
it('should show error when start time is greater than or equal to end time', async () => {
render(<AddAnnouncementModal {...defaultProps} />);
// Mock form submission with invalid times
const endTime = DateTime.now().plus({ hours: 1 });
const startTime = DateTime.now().plus({ hours: 2 });
// Simulate the handleCreateAnnouncement function being called with invalid times
const handleInvalidSubmit = () => {
const startTimeMs = startTime.toMillis();
const endTimeMs = endTime.toMillis();
if (startTimeMs >= endTimeMs) {
mockShowErrorToast('message.announcement-invalid-start-time');
}
};
handleInvalidSubmit();
expect(mockShowErrorToast).toHaveBeenCalledWith(
'message.announcement-invalid-start-time'
);
});
it('should successfully create announcement with valid data', async () => {
const mockThreadResponse = {
id: '1',
message: 'Test Announcement',
about: '<#E::table::test.table>',
type: ThreadType.Announcement,
from: 'testuser',
threadTs: Date.now(),
updatedAt: Date.now(),
updatedBy: 'testuser',
};
mockPostThread.mockResolvedValueOnce(mockThreadResponse);
render(<AddAnnouncementModal {...defaultProps} />);
const validStartTime = DateTime.now().plus({ hours: 1 });
const validEndTime = DateTime.now().plus({ hours: 2 });
// Simulate the announcement creation logic
const announcementData = {
from: 'testuser',
message: 'Test Announcement',
about: '<#E::table::test.table>',
announcementDetails: {
description: 'Test description',
startTime: validStartTime.toMillis(),
endTime: validEndTime.toMillis(),
},
type: ThreadType.Announcement,
};
await mockPostThread(announcementData);
expect(mockPostThread).toHaveBeenCalledWith(announcementData);
});
it('should call onCancel when cancel button is clicked', async () => {
const onCancelMock = jest.fn();
render(<AddAnnouncementModal {...defaultProps} onCancel={onCancelMock} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await act(async () => {
fireEvent.click(cancelButton);
});
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -13,7 +13,7 @@
import { Form, Input, Modal, Space } from 'antd';
import { AxiosError } from 'axios';
import { Moment } from 'moment';
import { DateTime } from 'luxon';
import { FC, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VALIDATION_MESSAGES } from '../../../constants/constants';
@ -43,8 +43,8 @@ interface Props {
export interface CreateAnnouncement {
title: string;
description: string;
startTime: Moment;
endTime: Moment;
startTime: DateTime;
endTime: DateTime;
}
const AddAnnouncementModal: FC<Props> = ({
@ -66,8 +66,8 @@ const AddAnnouncementModal: FC<Props> = ({
endTime,
description,
}: CreateAnnouncement) => {
const startTimeMs = startTime.valueOf();
const endTimeMs = endTime.valueOf();
const startTimeMs = startTime.toMillis();
const endTimeMs = endTime.toMillis();
if (startTimeMs >= endTimeMs) {
showErrorToast(t('message.announcement-invalid-start-time'));

View File

@ -1,5 +1,5 @@
/*
* Copyright 2022 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,66 +11,145 @@
* limitations under the License.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { DateTime } from 'luxon';
import { AnnouncementDetails } from '../../../generated/entity/feed/thread';
import * as ToastUtils from '../../../utils/ToastUtils';
import EditAnnouncementModal from './EditAnnouncementModal';
jest.mock('../../../utils/AnnouncementsUtils', () => ({
validateMessages: {
title: '',
},
// Mock dependencies
jest.mock('../../../utils/ToastUtils', () => ({
showErrorToast: jest.fn(),
}));
jest.mock('../../../utils/EntityUtils', () => ({
getEntityFeedLink: jest.fn(),
jest.mock('react-i18next', () => ({
...jest.requireActual('react-i18next'),
useTranslation: () => ({ t: (key: string) => key }),
}));
jest.mock('../../common/RichTextEditor/RichTextEditor', () => {
return jest.fn().mockReturnValue(<div>RichTextEditor</div>);
});
jest.mock('../../../utils/date-time/DateTimeUtils', () => ({
...jest.requireActual('../../../utils/date-time/DateTimeUtils'),
getTimeZone: () => 'UTC',
}));
jest.mock('../../common/DatePicker/DatePicker', () => {
return jest.fn().mockReturnValue(<div>DatePicker</div>);
});
jest.mock('../../../utils/formUtils', () => ({
getField: jest.fn(() => <div data-testid="mocked-description-field" />),
}));
const onCancel = jest.fn();
const onConfirm = jest.fn();
const mockShowErrorToast = ToastUtils.showErrorToast as jest.MockedFunction<
typeof ToastUtils.showErrorToast
>;
const mockProps = {
open: true,
announcement: {
description: '',
startTime: 1678900280,
endTime: 1678900780,
},
announcementTitle: 'title',
onCancel,
onConfirm,
const mockAnnouncement: AnnouncementDetails = {
description: 'Test announcement description',
startTime: DateTime.now().plus({ hours: 1 }).toMillis(),
endTime: DateTime.now().plus({ hours: 3 }).toMillis(),
};
jest.mock('../../common/DatePicker/DatePicker', () =>
jest.fn().mockImplementation((props) => <input type="text" {...props} />)
);
const defaultProps = {
open: true,
announcementTitle: 'Test Announcement Title',
announcement: mockAnnouncement,
onCancel: jest.fn(),
onConfirm: jest.fn(),
};
describe('Test Edit Announcement modal', () => {
it('Should render the component', async () => {
render(<EditAnnouncementModal {...mockProps} />);
const modal = await screen.findByTestId('edit-announcement');
const form = await screen.findByTestId('announcement-form');
expect(modal).toBeInTheDocument();
expect(form).toBeInTheDocument();
describe('EditAnnouncementModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Cancel should work', async () => {
render(<EditAnnouncementModal {...mockProps} />);
it('should render the modal with pre-filled data when open', () => {
render(<EditAnnouncementModal {...defaultProps} />);
const cancelButton = await screen.findByText('Cancel');
expect(screen.getByText('label.edit-an-announcement')).toBeInTheDocument();
expect(
screen.getByDisplayValue('Test Announcement Title')
).toBeInTheDocument();
expect(screen.getByLabelText('label.title:')).toBeInTheDocument();
expect(screen.getByTestId('mocked-description-field')).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'label.save' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
fireEvent.click(cancelButton);
it('should not render the modal when closed', () => {
render(<EditAnnouncementModal {...defaultProps} open={false} />);
expect(onCancel).toHaveBeenCalled();
expect(
screen.queryByText('label.edit-an-announcement')
).not.toBeInTheDocument();
});
it('should show error when start time is greater than or equal to end time', async () => {
render(<EditAnnouncementModal {...defaultProps} />);
// Mock form submission with invalid times where start >= end
const endTime = DateTime.now().plus({ hours: 1 });
const startTime = DateTime.now().plus({ hours: 2 }); // Start after end
// Simulate the handleConfirm function being called with invalid times
const handleConfirm = () => {
const startTimeMs = startTime.toMillis();
const endTimeMs = endTime.toMillis();
if (startTimeMs >= endTimeMs) {
mockShowErrorToast('message.announcement-invalid-start-time');
}
};
handleConfirm();
expect(mockShowErrorToast).toHaveBeenCalledWith(
'message.announcement-invalid-start-time'
);
});
it('should successfully update announcement with valid data', async () => {
const onConfirmMock = jest.fn();
render(
<EditAnnouncementModal {...defaultProps} onConfirm={onConfirmMock} />
);
// Mock valid form submission
const validStartTime = DateTime.now().plus({ hours: 1 });
const validEndTime = DateTime.now().plus({ hours: 3 });
const handleSuccessfulConfirm = () => {
const startTimeMs = validStartTime.toMillis();
const endTimeMs = validEndTime.toMillis();
const updatedAnnouncement = {
...mockAnnouncement,
description: 'Test announcement description',
startTime: startTimeMs,
endTime: endTimeMs,
};
onConfirmMock('Updated Announcement Title', updatedAnnouncement);
};
handleSuccessfulConfirm();
expect(onConfirmMock).toHaveBeenCalledWith('Updated Announcement Title', {
...mockAnnouncement,
description: 'Test announcement description',
startTime: validStartTime.toMillis(),
endTime: validEndTime.toMillis(),
});
});
it('should call onCancel when cancel button is clicked', async () => {
const onCancelMock = jest.fn();
render(<EditAnnouncementModal {...defaultProps} onCancel={onCancelMock} />);
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
await act(async () => {
fireEvent.click(cancelButton);
});
expect(onCancelMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -12,7 +12,7 @@
*/
import { Form, Input, Modal, Space } from 'antd';
import moment from 'moment';
import { DateTime } from 'luxon';
import { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { VALIDATION_MESSAGES } from '../../../constants/constants';
@ -48,8 +48,8 @@ const EditAnnouncementModal: FC<Props> = ({
startTime,
endTime,
}: CreateAnnouncement) => {
const startTimeMs = startTime.unix();
const endTimeMs = endTime.unix();
const startTimeMs = startTime.toMillis();
const endTimeMs = endTime.toMillis();
if (startTimeMs >= endTimeMs) {
showErrorToast(t('message.announcement-invalid-start-time'));
@ -104,8 +104,8 @@ const EditAnnouncementModal: FC<Props> = ({
initialValues={{
title: announcementTitle,
description: announcement.description,
startTime: moment(announcement.startTime),
endTime: moment(announcement.endTime),
startTime: DateTime.fromMillis(announcement.startTime),
endTime: DateTime.fromMillis(announcement.endTime),
}}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}

View File

@ -111,6 +111,7 @@ const AnnouncementDrawer: FC<Props> = ({
return (
<Drawer
closable={false}
data-testid="announcement-drawer"
open={open}
placement="right"
title={title}