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:
Shrushti Polekar 2025-10-01 16:21:43 +05:30 committed by GitHub
parent ae39c7e68e
commit a0fea6d11d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 327 additions and 16 deletions

View File

@ -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');
}
);
});
});

View File

@ -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}
/>
);
});

View File

@ -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>
</>

View File

@ -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
);
});
});
});

View File

@ -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;
}