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,
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

View File

@ -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"]')

View File

@ -526,7 +526,9 @@ const EntityLineageComponent: FunctionComponent<EntityLineageProp> = ({
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),

View File

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

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 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<HTMLDivElement> {
defaultValue?: string;
editorClass?: string;
className?: string;
placeHolder?: string;
onChangeHandler?: (value: string) => void;
onSave?: () => void;
focused?: boolean;
}
export const FeedEditor = forwardRef<editorRef, FeedEditorProp>(
(
{
@ -107,6 +99,45 @@ export const FeedEditor = forwardRef<editorRef, FeedEditorProp>(
source: matcher,
showDenotationChar: false,
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: {},
clipboard: {

View File

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

View File

@ -13,6 +13,8 @@
import { RightOutlined } from '@ant-design/icons';
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 i18next from 'i18next';
import { isEqual } from 'lodash';
@ -58,13 +60,16 @@ import {
getEntityPlaceHolder,
getPartialNameFromFQN,
getPartialNameFromTableFQN,
getRandomColor,
} from './CommonUtils';
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 { getEntityLink } from './TableUtils';
import { getRelativeDateByTimeStamp } from './TimeUtils';
import { showErrorToast } from './ToastUtils';
import { getUserProfilePic } from './UserDataUtils';
export const getEntityType = (entityLink: string) => {
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 = `<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 === '@') {
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);

View File

@ -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 = (
<Tooltip title={displayText}>
<Button
block
className="text-left truncate p-0"
data-testid={dataTestId}
icon={
<img
alt={serviceType}
className="m-r-sm"
height="16px"
src={serviceTypeLogo(serviceType)}
width="16px"
/>
}
key={fqdn}
type="text">
<Link
className="text-sm"
data-testid="data-name"
id={fqdn.replace(/\./g, '')}
to={entityLink}
onClick={onClickHandler}>
{displayText}
</Link>
</Button>
</Tooltip>
<Button
block
className="text-left truncate p-0"
data-testid={dataTestId}
icon={
<img
alt={serviceType}
className="m-r-sm"
height="16px"
src={serviceTypeLogo(serviceType)}
width="16px"
/>
}
key={fqdn}
type="text">
<Link
className="text-sm"
data-testid="data-name"
id={fqdn.replace(/\./g, '')}
to={entityLink}
onClick={onClickHandler}>
{displayText}
</Link>
</Button>
);
return retn;

View File

@ -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 <IconDataModel />;
case EntityType.TAG:
return <ClassificationIcon />;
case EntityType.GLOSSARY:
return <GlossaryIcon />;
case EntityType.GLOSSARY_TERM:
return <IconTerm />;
case SearchIndex.TABLE:
case EntityType.TABLE:
default: