diff --git a/openmetadata-ui/src/main/resources/ui/cypress/common/common.js b/openmetadata-ui/src/main/resources/ui/cypress/common/common.js index df30641c00d..a2f04b4cf77 100644 --- a/openmetadata-ui/src/main/resources/ui/cypress/common/common.js +++ b/openmetadata-ui/src/main/resources/ui/cypress/common/common.js @@ -475,8 +475,7 @@ export const visitEntityDetailsPage = ( serviceName, entity, dataTestId, - entityType, - count = 0 + entityType ) => { interceptURL('GET', '/api/v1/*/name/*', 'getEntityDetails'); interceptURL( @@ -484,11 +483,7 @@ export const visitEntityDetailsPage = ( `/api/v1/search/query?q=*&index=${SEARCH_INDEX[entity]}&from=*&size=**`, 'explorePageTabSearch' ); - interceptURL( - 'GET', - `/api/v1/search/suggest?q=*&index=*`, - `searchQuery-${entity}-${count}` - ); + interceptURL('GET', `/api/v1/search/suggest?q=*&index=*`, 'searchQuery'); interceptURL('GET', `/api/v1/search/*`, 'explorePageSearch'); const id = dataTestId ?? `${serviceName}-${term}`; @@ -500,35 +495,37 @@ export const visitEntityDetailsPage = ( // searching term in search box cy.get('[data-testid="searchBox"]').scrollIntoView().should('be.visible'); cy.get('[data-testid="searchBox"]').type(term); - verifyResponseStatusCode(`@searchQuery-${entity}-${count}`, 200); - cy.get('body').then(($body) => { - // checking if requested term is available in search suggestion - if ($body.find(`[data-testid="${id}"] [data-testid="data-name"]`).length) { - // if term is available in search suggestion, redirecting to entity details page - cy.get(`[data-testid="${id}"] [data-testid="data-name"]`) - .should('be.visible') - .first() - .click(); - } else { - // if term is not available in search suggestion, hitting enter to search box so it will redirect to explore page - cy.get('body').click(1, 1); - cy.get('[data-testid="searchBox"]').type('{enter}'); - verifyResponseStatusCode('@explorePageSearch', 200); + cy.wait('@searchQuery').then(() => { + cy.wait(500); + cy.get('body').then(($body) => { + // checking if requested term is available in search suggestion + if ( + $body.find(`[data-testid="${id}"] [data-testid="data-name"]`).length + ) { + // if term is available in search suggestion, redirecting to entity details page + cy.get(`[data-testid="${id}"] [data-testid="data-name"]`) + .should('be.visible') + .first() + .click(); + } else { + // if term is not available in search suggestion, + // hitting enter to search box so it will redirect to explore page + cy.get('body').click(1, 1); + cy.get('[data-testid="searchBox"]').type('{enter}'); + verifyResponseStatusCode('@explorePageSearch', 200); - cy.get(`[data-testid="${entity}-tab"]`).should('be.visible').click(); - cy.get(`[data-testid="${entity}-tab"]`).should('be.visible'); - verifyResponseStatusCode('@explorePageTabSearch', 200); + cy.get(`[data-testid="${entity}-tab"]`).should('be.visible').click(); + cy.get(`[data-testid="${entity}-tab"]`).should('be.visible'); + verifyResponseStatusCode('@explorePageTabSearch', 200); - cy.get(`[data-testid="${id}"]`) - .scrollIntoView() - .should('be.visible') - .click(); - } + cy.get(`[data-testid="${id}"]`).scrollIntoView().click(); + } + }); + + verifyResponseStatusCode('@getEntityDetails', 200); + cy.get('body').click(1, 1); + cy.get('[data-testid="searchBox"]').clear(); }); - - verifyResponseStatusCode('@getEntityDetails', 200); - cy.get('body').click(1, 1); - cy.get('[data-testid="searchBox"]').clear(); }; // add new tag to entity and its table diff --git a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Features/DataConsumerRole.spec.js b/openmetadata-ui/src/main/resources/ui/cypress/e2e/Features/DataConsumerRole.spec.js index 0a0f5775b8f..06b59fb11ce 100644 --- a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Features/DataConsumerRole.spec.js +++ b/openmetadata-ui/src/main/resources/ui/cypress/e2e/Features/DataConsumerRole.spec.js @@ -211,10 +211,7 @@ describe('DataConsumer Edit policy should work properly', () => { visitEntityDetailsPage( ENTITIES.table.term, ENTITIES.table.serviceName, - ENTITIES.table.entity, - undefined, - undefined, - 1 + ENTITIES.table.entity ); cy.get('[data-testid="add-tag"]') @@ -228,10 +225,7 @@ describe('DataConsumer Edit policy should work properly', () => { visitEntityDetailsPage( ENTITIES.dashboard.term, ENTITIES.dashboard.serviceName, - ENTITIES.dashboard.entity, - undefined, - undefined, - 1 + ENTITIES.dashboard.entity ); cy.get('[data-testid="add-tag"]') diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.component.tsx index 84a113a60dc..0309a8c9f35 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Entity/EntityLineage/EntityLineage.component.tsx @@ -526,7 +526,9 @@ const EntityLineageComponent: FunctionComponent = ({ setNodes((prevNodes) => { return prevNodes.map((prevNode) => { - const nodeTraced = prevNode.data.columns[column]; + const { columns } = prevNode.data; + const nodeTraced = columns && columns[column]; + prevNode.data = { ...prevNode.data, selected: !isUndefined(nodeTraced), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.css b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.css index 704ea39a19e..5d7b08f7458 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.css +++ b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.css @@ -1028,9 +1028,7 @@ .ql-mention-list-item { cursor: pointer; - line-height: 44px; - font-size: 16px; - padding: 0 20px; + padding: 8px 12px; vertical-align: middle; } .ql-mention-list-item.selected { @@ -1074,3 +1072,26 @@ button.ql-emoji { .ap { text-indent: 0px; } + +.mention-avatar { + height: 24px; + width: 24px; + border-radius: 50%; + font-size: 12px; +} + +.mention-profile-image { + display: inline-block; + height: 24px; + width: 24px; +} + +.mention-profile-image img { + width: 100%; + border-radius: 50%; +} + +.mention-icon-image > svg { + height: 24px; + width: 24px; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.interface.ts new file mode 100644 index 00000000000..521d8833e3d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.interface.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 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 { HTMLAttributes } from 'react'; + +export interface MentionSuggestionsItem { + id: string | undefined; + value: string; + link: string; + name: string; + type?: string; + avatarEle?: string; +} + +export interface FeedEditorProp extends HTMLAttributes { + defaultValue?: string; + editorClass?: string; + className?: string; + placeHolder?: string; + onChangeHandler?: (value: string) => void; + onSave?: () => void; + focused?: boolean; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.tsx b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.tsx index 1839262fbe5..1d031a46f37 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/FeedEditor/FeedEditor.tsx @@ -18,15 +18,16 @@ import 'quill-mention'; import QuillMarkdown from 'quilljs-markdown'; import React, { forwardRef, - HTMLAttributes, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; +import ReactDOMServer from 'react-dom/server'; import { useTranslation } from 'react-i18next'; import ReactQuill, { Quill } from 'react-quill'; +import { getEntityIcon } from 'utils/TableUtils'; import { MENTION_ALLOWED_CHARS, MENTION_DENOTATION_CHARS, @@ -36,6 +37,7 @@ import { HTMLToMarkdown, matcher } from '../../utils/FeedUtils'; import { insertMention, insertRef } from '../../utils/QuillUtils'; import { editorRef } from '../common/rich-text-editor/RichTextEditor.interface'; import './FeedEditor.css'; +import { FeedEditorProp } from './FeedEditor.interface'; Quill.register('modules/markdownOptions', QuillMarkdown); Quill.register('modules/emoji', Emoji); @@ -45,16 +47,6 @@ const strikethrough = (_node: any, delta: typeof Delta) => { return delta.compose(new Delta().retain(delta.length(), { strike: true })); }; -interface FeedEditorProp extends HTMLAttributes { - defaultValue?: string; - editorClass?: string; - className?: string; - placeHolder?: string; - onChangeHandler?: (value: string) => void; - onSave?: () => void; - focused?: boolean; -} - export const FeedEditor = forwardRef( ( { @@ -107,6 +99,45 @@ export const FeedEditor = forwardRef( source: matcher, showDenotationChar: false, renderLoading: () => `${t('label.loading')}...`, + renderItem: (item: Record) => { + if (!item.type) { + return `
+ ${item.avatarEle} + ${item.name} +
`; + } + + const breadcrumbsData = item.breadcrumbs + ? item.breadcrumbs + .map((obj: { name: string }) => obj.name) + .join('/') + : ''; + + const breadcrumbEle = breadcrumbsData + ? `
+ ${breadcrumbsData} +
` + : ''; + + const icon = ReactDOMServer.renderToString( + getEntityIcon(item.type) + ); + + const typeSpan = !breadcrumbEle + ? `${item.type}` + : ''; + + return `
+
${icon}
+
+ ${breadcrumbEle} +
+ ${typeSpan} + ${item.name} +
+
+
`; + }, }, markdownOptions: {}, clipboard: { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/nav-bar/NavBar.tsx b/openmetadata-ui/src/main/resources/ui/src/components/nav-bar/NavBar.tsx index 219efe712fb..30d8a21aefc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/nav-bar/NavBar.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/nav-bar/NavBar.tsx @@ -343,7 +343,10 @@ const NavBar = ({ width={30} /> -
+
{ return EntityLink.getEntityType(entityLink); @@ -153,54 +158,105 @@ export const getThreadField = ( }; export const buildMentionLink = (entityType: string, entityFqn: string) => { + if (entityType === EntityType.GLOSSARY_TERM) { + return `${document.location.protocol}//${document.location.host}/glossary/${entityFqn}`; + } else if (entityType === EntityType.TAG) { + const classificationFqn = Fqn.split(entityFqn); + + return `${document.location.protocol}//${document.location.host}/tags/${classificationFqn[0]}`; + } + return `${document.location.protocol}//${document.location.host}/${entityType}/${entityFqn}`; }; -export async function suggestions(searchTerm: string, mentionChar: string) { +const getAvatarElementAsString = async (userName: string) => { + const res = await getUserProfilePic(true, '', userName); + let avatarEle = ''; + if (!res) { + const { color, character } = getRandomColor(userName); + avatarEle = `
+ ${character} +
`; + } else { + avatarEle = `
+ user +
`; + } + + return avatarEle; +}; + +export async function suggestions( + searchTerm: string, + mentionChar: string +): Promise { if (mentionChar === '@') { let atValues = []; + if (!searchTerm) { const data = await getSearchedUsers(WILD_CARD_CHAR, 1, 5); const hits = data.data.hits.hits; - atValues = hits.map((hit) => { - const entityType = hit._source.entityType; - - return { - id: hit._id, - value: getEntityPlaceHolder( + atValues = await Promise.all( + hits.map(async (hit) => { + const avatarEle = + (await getAvatarElementAsString(hit._source.name)) ?? ''; + const entityType = hit._source.entityType; + const name = getEntityPlaceHolder( `@${hit._source.name ?? hit._source.displayName}`, hit._source.deleted - ), - link: buildMentionLink( - entityUrlMap[entityType as keyof typeof entityUrlMap], - hit._source.name - ), - }; - }); + ); + + return { + id: hit._id, + value: name, + link: buildMentionLink( + entityUrlMap[entityType as keyof typeof entityUrlMap], + hit._source.name + ), + name: hit._source.name, + avatarEle, + }; + }) + ); } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: any = await getUserSuggestions(searchTerm); const hits = data.data.suggest['metadata-suggest'][0]['options']; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - atValues = hits.map((hit: any) => { - const entityType = hit._source.entityType; - return { - id: hit._id, - value: getEntityPlaceHolder( + atValues = await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hits.map(async (hit: any) => { + const entityType = hit._source.entityType; + const name = getEntityPlaceHolder( `@${hit._source.name ?? hit._source.display_name}`, hit._source.deleted - ), - link: buildMentionLink( - entityUrlMap[entityType as keyof typeof entityUrlMap], - hit._source.name - ), - }; - }); + ); + + const avatarEle = await getAvatarElementAsString(hit._source.name); + + return { + id: hit._id, + value: name, + link: buildMentionLink( + entityUrlMap[entityType as keyof typeof entityUrlMap], + hit._source.name + ), + name: hit._source.name, + avatarEle, + }; + }) + ); } - return atValues; + return atValues as MentionSuggestionsItem[]; } else { let hashValues = []; if (!searchTerm) { @@ -209,6 +265,11 @@ export async function suggestions(searchTerm: string, mentionChar: string) { hashValues = hits.map((hit) => { const entityType = hit._source.entityType; + const breadcrumbs = getEntityBreadcrumbs( + hit._source, + entityType as EntityType, + false + ); return { id: hit._id, @@ -217,6 +278,9 @@ export async function suggestions(searchTerm: string, mentionChar: string) { entityType, getEncodedFqn(hit._source.fullyQualifiedName ?? '') ), + type: entityType, + name: hit._source.displayName || hit._source.name, + breadcrumbs, }; }); } else { @@ -225,6 +289,11 @@ export async function suggestions(searchTerm: string, mentionChar: string) { hashValues = hits.map((hit) => { const entityType = hit._source.entityType; + const breadcrumbs = getEntityBreadcrumbs( + hit._source as SearchedDataProps['data'][number]['_source'], + entityType as EntityType, + false + ); return { id: hit._id, @@ -233,6 +302,9 @@ export async function suggestions(searchTerm: string, mentionChar: string) { entityType, getEncodedFqn(hit._source.fullyQualifiedName ?? '') ), + type: entityType, + name: hit._source.displayName || hit._source.name, + breadcrumbs, }; }); } @@ -243,7 +315,7 @@ export async function suggestions(searchTerm: string, mentionChar: string) { export async function matcher( searchTerm: string, - renderList: (matches: string[], search: string) => void, + renderList: (matches: MentionSuggestionsItem[], search: string) => void, mentionChar: string ) { const matches = await suggestions(searchTerm, mentionChar); diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/SearchUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/SearchUtils.tsx index 0ec8e90f1ac..0fe288a86cf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/SearchUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/SearchUtils.tsx @@ -11,7 +11,7 @@ * limitations under the License. */ -import { Button, Tooltip } from 'antd'; +import { Button } from 'antd'; import { FqnPart } from 'enums/entity.enum'; import i18next from 'i18next'; import { isEmpty } from 'lodash'; @@ -160,32 +160,30 @@ export const getSuggestionElement = ( : name; const retn = ( - - - + ); return retn; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx index 5cb6be8fa9b..ee3dbd333d4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx @@ -14,6 +14,7 @@ import Icon from '@ant-design/icons'; import { Tooltip } from 'antd'; import { ExpandableConfig } from 'antd/lib/table/interface'; +import { ReactComponent as IconTerm } from 'assets/svg/book.svg'; import { ReactComponent as ClassificationIcon } from 'assets/svg/classification.svg'; import { ReactComponent as GlossaryIcon } from 'assets/svg/glossary.svg'; import { ReactComponent as ContainerIcon } from 'assets/svg/ic-storage.svg'; @@ -285,6 +286,13 @@ export const getEntityIcon = (indexType: string) => { case EntityType.DASHBOARD_DATA_MODEL: return ; + case EntityType.TAG: + return ; + case EntityType.GLOSSARY: + return ; + case EntityType.GLOSSARY_TERM: + return ; + case SearchIndex.TABLE: case EntityType.TABLE: default: