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 * 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}

View File

@ -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'],
})) || []
}
/> />
</> </>
)} )}

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 { export function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined; 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 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 || ' '}

View File

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