fix: change design of mention list item (#12811)

* fix: change design of mention list item

* feat: add avatar element in mentions list

* fix: revert tooltip change

* fix: cypress tests

* fix: cypress

* fix: cypress

* fix: revert intercept change

* fix: add test id

* fix: lineage exception

* cypress fix

* feat: show breadcrumbs in mention design

---------

Co-authored-by: Shailesh Parmar <shailesh.parmar.webdev@gmail.com>
This commit is contained in:
karanh37 2023-08-18 17:38:39 +05:30 committed by GitHub
parent 64a258fc3d
commit 9eb3f516cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 273 additions and 115 deletions

View File

@ -475,8 +475,7 @@ export const visitEntityDetailsPage = (
serviceName, serviceName,
entity, entity,
dataTestId, dataTestId,
entityType, entityType
count = 0
) => { ) => {
interceptURL('GET', '/api/v1/*/name/*', 'getEntityDetails'); interceptURL('GET', '/api/v1/*/name/*', 'getEntityDetails');
interceptURL( interceptURL(
@ -484,11 +483,7 @@ export const visitEntityDetailsPage = (
`/api/v1/search/query?q=*&index=${SEARCH_INDEX[entity]}&from=*&size=**`, `/api/v1/search/query?q=*&index=${SEARCH_INDEX[entity]}&from=*&size=**`,
'explorePageTabSearch' 'explorePageTabSearch'
); );
interceptURL( interceptURL('GET', `/api/v1/search/suggest?q=*&index=*`, 'searchQuery');
'GET',
`/api/v1/search/suggest?q=*&index=*`,
`searchQuery-${entity}-${count}`
);
interceptURL('GET', `/api/v1/search/*`, 'explorePageSearch'); interceptURL('GET', `/api/v1/search/*`, 'explorePageSearch');
const id = dataTestId ?? `${serviceName}-${term}`; const id = dataTestId ?? `${serviceName}-${term}`;
@ -500,35 +495,37 @@ export const visitEntityDetailsPage = (
// searching term in search box // searching term in search box
cy.get('[data-testid="searchBox"]').scrollIntoView().should('be.visible'); cy.get('[data-testid="searchBox"]').scrollIntoView().should('be.visible');
cy.get('[data-testid="searchBox"]').type(term); cy.get('[data-testid="searchBox"]').type(term);
verifyResponseStatusCode(`@searchQuery-${entity}-${count}`, 200); cy.wait('@searchQuery').then(() => {
cy.get('body').then(($body) => { cy.wait(500);
// checking if requested term is available in search suggestion cy.get('body').then(($body) => {
if ($body.find(`[data-testid="${id}"] [data-testid="data-name"]`).length) { // checking if requested term is available in search suggestion
// if term is available in search suggestion, redirecting to entity details page if (
cy.get(`[data-testid="${id}"] [data-testid="data-name"]`) $body.find(`[data-testid="${id}"] [data-testid="data-name"]`).length
.should('be.visible') ) {
.first() // if term is available in search suggestion, redirecting to entity details page
.click(); cy.get(`[data-testid="${id}"] [data-testid="data-name"]`)
} else { .should('be.visible')
// if term is not available in search suggestion, hitting enter to search box so it will redirect to explore page .first()
cy.get('body').click(1, 1); .click();
cy.get('[data-testid="searchBox"]').type('{enter}'); } else {
verifyResponseStatusCode('@explorePageSearch', 200); // 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').click();
cy.get(`[data-testid="${entity}-tab"]`).should('be.visible'); cy.get(`[data-testid="${entity}-tab"]`).should('be.visible');
verifyResponseStatusCode('@explorePageTabSearch', 200); verifyResponseStatusCode('@explorePageTabSearch', 200);
cy.get(`[data-testid="${id}"]`) cy.get(`[data-testid="${id}"]`).scrollIntoView().click();
.scrollIntoView() }
.should('be.visible') });
.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 // add new tag to entity and its table

View File

@ -211,10 +211,7 @@ describe('DataConsumer Edit policy should work properly', () => {
visitEntityDetailsPage( visitEntityDetailsPage(
ENTITIES.table.term, ENTITIES.table.term,
ENTITIES.table.serviceName, ENTITIES.table.serviceName,
ENTITIES.table.entity, ENTITIES.table.entity
undefined,
undefined,
1
); );
cy.get('[data-testid="add-tag"]') cy.get('[data-testid="add-tag"]')
@ -228,10 +225,7 @@ describe('DataConsumer Edit policy should work properly', () => {
visitEntityDetailsPage( visitEntityDetailsPage(
ENTITIES.dashboard.term, ENTITIES.dashboard.term,
ENTITIES.dashboard.serviceName, ENTITIES.dashboard.serviceName,
ENTITIES.dashboard.entity, ENTITIES.dashboard.entity
undefined,
undefined,
1
); );
cy.get('[data-testid="add-tag"]') cy.get('[data-testid="add-tag"]')

View File

@ -526,7 +526,9 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
setNodes((prevNodes) => { setNodes((prevNodes) => {
return prevNodes.map((prevNode) => { return prevNodes.map((prevNode) => {
const nodeTraced = prevNode.data.columns[column]; const { columns } = prevNode.data;
const nodeTraced = columns && columns[column];
prevNode.data = { prevNode.data = {
...prevNode.data, ...prevNode.data,
selected: !isUndefined(nodeTraced), selected: !isUndefined(nodeTraced),

View File

@ -1028,9 +1028,7 @@
.ql-mention-list-item { .ql-mention-list-item {
cursor: pointer; cursor: pointer;
line-height: 44px; padding: 8px 12px;
font-size: 16px;
padding: 0 20px;
vertical-align: middle; vertical-align: middle;
} }
.ql-mention-list-item.selected { .ql-mention-list-item.selected {
@ -1074,3 +1072,26 @@ button.ql-emoji {
.ap { .ap {
text-indent: 0px; 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;
}

View File

@ -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<HTMLDivElement> {
defaultValue?: string;
editorClass?: string;
className?: string;
placeHolder?: string;
onChangeHandler?: (value: string) => void;
onSave?: () => void;
focused?: boolean;
}

View File

@ -18,15 +18,16 @@ import 'quill-mention';
import QuillMarkdown from 'quilljs-markdown'; import QuillMarkdown from 'quilljs-markdown';
import React, { import React, {
forwardRef, forwardRef,
HTMLAttributes,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useMemo, useMemo,
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import ReactDOMServer from 'react-dom/server';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ReactQuill, { Quill } from 'react-quill'; import ReactQuill, { Quill } from 'react-quill';
import { getEntityIcon } from 'utils/TableUtils';
import { import {
MENTION_ALLOWED_CHARS, MENTION_ALLOWED_CHARS,
MENTION_DENOTATION_CHARS, MENTION_DENOTATION_CHARS,
@ -36,6 +37,7 @@ import { HTMLToMarkdown, matcher } from '../../utils/FeedUtils';
import { insertMention, insertRef } from '../../utils/QuillUtils'; import { insertMention, insertRef } from '../../utils/QuillUtils';
import { editorRef } from '../common/rich-text-editor/RichTextEditor.interface'; import { editorRef } from '../common/rich-text-editor/RichTextEditor.interface';
import './FeedEditor.css'; import './FeedEditor.css';
import { FeedEditorProp } from './FeedEditor.interface';
Quill.register('modules/markdownOptions', QuillMarkdown); Quill.register('modules/markdownOptions', QuillMarkdown);
Quill.register('modules/emoji', Emoji); 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 })); return delta.compose(new Delta().retain(delta.length(), { strike: true }));
}; };
interface FeedEditorProp extends HTMLAttributes<HTMLDivElement> {
defaultValue?: string;
editorClass?: string;
className?: string;
placeHolder?: string;
onChangeHandler?: (value: string) => void;
onSave?: () => void;
focused?: boolean;
}
export const FeedEditor = forwardRef<editorRef, FeedEditorProp>( export const FeedEditor = forwardRef<editorRef, FeedEditorProp>(
( (
{ {
@ -107,6 +99,45 @@ export const FeedEditor = forwardRef<editorRef, FeedEditorProp>(
source: matcher, source: matcher,
showDenotationChar: false, showDenotationChar: false,
renderLoading: () => `${t('label.loading')}...`, renderLoading: () => `${t('label.loading')}...`,
renderItem: (item: Record<string, any>) => {
if (!item.type) {
return `<div class="d-flex gap-2">
${item.avatarEle}
<span class="d-flex items-center truncate w-56">${item.name}</span>
</div>`;
}
const breadcrumbsData = item.breadcrumbs
? item.breadcrumbs
.map((obj: { name: string }) => obj.name)
.join('/')
: '';
const breadcrumbEle = breadcrumbsData
? `<div class="d-flex flex-wrap">
<span class="text-grey-muted truncate w-max-200 text-xss">${breadcrumbsData}</span>
</div>`
: '';
const icon = ReactDOMServer.renderToString(
getEntityIcon(item.type)
);
const typeSpan = !breadcrumbEle
? `<span class="text-grey-muted text-xs">${item.type}</span>`
: '';
return `<div class="d-flex items-center gap-2">
<div class="flex-center mention-icon-image">${icon}</div>
<div>
${breadcrumbEle}
<div class="d-flex flex-col">
${typeSpan}
<span class="font-medium truncate w-56">${item.name}</span>
</div>
</div>
</div>`;
},
}, },
markdownOptions: {}, markdownOptions: {},
clipboard: { clipboard: {

View File

@ -343,7 +343,10 @@ const NavBar = ({
width={30} width={30}
/> />
</Link> </Link>
<div className="m-auto relative" ref={searchContainerRef}> <div
className="m-auto relative"
data-testid="navbar-search-container"
ref={searchContainerRef}>
<Popover <Popover
content={ content={
!isTourRoute && !isTourRoute &&

View File

@ -13,6 +13,8 @@
import { RightOutlined } from '@ant-design/icons'; import { RightOutlined } from '@ant-design/icons';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { MentionSuggestionsItem } from 'components/FeedEditor/FeedEditor.interface';
import { SearchedDataProps } from 'components/searched-data/SearchedData.interface';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import i18next from 'i18next'; import i18next from 'i18next';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
@ -58,13 +60,16 @@ import {
getEntityPlaceHolder, getEntityPlaceHolder,
getPartialNameFromFQN, getPartialNameFromFQN,
getPartialNameFromTableFQN, getPartialNameFromTableFQN,
getRandomColor,
} from './CommonUtils'; } from './CommonUtils';
import EntityLink from './EntityLink'; import EntityLink from './EntityLink';
import { ENTITY_LINK_SEPARATOR } from './EntityUtils'; import { ENTITY_LINK_SEPARATOR, getEntityBreadcrumbs } from './EntityUtils';
import Fqn from './Fqn';
import { getEncodedFqn } from './StringsUtils'; import { getEncodedFqn } from './StringsUtils';
import { getEntityLink } from './TableUtils'; import { getEntityLink } from './TableUtils';
import { getRelativeDateByTimeStamp } from './TimeUtils'; import { getRelativeDateByTimeStamp } from './TimeUtils';
import { showErrorToast } from './ToastUtils'; import { showErrorToast } from './ToastUtils';
import { getUserProfilePic } from './UserDataUtils';
export const getEntityType = (entityLink: string) => { export const getEntityType = (entityLink: string) => {
return EntityLink.getEntityType(entityLink); return EntityLink.getEntityType(entityLink);
@ -153,54 +158,105 @@ export const getThreadField = (
}; };
export const buildMentionLink = (entityType: string, entityFqn: string) => { 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}`; 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 = `<div
class="flex-center flex-shrink align-middle mention-avatar"
data-testid="avatar" style="background-color: ${color}">
<span>${character}</span>
</div>`;
} else {
avatarEle = `<div
class="mention-profile-image">
<img
alt="user"
data-testid="profile-image"
referrerPolicy="no-referrer"
src="${res}"
/>
</div>`;
}
return avatarEle;
};
export async function suggestions(
searchTerm: string,
mentionChar: string
): Promise<MentionSuggestionsItem[]> {
if (mentionChar === '@') { if (mentionChar === '@') {
let atValues = []; let atValues = [];
if (!searchTerm) { if (!searchTerm) {
const data = await getSearchedUsers(WILD_CARD_CHAR, 1, 5); const data = await getSearchedUsers(WILD_CARD_CHAR, 1, 5);
const hits = data.data.hits.hits; const hits = data.data.hits.hits;
atValues = hits.map((hit) => { atValues = await Promise.all(
const entityType = hit._source.entityType; hits.map(async (hit) => {
const avatarEle =
return { (await getAvatarElementAsString(hit._source.name)) ?? '';
id: hit._id, const entityType = hit._source.entityType;
value: getEntityPlaceHolder( const name = getEntityPlaceHolder(
`@${hit._source.name ?? hit._source.displayName}`, `@${hit._source.name ?? hit._source.displayName}`,
hit._source.deleted hit._source.deleted
), );
link: buildMentionLink(
entityUrlMap[entityType as keyof typeof entityUrlMap], return {
hit._source.name id: hit._id,
), value: name,
}; link: buildMentionLink(
}); entityUrlMap[entityType as keyof typeof entityUrlMap],
hit._source.name
),
name: hit._source.name,
avatarEle,
};
})
);
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = await getUserSuggestions(searchTerm); const data: any = await getUserSuggestions(searchTerm);
const hits = data.data.suggest['metadata-suggest'][0]['options']; 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 { atValues = await Promise.all(
id: hit._id, // eslint-disable-next-line @typescript-eslint/no-explicit-any
value: getEntityPlaceHolder( hits.map(async (hit: any) => {
const entityType = hit._source.entityType;
const name = getEntityPlaceHolder(
`@${hit._source.name ?? hit._source.display_name}`, `@${hit._source.name ?? hit._source.display_name}`,
hit._source.deleted hit._source.deleted
), );
link: buildMentionLink(
entityUrlMap[entityType as keyof typeof entityUrlMap], const avatarEle = await getAvatarElementAsString(hit._source.name);
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 { } else {
let hashValues = []; let hashValues = [];
if (!searchTerm) { if (!searchTerm) {
@ -209,6 +265,11 @@ export async function suggestions(searchTerm: string, mentionChar: string) {
hashValues = hits.map((hit) => { hashValues = hits.map((hit) => {
const entityType = hit._source.entityType; const entityType = hit._source.entityType;
const breadcrumbs = getEntityBreadcrumbs(
hit._source,
entityType as EntityType,
false
);
return { return {
id: hit._id, id: hit._id,
@ -217,6 +278,9 @@ export async function suggestions(searchTerm: string, mentionChar: string) {
entityType, entityType,
getEncodedFqn(hit._source.fullyQualifiedName ?? '') getEncodedFqn(hit._source.fullyQualifiedName ?? '')
), ),
type: entityType,
name: hit._source.displayName || hit._source.name,
breadcrumbs,
}; };
}); });
} else { } else {
@ -225,6 +289,11 @@ export async function suggestions(searchTerm: string, mentionChar: string) {
hashValues = hits.map((hit) => { hashValues = hits.map((hit) => {
const entityType = hit._source.entityType; const entityType = hit._source.entityType;
const breadcrumbs = getEntityBreadcrumbs(
hit._source as SearchedDataProps['data'][number]['_source'],
entityType as EntityType,
false
);
return { return {
id: hit._id, id: hit._id,
@ -233,6 +302,9 @@ export async function suggestions(searchTerm: string, mentionChar: string) {
entityType, entityType,
getEncodedFqn(hit._source.fullyQualifiedName ?? '') 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( export async function matcher(
searchTerm: string, searchTerm: string,
renderList: (matches: string[], search: string) => void, renderList: (matches: MentionSuggestionsItem[], search: string) => void,
mentionChar: string mentionChar: string
) { ) {
const matches = await suggestions(searchTerm, mentionChar); const matches = await suggestions(searchTerm, mentionChar);

View File

@ -11,7 +11,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Tooltip } from 'antd'; import { Button } from 'antd';
import { FqnPart } from 'enums/entity.enum'; import { FqnPart } from 'enums/entity.enum';
import i18next from 'i18next'; import i18next from 'i18next';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@ -160,32 +160,30 @@ export const getSuggestionElement = (
: name; : name;
const retn = ( const retn = (
<Tooltip title={displayText}> <Button
<Button block
block className="text-left truncate p-0"
className="text-left truncate p-0" data-testid={dataTestId}
data-testid={dataTestId} icon={
icon={ <img
<img alt={serviceType}
alt={serviceType} className="m-r-sm"
className="m-r-sm" height="16px"
height="16px" src={serviceTypeLogo(serviceType)}
src={serviceTypeLogo(serviceType)} width="16px"
width="16px" />
/> }
} key={fqdn}
key={fqdn} type="text">
type="text"> <Link
<Link className="text-sm"
className="text-sm" data-testid="data-name"
data-testid="data-name" id={fqdn.replace(/\./g, '')}
id={fqdn.replace(/\./g, '')} to={entityLink}
to={entityLink} onClick={onClickHandler}>
onClick={onClickHandler}> {displayText}
{displayText} </Link>
</Link> </Button>
</Button>
</Tooltip>
); );
return retn; return retn;

View File

@ -14,6 +14,7 @@
import Icon from '@ant-design/icons'; import Icon from '@ant-design/icons';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
import { ExpandableConfig } from 'antd/lib/table/interface'; 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 ClassificationIcon } from 'assets/svg/classification.svg';
import { ReactComponent as GlossaryIcon } from 'assets/svg/glossary.svg'; import { ReactComponent as GlossaryIcon } from 'assets/svg/glossary.svg';
import { ReactComponent as ContainerIcon } from 'assets/svg/ic-storage.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: case EntityType.DASHBOARD_DATA_MODEL:
return <IconDataModel />; return <IconDataModel />;
case EntityType.TAG:
return <ClassificationIcon />;
case EntityType.GLOSSARY:
return <GlossaryIcon />;
case EntityType.GLOSSARY_TERM:
return <IconTerm />;
case SearchIndex.TABLE: case SearchIndex.TABLE:
case EntityType.TABLE: case EntityType.TABLE:
default: default: