mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-12-24 05:58:31 +00:00
fix(ui): mentions in notification box (#23619)
* fix mentions in notification box * fix activity feed mention redirection issue * added unit test * added playwright test * address pr comments * address pr comment
This commit is contained in:
parent
ae39c7e68e
commit
a0fea6d11d
@ -10,7 +10,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, Page, test as base } from '@playwright/test';
|
||||
import { TableClass } from '../../support/entity/TableClass';
|
||||
import { PersonaClass } from '../../support/persona/PersonaClass';
|
||||
import { UserClass } from '../../support/user/UserClass';
|
||||
@ -22,9 +22,11 @@ import {
|
||||
setUserDefaultPersona,
|
||||
} from '../../utils/customizeLandingPage';
|
||||
|
||||
const test = base;
|
||||
|
||||
const adminUser = new UserClass();
|
||||
const user1 = new UserClass();
|
||||
const seedEntity = new TableClass();
|
||||
const entity = new TableClass();
|
||||
const extraEntity = new TableClass();
|
||||
const testPersona = new PersonaClass();
|
||||
|
||||
@ -41,7 +43,7 @@ test.describe('FeedWidget on landing page', () => {
|
||||
await adminUser.create(apiContext);
|
||||
await adminUser.setAdminRole(apiContext);
|
||||
await user1.create(apiContext);
|
||||
await seedEntity.create(apiContext);
|
||||
await entity.create(apiContext);
|
||||
await extraEntity.create(apiContext);
|
||||
await testPersona.create(apiContext, [adminUser.responseData.id]);
|
||||
|
||||
@ -98,7 +100,7 @@ test.describe('FeedWidget on landing page', () => {
|
||||
const { apiContext, afterAction } = await performAdminLogin(browser);
|
||||
|
||||
try {
|
||||
await seedEntity.delete(apiContext);
|
||||
await entity.delete(apiContext);
|
||||
await extraEntity.delete(apiContext);
|
||||
await user1.delete(apiContext);
|
||||
await testPersona.delete(apiContext);
|
||||
@ -375,3 +377,194 @@ test.describe('FeedWidget on landing page', () => {
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mention notifications in Notification Box', () => {
|
||||
const adminUser = new UserClass();
|
||||
const user1 = new UserClass();
|
||||
const entity = new TableClass();
|
||||
|
||||
const test = base.extend<{
|
||||
adminPage: Page;
|
||||
user1Page: Page;
|
||||
}>({
|
||||
adminPage: async ({ browser }, use) => {
|
||||
const page = await browser.newPage();
|
||||
await adminUser.login(page);
|
||||
await use(page);
|
||||
await page.close();
|
||||
},
|
||||
user1Page: async ({ browser }, use) => {
|
||||
const page = await browser.newPage();
|
||||
await user1.login(page);
|
||||
await use(page);
|
||||
await page.close();
|
||||
},
|
||||
});
|
||||
|
||||
test.beforeAll('Setup entities and users', async ({ browser }) => {
|
||||
const { apiContext, afterAction } = await performAdminLogin(browser);
|
||||
|
||||
try {
|
||||
await adminUser.create(apiContext);
|
||||
await adminUser.setAdminRole(apiContext);
|
||||
await user1.create(apiContext);
|
||||
await entity.create(apiContext);
|
||||
} finally {
|
||||
await afterAction();
|
||||
}
|
||||
});
|
||||
|
||||
test('Mention notification shows correct user details in Notification box', async ({
|
||||
adminPage,
|
||||
user1Page,
|
||||
}) => {
|
||||
await test.step(
|
||||
'Admin user creates a conversation on an entity',
|
||||
async () => {
|
||||
await entity.visitEntityPage(adminPage);
|
||||
|
||||
await adminPage.getByTestId('activity_feed').click();
|
||||
await adminPage.waitForLoadState('networkidle');
|
||||
|
||||
await adminPage.waitForSelector('[data-testid="loader"]', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await adminPage.getByTestId('comments-input-field').click();
|
||||
|
||||
await adminPage
|
||||
.locator(
|
||||
'[data-testid="editor-wrapper"] [contenteditable="true"].ql-editor'
|
||||
)
|
||||
.fill('Initial conversation thread for mention test');
|
||||
|
||||
await expect(
|
||||
adminPage.locator('[data-testid="send-button"]')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
adminPage.locator('[data-testid="send-button"]')
|
||||
).not.toBeDisabled();
|
||||
|
||||
const postConversation = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/v1/feed') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.url().includes('/posts')
|
||||
);
|
||||
await adminPage.locator('[data-testid="send-button"]').click();
|
||||
await postConversation;
|
||||
}
|
||||
);
|
||||
|
||||
await test.step('User1 mentions admin user in a reply', async () => {
|
||||
await entity.visitEntityPage(user1Page);
|
||||
|
||||
await user1Page.getByTestId('activity_feed').click();
|
||||
await user1Page.waitForLoadState('networkidle');
|
||||
|
||||
await user1Page.waitForSelector('[data-testid="loader"]', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await user1Page.getByTestId('comments-input-field').click();
|
||||
|
||||
const editorLocator = user1Page.locator(
|
||||
'[data-testid="editor-wrapper"] [contenteditable="true"].ql-editor'
|
||||
);
|
||||
|
||||
await editorLocator.fill('Hey ');
|
||||
|
||||
const userSuggestionsResponse = user1Page.waitForResponse(
|
||||
`/api/v1/search/query?q=*${adminUser.responseData.name}***`
|
||||
);
|
||||
|
||||
await editorLocator.pressSequentially(`@${adminUser.responseData.name}`);
|
||||
await userSuggestionsResponse;
|
||||
|
||||
await user1Page
|
||||
.locator(`[data-value="@${adminUser.responseData.name}"]`)
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await editorLocator.type(', can you check this?');
|
||||
|
||||
await expect(
|
||||
user1Page.locator('[data-testid="send-button"]')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
user1Page.locator('[data-testid="send-button"]')
|
||||
).not.toBeDisabled();
|
||||
|
||||
const postMentionResponse = user1Page.waitForResponse(
|
||||
'/api/v1/feed/*/posts'
|
||||
);
|
||||
await user1Page.locator('[data-testid="send-button"]').click();
|
||||
await postMentionResponse;
|
||||
});
|
||||
|
||||
await test.step(
|
||||
'Admin user checks notification for correct user and timestamp',
|
||||
async () => {
|
||||
await adminPage.reload();
|
||||
await adminPage.waitForLoadState('networkidle');
|
||||
const notificationBell = adminPage.getByTestId('task-notifications');
|
||||
|
||||
await expect(notificationBell).toBeVisible();
|
||||
|
||||
const feedResponseForNotifications =
|
||||
adminPage.waitForResponse(`api/v1/feed?userId=*`);
|
||||
|
||||
await notificationBell.click();
|
||||
await feedResponseForNotifications;
|
||||
const notificationBox = adminPage.locator('.notification-box');
|
||||
|
||||
await expect(notificationBox).toBeVisible();
|
||||
|
||||
const mentionsTab = adminPage
|
||||
.locator('.notification-box')
|
||||
.getByText('Mentions');
|
||||
|
||||
const mentionsFeedResponse = adminPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/v1/feed') &&
|
||||
response.url().includes('filterType=MENTIONS') &&
|
||||
response.url().includes('type=Conversation')
|
||||
);
|
||||
|
||||
await mentionsTab.click();
|
||||
await mentionsFeedResponse;
|
||||
|
||||
const mentionsList = adminPage
|
||||
.getByRole('tabpanel', { name: 'Mentions' })
|
||||
.getByRole('list');
|
||||
|
||||
await expect(mentionsList).toBeVisible();
|
||||
|
||||
const firstNotificationItem = mentionsList
|
||||
.locator('li.ant-list-item.notification-dropdown-list-btn')
|
||||
.first();
|
||||
|
||||
const firstNotificationText = await firstNotificationItem.textContent();
|
||||
|
||||
expect(firstNotificationText?.toLowerCase()).toContain(
|
||||
user1.responseData.name.toLowerCase()
|
||||
);
|
||||
expect(firstNotificationText?.toLowerCase()).not.toContain(
|
||||
adminUser.responseData.name.toLowerCase()
|
||||
);
|
||||
|
||||
const mentionNotificationLink = firstNotificationItem.locator(
|
||||
'[data-testid^="notification-link-"]'
|
||||
);
|
||||
|
||||
const navigationPromise = adminPage.waitForURL(/activity_feed/);
|
||||
await mentionNotificationLink.click();
|
||||
await navigationPromise;
|
||||
await adminPage.waitForLoadState('networkidle');
|
||||
|
||||
expect(adminPage.url()).toContain('activity_feed');
|
||||
expect(adminPage.url()).toContain('/all');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -68,15 +68,38 @@ const NotificationBox = ({
|
||||
const entityType = getEntityType(feed.about);
|
||||
const entityFQN = getEntityFQN(feed.about);
|
||||
|
||||
// For mention notifications, get the actual user who made the mention from posts
|
||||
let actualUser = mainFeed.from;
|
||||
let actualTimestamp = mainFeed.postTs;
|
||||
|
||||
if (
|
||||
feed.type === ThreadType.Conversation &&
|
||||
feed.posts &&
|
||||
feed.posts.length > 0
|
||||
) {
|
||||
// Find the most recent post that contains a mention
|
||||
const mentionPost = feed.posts
|
||||
.filter(
|
||||
(post) =>
|
||||
post.message.includes('<#E::user::') && post.postTs !== undefined
|
||||
)
|
||||
.sort((a, b) => (b.postTs ?? 0) - (a.postTs ?? 0))[0];
|
||||
|
||||
if (mentionPost && mentionPost.postTs !== undefined) {
|
||||
actualUser = mentionPost.from;
|
||||
actualTimestamp = mentionPost.postTs;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationFeedCard
|
||||
createdBy={mainFeed.from}
|
||||
createdBy={actualUser}
|
||||
entityFQN={entityFQN as string}
|
||||
entityType={entityType as string}
|
||||
feedType={feed.type || ThreadType.Conversation}
|
||||
key={`${mainFeed.from} ${mainFeed.id}`}
|
||||
key={`${actualUser} ${mainFeed.id}`}
|
||||
task={feed}
|
||||
timestamp={mainFeed.postTs}
|
||||
timestamp={actualTimestamp}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@ -25,6 +25,7 @@ import { getEntityLinkFromType, getEntityName } from '../../utils/EntityUtils';
|
||||
import { entityDisplayName, prepareFeedLink } from '../../utils/FeedUtils';
|
||||
import Fqn from '../../utils/Fqn';
|
||||
import { getTaskDetailPath } from '../../utils/TasksUtils';
|
||||
import { ActivityFeedTabs } from '../ActivityFeed/ActivityFeedTab/ActivityFeedTab.interface';
|
||||
import ProfilePicture from '../common/ProfilePicture/ProfilePicture';
|
||||
import { SourceType } from '../SearchedData/SearchedData.interface';
|
||||
import { NotificationFeedProp } from './NotificationFeedCard.interface';
|
||||
@ -95,7 +96,7 @@ const NotificationFeedCard: FC<NotificationFeedProp> = ({
|
||||
className="no-underline"
|
||||
to={
|
||||
feedType === ThreadType.Conversation
|
||||
? prepareFeedLink(entityType, entityFQN)
|
||||
? prepareFeedLink(entityType, entityFQN, ActivityFeedTabs.ALL)
|
||||
: getTaskDetailPath(task)
|
||||
}>
|
||||
<List.Item.Meta
|
||||
@ -117,7 +118,11 @@ const NotificationFeedCard: FC<NotificationFeedProp> = ({
|
||||
<Link
|
||||
className="truncate"
|
||||
data-testid={`notification-link-${entityName}`}
|
||||
to={prepareFeedLink(entityType, entityFQN)}>
|
||||
to={prepareFeedLink(
|
||||
entityType,
|
||||
entityFQN,
|
||||
ActivityFeedTabs.ALL
|
||||
)}>
|
||||
{entityName}
|
||||
</Link>
|
||||
</>
|
||||
|
||||
@ -20,12 +20,16 @@ jest.mock('../../utils/date-time/DateTimeUtils', () => ({
|
||||
formatDateTime: jest.fn().mockImplementation((date) => date),
|
||||
getRelativeTime: jest.fn().mockImplementation((date) => date),
|
||||
}));
|
||||
|
||||
const mockPrepareFeedLink = jest.fn();
|
||||
const mockGetTaskDetailPath = jest.fn();
|
||||
|
||||
jest.mock('../../utils/FeedUtils', () => ({
|
||||
entityDisplayName: jest.fn().mockReturnValue('database.schema.table'),
|
||||
prepareFeedLink: jest.fn().mockReturnValue('entity-link'),
|
||||
prepareFeedLink: (...args: any[]) => mockPrepareFeedLink(...args),
|
||||
}));
|
||||
jest.mock('../../utils/TasksUtils', () => ({
|
||||
getTaskDetailPath: jest.fn().mockReturnValue('/'),
|
||||
getTaskDetailPath: (...args: any[]) => mockGetTaskDetailPath(...args),
|
||||
}));
|
||||
jest.mock('../common/ProfilePicture/ProfilePicture', () => {
|
||||
return jest
|
||||
@ -35,9 +39,13 @@ jest.mock('../common/ProfilePicture/ProfilePicture', () => {
|
||||
jest.mock('react-router-dom', () => ({
|
||||
Link: jest
|
||||
.fn()
|
||||
.mockImplementation(({ children }: { children: React.ReactNode }) => (
|
||||
<p data-testid="link">{children}</p>
|
||||
)),
|
||||
.mockImplementation(
|
||||
({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<p data-testid="link" data-to={to}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
),
|
||||
}));
|
||||
jest.mock('../../utils/EntityUtils', () => ({
|
||||
getEntityLinkFromType: jest.fn().mockReturnValue('/mock-entity-link'),
|
||||
@ -181,4 +189,80 @@ describe('Test NotificationFeedCard Component', () => {
|
||||
|
||||
expect(screen.getByText('1692612000000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Navigation URL Tests', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should navigate to tasks subtab for task notifications', async () => {
|
||||
const taskUrl = '/database/test.entity/activity_feed/tasks';
|
||||
mockGetTaskDetailPath.mockReturnValue(taskUrl);
|
||||
|
||||
const taskProps = {
|
||||
...mockProps,
|
||||
feedType: ThreadType.Task,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(<NotificationFeedCard {...taskProps} />);
|
||||
});
|
||||
|
||||
// Verify getTaskDetailPath was called with the task
|
||||
expect(mockGetTaskDetailPath).toHaveBeenCalledWith(mockThread);
|
||||
|
||||
// Verify Link component has the correct URL
|
||||
const linkElement = screen.getAllByTestId('link')[0]; // Main link
|
||||
|
||||
expect(linkElement).toHaveAttribute('data-to', taskUrl);
|
||||
});
|
||||
|
||||
it('should navigate to all subtab for conversation notifications', async () => {
|
||||
const conversationUrl = '/database/test.entity/activity_feed/all';
|
||||
mockPrepareFeedLink.mockReturnValue(conversationUrl);
|
||||
|
||||
const conversationProps = {
|
||||
...mockProps,
|
||||
feedType: ThreadType.Conversation,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(<NotificationFeedCard {...conversationProps} />);
|
||||
});
|
||||
|
||||
// Verify prepareFeedLink was called with ActivityFeedTabs.ALL
|
||||
expect(mockPrepareFeedLink).toHaveBeenCalledWith(
|
||||
mockProps.entityType,
|
||||
mockProps.entityFQN,
|
||||
'all' // ActivityFeedTabs.ALL value
|
||||
);
|
||||
|
||||
// Verify Link component has the correct URL
|
||||
const linkElement = screen.getAllByTestId('link')[0]; // Main link
|
||||
|
||||
expect(linkElement).toHaveAttribute('data-to', conversationUrl);
|
||||
});
|
||||
|
||||
it('should call prepareFeedLink with ALL subtab for entity links in conversation notifications', async () => {
|
||||
const entityLinkUrl = '/database/test.entity/activity_feed/all';
|
||||
mockPrepareFeedLink.mockReturnValue(entityLinkUrl);
|
||||
|
||||
const conversationProps = {
|
||||
...mockProps,
|
||||
feedType: ThreadType.Conversation,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
render(<NotificationFeedCard {...conversationProps} />);
|
||||
});
|
||||
|
||||
// Should be called twice - once for main link, once for entity name link
|
||||
expect(mockPrepareFeedLink).toHaveBeenCalledTimes(2);
|
||||
expect(mockPrepareFeedLink).toHaveBeenCalledWith(
|
||||
mockProps.entityType,
|
||||
mockProps.entityFQN,
|
||||
'all' // ActivityFeedTabs.ALL value
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -502,7 +502,11 @@ export const updateThreadData = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const prepareFeedLink = (entityType: string, entityFQN: string) => {
|
||||
export const prepareFeedLink = (
|
||||
entityType: string,
|
||||
entityFQN: string,
|
||||
subTab?: string
|
||||
) => {
|
||||
const withoutFeedEntities = [
|
||||
EntityType.WEBHOOK,
|
||||
EntityType.TYPE,
|
||||
@ -512,7 +516,9 @@ export const prepareFeedLink = (entityType: string, entityFQN: string) => {
|
||||
const entityLink = entityUtilClassBase.getEntityLink(entityType, entityFQN);
|
||||
|
||||
if (!withoutFeedEntities.includes(entityType as EntityType)) {
|
||||
return `${entityLink}/${TabSpecificField.ACTIVITY_FEED}`;
|
||||
const activityFeedLink = `${entityLink}/${TabSpecificField.ACTIVITY_FEED}`;
|
||||
|
||||
return subTab ? `${activityFeedLink}/${subTab}` : activityFeedLink;
|
||||
} else {
|
||||
return entityLink;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user