diff --git a/datahub-web-react/src/app/entity/Entity.tsx b/datahub-web-react/src/app/entity/Entity.tsx index c307605646fa15..61c4a27598940d 100644 --- a/datahub-web-react/src/app/entity/Entity.tsx +++ b/datahub-web-react/src/app/entity/Entity.tsx @@ -40,6 +40,36 @@ export enum IconStyleType { SVG, } +/** + * A standard set of Entity Capabilities that span across entity types. + */ +export enum EntityCapabilityType { + /** + * Ownership of an entity + */ + OWNERS, + /** + * Adding a glossary term to the entity + */ + GLOSSARY_TERMS, + /** + * Adding a tag to an entity + */ + TAGS, + /** + * Assigning the entity to a domain + */ + DOMAINS, + /** + * Deprecating an entity + */ + DEPRECATION, + /** + * Soft deleting an entity + */ + SOFT_DELETE, +} + /** * Base interface used for authoring DataHub Entities on the client side. * @@ -124,4 +154,9 @@ export interface Entity { * Returns generic entity properties for the entity */ getGenericEntityProperties: (data: T) => GenericEntityProperties | null; + + /** + * Returns the supported features for the entity + */ + supportedCapabilities: () => Set; } diff --git a/datahub-web-react/src/app/entity/EntityRegistry.tsx b/datahub-web-react/src/app/entity/EntityRegistry.tsx index f87e6e534cfbe8..6aa24d9cf2196e 100644 --- a/datahub-web-react/src/app/entity/EntityRegistry.tsx +++ b/datahub-web-react/src/app/entity/EntityRegistry.tsx @@ -1,6 +1,6 @@ import { Entity as EntityInterface, EntityType, SearchResult } from '../../types.generated'; import { FetchedEntity } from '../lineage/types'; -import { Entity, IconStyleType, PreviewType } from './Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from './Entity'; import { GenericEntityProperties } from './shared/types'; import { dictToQueryStringParams, urlEncodeUrn } from './shared/utils'; @@ -153,4 +153,17 @@ export default class EntityRegistry { const entity = validatedGet(type, this.entityTypeToEntity); return entity.getGenericEntityProperties(data); } + + getSupportedEntityCapabilities(type: EntityType): Set { + const entity = validatedGet(type, this.entityTypeToEntity); + return entity.supportedCapabilities(); + } + + getTypesWithSupportedCapabilities(capability: EntityCapabilityType): Set { + return new Set( + this.getEntities() + .filter((entity) => entity.supportedCapabilities().has(capability)) + .map((entity) => entity.type), + ); + } } diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx index e5062c9b801ab5..44cdd04e635f44 100644 --- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx +++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx @@ -1,7 +1,7 @@ import { LineChartOutlined } from '@ant-design/icons'; import * as React from 'react'; import { Chart, EntityType, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { ChartPreview } from './preview/ChartPreview'; import { GetChartQuery, useGetChartQuery, useUpdateChartMutation } from '../../../graphql/chart.generated'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; @@ -209,4 +209,15 @@ export class ChartEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx index d41e2587ad6108..7b5ef2164bd63b 100644 --- a/datahub-web-react/src/app/entity/container/ContainerEntity.tsx +++ b/datahub-web-react/src/app/entity/container/ContainerEntity.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { FolderOutlined } from '@ant-design/icons'; import { Container, EntityType, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { Preview } from './preview/Preview'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; @@ -164,4 +164,14 @@ export class ContainerEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx index 61de5297b9714b..016cb64d7347a7 100644 --- a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx +++ b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx @@ -6,7 +6,7 @@ import { useUpdateDashboardMutation, } from '../../../graphql/dashboard.generated'; import { Dashboard, EntityType, OwnershipType, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { SidebarOwnerSection } from '../shared/containers/profile/sidebar/Ownership/SidebarOwnerSection'; import { SidebarAboutSection } from '../shared/containers/profile/sidebar/SidebarAboutSection'; @@ -226,4 +226,15 @@ export class DashboardEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx index 021a752cdfc187..b148d1b25a1dd9 100644 --- a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx +++ b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ShareAltOutlined } from '@ant-design/icons'; import { DataFlow, EntityType, OwnershipType, SearchResult } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { useGetDataFlowQuery, useUpdateDataFlowMutation } from '../../../graphql/dataFlow.generated'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; @@ -158,4 +158,15 @@ export class DataFlowEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx index 4425c75f870f93..6aeb4ccd5e4622 100644 --- a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx +++ b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ConsoleSqlOutlined } from '@ant-design/icons'; import { DataJob, EntityType, OwnershipType, SearchResult } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { GetDataJobQuery, useGetDataJobQuery, useUpdateDataJobMutation } from '../../../graphql/dataJob.generated'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; @@ -195,4 +195,15 @@ export class DataJobEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index c89402953cab5d..8569dbc074c449 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DatabaseFilled, DatabaseOutlined } from '@ant-design/icons'; import { Typography } from 'antd'; import { Dataset, DatasetProperties, EntityType, OwnershipType, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { Preview } from './preview/Preview'; import { FIELDS_TO_HIGHLIGHT } from './search/highlights'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; @@ -322,4 +322,15 @@ export class DatasetEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx index 4a8b60edfb526c..f34b0dae98bd4e 100644 --- a/datahub-web-react/src/app/entity/domain/DomainEntity.tsx +++ b/datahub-web-react/src/app/entity/domain/DomainEntity.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { FolderOutlined } from '@ant-design/icons'; import { Domain, EntityType, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { Preview } from './preview/Preview'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab'; @@ -11,6 +11,7 @@ import { getDataForEntityType } from '../shared/containers/profile/utils'; import { useGetDomainQuery } from '../../../graphql/domain.generated'; import { DomainEntitiesTab } from './DomainEntitiesTab'; import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; +import { EntityActionItem } from '../shared/entity/EntityActions'; /** * Definition of the DataHub Domain entity. @@ -65,6 +66,7 @@ export class DomainEntity implements Entity { useUpdateQuery={undefined} getOverrideProperties={this.getOverridePropertiesFromEntity} headerDropdownItems={new Set([EntityMenuItems.COPY_URL, EntityMenuItems.DELETE])} + headerActionItems={new Set([EntityActionItem.BATCH_ADD_DOMAIN])} isNameEditable tabs={[ { @@ -134,4 +136,9 @@ export class DomainEntity implements Entity { getOverrideProperties: this.getOverridePropertiesFromEntity, }); }; + + supportedCapabilities = () => { + // TODO.. Determine whether SOFT_DELETE should go into here. + return new Set([EntityCapabilityType.OWNERS]); + }; } diff --git a/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx b/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx index 137109cff26ef6..5075d262153c39 100644 --- a/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx +++ b/datahub-web-react/src/app/entity/glossaryNode/GlossaryNodeEntity.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { useGetGlossaryNodeQuery } from '../../../graphql/glossaryNode.generated'; import { EntityType, GlossaryNode, SearchResult } from '../../../types.generated'; import GlossaryEntitiesPath from '../../glossary/GlossaryEntitiesPath'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { SidebarOwnerSection } from '../shared/containers/profile/sidebar/Ownership/SidebarOwnerSection'; import { SidebarAboutSection } from '../shared/containers/profile/sidebar/SidebarAboutSection'; @@ -135,6 +135,14 @@ class GlossaryNodeEntity implements Entity { getOverrideProperties: (data) => data, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } export default GlossaryNodeEntity; diff --git a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx index 5c9e9fc8a92c73..234d1d1aa6e467 100644 --- a/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx +++ b/datahub-web-react/src/app/entity/glossaryTerm/GlossaryTermEntity.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { BookFilled, BookOutlined } from '@ant-design/icons'; import { EntityType, GlossaryTerm, SearchResult } from '../../../types.generated'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { Preview } from './preview/Preview'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; @@ -16,6 +16,7 @@ import { DocumentationTab } from '../shared/tabs/Documentation/DocumentationTab' import { SidebarAboutSection } from '../shared/containers/profile/sidebar/SidebarAboutSection'; import GlossaryEntitiesPath from '../../glossary/GlossaryEntitiesPath'; import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; +import { EntityActionItem } from '../shared/entity/EntityActions'; /** * Definition of the DataHub Dataset entity. @@ -62,6 +63,7 @@ export class GlossaryTermEntity implements Entity { urn={urn} entityType={EntityType.GlossaryTerm} useEntityQuery={useGetGlossaryTermQuery as any} + headerActionItems={new Set([EntityActionItem.BATCH_ADD_GLOSSARY_TERM])} headerDropdownItems={ new Set([ EntityMenuItems.COPY_URL, @@ -154,4 +156,12 @@ export class GlossaryTermEntity implements Entity { getOverrideProperties: (data) => data, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/group/Group.tsx b/datahub-web-react/src/app/entity/group/Group.tsx index 81fecd79294bac..89c5823428c4ec 100644 --- a/datahub-web-react/src/app/entity/group/Group.tsx +++ b/datahub-web-react/src/app/entity/group/Group.tsx @@ -68,4 +68,8 @@ export class GroupEntity implements Entity { getGenericEntityProperties = (group: CorpGroup) => { return getDataForEntityType({ data: group, entityType: this.type, getOverrideProperties: (data) => data }); }; + + supportedCapabilities = () => { + return new Set([]); + }; } diff --git a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx index c3f0a490986963..98781da4f9f5d3 100644 --- a/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx +++ b/datahub-web-react/src/app/entity/mlFeature/MLFeatureEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DotChartOutlined } from '@ant-design/icons'; import { MlFeature, EntityType, SearchResult, OwnershipType } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { GenericEntityProperties } from '../shared/types'; @@ -172,4 +172,15 @@ export class MLFeatureEntity implements Entity { platform: entity?.['featureTables']?.relationships?.[0]?.entity?.platform?.name, }; }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx index 64c1caf09ee75a..3aa4fbc86afc4c 100644 --- a/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx +++ b/datahub-web-react/src/app/entity/mlFeatureTable/MLFeatureTableEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DotChartOutlined } from '@ant-design/icons'; import { MlFeatureTable, EntityType, SearchResult, OwnershipType } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { GenericEntityProperties } from '../shared/types'; import { useGetMlFeatureTableQuery } from '../../../graphql/mlFeatureTable.generated'; @@ -159,4 +159,15 @@ export class MLFeatureTableEntity implements Entity { getOverrideProperties: (data) => data, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx index 99eb7a17aa2e92..329ecde7af9f14 100644 --- a/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx +++ b/datahub-web-react/src/app/entity/mlModel/MLModelEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { CodeSandboxOutlined } from '@ant-design/icons'; import { MlModel, EntityType, SearchResult, OwnershipType } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; import { useGetMlModelQuery } from '../../../graphql/mlModel.generated'; @@ -141,4 +141,15 @@ export class MLModelEntity implements Entity { getGenericEntityProperties = (mlModel: MlModel) => { return getDataForEntityType({ data: mlModel, entityType: this.type, getOverrideProperties: (data) => data }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx index 255d8e8a7cedf7..a42809aa078c79 100644 --- a/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx +++ b/datahub-web-react/src/app/entity/mlModelGroup/MLModelGroupEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { CodeSandboxOutlined } from '@ant-design/icons'; import { MlModelGroup, EntityType, SearchResult, OwnershipType } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { GenericEntityProperties } from '../shared/types'; import { EntityProfile } from '../shared/containers/profile/EntityProfile'; @@ -130,4 +130,15 @@ export class MLModelGroupEntity implements Entity { getOverrideProperties: (data) => data, }); }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx index c10afe2ecfafcb..647e1c051e5a92 100644 --- a/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx +++ b/datahub-web-react/src/app/entity/mlPrimaryKey/MLPrimaryKeyEntity.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { DotChartOutlined } from '@ant-design/icons'; import { MlPrimaryKey, EntityType, SearchResult, OwnershipType } from '../../../types.generated'; import { Preview } from './preview/Preview'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { GenericEntityProperties } from '../shared/types'; import { GetMlPrimaryKeyQuery, useGetMlPrimaryKeyQuery } from '../../../graphql/mlPrimaryKey.generated'; @@ -170,4 +170,15 @@ export class MLPrimaryKeyEntity implements Entity { platform: entity?.['featureTables']?.relationships?.[0]?.entity?.platform?.name, }; }; + + supportedCapabilities = () => { + return new Set([ + EntityCapabilityType.OWNERS, + EntityCapabilityType.GLOSSARY_TERMS, + EntityCapabilityType.TAGS, + EntityCapabilityType.DOMAINS, + EntityCapabilityType.DEPRECATION, + EntityCapabilityType.SOFT_DELETE, + ]); + }; } diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearch.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearch.tsx index 313f940b307b8a..039474d124720a 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearch.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearch.tsx @@ -4,8 +4,6 @@ import { useHistory, useLocation, useParams } from 'react-router'; import { message } from 'antd'; import styled from 'styled-components'; import { ApolloError } from '@apollo/client'; -import type { CheckboxValueType } from 'antd/es/checkbox/Group'; - import { useEntityRegistry } from '../../../../../useEntityRegistry'; import { EntityType, FacetFilterInput } from '../../../../../../types.generated'; import useFilters from '../../../../../search/utils/useFilters'; @@ -17,11 +15,14 @@ import EmbeddedListSearchHeader from './EmbeddedListSearchHeader'; import { useGetSearchResultsForMultipleQuery } from '../../../../../../graphql/search.generated'; import { GetSearchResultsParams, SearchResultsInterface } from './types'; import { useEntityQueryParams } from '../../../containers/profile/utils'; +import { isListSubset } from '../../../utils'; +import { EntityAndType } from '../../../types'; const Container = styled.div` display: flex; flex-direction: column; height: 100%; + overflow-y: hidden; `; // this extracts the response from useGetSearchResultsForMultipleQuery into a common interface other search endpoints can also produce @@ -97,9 +98,9 @@ export const EmbeddedListSearch = ({ .map((filter) => filter.value.toUpperCase() as EntityType); const [showFilters, setShowFilters] = useState(defaultShowFilters || false); - - const [showSelectMode, setShowSelectMode] = useState(false); - const [checkedSearchResults, setCheckedSearchResults] = useState([]); + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedEntities, setSelectedEntities] = useState([]); + const [numResultsPerPage, setNumResultsPerPage] = useState(SearchCfg.RESULTS_PER_PAGE); const { refetch } = useGetSearchResults({ variables: { @@ -123,13 +124,18 @@ export const EmbeddedListSearch = ({ input: { types: entityFilters, query, - start: (page - 1) * SearchCfg.RESULTS_PER_PAGE, - count: SearchCfg.RESULTS_PER_PAGE, + start: (page - 1) * numResultsPerPage, + count: numResultsPerPage, filters: finalFilters, }, }, }); + const searchResultEntities = + data?.searchResults?.map((result) => ({ urn: result.entity.urn, type: result.entity.type })) || []; + const searchResultUrns = searchResultEntities.map((entity) => entity.urn); + const selectedEntityUrns = selectedEntities.map((entity) => entity.urn); + const onSearch = (q: string) => { const finalQuery = addFixedQuery(q as string, fixedQuery as string, emptySearchQuery as string); navigateToEntitySearchUrl({ @@ -166,10 +172,32 @@ export const EmbeddedListSearch = ({ }); }; - const toggleFilters = () => { + const onToggleFilters = () => { setShowFilters(!showFilters); }; + /** + * Invoked when the "select all" checkbox is clicked. + * + * This method either adds the entire current page of search results to + * the list of selected entities, or removes the current page from the set of selected entities. + */ + const onChangeSelectAll = (selected: boolean) => { + if (selected) { + // Add current page of urns to the master selected entity list + const entitiesToAdd = searchResultEntities.filter( + (entity) => + selectedEntities.findIndex( + (element) => element.urn === entity.urn && element.type === entity.type, + ) < 0, + ); + setSelectedEntities(Array.from(new Set(selectedEntities.concat(entitiesToAdd)))); + } else { + // Filter out the current page of entity urns from the list + setSelectedEntities(selectedEntities.filter((entity) => searchResultUrns.indexOf(entity.urn) === -1)); + } + }; + useEffect(() => { if (defaultFilters) { onChangeFilters(defaultFilters); @@ -178,6 +206,12 @@ export const EmbeddedListSearch = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!isSelectMode) { + setSelectedEntities([]); + } + }, [isSelectMode]); + // Filter out the persistent filter values const filteredFilters = data?.facets?.filter((facet) => facet.field !== fixedFilter?.field) || []; @@ -187,14 +221,16 @@ export const EmbeddedListSearch = ({ 0 && isListSubset(searchResultUrns, selectedEntityUrns)} + setIsSelectMode={setIsSelectMode} + selectedEntities={selectedEntities} + onChangeSelectAll={onChangeSelectAll} /> ); diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchHeader.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchHeader.tsx index a02963083d8b9b..adca92e04845a8 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchHeader.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchHeader.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { Button, Typography } from 'antd'; -import { CloseCircleOutlined, FilterOutlined } from '@ant-design/icons'; -import type { CheckboxValueType } from 'antd/es/checkbox/Group'; +import { FilterOutlined } from '@ant-design/icons'; import styled from 'styled-components'; import TabToolbar from '../TabToolbar'; import { SearchBar } from '../../../../../search/SearchBar'; @@ -9,30 +8,26 @@ import { useEntityRegistry } from '../../../../../useEntityRegistry'; import { EntityType, FacetFilterInput, SearchAcrossEntitiesInput } from '../../../../../../types.generated'; import { SearchResultsInterface } from './types'; import SearchExtendedMenu from './SearchExtendedMenu'; -// import SearchActionMenu from './SearchActionMenu'; +import { SearchSelectBar } from './SearchSelectBar'; +import { EntityAndType } from '../../../types'; const HeaderContainer = styled.div` display: flex; justify-content: space-between; - padding-bottom: 16px; width: 100%; + padding-right: 4px; + padding-left: 4px; `; const SearchAndDownloadContainer = styled.div` display: flex; + align-items: center; `; const SearchMenuContainer = styled.div` - margin-top: 7px; margin-left: 10px; `; -const SelectedText = styled(Typography.Text)` - width: 70px; - top: 5px; - position: relative; -`; - type Props = { onSearch: (q: string) => void; onToggleFilters: () => void; @@ -43,9 +38,11 @@ type Props = { entityFilters: EntityType[]; filters: FacetFilterInput[]; query: string; - setShowSelectMode: (showSelectMode: boolean) => any; - showSelectMode: boolean; - checkedSearchResults: CheckboxValueType[]; + isSelectMode: boolean; + isSelectAll: boolean; + selectedEntities: EntityAndType[]; + setIsSelectMode: (showSelectMode: boolean) => any; + onChangeSelectAll: (selected: boolean) => void; }; export default function EmbeddedListSearchHeader({ @@ -56,70 +53,63 @@ export default function EmbeddedListSearchHeader({ entityFilters, filters, query, - setShowSelectMode, - showSelectMode, - checkedSearchResults, + isSelectMode, + isSelectAll, + selectedEntities, + setIsSelectMode, + onChangeSelectAll, }: Props) { const entityRegistry = useEntityRegistry(); - const onQueryChange = (newQuery: string) => { - onSearch(newQuery); - }; - return ( - - - - - {showSelectMode && {`${checkedSearchResults.length} selected`}} - - {/* TODO: in the future, when we add more menu items, we'll show this always */} - {showSelectMode ? ( - <> - - {/* - - */} - - ) : ( + <> + + + + + - )} - - - + + + + {isSelectMode && ( + + { + setIsSelectMode(false); + }} + /> + + )} + ); } diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx index 1b02f37d03bf2d..6fc3bc3bd9e984 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/EmbeddedListSearchResults.tsx @@ -1,18 +1,17 @@ import React from 'react'; import { Pagination, Typography } from 'antd'; import styled from 'styled-components'; -import type { CheckboxValueType } from 'antd/es/checkbox/Group'; import { FacetFilterInput, FacetMetadata, SearchResults as SearchResultType } from '../../../../../../types.generated'; import { SearchFilters } from '../../../../../search/SearchFilters'; import { SearchCfg } from '../../../../../../conf'; import { EntityNameList } from '../../../../../recommendations/renderer/component/EntityNameList'; import { ReactComponent as LoadingSvg } from '../../../../../../images/datahub-logo-color-loading_pendulum.svg'; +import { EntityAndType } from '../../../types'; const SearchBody = styled.div` + height: 100%; + overflow-y: scroll; display: flex; - flex-direction: row; - flex: 1 1 auto; - overflow-y: hidden; `; const PaginationInfo = styled(Typography.Text)` @@ -30,16 +29,15 @@ const FiltersContainer = styled.div` `; const ResultContainer = styled.div` - flex: 1; + height: auto; overflow: auto; - display: flex; - flex-direction: column; + flex: 1; `; -const PaginationInfoContainer = styled.div` +const PaginationInfoContainer = styled.span` padding: 8px; padding-left: 16px; - border-bottom: 1px solid; + border-top: 1px solid; border-color: ${(props) => props.theme.styles['border-color-base']}; display: flex; justify-content: space-between; @@ -73,11 +71,6 @@ const SearchFilterContainer = styled.div` overflow: hidden; `; -const LoadingText = styled.div` - margin-top: 18px; - font-size: 12px; -`; - const LoadingContainer = styled.div` padding-top: 40px; padding-bottom: 40px; @@ -95,9 +88,11 @@ interface Props { showFilters?: boolean; onChangeFilters: (filters: Array) => void; onChangePage: (page: number) => void; - showSelectMode: boolean; - setCheckedSearchResults: (checkedSearchResults: Array) => any; - checkedSearchResults: CheckboxValueType[]; + isSelectMode: boolean; + selectedEntities: EntityAndType[]; + setSelectedEntities: (entities: EntityAndType[]) => any; + numResultsPerPage: number; + setNumResultsPerPage: (numResults: number) => void; } export const EmbeddedListSearchResults = ({ @@ -109,19 +104,17 @@ export const EmbeddedListSearchResults = ({ showFilters, onChangeFilters, onChangePage, - showSelectMode, - setCheckedSearchResults, - checkedSearchResults, + isSelectMode, + selectedEntities, + setSelectedEntities, + numResultsPerPage, + setNumResultsPerPage, }: Props) => { const pageStart = searchResponse?.start || 0; const pageSize = searchResponse?.count || 0; const totalResults = searchResponse?.total || 0; const lastResultIndex = pageStart + pageSize > totalResults ? totalResults : pageStart + pageSize; - const onFilterSelect = (newFilters) => { - onChangeFilters(newFilters); - }; - return ( <> @@ -133,7 +126,7 @@ export const EmbeddedListSearchResults = ({ loading={loading} facets={filters || []} selectedFilters={selectedFilters} - onFilterSelect={onFilterSelect} + onFilterSelect={(newFilters) => onChangeFilters(newFilters)} /> @@ -142,47 +135,45 @@ export const EmbeddedListSearchResults = ({ {loading && ( - Searching for related entities... )} {!loading && ( - <> - 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 - degree: searchResult['degree'], - })) || [] - } - showSelectMode={showSelectMode} - setCheckedSearchResults={setCheckedSearchResults} - checkedSearchResults={checkedSearchResults} - /> - - )} - - - - {lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex} - {' '} - of {totalResults} - - 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 + degree: searchResult['degree'], + })) || [] + } + isSelectMode={isSelectMode} + selectedEntities={selectedEntities} + setSelectedEntities={setSelectedEntities} + bordered={false} /> - - + )} + + + + {lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex} + {' '} + of {totalResults} + + SearchCfg.RESULTS_PER_PAGE} + onShowSizeChange={(_currNum, newNum) => setNumResultsPerPage(newNum)} + pageSizeOptions={['10', '20', '50', '100']} + /> + + ); }; diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchActionMenu.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchActionMenu.tsx deleted file mode 100644 index 1734fced241347..00000000000000 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchActionMenu.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { Button, Dropdown, Menu } from 'antd'; -import { MoreOutlined, PlusOutlined } from '@ant-design/icons'; -import styled from 'styled-components'; -import type { CheckboxValueType } from 'antd/es/checkbox/Group'; - -const MenuIcon = styled(MoreOutlined)` - font-size: 15px; - height: 20px; -`; - -const SelectButton = styled(Button)` - font-size: 12px; - padding-left: 12px; - padding-right: 12px; -`; - -type Props = { - checkedSearchResults: CheckboxValueType[]; -}; - -// currently only contains Download As Csv but will be extended to contain other actions as well -export default function SearchActionMenu({ checkedSearchResults }: Props) { - console.log('checkedSearchResults:: ', checkedSearchResults); - const menu = ( - - - - - Add Tags - - - - - - Add Terms - - - - - - Add Owners - - - - ); - - return ( - <> - - - - - ); -} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchExtendedMenu.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchExtendedMenu.tsx index 0066fc0991183f..2054b6c5e3a6ad 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchExtendedMenu.tsx +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchExtendedMenu.tsx @@ -1,8 +1,6 @@ import React, { useState } from 'react'; -import { Dropdown, Menu } from 'antd'; -import { MoreOutlined } from '@ant-design/icons'; -// import { Button, Dropdown, Menu } from 'antd'; -// import { MoreOutlined, SelectOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Menu } from 'antd'; +import { FormOutlined, MoreOutlined } from '@ant-design/icons'; import styled from 'styled-components'; import { EntityType, FacetFilterInput, SearchAcrossEntitiesInput } from '../../../../../../types.generated'; import { SearchResultsInterface } from './types'; @@ -14,11 +12,11 @@ const MenuIcon = styled(MoreOutlined)` height: 20px; `; -// const SelectButton = styled(Button)` -// font-size: 12px; -// padding-left: 12px; -// padding-right: 12px; -// `; +const SelectButton = styled(Button)` + font-size: 12px; + padding-left: 12px; + padding-right: 12px; +`; type Props = { callSearchOnVariables: (variables: { @@ -41,8 +39,6 @@ export default function SearchExtendedMenu({ const [isDownloadingCsv, setIsDownloadingCsv] = useState(false); const [showDownloadAsCsvModal, setShowDownloadAsCsvModal] = useState(false); - // TO DO: Need to implement Select Mode - console.log('setShowSelectMode:', setShowSelectMode); const menu = ( @@ -51,14 +47,14 @@ export default function SearchExtendedMenu({ setShowDownloadAsCsvModal={setShowDownloadAsCsvModal} /> - {/* - {setShowSelectMode && ( + {setShowSelectMode && ( + setShowSelectMode(true)}> - - Select... + + Edit... - )} - */} + + )} ); diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelect.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelect.tsx new file mode 100644 index 00000000000000..512dfe6348fa9e --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelect.tsx @@ -0,0 +1,176 @@ +import React, { useState } from 'react'; +import { Button, message, Typography } from 'antd'; +import styled from 'styled-components'; +import { FilterOutlined } from '@ant-design/icons'; + +import { useEntityRegistry } from '../../../../../useEntityRegistry'; +import { EntityType, FacetFilterInput } from '../../../../../../types.generated'; +import { ENTITY_FILTER_NAME } from '../../../../../search/utils/constants'; +import { SearchCfg } from '../../../../../../conf'; +import { EmbeddedListSearchResults } from './EmbeddedListSearchResults'; +import { useGetSearchResultsForMultipleQuery } from '../../../../../../graphql/search.generated'; +import { isListSubset } from '../../../utils'; +import { SearchBar } from '../../../../../search/SearchBar'; +import { ANTD_GRAY } from '../../../constants'; +import { EntityAndType } from '../../../types'; +import { SearchSelectBar } from './SearchSelectBar'; +import TabToolbar from '../TabToolbar'; + +const Container = styled.span` + display: flex; + flex-direction: column; + height: 100%; +`; + +const SearchBarContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px; + border-bottom: 1px solid ${ANTD_GRAY[4]}; +`; + +const SEARCH_BAR_STYLE = { + maxWidth: 680, + padding: 0, +}; + +const SEARCH_INPUT_STYLE = { + height: 40, + fontSize: 12, +}; + +type Props = { + fixedEntityTypes?: Array | null; + placeholderText?: string | null; + selectedEntities: EntityAndType[]; + setSelectedEntities: (Entities: EntityAndType[]) => void; +}; + +/** + * An embeddable component that can be used for searching & selecting a subset of the entities on the Metadata Graph + * in order to perform some action. + * + * This component provides easy ways to filter for a specific set of entity types, and provides a set of entity urns + * when the selection is complete. + */ +export const SearchSelect = ({ fixedEntityTypes, placeholderText, selectedEntities, setSelectedEntities }: Props) => { + const entityRegistry = useEntityRegistry(); + + // Component state + const [query, setQuery] = useState(''); + const [page, setPage] = useState(1); + const [filters, setFilters] = useState>([]); + const [showFilters, setShowFilters] = useState(false); + const [numResultsPerPage, setNumResultsPerPage] = useState(SearchCfg.RESULTS_PER_PAGE); + + // Compute search filters + const entityFilters: Array = filters + .filter((filter) => filter.field === ENTITY_FILTER_NAME) + .map((filter) => filter.value.toUpperCase() as EntityType); + const finalEntityTypes = (entityFilters.length > 0 && entityFilters) || fixedEntityTypes || []; + + // Execute search + const { data, loading, error } = useGetSearchResultsForMultipleQuery({ + variables: { + input: { + types: finalEntityTypes, + query, + start: (page - 1) * numResultsPerPage, + count: numResultsPerPage, + filters, + }, + }, + }); + + const searchAcrossEntities = data?.searchAcrossEntities; + const searchResultEntities = + searchAcrossEntities?.searchResults?.map((result) => ({ urn: result.entity.urn, type: result.entity.type })) || + []; + const searchResultUrns = searchResultEntities.map((entity) => entity.urn); + const selectedEntityUrns = selectedEntities.map((entity) => entity.urn); + const facets = searchAcrossEntities?.facets || []; + + const onSearch = (q: string) => { + setQuery(q); + }; + + const onChangeFilters = (newFilters: Array) => { + setFilters(newFilters); + }; + + const onChangePage = (newPage: number) => { + setPage(newPage); + }; + + const onToggleFilters = () => { + setShowFilters(!showFilters); + }; + + /** + * Invoked when the "select all" checkbox is clicked. + * + * This method either adds the entire current page of search results to + * the list of selected entities, or removes the current page from the set of selected entities. + */ + const onChangeSelectAll = (selected: boolean) => { + if (selected) { + // Add current page of urns to the master selected entity list + const entitiesToAdd = searchResultEntities.filter( + (entity) => + selectedEntities.findIndex( + (element) => element.urn === entity.urn && element.type === entity.type, + ) < 0, + ); + setSelectedEntities(Array.from(new Set(selectedEntities.concat(entitiesToAdd)))); + } else { + // Filter out the current page of entity urns from the list + setSelectedEntities(selectedEntities.filter((entity) => searchResultUrns.indexOf(entity.urn) === -1)); + } + }; + + return ( + + {error && message.error(`Failed to complete search: ${error && error.message}`)} + + + + + + 0 && isListSubset(searchResultUrns, selectedEntityUrns)} + onChangeSelectAll={onChangeSelectAll} + showCancel={false} + showActions={false} + /> + + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectActions.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectActions.tsx new file mode 100644 index 00000000000000..a6447b2f8d6c0a --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectActions.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import OwnersDropdown from './action/OwnersDropdown'; +import GlossaryTermDropdown from './action/GlossaryTermsDropdown'; +import TagsDropdown from './action/TagsDropdown'; +import DomainDropdown from './action/DomainsDropdown'; +import DeprecationDropdown from './action/DeprecationDropdown'; +import DeleteDropdown from './action/DeleteDropdown'; +import { EntityType } from '../../../../../../types.generated'; +import { EntityCapabilityType } from '../../../../Entity'; +import { useEntityRegistry } from '../../../../../useEntityRegistry'; +import { EntityAndType } from '../../../types'; +import { SelectActionGroups } from './types'; + +/** + * The set of action groups that are visible by default. + */ +const DEFAULT_ACTION_GROUPS = [ + SelectActionGroups.CHANGE_OWNERS, + SelectActionGroups.CHANGE_TAGS, + SelectActionGroups.CHANGE_GLOSSARY_TERMS, + SelectActionGroups.CHANGE_DOMAINS, + SelectActionGroups.CHANGE_DEPRECATION, + SelectActionGroups.DELETE, +]; + +type Props = { + selectedEntities: EntityAndType[]; + visibleActionGroups?: Set; +}; + +/** + * A component used for rendering a group of actions to take on a group of selected entities such + * as changing owners, tags, domains, etc. + */ +export const SearchSelectActions = ({ + selectedEntities, + visibleActionGroups = new Set(DEFAULT_ACTION_GROUPS), +}: Props) => { + const entityRegistry = useEntityRegistry(); + + /** + * Extract the urns and entity types, which are used for a) qualifying actions + * and b) executing actions. + */ + const selectedEntityUrns = selectedEntities.map((entity) => entity.urn); + const selectedEntityTypes = new Set(selectedEntities.map((entity) => entity.type)); + + /** + * Returns true if a specific capability is supported by ALL entities in a set. + */ + const isEntityCapabilitySupported = (type: EntityCapabilityType, entityTypes: Set) => { + return Array.from(entityTypes).every((entityType) => + entityRegistry.getSupportedEntityCapabilities(entityType).has(type), + ); + }; + + return ( + <> + {visibleActionGroups.has(SelectActionGroups.CHANGE_OWNERS) && ( + + )} + {visibleActionGroups.has(SelectActionGroups.CHANGE_GLOSSARY_TERMS) && ( + + )} + {visibleActionGroups.has(SelectActionGroups.CHANGE_TAGS) && ( + + )} + {visibleActionGroups.has(SelectActionGroups.CHANGE_DOMAINS) && ( + + )} + {visibleActionGroups.has(SelectActionGroups.CHANGE_DEPRECATION) && ( + + )} + {visibleActionGroups.has(SelectActionGroups.DELETE) && ( + + )} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectBar.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectBar.tsx new file mode 100644 index 00000000000000..c4e7f785caeb7b --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectBar.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Button, Checkbox, Modal, Typography } from 'antd'; +import styled from 'styled-components'; +import { ANTD_GRAY } from '../../../constants'; +import { EntityAndType } from '../../../types'; +import { SearchSelectActions } from './SearchSelectActions'; + +const CheckboxContainer = styled.div` + display: flex; + justify-content: left; + align-items: center; +`; + +const ActionsContainer = styled.div` + display: flex; + align-items: center; +`; + +const CancelButton = styled(Button)` + && { + margin-left: 8px; + padding: 0px; + color: ${ANTD_GRAY[7]}; + } +`; + +const StyledCheckbox = styled(Checkbox)` + margin-right: 12px; + padding-bottom: 0px; +`; + +type Props = { + isSelectAll: boolean; + selectedEntities?: EntityAndType[]; + showCancel?: boolean; + showActions?: boolean; + onChangeSelectAll: (selected: boolean) => void; + onCancel?: () => void; +}; + +/** + * A header for use when an entity search select experience is active. + * + * This component provides a select all checkbox and a set of actions that can be taken on the selected entities. + */ +export const SearchSelectBar = ({ + isSelectAll, + selectedEntities = [], + showCancel = true, + showActions = true, + onChangeSelectAll, + onCancel, +}: Props) => { + const selectedEntityCount = selectedEntities.length; + const onClickCancel = () => { + if (selectedEntityCount > 0) { + Modal.confirm({ + title: `Exit Selection`, + content: `Are you sure you want to exit? ${selectedEntityCount} selection(s) will be cleared.`, + onOk() { + onCancel?.(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + } else { + onCancel?.(); + } + }; + + return ( + <> + + onChangeSelectAll(e.target.checked as boolean)} + /> + + {selectedEntityCount} selected + + + + {showActions && } + {showCancel && ( + + Done + + )} + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectModal.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectModal.tsx new file mode 100644 index 00000000000000..b7ee4e72433afc --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/SearchSelectModal.tsx @@ -0,0 +1,93 @@ +import { Button, Modal } from 'antd'; +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { EntityType } from '../../../../../../types.generated'; +import ClickOutside from '../../../../../shared/ClickOutside'; +import { EntityAndType } from '../../../types'; +import { SearchSelect } from './SearchSelect'; + +const StyledModal = styled(Modal)` + top: 30px; +`; + +const MODAL_WIDTH_PX = 800; + +const MODAL_BODY_STYLE = { padding: 0, height: '70vh' }; + +type Props = { + fixedEntityTypes?: Array | null; + placeholderText?: string | null; + titleText?: string | null; + continueText?: string | null; + onContinue: (entityUrns: string[]) => void; + onCancel?: () => void; +}; + +/** + * Modal that can be used for searching & selecting a subset of the entities in the Metadata Graph in order to take a specific action. + * + * This component provides easy ways to filter for a specific set of entity types, and provides a set of entity urns + * when the selection is complete. + */ +export const SearchSelectModal = ({ + fixedEntityTypes, + placeholderText, + titleText, + continueText, + onContinue, + onCancel, +}: Props) => { + const [selectedEntities, setSelectedEntities] = useState([]); + + const onCancelSelect = () => { + if (selectedEntities.length > 0) { + Modal.confirm({ + title: `Exit Selection`, + content: `Are you sure you want to exit? ${selectedEntities.length} selection(s) will be cleared.`, + onOk() { + onCancel?.(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + } else { + onCancel?.(); + } + }; + + return ( + + + + + + } + > + + + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/ActionDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/ActionDropdown.tsx new file mode 100644 index 00000000000000..8158f052a6eb16 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/ActionDropdown.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Button, Dropdown, Menu, Tooltip } from 'antd'; +import { CaretDownOutlined } from '@ant-design/icons'; +import styled from 'styled-components'; +import MenuItem from 'antd/lib/menu/MenuItem'; +import { ANTD_GRAY } from '../../../../constants'; + +const DownArrow = styled(CaretDownOutlined)` + && { + padding-top: 4px; + font-size: 8px; + margin-left: 2px; + margin-top: 2px; + color: ${ANTD_GRAY[7]}; + } +`; + +const StyledMenuItem = styled(MenuItem)` + && { + padding: 0px; + } +`; + +const ActionButton = styled(Button)` + font-weight: normal; +`; + +const DropdownWrapper = styled.div<{ + disabled: boolean; +}>` + cursor: ${(props) => (props.disabled ? 'normal' : 'pointer')}; + color: ${(props) => (props.disabled ? ANTD_GRAY[7] : 'none')}; + display: flex; + margin-left: 12px; + margin-right: 12px; +`; + +export type Action = { + title: React.ReactNode; + onClick: () => void; +}; + +type Props = { + name: string; + actions: Array; + disabled?: boolean; +}; + +export default function ActionDropdown({ name, actions, disabled }: Props) { + return ( + + + {actions.map((action) => ( + + + {action.title} + + + ))} + + } + > + + {name} + + + + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeleteDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeleteDropdown.tsx new file mode 100644 index 00000000000000..572e0af1e278f2 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeleteDropdown.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function DeleteDropdown({ urns, disabled = false }: Props) { + return ( + null, + }, + { + title: 'Mark as undeleted', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx new file mode 100644 index 00000000000000..957654d657f053 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DeprecationDropdown.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function DeprecationDropdown({ urns, disabled = false }: Props) { + return ( + null, + }, + { + title: 'Mark as undeprecated', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/DomainsDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DomainsDropdown.tsx new file mode 100644 index 00000000000000..50dfc00a525455 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/DomainsDropdown.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function DomainsDropdown({ urns, disabled = false }: Props) { + return ( + null, + }, + { + title: 'Unset domain', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/GlossaryTermsDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/GlossaryTermsDropdown.tsx new file mode 100644 index 00000000000000..c4f190a39dafbd --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/GlossaryTermsDropdown.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function GlossaryTermsDropdown({ urns, disabled = false }: Props) { + return ( + null, + }, + { + title: 'Remove glossary terms', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/OwnersDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/OwnersDropdown.tsx new file mode 100644 index 00000000000000..f5267d7e8ffc33 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/OwnersDropdown.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import { EntityType } from '../../../../../../../types.generated'; +import { AddOwnersModal } from '../../../../containers/profile/sidebar/Ownership/AddOwnersModal'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function OwnersDropdown({ urns, disabled = false }: Props) { + const [showAddModal, setShowAddModal] = useState(false); + return ( + <> + setShowAddModal(true), + }, + { + title: 'Remove owners', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + {showAddModal && urns.length > 0 && ( + { + setShowAddModal(false); + }} + /> + )} + + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/action/TagsDropdown.tsx b/datahub-web-react/src/app/entity/shared/components/styled/search/action/TagsDropdown.tsx new file mode 100644 index 00000000000000..e75657f228aca9 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/action/TagsDropdown.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import ActionDropdown from './ActionDropdown'; + +type Props = { + urns: Array; + disabled: boolean; +}; + +// eslint-disable-next-line +export default function TagsDropdown({ urns, disabled = false }: Props) { + return ( + null, + }, + { + title: 'Remove tags', + onClick: () => null, + }, + ]} + disabled={disabled} + /> + ); +} diff --git a/datahub-web-react/src/app/entity/shared/components/styled/search/types.ts b/datahub-web-react/src/app/entity/shared/components/styled/search/types.ts index 5ccfd66b7a8f5f..9d8ed6e97e7622 100644 --- a/datahub-web-react/src/app/entity/shared/components/styled/search/types.ts +++ b/datahub-web-react/src/app/entity/shared/components/styled/search/types.ts @@ -35,3 +35,15 @@ export type SearchResultsInterface = { /** Candidate facet aggregations used for search filtering */ facets?: Maybe>; }; + +/** + * Supported Action Groups for search-select feature. + */ +export enum SelectActionGroups { + CHANGE_OWNERS, + CHANGE_TAGS, + CHANGE_GLOSSARY_TERMS, + CHANGE_DOMAINS, + CHANGE_DEPRECATION, + DELETE, +} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx index b7af756f8a2ad0..f8c69fc7ae7511 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/EntityProfile.tsx @@ -31,6 +31,7 @@ import GlossaryBrowser from '../../../../glossary/GlossaryBrowser/GlossaryBrowse import GlossarySearch from '../../../../glossary/GlossarySearch'; import { BrowserWrapper, MAX_BROWSER_WIDTH, MIN_BROWSWER_WIDTH } from '../../../../glossary/BusinessGlossaryPage'; import { combineEntityDataWithSiblings, useIsSeparateSiblingsMode } from '../../siblingUtils'; +import { EntityActionItem } from '../../entity/EntityActions'; type Props = { urn: string; @@ -57,6 +58,7 @@ type Props = { customNavBar?: React.ReactNode; subHeader?: EntitySubHeaderSection; headerDropdownItems?: Set; + headerActionItems?: Set; displayGlossaryBrowser?: boolean; isNameEditable?: boolean; }; @@ -141,6 +143,7 @@ export const EntityProfile = ({ sidebarSections, customNavBar, headerDropdownItems, + headerActionItems, displayGlossaryBrowser, isNameEditable, subHeader, @@ -262,7 +265,11 @@ export const EntityProfile = ({ )} {!loading && ( <> - + @@ -330,6 +337,7 @@ export const EntityProfile = ({
void; headerDropdownItems?: Set; + headerActionItems?: Set; isNameEditable?: boolean; subHeader?: EntitySubHeaderSection; }; -export const EntityHeader = ({ refreshBrowser, headerDropdownItems, isNameEditable, subHeader }: Props) => { +export const EntityHeader = ({ + refreshBrowser, + headerDropdownItems, + headerActionItems, + isNameEditable, + subHeader, +}: Props) => { + // TODO: Uncomment once we support header-based actions flow. + console.log(headerActionItems); + const { urn, entityType, entityData } = useEntityData(); const refetch = useRefetch(); const me = useGetAuthenticatedUser(); @@ -143,6 +154,9 @@ export const EntityHeader = ({ refreshBrowser, headerDropdownItems, isNameEditab )} + {/* headerActionItems && ( + + ) */} setCopiedUrn(true)} /> {headerDropdownItems && ( ; + refetchForEntity?: () => void; +} + +function EntityActions(props: Props) { + // eslint ignore react/no-unused-prop-types + const entityRegistry = useEntityRegistry(); + const { actionItems, refetchForEntity } = props; + const [isBatchAddGlossaryTermModalVisible, setIsBatchAddGlossaryTermModalVisible] = useState(false); + const [isBatchSetDomainModalVisible, setIsBatchSetDomainModalVisible] = useState(false); + + // eslint-disable-next-line + const batchAddGlossaryTerms = (entityUrns: Array) => { + refetchForEntity?.(); + setIsBatchAddGlossaryTermModalVisible(false); + message.success('Successfully added glossary terms!'); + }; + + // eslint-disable-next-line + const batchSetDomains = (entityUrns: Array) => { + refetchForEntity?.(); + setIsBatchSetDomainModalVisible(false); + message.success('Successfully added assets!'); + }; + + return ( + <> +
+ {actionItems.has(EntityActionItem.BATCH_ADD_GLOSSARY_TERM) && ( + + )} + {actionItems.has(EntityActionItem.BATCH_ADD_DOMAIN) && ( + + )} +
+ {isBatchAddGlossaryTermModalVisible && ( + setIsBatchAddGlossaryTermModalVisible(false)} + fixedEntityTypes={Array.from( + entityRegistry.getTypesWithSupportedCapabilities(EntityCapabilityType.GLOSSARY_TERMS), + )} + /> + )} + {isBatchSetDomainModalVisible && ( + setIsBatchSetDomainModalVisible(false)} + fixedEntityTypes={Array.from( + entityRegistry.getTypesWithSupportedCapabilities(EntityCapabilityType.DOMAINS), + )} + /> + )} + + ); +} + +export default EntityActions; diff --git a/datahub-web-react/src/app/entity/shared/types.ts b/datahub-web-react/src/app/entity/shared/types.ts index 18412efb3db12d..76f366e3f19341 100644 --- a/datahub-web-react/src/app/entity/shared/types.ts +++ b/datahub-web-react/src/app/entity/shared/types.ts @@ -132,3 +132,8 @@ export type EntityContextType = { export type RequiredAndNotNull = { [P in keyof T]-?: Exclude; }; + +export type EntityAndType = { + urn: string; + type: EntityType; +}; diff --git a/datahub-web-react/src/app/entity/shared/utils.ts b/datahub-web-react/src/app/entity/shared/utils.ts index d7026091949ba6..065115b56ce2bc 100644 --- a/datahub-web-react/src/app/entity/shared/utils.ts +++ b/datahub-web-react/src/app/entity/shared/utils.ts @@ -73,3 +73,10 @@ export function getPlatformName(entityData: GenericEntityProperties | null) { export const EDITED_DESCRIPTIONS_CACHE_NAME = 'editedDescriptions'; export const FORBIDDEN_URN_CHARS_REGEX = /.*[(),\\].*/; + +/** + * Utility function for checking whether a list is a subset of another. + */ +export const isListSubset = (l1, l2): boolean => { + return l1.every((result) => l2.indexOf(result) >= 0); +}; diff --git a/datahub-web-react/src/app/entity/tag/Tag.tsx b/datahub-web-react/src/app/entity/tag/Tag.tsx index 7cc0ad8910d911..57a9043953235b 100644 --- a/datahub-web-react/src/app/entity/tag/Tag.tsx +++ b/datahub-web-react/src/app/entity/tag/Tag.tsx @@ -3,7 +3,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { Tag, EntityType, SearchResult } from '../../../types.generated'; import DefaultPreviewCard from '../../preview/DefaultPreviewCard'; -import { Entity, IconStyleType, PreviewType } from '../Entity'; +import { Entity, EntityCapabilityType, IconStyleType, PreviewType } from '../Entity'; import { getDataForEntityType } from '../shared/containers/profile/utils'; import { urlEncodeUrn } from '../shared/utils'; import TagProfile from './TagProfile'; @@ -75,4 +75,8 @@ export class TagEntity implements Entity { getGenericEntityProperties = (tag: Tag) => { return getDataForEntityType({ data: tag, entityType: this.type, getOverrideProperties: (data) => data }); }; + + supportedCapabilities = () => { + return new Set([EntityCapabilityType.OWNERS]); + }; } diff --git a/datahub-web-react/src/app/entity/user/User.tsx b/datahub-web-react/src/app/entity/user/User.tsx index baa2da76c7c28c..d5a8ac4b948b4a 100644 --- a/datahub-web-react/src/app/entity/user/User.tsx +++ b/datahub-web-react/src/app/entity/user/User.tsx @@ -75,4 +75,8 @@ export class UserEntity implements Entity { getGenericEntityProperties = (user: CorpUser) => { return getDataForEntityType({ data: user, entityType: this.type, getOverrideProperties: (data) => data }); }; + + supportedCapabilities = () => { + return new Set([]); + }; } diff --git a/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx b/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx index eb3fe67d7fa3f3..c621c7d393d6f8 100644 --- a/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx +++ b/datahub-web-react/src/app/recommendations/renderer/component/EntityNameList.tsx @@ -1,14 +1,20 @@ import React from 'react'; import { Divider, List, Checkbox } from 'antd'; -import type { CheckboxValueType } from 'antd/es/checkbox/Group'; import styled from 'styled-components'; import { Entity } from '../../../../types.generated'; import { useEntityRegistry } from '../../../useEntityRegistry'; import DefaultPreviewCard from '../../../preview/DefaultPreviewCard'; import { IconStyleType } from '../../../entity/Entity'; import { capitalizeFirstLetter } from '../../../shared/textUtil'; +import { EntityAndType } from '../../../entity/shared/types'; + +const StyledCheckbox = styled(Checkbox)` + margin-right: 12px; +`; const StyledList = styled(List)` + overflow-y: scroll; + height: 100%; margin-top: -1px; box-shadow: ${(props) => props.theme.styles['box-shadow']}; flex: 1; @@ -38,11 +44,13 @@ const StyledList = styled(List)` } ` as typeof List; -const ListItem = styled.div` +const ListItem = styled.div<{ isSelectMode: boolean }>` padding-right: 40px; - padding-left: 40px; + padding-left: ${(props) => (props.isSelectMode ? '20px' : '40px')}; padding-top: 16px; padding-bottom: 8px; + display: flex; + align-items: center; `; const ThinDivider = styled(Divider)` @@ -54,34 +62,6 @@ type AdditionalProperties = { degree?: number; }; -const CheckBoxGroup = styled(Checkbox.Group)` - flex: 1; - width: 100%; - background-color: rgb(255, 255, 255); - padding-right: 32px; - padding-left: 32px; - padding-top: 8px; - padding-bottom: 8px; - > .ant-checkbox-group-item { - display: block; - margin-right: 0; - } - &&& .ant-checkbox { - display: inline-block; - position: relative; - top: 48px; - } -`; - -const LabelContainer = styled.span` - position: relative; - left: 24px; - bottom: 6px; - * { - pointer-events: none; - } -`; - 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 @@ -89,20 +69,24 @@ type Props = { additionalPropertiesList?: Array; entities: Array; onClick?: (index: number) => void; - showSelectMode?: boolean; - setCheckedSearchResults?: (checkedSearchResults: Array) => any; - checkedSearchResults?: CheckboxValueType[]; + isSelectMode?: boolean; + selectedEntities?: EntityAndType[]; + setSelectedEntities?: (entities: EntityAndType[]) => any; + bordered?: boolean; }; export const EntityNameList = ({ additionalPropertiesList, entities, onClick, - showSelectMode, - setCheckedSearchResults, - checkedSearchResults, + isSelectMode, + selectedEntities = [], + setSelectedEntities, + bordered = true, }: Props) => { const entityRegistry = useEntityRegistry(); + const selectedEntityUrns = selectedEntities?.map((entity) => entity.urn) || []; + if ( additionalPropertiesList?.length !== undefined && additionalPropertiesList.length > 0 && @@ -114,94 +98,65 @@ export const EntityNameList = ({ ); } - const onChange = (checkedValues: CheckboxValueType[]) => { - setCheckedSearchResults?.(checkedValues); + /** + * Invoked when a new entity is selected. Simply updates the state of the list of selected entities. + */ + const onSelectEntity = (selectedEntity: EntityAndType, selected: boolean) => { + if (selected) { + setSelectedEntities?.([...selectedEntities, selectedEntity]); + } else { + setSelectedEntities?.(selectedEntities?.filter((entity) => entity.urn !== selectedEntity.urn) || []); + } }; - const options = entities.map((entity, index) => { - const additionalProperties = additionalPropertiesList?.[index]; - const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity); - const platformLogoUrl = genericProps?.platform?.properties?.logoUrl; - const platformName = - genericProps?.platform?.properties?.displayName || capitalizeFirstLetter(genericProps?.platform?.name); - const entityTypeName = entityRegistry.getEntityName(entity.type); - const displayName = entityRegistry.getDisplayName(entity.type, entity); - const url = entityRegistry.getEntityUrl(entity.type, entity.urn); - const fallbackIcon = entityRegistry.getIcon(entity.type, 18, IconStyleType.ACCENT); - const subType = genericProps?.subTypes?.typeNames?.length && genericProps?.subTypes?.typeNames[0]; - const entityCount = genericProps?.entityCount; - return { - label: ( - - onClick?.(index)} - entityCount={entityCount} - degree={additionalProperties?.degree} - /> - - - ), - value: entity.urn, - }; - }); - return ( - <> - {showSelectMode ? ( - - ) : ( - { - const additionalProperties = additionalPropertiesList?.[index]; - const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity); - const platformLogoUrl = genericProps?.platform?.properties?.logoUrl; - const platformName = - genericProps?.platform?.properties?.displayName || - capitalizeFirstLetter(genericProps?.platform?.name); - const entityTypeName = entityRegistry.getEntityName(entity.type); - const displayName = entityRegistry.getDisplayName(entity.type, entity); - const url = entityRegistry.getEntityUrl(entity.type, entity.urn); - const fallbackIcon = entityRegistry.getIcon(entity.type, 18, IconStyleType.ACCENT); - const subType = - genericProps?.subTypes?.typeNames?.length && genericProps?.subTypes?.typeNames[0]; - const entityCount = genericProps?.entityCount; - return ( - <> - - onClick?.(index)} - entityCount={entityCount} - degree={additionalProperties?.degree} - /> - - - - ); - }} - /> - )} - + { + const additionalProperties = additionalPropertiesList?.[index]; + const genericProps = entityRegistry.getGenericEntityProperties(entity.type, entity); + const platformLogoUrl = genericProps?.platform?.properties?.logoUrl; + const platformName = + genericProps?.platform?.properties?.displayName || + capitalizeFirstLetter(genericProps?.platform?.name); + const entityTypeName = entityRegistry.getEntityName(entity.type); + const displayName = entityRegistry.getDisplayName(entity.type, entity); + const url = entityRegistry.getEntityUrl(entity.type, entity.urn); + const fallbackIcon = entityRegistry.getIcon(entity.type, 18, IconStyleType.ACCENT); + const subType = genericProps?.subTypes?.typeNames?.length && genericProps?.subTypes?.typeNames[0]; + const entityCount = genericProps?.entityCount; + return ( + <> + + {isSelectMode && ( + = 0} + onChange={(e) => + onSelectEntity({ urn: entity.urn, type: entity.type }, e.target.checked) + } + /> + )} + onClick?.(index)} + entityCount={entityCount} + degree={additionalProperties?.degree} + /> + + + + ); + }} + /> ); }; diff --git a/datahub-web-react/src/app/search/SearchPage.tsx b/datahub-web-react/src/app/search/SearchPage.tsx index 203810536d9aec..366b2046d126d5 100644 --- a/datahub-web-react/src/app/search/SearchPage.tsx +++ b/datahub-web-react/src/app/search/SearchPage.tsx @@ -12,6 +12,7 @@ import { useGetSearchResultsForMultipleQuery } from '../../graphql/search.genera import { SearchCfg } from '../../conf'; import { ENTITY_FILTER_NAME } from './utils/constants'; import { GetSearchResultsParams } from '../entity/shared/components/styled/search/types'; +import { EntityAndType } from '../entity/shared/types'; type SearchPageParams = { type?: string; @@ -38,6 +39,8 @@ export const SearchPage = () => { .map((filter) => filter.value.toUpperCase() as EntityType); const [numResultsPerPage, setNumResultsPerPage] = useState(SearchCfg.RESULTS_PER_PAGE); + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedEntities, setSelectedEntities] = useState([]); const { data, loading, error } = useGetSearchResultsForMultipleQuery({ variables: { @@ -51,6 +54,13 @@ export const SearchPage = () => { }, }); + const searchResultEntities = + data?.searchAcrossEntities?.searchResults?.map((result) => ({ + urn: result.entity.urn, + type: result.entity.type, + })) || []; + const searchResultUrns = searchResultEntities.map((entity) => entity.urn); + // we need to extract refetch on its own so paging thru results for csv download // doesnt also update search results const { refetch } = useGetSearchResultsForMultipleQuery({ @@ -69,6 +79,36 @@ export const SearchPage = () => { return refetch(variables).then((res) => res.data.searchAcrossEntities); }; + const onChangeFilters = (newFilters: Array) => { + navigateToSearchUrl({ type: activeType, query, page: 1, filters: newFilters, history }); + }; + + const onChangePage = (newPage: number) => { + navigateToSearchUrl({ type: activeType, query, page: newPage, filters, history }); + }; + + /** + * Invoked when the "select all" checkbox is clicked. + * + * This method either adds the entire current page of search results to + * the list of selected entities, or removes the current page from the set of selected entities. + */ + const onChangeSelectAll = (selected: boolean) => { + if (selected) { + // Add current page of urns to the master selected entity list + const entitiesToAdd = searchResultEntities.filter( + (entity) => + selectedEntities.findIndex( + (element) => element.urn === entity.urn && element.type === entity.type, + ) < 0, + ); + setSelectedEntities(Array.from(new Set(selectedEntities.concat(entitiesToAdd)))); + } else { + // Filter out the current page of entity urns from the list + setSelectedEntities(selectedEntities.filter((entity) => searchResultUrns.indexOf(entity.urn) === -1)); + } + }; + useEffect(() => { if (!loading) { analytics.event({ @@ -79,13 +119,16 @@ export const SearchPage = () => { } }, [query, data, loading]); - const onChangeFilters = (newFilters: Array) => { - navigateToSearchUrl({ type: activeType, query, page: 1, filters: newFilters, history }); - }; + useEffect(() => { + // When the query changes, then clear the select mode state + setIsSelectMode(false); + }, [query]); - const onChangePage = (newPage: number) => { - navigateToSearchUrl({ type: activeType, query, page: newPage, filters, history }); - }; + useEffect(() => { + if (!isSelectMode) { + setSelectedEntities([]); + } + }, [isSelectMode]); return ( <> @@ -106,6 +149,11 @@ export const SearchPage = () => { onChangePage={onChangePage} numResultsPerPage={numResultsPerPage} setNumResultsPerPage={setNumResultsPerPage} + isSelectMode={isSelectMode} + selectedEntities={selectedEntities} + setSelectedEntities={setSelectedEntities} + setIsSelectMode={setIsSelectMode} + onChangeSelectAll={onChangeSelectAll} /> ); diff --git a/datahub-web-react/src/app/search/SearchResultList.tsx b/datahub-web-react/src/app/search/SearchResultList.tsx new file mode 100644 index 00000000000000..4197ce190b963b --- /dev/null +++ b/datahub-web-react/src/app/search/SearchResultList.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { Button, Checkbox, Divider, Empty, List, ListProps } from 'antd'; +import styled from 'styled-components'; +import { useHistory } from 'react-router'; +import { RocketOutlined } from '@ant-design/icons'; +import { navigateToSearchUrl } from './utils/navigateToSearchUrl'; +import { ANTD_GRAY } from '../entity/shared/constants'; +import { CombinedSearchResult, SEPARATE_SIBLINGS_URL_PARAM } from '../entity/shared/siblingUtils'; +import { CompactEntityNameList } from '../recommendations/renderer/component/CompactEntityNameList'; +import { useEntityRegistry } from '../useEntityRegistry'; +import { SearchResult } from '../../types.generated'; +import analytics, { EventType } from '../analytics'; +import { EntityAndType } from '../entity/shared/types'; + +const ResultList = styled(List)` + &&& { + width: 100%; + border-color: ${(props) => props.theme.styles['border-color-base']}; + margin-top: 8px; + padding: 16px 32px; + border-radius: 0px; + } +`; + +const StyledCheckbox = styled(Checkbox)` + margin-right: 12px; +`; + +const NoDataContainer = styled.div` + > div { + margin-top: 28px; + margin-bottom: 28px; + } +`; + +const ThinDivider = styled(Divider)` + margin-top: 16px; + margin-bottom: 16px; +`; + +const SiblingResultContainer = styled.div` + margin-top: 6px; +`; + +const ListItem = styled.div<{ isSelectMode: boolean }>` + display: flex; + align-items: center; + padding: 0px; +`; + +type Props = { + query: string; + searchResults: CombinedSearchResult[]; + totalResultCount: number; + isSelectMode: boolean; + selectedEntities: EntityAndType[]; + setSelectedEntities: (entities: EntityAndType[]) => any; +}; + +export const SearchResultList = ({ + query, + searchResults, + totalResultCount, + isSelectMode, + selectedEntities, + setSelectedEntities, +}: Props) => { + const history = useHistory(); + const entityRegistry = useEntityRegistry(); + const selectedEntityUrns = selectedEntities.map((entity) => entity.urn); + + const onClickResult = (result: SearchResult, index: number) => { + analytics.event({ + type: EventType.SearchResultClickEvent, + query, + entityUrn: result.entity.urn, + entityType: result.entity.type, + index, + total: totalResultCount, + }); + }; + + /** + * Invoked when a new entity is selected. Simply updates the state of the list of selected entities. + */ + const onSelectEntity = (selectedEntity: EntityAndType, selected: boolean) => { + if (selected) { + setSelectedEntities?.([...selectedEntities, selectedEntity]); + } else { + setSelectedEntities?.(selectedEntities?.filter((entity) => entity.urn !== selectedEntity.urn) || []); + } + }; + + return ( + <> + >> + dataSource={searchResults} + split={false} + locale={{ + emptyText: ( + + + + + ), + }} + renderItem={(item, index) => ( + <> + onClickResult(item, index)} + // class name for counting in test purposes only + className="test-search-result" + > + {isSelectMode && ( + = 0} + onChange={(e) => + onSelectEntity( + { urn: item.entity.urn, type: item.entity.type }, + e.target.checked, + ) + } + /> + )} + {entityRegistry.renderSearchResult(item.entity.type, item)} + + {item.matchedEntities && item.matchedEntities.length > 0 && ( + + + + )} + + + )} + /> + + ); +}; diff --git a/datahub-web-react/src/app/search/SearchResults.tsx b/datahub-web-react/src/app/search/SearchResults.tsx index 88c97ae6871f7d..9ecc2e1d1867b1 100644 --- a/datahub-web-react/src/app/search/SearchResults.tsx +++ b/datahub-web-react/src/app/search/SearchResults.tsx @@ -1,8 +1,6 @@ import React from 'react'; -import { Button, Divider, Empty, List, ListProps, Pagination, Typography } from 'antd'; +import { Pagination, Typography } from 'antd'; import styled from 'styled-components'; -import { RocketOutlined } from '@ant-design/icons'; -import { useHistory } from 'react-router'; import { Message } from '../shared/Message'; import { Entity, @@ -11,35 +9,19 @@ import { FacetMetadata, MatchedField, SearchAcrossEntitiesInput, - SearchResult, } from '../../types.generated'; import { SearchFilters } from './SearchFilters'; -import { useEntityRegistry } from '../useEntityRegistry'; -import analytics from '../analytics/analytics'; -import { EventType } from '../analytics'; import { SearchCfg } from '../../conf'; -import { navigateToSearchUrl } from './utils/navigateToSearchUrl'; -import { ANTD_GRAY } from '../entity/shared/constants'; import { SearchResultsRecommendations } from './SearchResultsRecommendations'; import { useGetAuthenticatedUser } from '../useGetAuthenticatedUser'; import { SearchResultsInterface } from '../entity/shared/components/styled/search/types'; import SearchExtendedMenu from '../entity/shared/components/styled/search/SearchExtendedMenu'; -import { - CombinedSearchResult, - combineSiblingsInSearchResults, - SEPARATE_SIBLINGS_URL_PARAM, -} from '../entity/shared/siblingUtils'; -import { CompactEntityNameList } from '../recommendations/renderer/component/CompactEntityNameList'; - -const ResultList = styled(List)` - &&& { - width: 100%; - border-color: ${(props) => props.theme.styles['border-color-base']}; - margin-top: 8px; - padding: 16px 32px; - border-radius: 0px; - } -`; +import { combineSiblingsInSearchResults } from '../entity/shared/siblingUtils'; +import { SearchSelectBar } from '../entity/shared/components/styled/search/SearchSelectBar'; +import { SearchResultList } from './SearchResultList'; +import { isListSubset } from '../entity/shared/utils'; +import TabToolbar from '../entity/shared/components/styled/TabToolbar'; +import { EntityAndType } from '../entity/shared/types'; const SearchBody = styled.div` display: flex; @@ -68,12 +50,14 @@ const PaginationControlContainer = styled.div` `; const PaginationInfoContainer = styled.div` - margin-top: 15px; - padding-left: 16px; + padding-left: 32px; + padding-right: 32px; + height: 47px; border-bottom: 1px solid; border-color: ${(props) => props.theme.styles['border-color-base']}; display: flex; justify-content: space-between; + align-items: center; `; const FiltersHeader = styled.div` @@ -95,29 +79,16 @@ const SearchFilterContainer = styled.div` padding-top: 10px; `; -const NoDataContainer = styled.div` - > div { - margin-top: 28px; - margin-bottom: 28px; - } -`; - -const ThinDivider = styled(Divider)` - margin-top: 16px; - margin-bottom: 16px; -`; - const SearchResultsRecommendationsContainer = styled.div` margin-top: 40px; `; -const SearchMenuContainer = styled.div` - margin-right: 32px; +const StyledTabToolbar = styled(TabToolbar)` + padding-left: 32px; + padding-right: 32px; `; -const SiblingResultContainer = styled.div` - margin-top: 6px; -`; +const SearchMenuContainer = styled.div``; interface Props { query: string; @@ -143,6 +114,11 @@ interface Props { filtersWithoutEntities: FacetFilterInput[]; numResultsPerPage: number; setNumResultsPerPage: (numResults: number) => void; + isSelectMode: boolean; + selectedEntities: EntityAndType[]; + setSelectedEntities: (entities: EntityAndType[]) => void; + setIsSelectMode: (showSelectMode: boolean) => any; + onChangeSelectAll: (selected: boolean) => void; } export const SearchResults = ({ @@ -159,38 +135,22 @@ export const SearchResults = ({ filtersWithoutEntities, numResultsPerPage, setNumResultsPerPage, + isSelectMode, + selectedEntities, + setIsSelectMode, + setSelectedEntities, + onChangeSelectAll, }: Props) => { const pageStart = searchResponse?.start || 0; const pageSize = searchResponse?.count || 0; const totalResults = searchResponse?.total || 0; const lastResultIndex = pageStart + pageSize > totalResults ? totalResults : pageStart + pageSize; - - const entityRegistry = useEntityRegistry(); const authenticatedUserUrn = useGetAuthenticatedUser()?.corpUser?.urn; - - const onResultClick = (result: SearchResult, index: number) => { - analytics.event({ - type: EventType.SearchResultClickEvent, - query, - entityUrn: result.entity.urn, - entityType: result.entity.type, - index, - total: totalResults, - }); - }; - - const onFilterSelect = (newFilters) => { - onChangeFilters(newFilters); - }; - - const updateNumResults = (_currentNum: number, newNum: number) => { - setNumResultsPerPage(newNum); - }; - - const history = useHistory(); - const combinedSiblingSearchResults = combineSiblingsInSearchResults(searchResponse?.searchResults); + const searchResultUrns = combinedSiblingSearchResults.map((result) => result.entity.urn) || []; + const selectedEntityUrns = selectedEntities.map((entity) => entity.urn); + return ( <> {loading && } @@ -203,71 +163,53 @@ export const SearchResults = ({ loading={loading} facets={filters || []} selectedFilters={selectedFilters} - onFilterSelect={onFilterSelect} + onFilterSelect={(newFilters) => onChangeFilters(newFilters)} /> - - Showing{' '} - - {lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex} - {' '} - of {totalResults} results - - - - + <> + + Showing{' '} + + {lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex} + {' '} + of {totalResults} results + + + + + + {isSelectMode && ( + + 0 && + isListSubset(searchResultUrns, selectedEntityUrns) + } + selectedEntities={selectedEntities} + onChangeSelectAll={onChangeSelectAll} + onCancel={() => setIsSelectMode(false)} + /> + + )} {!loading && ( <> - >> - dataSource={combinedSiblingSearchResults} - split={false} - locale={{ - emptyText: ( - - - - - ), - }} - renderItem={(item, index) => ( - <> - onResultClick(item, index)} - // class name for counting in test purposes only - className="test-search-result" - > - {entityRegistry.renderSearchResult(item.entity.type, item)} - - {item.matchedEntities && item.matchedEntities.length > 0 && ( - - - - )} - - - )} + SearchCfg.RESULTS_PER_PAGE} - onShowSizeChange={updateNumResults} + onShowSizeChange={(_currNum, newNum) => setNumResultsPerPage(newNum)} pageSizeOptions={['10', '20', '50']} />