mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-14 03:26:47 +00:00
feat(embedded search results): support custom endpoints in embedded search result (#3986)
This commit is contained in:
parent
9366a47f88
commit
eac3c62667
@ -2,27 +2,59 @@ import React, { useState } from 'react';
|
|||||||
import * as QueryString from 'query-string';
|
import * as QueryString from 'query-string';
|
||||||
import { useHistory, useLocation, useParams } from 'react-router';
|
import { useHistory, useLocation, useParams } from 'react-router';
|
||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
|
import { ApolloError } from '@apollo/client';
|
||||||
|
|
||||||
import { useEntityRegistry } from '../../../../../useEntityRegistry';
|
import { useEntityRegistry } from '../../../../../useEntityRegistry';
|
||||||
import { EntityType, FacetFilterInput } from '../../../../../../types.generated';
|
import { EntityType, FacetFilterInput, FacetMetadata, Maybe, Scalars } from '../../../../../../types.generated';
|
||||||
import useFilters from '../../../../../search/utils/useFilters';
|
import useFilters from '../../../../../search/utils/useFilters';
|
||||||
import { ENTITY_FILTER_NAME } from '../../../../../search/utils/constants';
|
import { ENTITY_FILTER_NAME } from '../../../../../search/utils/constants';
|
||||||
import { useGetSearchResultsForMultipleQuery } from '../../../../../../graphql/search.generated';
|
|
||||||
import { SearchCfg } from '../../../../../../conf';
|
import { SearchCfg } from '../../../../../../conf';
|
||||||
import { navigateToEntitySearchUrl } from './navigateToEntitySearchUrl';
|
import { navigateToEntitySearchUrl } from './navigateToEntitySearchUrl';
|
||||||
import { EmbeddedListSearchResults } from './EmbeddedListSearchResults';
|
import { EmbeddedListSearchResults } from './EmbeddedListSearchResults';
|
||||||
import EmbeddedListSearchHeader from './EmbeddedListSearchHeader';
|
import EmbeddedListSearchHeader from './EmbeddedListSearchHeader';
|
||||||
|
import { useGetSearchResultsForMultipleQuery } from '../../../../../../graphql/search.generated';
|
||||||
|
import { GetSearchResultsParams, SearchResultInterface } from './types';
|
||||||
|
|
||||||
|
// this extracts the response from useGetSearchResultsForMultipleQuery into a common interface other search endpoints can also produce
|
||||||
|
function useWrappedSearchResults(params: GetSearchResultsParams) {
|
||||||
|
const { data, loading, error } = useGetSearchResultsForMultipleQuery(params);
|
||||||
|
return { data: data?.searchAcrossEntities, loading, error };
|
||||||
|
}
|
||||||
|
|
||||||
type SearchPageParams = {
|
type SearchPageParams = {
|
||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SearchResultsInterface = {
|
||||||
|
/** The offset of the result set */
|
||||||
|
start: Scalars['Int'];
|
||||||
|
/** The number of entities included in the result set */
|
||||||
|
count: Scalars['Int'];
|
||||||
|
/** The total number of search results matching the query and filters */
|
||||||
|
total: Scalars['Int'];
|
||||||
|
/** The search result entities */
|
||||||
|
searchResults: Array<SearchResultInterface>;
|
||||||
|
/** Candidate facet aggregations used for search filtering */
|
||||||
|
facets?: Maybe<Array<FacetMetadata>>;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
emptySearchQuery?: string | null;
|
emptySearchQuery?: string | null;
|
||||||
fixedFilter?: FacetFilterInput | null;
|
fixedFilter?: FacetFilterInput | null;
|
||||||
placeholderText?: string | null;
|
placeholderText?: string | null;
|
||||||
|
useGetSearchResults?: (params: GetSearchResultsParams) => {
|
||||||
|
data: SearchResultsInterface | undefined | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: ApolloError | undefined;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderText }: Props) => {
|
export const EmbeddedListSearch = ({
|
||||||
|
emptySearchQuery,
|
||||||
|
fixedFilter,
|
||||||
|
placeholderText,
|
||||||
|
useGetSearchResults = useWrappedSearchResults,
|
||||||
|
}: Props) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const entityRegistry = useEntityRegistry();
|
const entityRegistry = useEntityRegistry();
|
||||||
@ -42,7 +74,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
|
|||||||
|
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
const { data, loading, error } = useGetSearchResultsForMultipleQuery({
|
const { data, loading, error } = useGetSearchResults({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
types: entityFilters,
|
types: entityFilters,
|
||||||
@ -99,8 +131,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Filter out the persistent filter values
|
// Filter out the persistent filter values
|
||||||
const filteredFilters =
|
const filteredFilters = data?.facets?.filter((facet) => facet.field !== fixedFilter?.field) || [];
|
||||||
data?.searchAcrossEntities?.facets?.filter((facet) => facet.field !== fixedFilter?.field) || [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -112,7 +143,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
|
|||||||
/>
|
/>
|
||||||
<EmbeddedListSearchResults
|
<EmbeddedListSearchResults
|
||||||
loading={loading}
|
loading={loading}
|
||||||
searchResponse={data?.searchAcrossEntities}
|
searchResponse={data}
|
||||||
filters={filteredFilters}
|
filters={filteredFilters}
|
||||||
selectedFilters={filters}
|
selectedFilters={filters}
|
||||||
onChangeFilters={onChangeFilters}
|
onChangeFilters={onChangeFilters}
|
||||||
|
|||||||
@ -115,6 +115,13 @@ export const EmbeddedListSearchResults = ({
|
|||||||
entities={
|
entities={
|
||||||
searchResponse?.searchResults?.map((searchResult) => searchResult.entity) || []
|
searchResponse?.searchResults?.map((searchResult) => searchResult.entity) || []
|
||||||
}
|
}
|
||||||
|
additionalPropertiesList={
|
||||||
|
searchResponse?.searchResults?.map((searchResult) => ({
|
||||||
|
// when we add impact analysis, we will want to pipe the path to each element to the result this
|
||||||
|
// eslint-disable-next-line @typescript-eslint/dot-notation
|
||||||
|
path: searchResult['path'],
|
||||||
|
})) || []
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
MatchedField,
|
||||||
|
Maybe,
|
||||||
|
SearchAcrossEntitiesInput,
|
||||||
|
SearchInsight,
|
||||||
|
} from '../../../../../../types.generated';
|
||||||
|
|
||||||
|
export type GetSearchResultsParams = {
|
||||||
|
variables: {
|
||||||
|
input: SearchAcrossEntitiesInput;
|
||||||
|
};
|
||||||
|
} & Record<string, any>;
|
||||||
|
|
||||||
|
export type SearchResultInterface = {
|
||||||
|
entity: Entity;
|
||||||
|
/** Insights about why the search result was matched */
|
||||||
|
insights?: Maybe<Array<SearchInsight>>;
|
||||||
|
/** Matched field hint */
|
||||||
|
matchedFields: Array<MatchedField>;
|
||||||
|
paths?: Array<Entity>;
|
||||||
|
} & Record<string, any>;
|
||||||
@ -11,6 +11,12 @@ export function urlEncodeUrn(urn: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getNumberWithOrdinal(n) {
|
||||||
|
const suffixes = ['th', 'st', 'nd', 'rd'];
|
||||||
|
const v = n % 100;
|
||||||
|
return n + (suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]);
|
||||||
|
}
|
||||||
|
|
||||||
export function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
|
export function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
|
||||||
return value !== null && value !== undefined;
|
return value !== null && value !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,15 @@
|
|||||||
import { Image, Typography } from 'antd';
|
import { Image, Tooltip, Typography } from 'antd';
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { GlobalTags, Owner, GlossaryTerms, SearchInsight } from '../../types.generated';
|
import { GlobalTags, Owner, GlossaryTerms, SearchInsight, Entity } from '../../types.generated';
|
||||||
import { useEntityRegistry } from '../useEntityRegistry';
|
import { useEntityRegistry } from '../useEntityRegistry';
|
||||||
import AvatarsGroup from '../shared/avatar/AvatarsGroup';
|
import AvatarsGroup from '../shared/avatar/AvatarsGroup';
|
||||||
import TagTermGroup from '../shared/tags/TagTermGroup';
|
import TagTermGroup from '../shared/tags/TagTermGroup';
|
||||||
import { ANTD_GRAY } from '../entity/shared/constants';
|
import { ANTD_GRAY } from '../entity/shared/constants';
|
||||||
import NoMarkdownViewer from '../entity/shared/components/styled/StripMarkdownText';
|
import NoMarkdownViewer from '../entity/shared/components/styled/StripMarkdownText';
|
||||||
|
import { getNumberWithOrdinal } from '../entity/shared/utils';
|
||||||
|
import { useEntityData } from '../entity/shared/EntityContext';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
@ -26,6 +28,9 @@ interface Props {
|
|||||||
dataTestID?: string;
|
dataTestID?: string;
|
||||||
titleSizePx?: number;
|
titleSizePx?: number;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
// this is provided by the impact analysis view. it is used to display
|
||||||
|
// how the listed node is connected to the source node
|
||||||
|
path?: Entity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const PreviewContainer = styled.div`
|
const PreviewContainer = styled.div`
|
||||||
@ -129,7 +134,11 @@ export default function DefaultPreviewCard({
|
|||||||
titleSizePx,
|
titleSizePx,
|
||||||
dataTestID,
|
dataTestID,
|
||||||
onClick,
|
onClick,
|
||||||
|
path,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// sometimes these lists will be rendered inside an entity container (for example, in the case of impact analysis)
|
||||||
|
// in those cases, we may want to enrich the preview w/ context about the container entity
|
||||||
|
const { entityData } = useEntityData();
|
||||||
const entityRegistry = useEntityRegistry();
|
const entityRegistry = useEntityRegistry();
|
||||||
const insightViews: Array<ReactNode> = [
|
const insightViews: Array<ReactNode> = [
|
||||||
...(insights?.map((insight) => (
|
...(insights?.map((insight) => (
|
||||||
@ -153,6 +162,18 @@ export default function DefaultPreviewCard({
|
|||||||
{platform && <PlatformText>{platform}</PlatformText>}
|
{platform && <PlatformText>{platform}</PlatformText>}
|
||||||
{(logoUrl || logoComponent || platform) && <PlatformDivider />}
|
{(logoUrl || logoComponent || platform) && <PlatformDivider />}
|
||||||
<PlatformText>{type}</PlatformText>
|
<PlatformText>{type}</PlatformText>
|
||||||
|
{path && (
|
||||||
|
<span>
|
||||||
|
<PlatformDivider />
|
||||||
|
<Tooltip
|
||||||
|
title={`This entity is a ${getNumberWithOrdinal(
|
||||||
|
path?.length + 1,
|
||||||
|
)} degree connection to ${entityData?.name || 'the source entity'}`}
|
||||||
|
>
|
||||||
|
<PlatformText>{getNumberWithOrdinal(path?.length + 1)}</PlatformText>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</PlatformInfo>
|
</PlatformInfo>
|
||||||
<EntityTitle onClick={onClick} $titleSizePx={titleSizePx}>
|
<EntityTitle onClick={onClick} $titleSizePx={titleSizePx}>
|
||||||
{name || ' '}
|
{name || ' '}
|
||||||
|
|||||||
@ -38,18 +38,37 @@ const ThinDivider = styled(Divider)`
|
|||||||
margin: 0px;
|
margin: 0px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
type AdditionalProperties = {
|
||||||
|
path?: Entity[];
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
// additional data about the search result that is not part of the entity used to enrich the
|
||||||
|
// presentation of the entity. For example, metadata about how the entity is related for the case
|
||||||
|
// of impact analysis
|
||||||
|
additionalPropertiesList?: Array<AdditionalProperties>;
|
||||||
entities: Array<Entity>;
|
entities: Array<Entity>;
|
||||||
onClick?: (index: number) => void;
|
onClick?: (index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EntityNameList = ({ entities, onClick }: Props) => {
|
export const EntityNameList = ({ additionalPropertiesList, entities, onClick }: Props) => {
|
||||||
const entityRegistry = useEntityRegistry();
|
const entityRegistry = useEntityRegistry();
|
||||||
|
if (
|
||||||
|
additionalPropertiesList?.length !== undefined &&
|
||||||
|
additionalPropertiesList.length > 0 &&
|
||||||
|
additionalPropertiesList?.length !== entities.length
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
'Warning: additionalPropertiesList length provided to EntityNameList does not match entity array length',
|
||||||
|
{ additionalPropertiesList, entities },
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<StyledList
|
<StyledList
|
||||||
bordered
|
bordered
|
||||||
dataSource={entities}
|
dataSource={entities}
|
||||||
renderItem={(entity, index) => {
|
renderItem={(entity, index) => {
|
||||||
|
const additionalProperties = additionalPropertiesList?.[index];
|
||||||
const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity);
|
const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity);
|
||||||
const platformLogoUrl = genericProps?.platform?.properties?.logoUrl;
|
const platformLogoUrl = genericProps?.platform?.properties?.logoUrl;
|
||||||
const platformName =
|
const platformName =
|
||||||
@ -73,6 +92,7 @@ export const EntityNameList = ({ entities, onClick }: Props) => {
|
|||||||
tags={genericProps?.globalTags || undefined}
|
tags={genericProps?.globalTags || undefined}
|
||||||
glossaryTerms={genericProps?.glossaryTerms || undefined}
|
glossaryTerms={genericProps?.glossaryTerms || undefined}
|
||||||
onClick={() => onClick?.(index)}
|
onClick={() => onClick?.(index)}
|
||||||
|
path={additionalProperties?.path}
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ThinDivider />
|
<ThinDivider />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user