refactor(React Incubation): Entity Interface & EntityRegistry (#2077)

* React App: Entity Registry!  (#15)

Introducing the Entity Registry: an Entity-oriented Architecture

Co-authored-by: John Joyce <john@acryl.io>
This commit is contained in:
John Joyce 2021-02-03 11:49:51 -08:00 committed by GitHub
parent 4cdef602b4
commit 9d38ae4dd4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 498 additions and 336 deletions

View File

@ -175,7 +175,7 @@ type Dataset {
name: String!
origin: FabricType
origin: FabricType!
description: String
@ -282,7 +282,7 @@ type AggregationMetadata {
input AutoCompleteInput {
type: EntityType!
query: String!
field: String! # Field name
field: String # Field name
limit: Int
filters: [FacetFilterInput!]
}

View File

@ -1,10 +1,14 @@
import React from 'react';
import React, { useMemo } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';
import { MockedProvider } from '@apollo/client/testing';
import './App.css';
import { Routes } from './components/Routes';
import { mocks } from './Mocks';
import EntityRegistry from './components/entity/EntityRegistry';
import { DatasetEntity } from './components/entity/dataset/DatasetEntity';
import { UserEntity } from './components/entity/user/User';
import { EntityRegistryContext } from './entityRegistryContext';
// Enable to use the Apollo MockProvider instead of a real HTTP client
const MOCK_MODE = true;
@ -25,18 +29,27 @@ const client = new ApolloClient({
});
const App: React.VFC = () => {
// TODO: Explore options to dynamically configure this.
const entityRegistry = useMemo(() => {
const register = new EntityRegistry();
register.register(new DatasetEntity());
register.register(new UserEntity());
return register;
}, []);
return (
<Router>
{/* Temporary: For local testing during development. */}
{MOCK_MODE ? (
<MockedProvider mocks={mocks} addTypename={false}>
<Routes />
</MockedProvider>
) : (
<ApolloProvider client={client}>
<Routes />
</ApolloProvider>
)}
<EntityRegistryContext.Provider value={entityRegistry}>
{/* Temporary: For local testing during development. */}
{MOCK_MODE ? (
<MockedProvider mocks={mocks} addTypename={false}>
<Routes />
</MockedProvider>
) : (
<ApolloProvider client={client}>
<Routes />
</ApolloProvider>
)}
</EntityRegistryContext.Provider>
</Router>
);
};

View File

@ -304,7 +304,6 @@ export const mocks = [
input: {
type: 'DATASET',
query: 't',
field: 'name',
},
},
},
@ -324,7 +323,6 @@ export const mocks = [
input: {
type: 'USER',
query: 'j',
field: 'ldap',
},
},
},

View File

@ -3,13 +3,13 @@ import { Switch, Route, RouteProps, Redirect } from 'react-router-dom';
import { useReactiveVar } from '@apollo/client';
import { BrowseTypesPage } from './browse/BrowseTypesPage';
import { BrowseResultsPage } from './browse/BrowseResultsPage';
import { DatasetPage } from './entity/dataset/DatasetPage';
import { UserPage } from './entity/user/UserPage';
import { SearchPage } from './search/SearchPage';
import { LogIn } from './auth/LogIn';
import { NoPageFound } from './shared/NoPageFound';
import { isLoggedInVar } from './auth/checkAuthStatus';
import { EntityPage } from './entity/EntityPage';
import { PageRoutes } from '../conf/Global';
import { useEntityRegistry } from './useEntityRegistry';
const ProtectedRoute = ({
isLoggedIn,
@ -28,16 +28,22 @@ const ProtectedRoute = ({
*/
export const Routes = (): JSX.Element => {
const isLoggedIn = useReactiveVar(isLoggedInVar);
const entityRegistry = useEntityRegistry();
return (
<div>
<Switch>
<ProtectedRoute isLoggedIn={isLoggedIn} exact path="/" render={() => <BrowseTypesPage />} />
<Route path={PageRoutes.LOG_IN} component={LogIn} />
<ProtectedRoute
isLoggedIn={isLoggedIn}
path={`${PageRoutes.DATASETS}/:urn`}
render={() => <DatasetPage />}
/>
{entityRegistry.getEntities().map((entity) => (
<ProtectedRoute
isLoggedIn={isLoggedIn}
path={`/${entity.getPathName()}/:urn`}
render={() => <EntityPage entityType={entity.type} />}
/>
))}
<ProtectedRoute isLoggedIn={isLoggedIn} path={PageRoutes.SEARCH} render={() => <SearchPage />} />
<ProtectedRoute
isLoggedIn={isLoggedIn}
@ -55,7 +61,6 @@ export const Routes = (): JSX.Element => {
path={PageRoutes.BROWSE_RESULTS}
render={() => <BrowseResultsPage />}
/>
<ProtectedRoute isLoggedIn={isLoggedIn} path={PageRoutes.USERS} render={() => <UserPage />} />
<Route component={NoPageFound} />
</Switch>
</div>

View File

@ -7,7 +7,7 @@ import Cookies from 'js-cookie';
import { Redirect } from 'react-router';
import styles from './login.module.css';
import { useLoginMutation } from '../../graphql/auth.generated';
import { Message } from '../generic/Message';
import { Message } from '../shared/Message';
import { isLoggedInVar } from './checkAuthStatus';
import { GlobalCfg } from '../../conf';

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { EntityType } from '../shared/EntityTypeUtil';
import React from 'react';
import { EntityType } from '../../types.generated';
interface Props {
type: EntityType; // type of entity associated with the urn. may be able to provide a util to infer this from the urn. we use this to fetch the results.

View File

@ -1,8 +1,9 @@
import * as React from 'react';
import React from 'react';
import { Link } from 'react-router-dom';
import { Breadcrumb, Row } from 'antd';
import { PageRoutes } from '../../conf/Global';
import { EntityType, toCollectionName, toPathName } from '../shared/EntityTypeUtil';
import { useEntityRegistry } from '../useEntityRegistry';
import { EntityType } from '../../types.generated';
interface Props {
type: EntityType;
@ -13,11 +14,13 @@ interface Props {
* Responsible for rendering a clickable browse path view.
*/
export const BrowsePath = ({ type, path }: Props) => {
const entityRegistry = useEntityRegistry();
const createPartialPath = (parts: Array<string>) => {
return parts.join('/');
};
const baseBrowsePath = `${PageRoutes.BROWSE}/${toPathName(type)}`;
const baseBrowsePath = `${PageRoutes.BROWSE}/${entityRegistry.getPathName(type)}`;
const pathCrumbs = path.map((part, index) => (
<Breadcrumb.Item>
@ -29,7 +32,7 @@ export const BrowsePath = ({ type, path }: Props) => {
<Row style={{ backgroundColor: 'white', padding: '10px 100px', borderBottom: '1px solid #dcdcdc' }}>
<Breadcrumb style={{ fontSize: '16px' }}>
<Breadcrumb.Item>
<Link to={baseBrowsePath}>{toCollectionName(type)}</Link>
<Link to={baseBrowsePath}>{entityRegistry.getCollectionName(type)}</Link>
</Breadcrumb.Item>
{pathCrumbs}
</Breadcrumb>

View File

@ -2,13 +2,13 @@ import React from 'react';
import { Card } from 'antd';
import { Link } from 'react-router-dom';
type Props = {
export interface BrowseResultProps {
url: string;
name: string;
count?: number | undefined;
};
}
export default function BrowseResultCard({ url, count, name }: Props) {
export default function BrowseResultCard({ url, count, name }: BrowseResultProps) {
return (
<Link to={url}>
<Card hoverable>

View File

@ -1,11 +1,12 @@
import React from 'react';
import { Col, Pagination, Row } from 'antd';
import { Content } from 'antd/lib/layout/layout';
import React from 'react';
import { BrowseResultEntity, BrowseResultGroup } from '../../types.generated';
import { BrowseResultEntity, BrowseResultGroup, EntityType } from '../../types.generated';
import BrowseResultCard from './BrowseResultCard';
import { browseEntityResultToUrl } from './util/entityToUrl';
import { useEntityRegistry } from '../useEntityRegistry';
interface Props {
type: EntityType;
title: string;
rootPath: string;
pageStart: number;
@ -20,6 +21,7 @@ interface Props {
* Display browse groups + entities.
*/
export const BrowseResults = ({
type,
title,
rootPath,
pageStart,
@ -29,6 +31,7 @@ export const BrowseResults = ({
groups,
onChangePage,
}: Props) => {
const entityRegistry = useEntityRegistry();
return (
<div>
<Content style={{ backgroundColor: 'white', padding: '25px 100px' }}>
@ -40,9 +43,7 @@ export const BrowseResults = ({
</Col>
))}
{entities.map((entity) => (
<Col span={24}>
<BrowseResultCard name={entity.name} url={browseEntityResultToUrl(entity)} />
</Col>
<Col span={24}>{entityRegistry.renderBrowse(type, entity)}</Col>
))}
<Col span={24}>
<Pagination

View File

@ -1,16 +1,14 @@
import * as React from 'react';
import React from 'react';
import { Redirect, useHistory, useLocation, useParams } from 'react-router';
import * as QueryString from 'query-string';
import { Affix } from 'antd';
import { fromPathName, toCollectionName } from '../shared/EntityTypeUtil';
import { BrowseCfg } from '../../conf';
import { BrowseResults } from './BrowseResults';
import { SearchablePage } from '../search/SearchablePage';
import { useGetBrowseResultsQuery } from '../../graphql/browse.generated';
import { BrowsePath } from './BrowsePath';
import { PageRoutes } from '../../conf/Global';
const { RESULTS_PER_PAGE } = BrowseCfg;
import { useEntityRegistry } from '../useEntityRegistry';
type BrowseResultsPageParams = {
type: string;
@ -21,9 +19,11 @@ export const BrowseResultsPage = () => {
const history = useHistory();
const { type } = useParams<BrowseResultsPageParams>();
const entityRegistry = useEntityRegistry();
const rootPath = location.pathname;
const params = QueryString.parse(location.search);
const entityType = fromPathName(type);
const entityType = entityRegistry.getTypeFromPathName(type);
const path = rootPath.split('/').slice(3);
const page = Number(params.page) || 1;
@ -32,8 +32,8 @@ export const BrowseResultsPage = () => {
input: {
type: entityType,
path,
start: (page - 1) * RESULTS_PER_PAGE,
count: RESULTS_PER_PAGE,
start: (page - 1) * BrowseCfg.RESULTS_PER_PAGE,
count: BrowseCfg.RESULTS_PER_PAGE,
filters: null,
},
},
@ -59,10 +59,11 @@ export const BrowseResultsPage = () => {
{loading && <p>Loading browse results...</p>}
{data && data.browse && (
<BrowseResults
type={entityType}
rootPath={rootPath}
title={path.length > 0 ? path[path.length - 1] : toCollectionName(entityType)}
pageSize={RESULTS_PER_PAGE}
pageStart={page * RESULTS_PER_PAGE}
title={path.length > 0 ? path[path.length - 1] : entityRegistry.getCollectionName(entityType)}
pageSize={BrowseCfg.RESULTS_PER_PAGE}
pageStart={page * BrowseCfg.RESULTS_PER_PAGE}
groups={data.browse.metadata.groups}
entities={data.browse.entities}
totalResults={data.browse.total}

View File

@ -1,25 +1,23 @@
import * as React from 'react';
import React from 'react';
import 'antd/dist/antd.css';
import { Link } from 'react-router-dom';
import { Col, Row, Card } from 'antd';
import { Content } from 'antd/lib/layout/layout';
import { BrowseCfg } from '../../conf';
import { SearchablePage } from '../search/SearchablePage';
import { toCollectionName, toPathName } from '../shared/EntityTypeUtil';
import { PageRoutes } from '../../conf/Global';
import '../../App.css';
const { BROWSABLE_ENTITY_TYPES } = BrowseCfg;
import { useEntityRegistry } from '../useEntityRegistry';
export const BrowseTypesPage = () => {
const entityRegistry = useEntityRegistry();
return (
<SearchablePage>
<Content style={{ backgroundColor: 'white', padding: '25px 100px' }}>
<h1 className="ant-typography">Browse</h1>
<Row gutter={[16, 16]}>
{BROWSABLE_ENTITY_TYPES.map((entityType) => (
{entityRegistry.getBrowseEntityTypes().map((entityType) => (
<Col xs={24} sm={24} md={8}>
<Link to={`${PageRoutes.BROWSE}/${toPathName(entityType)}`}>
<Link to={`${PageRoutes.BROWSE}/${entityRegistry.getPathName(entityType)}`}>
<Card
style={{
padding: '30px 0px',
@ -29,7 +27,7 @@ export const BrowseTypesPage = () => {
hoverable
>
<div style={{ fontSize: '18px', fontWeight: 'bold', color: '#0073b1' }}>
{toCollectionName(entityType)}
{entityRegistry.getCollectionName(entityType)}
</div>
</Card>
</Link>

View File

@ -1,8 +0,0 @@
// NOTE: this file is a temporary solution. Soon, entity classes will provide the urls via their asBrowseResult method.
import { PageRoutes } from '../../../conf/Global';
import { BrowseResultEntity } from '../../../types.generated';
export function browseEntityResultToUrl(browseResultEntity: BrowseResultEntity) {
return `${PageRoutes.DATASETS}/${browseResultEntity.urn}`;
}

View File

@ -0,0 +1,58 @@
import { EntityType } from '../../types.generated';
export enum PreviewType {
/**
* A preview shown within the search experience
*/
SEARCH,
/**
* A preview shown within the browse experience
*/
BROWSE,
/**
* A generic preview shown within other entity pages, etc.
*/
PREVIEW,
}
/**
* Base interface used for authoring DataHub Entities on the client side.
*
* <T> the generated GraphQL data type associated with the entity.
*/
export interface Entity<T> {
/**
* Corresponding GQL EntityType.
*/
type: EntityType;
/**
* Returns whether the entity search is enabled
*/
isSearchEnabled: () => boolean;
/**
* Returns whether the entity browse is enabled
*/
isBrowseEnabled: () => boolean;
/**
* Returns the name of the entity as it appears in a URL, e.g. '/dataset/:urn'.
*/
getPathName: () => string;
/**
* Returns the plural name of the entity used when displaying collections (search, browse results), e.g. 'Datasets'.
*/
getCollectionName: () => string;
/**
* Renders the 'profile' of the entity on an entity details page.
*/
renderProfile: (urn: string) => JSX.Element;
/**
* Renders a preview of the entity across different use cases like search, browse, etc.
*/
renderPreview: (type: PreviewType, data: T) => JSX.Element;
}

View File

@ -0,0 +1,27 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { EntityType } from '../../types.generated';
import { BrowsableEntityPage } from '../browse/BrowsableEntityPage';
import { useEntityRegistry } from '../useEntityRegistry';
interface RouteParams {
urn: string;
}
interface Props {
entityType: EntityType;
}
/**
* Responsible for rendering an Entity Profile
*/
export const EntityPage = ({ entityType }: Props) => {
const { urn } = useParams<RouteParams>();
const entityRegistry = useEntityRegistry();
return (
<BrowsableEntityPage urn={urn} type={entityType}>
{entityRegistry.renderProfile(entityType, urn)}
</BrowsableEntityPage>
);
};

View File

@ -0,0 +1,91 @@
import { EntityType } from '../../types.generated';
import { Entity, PreviewType } from './Entity';
function validatedGet<K, V>(key: K, map: Map<K, V>): V {
if (map.has(key)) {
return map.get(key) as V;
}
throw new Error(`Unrecognized key ${key} provided in map ${JSON.stringify(map)}`);
}
/**
* Serves as a singleton registry for all DataHub entities to appear on the frontend.
*/
export default class EntityRegistry {
entities: Array<Entity<any>> = new Array<Entity<any>>();
entityTypeToEntity: Map<EntityType, Entity<any>> = new Map<EntityType, Entity<any>>();
collectionNameToEntityType: Map<string, EntityType> = new Map<string, EntityType>();
pathNameToEntityType: Map<string, EntityType> = new Map<string, EntityType>();
register(entity: Entity<any>) {
this.entities.push(entity);
this.entityTypeToEntity.set(entity.type, entity);
this.collectionNameToEntityType.set(entity.getCollectionName(), entity.type);
this.pathNameToEntityType.set(entity.getPathName(), entity.type);
}
getEntities(): Array<Entity<any>> {
return this.entities;
}
getSearchEntityTypes(): Array<EntityType> {
return this.entities.filter((entity) => entity.isSearchEnabled()).map((entity) => entity.type);
}
getDefaultSearchEntityType(): EntityType {
return this.entities[0].type;
}
getBrowseEntityTypes(): Array<EntityType> {
return this.entities.filter((entity) => entity.isBrowseEnabled()).map((entity) => entity.type);
}
getCollectionName(type: EntityType): string {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.getCollectionName();
}
getTypeFromCollectionName(name: string): EntityType {
return validatedGet(name, this.collectionNameToEntityType);
}
getPathName(type: EntityType): string {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.getPathName();
}
getTypeFromPathName(pathName: string): EntityType {
return validatedGet(pathName, this.pathNameToEntityType);
}
getTypeOrDefaultFromPathName(pathName: string, def: EntityType): EntityType {
try {
return validatedGet(pathName, this.pathNameToEntityType);
} catch (e) {
return def;
}
}
renderProfile(type: EntityType, urn: string): JSX.Element {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.renderProfile(urn);
}
renderPreview<T>(entityType: EntityType, type: PreviewType, data: T): JSX.Element {
const entity = validatedGet(entityType, this.entityTypeToEntity);
return entity.renderPreview(type, data);
}
renderSearchResult<T>(type: EntityType, data: T): JSX.Element {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.renderPreview(PreviewType.SEARCH, data);
}
renderBrowse(type: EntityType, { urn, name }: { urn: string; name: string }): JSX.Element {
const entity = validatedGet(type, this.entityTypeToEntity);
return entity.renderPreview(PreviewType.BROWSE, { urn, name });
}
}

View File

@ -0,0 +1,36 @@
import * as React from 'react';
import { Dataset, EntityType } from '../../../types.generated';
import { Profile } from './profile/Profile';
import { Entity, PreviewType } from '../Entity';
import { Preview } from './preview/Preview';
/**
* Definition of the DataHub Dataset entity.
*/
export class DatasetEntity implements Entity<Dataset> {
type: EntityType = EntityType.Dataset;
isSearchEnabled = () => true;
isBrowseEnabled = () => true;
getAutoCompleteFieldName = () => 'name';
getPathName = () => 'dataset';
getCollectionName = () => 'Datasets';
renderProfile = (urn: string) => <Profile urn={urn} />;
renderPreview = (_: PreviewType, data: any) => {
return (
<Preview
urn={data.urn}
name={data.name}
origin={data.origin}
description={data.description}
platformNativeType={data.platformNativeType}
/>
);
};
}

View File

@ -0,0 +1,54 @@
import React from 'react';
import { EntityType, FabricType, PlatformNativeType } from '../../../../types.generated';
import DefaultPreviewCard from '../../../preview/DefaultPreviewCard';
import { useEntityRegistry } from '../../../useEntityRegistry';
export const Preview = ({
urn,
name,
origin,
description,
platformNativeType,
}: {
urn: string;
name: string;
origin: FabricType;
description?: string | null;
platformNativeType?: PlatformNativeType | null;
}): JSX.Element => {
const entityRegistry = useEntityRegistry();
// TODO: Should we rename the search result card?
return (
<DefaultPreviewCard
url={`/${entityRegistry.getPathName(EntityType.Dataset)}/${urn}`}
title={<div style={{ margin: '5px 0px 5px 2px', fontSize: '20px', fontWeight: 'bold' }}>{name}</div>}
>
<>
<div style={{ margin: '0px 0px 15px 0px' }}>{description}</div>
<div
style={{
width: '150px',
margin: '5px 0px 5px 0px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<b style={{ justifySelf: 'left' }}>Data Origin</b>
<div style={{ justifySelf: 'right' }}>{origin}</div>
</div>
<div
style={{
width: '150px',
margin: '5px 0px 5px 0px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<b>Platform</b>
<div>{platformNativeType}</div>
</div>
</>
</DefaultPreviewCard>
);
};

View File

@ -1,10 +1,10 @@
import { AutoComplete, Avatar, Button, Col, Row, Select, Table } from 'antd';
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { EntityType, Owner, OwnershipType } from '../../../types.generated';
import defaultAvatar from '../../../images/default_avatar.png';
import { PageRoutes } from '../../../conf/Global';
import { useGetAutoCompleteResultsLazyQuery } from '../../../graphql/search.generated';
import { EntityType, Owner, OwnershipType } from '../../../../types.generated';
import defaultAvatar from '../../../../images/default_avatar.png';
import { useGetAutoCompleteResultsLazyQuery } from '../../../../graphql/search.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
const OWNER_SEARCH_PLACEHOLDER = 'Enter an LDAP...';
const NUMBER_OWNERS_REQUIRED = 2;
@ -25,6 +25,8 @@ export const Ownership: React.FC<Props> = ({
}: Props): JSX.Element => {
console.log(_lastModifiedAt);
const entityRegistry = useEntityRegistry();
const getOwnerTableData = (ownerArr: Array<Owner>) => {
const rows = ownerArr.map((owner) => ({
urn: owner.owner.urn,
@ -93,7 +95,7 @@ export const Ownership: React.FC<Props> = ({
title: 'LDAP',
dataIndex: 'ldap',
render: (text: string, record: any) => (
<Link to={`${PageRoutes.USERS}/${record.urn}`}>
<Link to={`${entityRegistry.getPathName(EntityType.User)}/${record.urn}`}>
<Avatar
style={{
marginRight: '15px',

View File

@ -1,15 +1,13 @@
import * as React from 'react';
import { Link, useParams } from 'react-router-dom';
import React from 'react';
import { Avatar, Col, Row, Tooltip } from 'antd';
import { BrowsableEntityPage } from '../../browse/BrowsableEntityPage';
import { useGetDatasetQuery } from '../../../graphql/dataset.generated';
import { EntityType } from '../../shared/EntityTypeUtil';
import defaultAvatar from '../../../images/default_avatar.png';
import { Link } from 'react-router-dom';
import { useGetDatasetQuery } from '../../../../graphql/dataset.generated';
import defaultAvatar from '../../../../images/default_avatar.png';
import { Ownership as OwnershipView } from './Ownership';
import { Schema as SchemaView } from './Schema';
import { GenericEntityDetails } from '../../shared/GenericEntityDetails';
import { PageRoutes } from '../../../conf/Global';
import { EntityProfile } from '../../../shared/EntityProfile';
import { EntityType } from '../../../../types.generated';
import { useEntityRegistry } from '../../../useEntityRegistry';
export enum TabType {
Ownership = 'Ownership',
@ -17,18 +15,13 @@ export enum TabType {
}
const ENABLED_TAB_TYPES = [TabType.Ownership, TabType.Schema];
interface RouteParams {
urn: string;
}
const EMPTY_OWNER_ARR: never[] = [];
/**
* Responsible for display the Dataset Page
*/
export const DatasetPage: React.VFC = () => {
const { urn } = useParams<RouteParams>();
export const Profile = ({ urn }: { urn: string }): JSX.Element => {
const entityRegistry = useEntityRegistry();
const { loading, error, data } = useGetDatasetQuery({ variables: { urn } });
@ -46,7 +39,7 @@ export const DatasetPage: React.VFC = () => {
ownership.owners &&
ownership.owners.map((owner: any) => (
<Tooltip title={owner.owner.info?.fullName}>
<Link to={`${PageRoutes.USERS}/${owner.owner.urn}`}>
<Link to={`${entityRegistry.getPathName(EntityType.User)}/${owner.owner.urn}`}>
<Avatar
style={{
color: '#f56a00',
@ -87,11 +80,11 @@ export const DatasetPage: React.VFC = () => {
};
return (
<BrowsableEntityPage urn={urn} type={EntityType.Dataset}>
<>
{loading && <p>Loading...</p>}
{data && !data.dataset && !error && <p>Unable to find dataset with urn {urn}</p>}
{data && data.dataset && !error && (
<GenericEntityDetails
<EntityProfile
title={data.dataset.name}
tags={data.dataset.tags}
body={getBody(data.dataset?.description || '', data.dataset?.ownership)}
@ -99,6 +92,6 @@ export const DatasetPage: React.VFC = () => {
/>
)}
{error && <p>Failed to load dataset with urn {urn}</p>}
</BrowsableEntityPage>
</>
);
};

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import { CorpUser, EntityType } from '../../../types.generated';
import { Entity, PreviewType } from '../Entity';
import { UserPage } from './UserPage';
/**
* Definition of the DataHub Dataset entity.
*/
export class UserEntity implements Entity<CorpUser> {
type: EntityType = EntityType.User;
isSearchEnabled = () => true;
isBrowseEnabled = () => false;
getAutoCompleteFieldName = () => 'username';
getPathName: () => string = () => 'user';
getCollectionName: () => string = () => 'Users';
renderProfile: (urn: string) => JSX.Element = (_) => <UserPage />;
renderPreview = (_: PreviewType, _1: CorpUser) => <p>Hello</p>;
}

View File

@ -0,0 +1,20 @@
import { Divider } from 'antd';
import React from 'react';
import { Link } from 'react-router-dom';
interface Props extends React.PropsWithChildren<any> {
title?: React.ReactNode;
url: string;
}
export default function DefaultPreviewCard({ title, url, children }: Props) {
return (
<div style={{ padding: '0px 24px' }}>
<Link to={url} style={{ color: '#0073b1', padding: '0px 24px' }} type="link">
{title}
</Link>
<div style={{ padding: '20px 5px 5px 5px' }}>{children}</div>
<Divider />
</div>
);
}

View File

@ -2,20 +2,16 @@ import React from 'react';
import * as QueryString from 'query-string';
import { useHistory, useLocation, useParams } from 'react-router';
import { Affix, Col, Row, Tabs, Layout } from 'antd';
import { SearchablePage } from './SearchablePage';
import { fromCollectionName, fromPathnameOrEmptyString, toCollectionName } from '../shared/EntityTypeUtil';
import { useGetSearchResultsQuery } from '../../graphql/search.generated';
import { SearchResults } from './SearchResults';
import { EntityType, FacetFilterInput, PlatformNativeType } from '../../types.generated';
import { SearchFilters } from './SearchFilters';
import { SearchCfg } from '../../conf';
import { PageRoutes } from '../../conf/Global';
import { useEntityRegistry } from '../useEntityRegistry';
import { FacetFilterInput } from '../../types.generated';
import useFilters from './utils/useFilters';
import { navigateToSearchUrl } from './utils/navigateToSearchUrl';
const { SEARCHABLE_ENTITY_TYPES, RESULTS_PER_PAGE } = SearchCfg;
type SearchPageParams = {
type?: string;
};
@ -23,14 +19,20 @@ type SearchPageParams = {
/**
* A dedicated search page.
*
* TODO: Read / write filter parameters from the URL query parameters.
* TODO: Read / write filter parameters from / to the URL query parameters.
*/
export const SearchPage = () => {
const history = useHistory();
const location = useLocation();
const params = QueryString.parse(location.search, { arrayFormat: 'comma' });
const type = fromPathnameOrEmptyString(useParams<SearchPageParams>().type || '') || SEARCHABLE_ENTITY_TYPES[0];
const entityRegistry = useEntityRegistry();
const searchTypes = entityRegistry.getSearchEntityTypes();
const params = QueryString.parse(location.search);
const type = entityRegistry.getTypeOrDefaultFromPathName(
useParams<SearchPageParams>().type || '',
entityRegistry.getDefaultSearchEntityType(),
);
const query: string = params.query ? (params.query as string) : '';
const page: number = params.page && Number(params.page as string) > 0 ? Number(params.page as string) : 1;
const filters: Array<FacetFilterInput> = useFilters(params);
@ -40,16 +42,16 @@ export const SearchPage = () => {
input: {
type,
query,
start: (page - 1) * RESULTS_PER_PAGE,
count: RESULTS_PER_PAGE,
start: (page - 1) * SearchCfg.RESULTS_PER_PAGE,
count: SearchCfg.RESULTS_PER_PAGE,
filters,
},
},
});
const onSearchTypeChange = (newType: string) => {
const entityType = fromCollectionName(newType);
navigateToSearchUrl({ type: entityType, query, page: 1, history });
const entityType = entityRegistry.getTypeFromCollectionName(newType);
navigateToSearchUrl({ type: entityType, query, page: 1, history, entityRegistry });
};
const onFilterSelect = (selected: boolean, field: string, value: string) => {
@ -57,68 +59,15 @@ export const SearchPage = () => {
? [...filters, { field, value }]
: filters.filter((filter) => filter.field !== field || filter.value !== value);
navigateToSearchUrl({ type, query, page: 1, filters: newFilters, history });
navigateToSearchUrl({ type, query, page: 1, filters: newFilters, history, entityRegistry });
};
const onResultsPageChange = (newPage: number) => {
navigateToSearchUrl({ type, query, page: newPage, filters, history });
};
const navigateToDataset = (urn: string) => {
return history.push({
pathname: `${PageRoutes.DATASETS}/${urn}`,
});
};
const toDatasetSearchResult = (dataset: {
urn: string;
name: string;
origin: string;
description: string;
platformNativeType: PlatformNativeType;
}) => {
return {
title: (
<div style={{ margin: '5px 0px 5px 2px', fontSize: '20px', fontWeight: 'bold' }}>{dataset.name}</div>
),
preview: (
<>
<div style={{ margin: '0px 0px 15px 0px' }}>{dataset.description}</div>
<div
style={{
width: '150px',
margin: '5px 0px 5px 0px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<b style={{ justifySelf: 'left' }}>Data Origin</b>
<div style={{ justifySelf: 'right' }}>{dataset.origin}</div>
</div>
<div
style={{
width: '150px',
margin: '5px 0px 5px 0px',
display: 'flex',
justifyContent: 'space-between',
}}
>
<b>Platform</b>
<div>{dataset.platformNativeType}</div>
</div>
</>
),
onNavigate: () => navigateToDataset(dataset.urn),
};
navigateToSearchUrl({ type, query, page: newPage, filters, history, entityRegistry });
};
const toSearchResults = (elements: any) => {
switch (type) {
case EntityType.Dataset:
return elements.map((element: any) => toDatasetSearchResult(element));
default:
throw new Error(`Search for entity of type ${type} currently not supported!`);
}
return elements.map((element: any) => entityRegistry.renderSearchResult(type, element));
};
const searchResults = toSearchResults(data?.search?.elements || []);
@ -129,12 +78,15 @@ export const SearchPage = () => {
<Affix offsetTop={64}>
<Tabs
tabBarStyle={{ backgroundColor: 'white', padding: '0px 165px', marginBottom: '0px' }}
activeKey={toCollectionName(type)}
activeKey={entityRegistry.getCollectionName(type)}
size="large"
onChange={onSearchTypeChange}
>
{SEARCHABLE_ENTITY_TYPES.map((t) => (
<Tabs.TabPane tab={toCollectionName(t)} key={toCollectionName(t)} />
{searchTypes.map((t) => (
<Tabs.TabPane
tab={entityRegistry.getCollectionName(t)}
key={entityRegistry.getCollectionName(t)}
/>
))}
</Tabs>
</Affix>
@ -151,7 +103,7 @@ export const SearchPage = () => {
{error && !data && <p>Search error!</p>}
{data?.search && (
<SearchResults
typeName={toCollectionName(type)}
typeName={entityRegistry.getCollectionName(type)}
results={searchResults}
pageStart={data?.search?.start}
pageSize={data.search?.count}

View File

@ -1,58 +1,35 @@
import { Button, Card, Divider, Pagination } from 'antd';
import { Card, Pagination } from 'antd';
import * as React from 'react';
interface SearchResult {
title?: React.ReactNode; // Title content
preview?: React.ReactNode; // Body content
onNavigate: () => void; // Invoked when the search result is clicked.
}
interface Props {
typeName: string;
pageStart: number;
pageSize: number;
totalResults: number;
results: Array<SearchResult>;
results: Array<JSX.Element>;
onChangePage: (page: number) => void;
}
export const SearchResults = ({
typeName: _typeName,
pageStart: _pageStart,
pageSize: _pageSize,
totalResults: _totalResults,
results: _results,
onChangePage: _onChangePage,
}: Props) => {
export const SearchResults = ({ typeName, pageStart, pageSize, totalResults, results, onChangePage }: Props) => {
return (
<Card
style={{ border: '1px solid #d2d2d2' }}
title={<h1 style={{ marginBottom: '0px' }}>{_typeName}</h1>}
title={<h1 style={{ marginBottom: '0px' }}>{typeName}</h1>}
bodyStyle={{ padding: '24px 0px' }}
extra={
<div style={{ color: 'grey' }}>
Showing {_pageStart * _pageSize} - {_pageStart * _pageSize + _pageSize} of {_totalResults} results
Showing {pageStart * pageSize} - {pageStart * pageSize + pageSize} of {totalResults} results
</div>
}
>
{_results.map((result) => (
<>
<div style={{ padding: '0px 24px' }}>
<Button onClick={result.onNavigate} style={{ color: '#0073b1', padding: '0px' }} type="link">
{result.title}
</Button>
<div style={{ padding: '20px 5px 5px 5px' }}>{result.preview}</div>
</div>
<Divider />
</>
))}
{results}
<Pagination
style={{ width: '100%', display: 'flex', justifyContent: 'center' }}
current={_pageStart}
pageSize={_pageSize}
total={_totalResults / _pageSize}
current={pageStart}
pageSize={pageSize}
total={totalResults / pageSize}
showLessItems
onChange={_onChangePage}
onChange={onChangePage}
/>
</Card>
);

View File

@ -1,25 +1,15 @@
import * as React from 'react';
import React from 'react';
import 'antd/dist/antd.css';
import { Layout } from 'antd';
import { useHistory } from 'react-router';
import { SearchHeader } from './SearchHeader';
import { EntityType, fromCollectionName, toCollectionName } from '../shared/EntityTypeUtil';
import { SearchCfg } from '../../conf';
import { useEntityRegistry } from '../useEntityRegistry';
import { useGetAutoCompleteResultsLazyQuery } from '../../graphql/search.generated';
import { navigateToSearchUrl } from './utils/navigateToSearchUrl';
const { SEARCHABLE_ENTITY_TYPES, SEARCH_BAR_PLACEHOLDER_TEXT, SHOW_ALL_ENTITIES_SEARCH_TYPE } = SearchCfg;
import { EntityType } from '../../types.generated';
const ALL_ENTITIES_SEARCH_TYPE_NAME = 'All Entities';
const EMPTY_STRING = '';
const DEFAULT_SELECTED_ENTITY_TYPE_NAME = SHOW_ALL_ENTITIES_SEARCH_TYPE
? ALL_ENTITIES_SEARCH_TYPE_NAME
: toCollectionName(SEARCHABLE_ENTITY_TYPES[0]);
const SUPPORTED_SEARCH_TYPE_NAMES = SHOW_ALL_ENTITIES_SEARCH_TYPE
? [ALL_ENTITIES_SEARCH_TYPE_NAME, ...SEARCHABLE_ENTITY_TYPES.map((entityType) => toCollectionName(entityType))]
: [...SEARCHABLE_ENTITY_TYPES.map((entityType) => toCollectionName(entityType))];
interface Props extends React.PropsWithChildren<any> {
initialType?: EntityType;
@ -28,60 +18,58 @@ interface Props extends React.PropsWithChildren<any> {
const defaultProps = {
initialType: undefined,
initialQuery: EMPTY_STRING,
initialQuery: '',
};
/**
* A page that includes a sticky search header (nav bar)
*/
export const SearchablePage = ({
initialType: _initialType,
initialQuery: _initialQuery,
children: _children,
}: Props) => {
export const SearchablePage = ({ initialType, initialQuery, children }: Props) => {
const history = useHistory();
const initialSearchTypeName = _initialType ? toCollectionName(_initialType) : DEFAULT_SELECTED_ENTITY_TYPE_NAME;
const entityRegistry = useEntityRegistry();
const searchTypes = entityRegistry.getSearchEntityTypes();
const searchTypeNames = searchTypes.map((entityType) => entityRegistry.getCollectionName(entityType));
if (!SUPPORTED_SEARCH_TYPE_NAMES.includes(initialSearchTypeName)) {
throw new Error(`Unsupported search EntityType ${_initialType} provided!`);
}
const initialSearchTypeName =
initialType && searchTypes.includes(initialType)
? entityRegistry.getCollectionName(initialType)
: ALL_ENTITIES_SEARCH_TYPE_NAME;
const [getAutoCompleteResults, { data: suggestionsData }] = useGetAutoCompleteResultsLazyQuery();
const search = (type: string, query: string) => {
navigateToSearchUrl({
type: ALL_ENTITIES_SEARCH_TYPE_NAME === type ? SEARCHABLE_ENTITY_TYPES[0] : fromCollectionName(type),
type:
ALL_ENTITIES_SEARCH_TYPE_NAME === type
? searchTypes[0]
: entityRegistry.getTypeFromCollectionName(type),
query,
history,
entityRegistry,
});
};
const autoComplete = (type: string, query: string) => {
const entityType =
ALL_ENTITIES_SEARCH_TYPE_NAME === type ? SEARCHABLE_ENTITY_TYPES[0] : fromCollectionName(type);
const autoCompleteField = SearchCfg.getAutoCompleteFieldName(entityType);
if (autoCompleteField) {
getAutoCompleteResults({
variables: {
input: {
type: entityType,
query,
field: autoCompleteField,
},
ALL_ENTITIES_SEARCH_TYPE_NAME === type ? searchTypes[0] : entityRegistry.getTypeFromCollectionName(type);
getAutoCompleteResults({
variables: {
input: {
type: entityType,
query,
},
});
}
},
});
};
return (
<Layout>
<SearchHeader
types={SUPPORTED_SEARCH_TYPE_NAMES}
types={searchTypeNames}
initialType={initialSearchTypeName}
initialQuery={_initialQuery as string}
placeholderText={SEARCH_BAR_PLACEHOLDER_TEXT}
initialQuery={initialQuery as string}
placeholderText={SearchCfg.SEARCH_BAR_PLACEHOLDER_TEXT}
suggestions={
(suggestionsData && suggestionsData?.autoComplete && suggestionsData.autoComplete.suggestions) || []
}
@ -89,7 +77,7 @@ export const SearchablePage = ({
onQueryChange={autoComplete}
authenticatedUserUrn="urn:li:corpuser:0"
/>
<div style={{ marginTop: 64 }}>{_children}</div>
<div style={{ marginTop: 64 }}>{children}</div>
</Layout>
);
};

View File

@ -3,8 +3,8 @@ import { RouteComponentProps } from 'react-router-dom';
import filtersToQueryStringParams from './filtersToQueryStringParams';
import { EntityType, FacetFilterInput } from '../../../types.generated';
import { toPathName } from '../../shared/EntityTypeUtil';
import { PageRoutes } from '../../../conf/Global';
import EntityRegistry from '../../entity/EntityRegistry';
export const navigateToSearchUrl = ({
type: newType,
@ -12,12 +12,14 @@ export const navigateToSearchUrl = ({
page: newPage = 1,
filters: newFilters,
history,
entityRegistry,
}: {
type: EntityType;
query?: string;
page?: number;
filters?: Array<FacetFilterInput>;
history: RouteComponentProps['history'];
entityRegistry: EntityRegistry;
}) => {
const search = QueryString.stringify(
{
@ -29,7 +31,7 @@ export const navigateToSearchUrl = ({
);
history.push({
pathname: `${PageRoutes.SEARCH}/${toPathName(newType)}`,
pathname: `${PageRoutes.SEARCH}/${entityRegistry.getPathName(newType)}`,
search,
});
};

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { Col, Row, Tag, Divider, Layout } from 'antd';
import { RoutedTabs } from './RoutedTabs';
interface Props {
export interface EntityProfileProps {
title: string;
tags?: Array<string>;
body: React.ReactNode;
@ -19,9 +19,9 @@ const defaultProps = {
};
/**
* A generic container view for presenting Entity details.
* A default container view for presenting Entity details.
*/
export const GenericEntityDetails = ({ title: _title, tags: _tags, body: _body, tabs: _tabs }: Props) => {
export const EntityProfile = ({ title: _title, tags: _tags, body: _body, tabs: _tabs }: EntityProfileProps) => {
const defaultTabPath = _tabs && _tabs?.length > 0 ? _tabs[0].path : '';
/* eslint-disable spaced-comment */
@ -48,4 +48,4 @@ export const GenericEntityDetails = ({ title: _title, tags: _tags, body: _body,
);
};
GenericEntityDetails.defaultProps = defaultProps;
EntityProfile.defaultProps = defaultProps;

View File

@ -1,52 +0,0 @@
import { EntityType } from '../../types.generated';
export { EntityType };
export const fromPathName = (pathName: string): EntityType => {
switch (pathName) {
case 'dataset':
return EntityType.Dataset;
case 'user':
return EntityType.User;
default:
throw new Error(`Unrecognized pathName ${pathName} provided.`);
}
};
export const fromPathnameOrEmptyString = (pathName: string): EntityType | null => {
if (pathName === '') return null;
return fromPathName(pathName);
};
export const toPathName = (type: EntityType): string => {
switch (type) {
case EntityType.Dataset:
return 'dataset';
case EntityType.User:
return 'user';
default:
throw new Error(`Unrecognized type ${type} provided.`);
}
};
export const fromCollectionName = (name: string): EntityType => {
switch (name) {
case 'Datasets':
return EntityType.Dataset;
case 'Users':
return EntityType.User;
default:
throw new Error(`Unrecognized name ${name} provided.`);
}
};
export const toCollectionName = (type: EntityType): string => {
switch (type) {
case EntityType.Dataset:
return 'Datasets';
case EntityType.User:
return 'Users';
default:
throw new Error(`Unrecognized type ${type} provided.`);
}
};

View File

@ -1,8 +1,9 @@
import React from 'react';
import { Avatar } from 'antd';
import * as React from 'react';
import { Link } from 'react-router-dom';
import { PageRoutes } from '../../conf/Global';
import defaultAvatar from '../../images/default_avatar.png';
import { EntityType } from '../../types.generated';
import { useEntityRegistry } from '../useEntityRegistry';
interface Props {
urn: string;
@ -14,8 +15,9 @@ const defaultProps = {
};
export const ManageAccount = ({ urn: _urn, pictureLink: _pictureLink }: Props) => {
const entityRegistry = useEntityRegistry();
return (
<Link to={`${PageRoutes.USERS}/${_urn}`}>
<Link to={`${entityRegistry.getPathName(EntityType.User)}/${_urn}`}>
<Avatar
style={{
marginRight: '15px',

View File

@ -0,0 +1,9 @@
import { useContext } from 'react';
import { EntityRegistryContext } from '../entityRegistryContext';
/**
* Fetch an instance of EntityRegistry from the React context.
*/
export function useEntityRegistry() {
return useContext(EntityRegistryContext);
}

View File

@ -1,10 +1,3 @@
import { EntityType } from '../components/shared/EntityTypeUtil';
/*
Browsable Entity Types
*/
export const BROWSABLE_ENTITY_TYPES = [EntityType.Dataset];
/*
Number of results shown per browse results page
*/

View File

@ -1,12 +1,11 @@
import DataHubLogo from '../images/landing-logo.png';
/*
Reference to the Logo Image used in Log in page and in search header
*/
export const LOGO_IMAGE = DataHubLogo;
/*
Top-level page route names
Default top-level page route names (excludes entity pages)
*/
export enum PageRoutes {
LOG_IN = '/login',

View File

@ -1,32 +1,3 @@
import { EntityType } from '../components/shared/EntityTypeUtil';
/*
Searchable Entity Types
*/
export const SEARCHABLE_ENTITY_TYPES = [EntityType.Dataset, EntityType.User];
/*
Default AutoComplete field by entity. Required if autocomplete is desired on a SEARCHABLE_ENTITY_TYPE.
Note that we need to consider this further, because in some cases fields may differ: eg. ldap vs name search for Users.
*/
export const getAutoCompleteFieldName = (type: EntityType) => {
switch (type) {
case EntityType.Dataset:
case EntityType.User:
return 'name';
default:
return null;
}
};
/*
Whether to enable the 'All Entities' search type as the default entity type.
If false, the default search entity will be the first entry in
SEARCH_ENTITY_TYPES.
*/
export const SHOW_ALL_ENTITIES_SEARCH_TYPE = true;
/*
Placeholder text appearing in the search bar
*/

View File

@ -0,0 +1,4 @@
import React from 'react';
import EntityRegistry from './components/entity/EntityRegistry';
export const EntityRegistryContext = React.createContext(new EntityRegistry());