feat(embedded search results): support custom endpoints in embedded search result (#3986)

This commit is contained in:
Gabe Lyons 2022-01-27 07:58:24 -08:00 committed by GitHub
parent 9366a47f88
commit eac3c62667
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 117 additions and 10 deletions

View File

@ -2,27 +2,59 @@ import React, { useState } from 'react';
import * as QueryString from 'query-string';
import { useHistory, useLocation, useParams } from 'react-router';
import { message } from 'antd';
import { ApolloError } from '@apollo/client';
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 { ENTITY_FILTER_NAME } from '../../../../../search/utils/constants';
import { useGetSearchResultsForMultipleQuery } from '../../../../../../graphql/search.generated';
import { SearchCfg } from '../../../../../../conf';
import { navigateToEntitySearchUrl } from './navigateToEntitySearchUrl';
import { EmbeddedListSearchResults } from './EmbeddedListSearchResults';
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?: 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 = {
emptySearchQuery?: string | null;
fixedFilter?: FacetFilterInput | 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 location = useLocation();
const entityRegistry = useEntityRegistry();
@ -42,7 +74,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
const [showFilters, setShowFilters] = useState(false);
const { data, loading, error } = useGetSearchResultsForMultipleQuery({
const { data, loading, error } = useGetSearchResults({
variables: {
input: {
types: entityFilters,
@ -99,8 +131,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
};
// Filter out the persistent filter values
const filteredFilters =
data?.searchAcrossEntities?.facets?.filter((facet) => facet.field !== fixedFilter?.field) || [];
const filteredFilters = data?.facets?.filter((facet) => facet.field !== fixedFilter?.field) || [];
return (
<>
@ -112,7 +143,7 @@ export const EmbeddedListSearch = ({ emptySearchQuery, fixedFilter, placeholderT
/>
<EmbeddedListSearchResults
loading={loading}
searchResponse={data?.searchAcrossEntities}
searchResponse={data}
filters={filteredFilters}
selectedFilters={filters}
onChangeFilters={onChangeFilters}

View File

@ -115,6 +115,13 @@ export const EmbeddedListSearchResults = ({
entities={
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'],
})) || []
}
/>
</>
)}

View File

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

View File

@ -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 {
return value !== null && value !== undefined;
}

View File

@ -1,13 +1,15 @@
import { Image, Typography } from 'antd';
import { Image, Tooltip, Typography } from 'antd';
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
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 AvatarsGroup from '../shared/avatar/AvatarsGroup';
import TagTermGroup from '../shared/tags/TagTermGroup';
import { ANTD_GRAY } from '../entity/shared/constants';
import NoMarkdownViewer from '../entity/shared/components/styled/StripMarkdownText';
import { getNumberWithOrdinal } from '../entity/shared/utils';
import { useEntityData } from '../entity/shared/EntityContext';
interface Props {
name: string;
@ -26,6 +28,9 @@ interface Props {
dataTestID?: string;
titleSizePx?: number;
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`
@ -129,7 +134,11 @@ export default function DefaultPreviewCard({
titleSizePx,
dataTestID,
onClick,
path,
}: 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 insightViews: Array<ReactNode> = [
...(insights?.map((insight) => (
@ -153,6 +162,18 @@ export default function DefaultPreviewCard({
{platform && <PlatformText>{platform}</PlatformText>}
{(logoUrl || logoComponent || platform) && <PlatformDivider />}
<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>
<EntityTitle onClick={onClick} $titleSizePx={titleSizePx}>
{name || ' '}

View File

@ -38,18 +38,37 @@ const ThinDivider = styled(Divider)`
margin: 0px;
`;
type AdditionalProperties = {
path?: Entity[];
};
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>;
onClick?: (index: number) => void;
};
export const EntityNameList = ({ entities, onClick }: Props) => {
export const EntityNameList = ({ additionalPropertiesList, entities, onClick }: Props) => {
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 (
<StyledList
bordered
dataSource={entities}
renderItem={(entity, index) => {
const additionalProperties = additionalPropertiesList?.[index];
const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity);
const platformLogoUrl = genericProps?.platform?.properties?.logoUrl;
const platformName =
@ -73,6 +92,7 @@ export const EntityNameList = ({ entities, onClick }: Props) => {
tags={genericProps?.globalTags || undefined}
glossaryTerms={genericProps?.glossaryTerms || undefined}
onClick={() => onClick?.(index)}
path={additionalProperties?.path}
/>
</ListItem>
<ThinDivider />