diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts index e01945bc9e6..8a0d3a6e696 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ActivityFeed.spec.ts @@ -10,14 +10,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { expect, Page, test as base } from '@playwright/test'; +import { test as base, expect, Page } from '@playwright/test'; +import { ApiEndpointClass } from '../../support/entity/ApiEndpointClass'; +import { DatabaseClass } from '../../support/entity/DatabaseClass'; import { EntityDataClass } from '../../support/entity/EntityDataClass'; import { TableClass } from '../../support/entity/TableClass'; import { PersonaClass } from '../../support/persona/PersonaClass'; import { UserClass } from '../../support/user/UserClass'; import { REACTION_EMOJIS, reactOnFeed } from '../../utils/activityFeed'; import { performAdminLogin } from '../../utils/admin'; -import { redirectToHomePage } from '../../utils/common'; +import { redirectToHomePage, uuid } from '../../utils/common'; import { navigateToCustomizeLandingPage, setUserDefaultPersona, @@ -593,3 +595,200 @@ test.describe('Mention notifications in Notification Box', () => { ); }); }); + +test.describe('Mentions: Chinese character encoding in activity feed', () => { + const database = new DatabaseClass(); + const endpointName = `测试Endpoint-${uuid()}`; + const apiEndpoint = new ApiEndpointClass(undefined, endpointName); + let schemaFqn: string; + const userName = `测试-${uuid()}`; + + test.beforeAll('Create database, schema, and user with Chinese name', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await database.create(apiContext); + await apiEndpoint.create(apiContext); + await adminUser.create(apiContext); + schemaFqn = database.schemaResponseData.fullyQualifiedName; + const user = new UserClass({ + firstName: userName, + lastName: '', + email: `${userName}@example.com`, + password: 'User@OMD123', + }); + + await user.create(apiContext); + await afterAction(); + }); + + test.beforeEach(async ({ page }) => { + await adminUser.login(page); + await redirectToHomePage(page); + }); + + test('Should allow mentioning a user with Chinese characters in the activity feed', async ({ + page, + }) => { + test.slow(); + const feedPromise = page.waitForResponse((response) => { + const url = response.url(); + return ( + url.includes('/api/v1/feed') && + url.includes('entityLink=') && + url.includes('type=Conversation') && + response.request().method() === 'GET' + ); + }); + await page.goto(`/databaseSchema/${schemaFqn}/activity_feed/all`); + const feedResponse = await feedPromise; + expect(feedResponse.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await page.getByTestId('comments-input-field').click(); + + const editorLocator = page.locator( + '[data-testid="editor-wrapper"] [contenteditable="true"].ql-editor' + ); + + await editorLocator.fill('Hey '); + + await editorLocator.click(); + + await page.keyboard.press('@'); + const userSuggestionsResponse = page.waitForResponse((response) => { + const url = response.url(); + + return ( + url.includes('/api/v1/search/query') && + url.includes(encodeURIComponent(userName)) + ); + }); + await editorLocator.pressSequentially(userName); + await userSuggestionsResponse; + + await page.locator(`[data-value="@${userName}"]`).first().click(); + + await expect(page.locator('[data-testid="send-button"]')).toBeVisible(); + await expect( + page.locator('[data-testid="send-button"]') + ).not.toBeDisabled(); + + const postMentionResponse = page.waitForResponse('/api/v1/feed/*/posts'); + await page.locator('[data-testid="send-button"]').click(); + await postMentionResponse; + const replyCard = page + .getByTestId('feed-reply-card') + .filter({ hasText: `Hey @${userName}` }); + await expect(replyCard).toBeVisible(); + await expect(replyCard.getByTestId('viewer-container')).toHaveText( + `Hey @${userName}` + ); + const userMentionLink = replyCard.getByRole('link', { + name: `@${userName}`, + }); + await expect(userMentionLink).toBeVisible(); + await expect(userMentionLink).toHaveAttribute( + 'href', + new RegExp(`/users/${userName}$`) + ); + + const [newPage] = await Promise.all([ + page.context().waitForEvent('page'), + userMentionLink.click(), + ]); + + await newPage.waitForResponse((response) => + response.url().includes(`/api/v1/users/name/${encodeURIComponent(userName)}`) + ); + + await waitForAllLoadersToDisappear(newPage); + expect(newPage.getByTestId('user-display-name')).toHaveText(userName); + }); + + + test('Should encode the chinese character while mentioning api endpoint', async ({ page }) => { + test.slow(); + const feedPromise = page.waitForResponse((response) => { + const url = response.url(); + return ( + url.includes('/api/v1/feed') && + url.includes('entityLink=') && + url.includes('type=Conversation') && + response.request().method() === 'GET' + ); + }); + + await page.goto(`/databaseSchema/${schemaFqn}/activity_feed/all`); + const feedResponse = await feedPromise; + expect(feedResponse.status()).toBe(200); + await waitForAllLoadersToDisappear(page); + + await page.getByTestId('comments-input-field').click(); + + const editorLocator = page.locator( + '[data-testid="editor-wrapper"] [contenteditable="true"].ql-editor' + ); + + await editorLocator.fill('Check '); + + await editorLocator.click(); + + await page.keyboard.press('#'); + const endpointSuggestionsResponse = page.waitForResponse((response) => { + const url = response.url(); + return ( + url.includes('/api/v1/search/query') && + url.includes(encodeURIComponent(endpointName)) + ); + }); + + await editorLocator.pressSequentially(endpointName); + await endpointSuggestionsResponse; + + await page.locator(`[data-value="#apiEndpoint/${endpointName}"]`).first().click(); + + await expect(page.locator('[data-testid="send-button"]')).toBeVisible(); + await expect( + page.locator('[data-testid="send-button"]') + ).not.toBeDisabled(); + + const postMentionResponse = page.waitForResponse('/api/v1/feed/*/posts'); + await page.locator('[data-testid="send-button"]').click(); + await postMentionResponse; + + const endpointFqn = apiEndpoint.entityResponseData.fullyQualifiedName; + + const replyCard = page + .getByTestId('feed-reply-card') + .filter({ hasText: `Check #${endpointFqn}` }); + await expect(replyCard).toBeVisible(); + + await expect(replyCard.getByTestId('viewer-container')).toHaveText( + `Check #${endpointFqn}` + ); + + const endpointMentionLink = replyCard.getByRole('link', { + name: endpointFqn, + }); + await expect(endpointMentionLink).toBeVisible(); + await expect(endpointMentionLink).toHaveAttribute( + 'href', + new RegExp(`/apiEndpoint/${endpointFqn}$`) + ); + const [newPage] = await Promise.all([ + page.context().waitForEvent('page'), + endpointMentionLink.click(), + ]); + + await newPage.waitForResponse((response) => + response.url().includes('/api/v1/apiEndpoints/name/') && + response.request().method() === 'GET' + ); + + await waitForAllLoadersToDisappear(newPage); + + await expect(newPage.getByTestId('entity-header-display-name')).toHaveText( + endpointName + ); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts index 4a7f479037c..f176b544ab5 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/ApiEndpointClass.ts @@ -90,7 +90,7 @@ export class ApiEndpointClass extends EntityClass { entityResponseData: ResponseDataWithServiceType = {} as ResponseDataWithServiceType; - constructor(name?: string) { + constructor(name?: string, apiEndpointName?: string) { super(EntityTypeEndpoint.API_ENDPOINT); this.serviceName = name ?? `pw-api-service-${uuid()}`; @@ -115,7 +115,7 @@ export class ApiEndpointClass extends EntityClass { service: this.service.name, }; - this.apiEndpointName = `pw-api-endpoint-${uuid()}`; + this.apiEndpointName = apiEndpointName ?? `pw-api-endpoint-${uuid()}`; this.fqn = `${this.service.name}.${this.apiCollection.name}.${this.apiEndpointName}.requestSchema`; this.children = [ diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts index 8d1b163d2ec..af54cfc52a7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/Feeds.constants.ts @@ -41,7 +41,7 @@ export const confirmStateInitialValue = { isThread: false, }; -export const MENTION_ALLOWED_CHARS = /^[A-Za-z0-9_.-]*$/; +export const MENTION_ALLOWED_CHARS = /^[^\s]*$/; export const MENTION_DENOTATION_CHARS = ['@', '#']; export const TOOLBAR_ITEMS = [ diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx index 927e9bfa8ff..63b862c09fe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.test.tsx @@ -22,6 +22,7 @@ import { getEntityType, getFeedHeaderTextFromCardStyle, getFieldOperationIcon, + getFrontEndFormat, suggestions, } from './FeedUtils'; @@ -45,8 +46,8 @@ jest.mock('../rest/searchAPI', () => ({ })); jest.mock('./StringsUtils', () => ({ - getEncodedFqn: jest.fn().mockImplementation((fqn) => fqn), - getDecodedFqn: jest.fn().mockImplementation((fqn) => fqn), + getEncodedFqn: jest.fn().mockImplementation((fqn) => encodeURIComponent(fqn)), + getDecodedFqn: jest.fn().mockImplementation((fqn) => decodeURIComponent(fqn)), })); jest.mock('./FeedUtils', () => ({ @@ -108,8 +109,7 @@ describe('Feed Utils', () => { const message = `<#E::user::"admin.test"|[@admin.test](http://localhost:3000/users/%22admin.test%22)> test`; const result = getBackendFormat(message); - // eslint-disable-next-line no-useless-escape - const expectedResult = `<#E::user::\"admin.test\"|<#E::user::%22admin.test%22|[@admin.test](http://localhost:3000/users/%22admin.test%22)>> test`; + const expectedResult = `<#E::user::"admin.test"|<#E::user::"admin.test"|[@admin.test](http://localhost:3000/users/%22admin.test%22)>> test`; expect(result).toStrictEqual(expectedResult); }); @@ -262,3 +262,43 @@ describe('getFieldOperationIcon', () => { expect(stringResult).toContain(FieldOperation.Deleted); }); }); + +describe('getFrontEndFormat', () => { + it('should return correct frontend format for user mention', () => { + const message = + '<#E::user::admin|[@admin](http://localhost:3000/users/admin)>'; + const result = getFrontEndFormat(message); + + expect(result).toBe('[@admin](http://localhost:3000/users/admin)'); + }); + + it('should return correct frontend format for api endpoint with chinese characters', () => { + const encodedFqn = 'pw-api-service.collection.%E6%B5%8B%E8%AF%95'; + const decodedFqn = 'pw-api-service.collection.测试'; + const message = `<#E::apiEndpoint::${encodedFqn}|[#apiEndpoint/${decodedFqn}](http://localhost:3000/apiEndpoint/${encodedFqn})>`; + + const result = getFrontEndFormat(message); + + expect(result).toBe( + `[#apiEndpoint/${decodedFqn}](http://localhost:3000/apiEndpoint/${decodedFqn})` + ); + }); + + it('should return correct frontend format for user mention with encoded characters in URL', () => { + const userName = '测试'; + const encodedName = encodeURIComponent(userName); + const message = `<#E::user::${userName}|[@${userName}](http://localhost:3000/users/${encodedName})>`; + + const result = getFrontEndFormat(message); + + expect(result).toBe( + `[@${userName}](http://localhost:3000/users/${userName})` + ); + }); + + it('should handle message without mentions', () => { + const message = 'Hello world'; + + expect(getFrontEndFormat(message)).toBe('Hello world'); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx index 7f9a31c48c7..880056191b9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/FeedUtils.tsx @@ -271,7 +271,7 @@ export const userMentionItemWithAvatar = ( /> ) : (
{character} @@ -344,7 +344,18 @@ export const getFrontEndFormat = (message: string) => { (m) => getEntityLinkDetail(m) ?? [] ); entityLinkList.forEach((m, i) => { - const markdownLink = entityLinkDetails[i][3]; + let markdownLink = entityLinkDetails[i]?.[3]; + const entityType = entityLinkDetails[i]?.[1]; + const entityFqn = entityLinkDetails[i]?.[2]; + const linkText = entityLinkDetails[i]?.[4]; + const entityUrl = entityLinkDetails[i]?.[5]; + + if (entityType && entityFqn && entityUrl) { + const decodedUrl = getDecodedFqn(entityUrl); + + markdownLink = `[${linkText}](${decodedUrl})`; + } + updatedMessage = updatedMessage.replaceAll(m, markdownLink); }); @@ -386,8 +397,7 @@ export const deletePost = async ( if (isThread) { try { const data = await deleteThread(threadId); - callback && - callback((prev) => prev.filter((thread) => thread.id !== data.id)); + callback?.((prev) => prev.filter((thread) => thread.id !== data.id)); } catch (error) { showErrorToast(error as AxiosError); } @@ -515,12 +525,12 @@ export const prepareFeedLink = ( const entityLink = entityUtilClassBase.getEntityLink(entityType, entityFQN); - if (!withoutFeedEntities.includes(entityType as EntityType)) { + if (withoutFeedEntities.includes(entityType as EntityType)) { + return entityLink; + } else { const activityFeedLink = `${entityLink}/${TabSpecificField.ACTIVITY_FEED}`; return subTab ? `${activityFeedLink}/${subTab}` : activityFeedLink; - } else { - return entityLink; } }; @@ -580,7 +590,7 @@ export const entityDisplayName = (entityType: string, entityFQN: string) => { // Remove quotes if the name is wrapped in quotes if (displayName) { - displayName = displayName.replace(/(?:^"+)|(?:"+$)/g, ''); + displayName = displayName.replaceAll(/(?:^"+)|(?:"+$)/g, ''); } return displayName;