diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java index c025e465d9..f142f07f7e 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java @@ -1,5 +1,7 @@ package com.linkedin.datahub.graphql.resolvers.domain; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.SetMode; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; @@ -65,7 +67,7 @@ public class CreateDomainResolver implements DataFetcher { + + private MetadataChangeProposal left; + + public CreateDomainProposalMatcher(MetadataChangeProposal left) { + this.left = left; + } + + @Override + public boolean matches(MetadataChangeProposal right) { + return left.getEntityType().equals(right.getEntityType()) + && left.getAspectName().equals(right.getAspectName()) + && left.getChangeType().equals(right.getChangeType()) + && domainPropertiesMatch(left.getAspect(), right.getAspect()); + } + + private boolean domainPropertiesMatch(GenericAspect left, GenericAspect right) { + DomainProperties leftProps = GenericRecordUtils.deserializeAspect( + left.getValue(), + "application/json", + DomainProperties.class + ); + + DomainProperties rightProps = GenericRecordUtils.deserializeAspect( + right.getValue(), + "application/json", + DomainProperties.class + ); + + // Omit timestamp comparison. + return leftProps.getName().equals(rightProps.getName()) + && leftProps.getDescription().equals(rightProps.getDescription()) + && leftProps.getCreated().getActor().equals(rightProps.getCreated().getActor()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java index 2cfab63cd4..7e8b019503 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java @@ -1,6 +1,9 @@ package com.linkedin.datahub.graphql.resolvers.domain; import com.datahub.authentication.Authentication; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CreateDomainInput; import com.linkedin.domain.DomainProperties; @@ -28,6 +31,7 @@ public class CreateDomainResolverTest { "test-name", "test-description" ); + private static final Urn TEST_ACTOR_URN = UrnUtils.getUrn("urn:li:corpuser:test"); private static final String TEST_ENTITY_URN = "urn:li:dataset:(urn:li:dataPlatform:mysql,my-test,PROD)"; private static final String TEST_TAG_1_URN = "urn:li:tag:test-id-1"; private static final String TEST_TAG_2_URN = "urn:li:tag:test-id-2"; @@ -55,13 +59,14 @@ public class CreateDomainResolverTest { DomainProperties props = new DomainProperties(); props.setDescription("test-description"); props.setName("test-name"); + props.setCreated(new AuditStamp().setActor(TEST_ACTOR_URN).setTime(0L)); proposal.setAspectName(Constants.DOMAIN_PROPERTIES_ASPECT_NAME); proposal.setAspect(GenericRecordUtils.serializeAspect(props)); proposal.setChangeType(ChangeType.UPSERT); // Not ideal to match against "any", but we don't know the auto-generated execution request id Mockito.verify(mockClient, Mockito.times(1)).ingestProposal( - Mockito.eq(proposal), + Mockito.argThat(new CreateDomainProposalMatcher(proposal)), Mockito.any(Authentication.class) ); } diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/ListDomainsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/ListDomainsResolverTest.java index 615e23ed3e..743ca64722 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/ListDomainsResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/ListDomainsResolverTest.java @@ -7,17 +7,19 @@ import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.ListDomainsInput; import com.linkedin.entity.client.EntityClient; import com.linkedin.metadata.Constants; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; import com.linkedin.metadata.search.SearchEntity; import com.linkedin.metadata.search.SearchEntityArray; import com.linkedin.metadata.search.SearchResult; import com.linkedin.r2.RemoteInvocationException; import graphql.schema.DataFetchingEnvironment; -import java.util.Collections; import java.util.concurrent.CompletionException; import org.mockito.Mockito; import org.testng.annotations.Test; import static com.linkedin.datahub.graphql.TestUtils.*; +import static com.linkedin.metadata.Constants.*; import static org.testng.Assert.*; @@ -37,7 +39,8 @@ public class ListDomainsResolverTest { Mockito.when(mockClient.search( Mockito.eq(Constants.DOMAIN_ENTITY_NAME), Mockito.eq(""), - Mockito.eq(Collections.emptyMap()), + Mockito.eq(null), + Mockito.eq(new SortCriterion().setField(DOMAIN_CREATED_TIME_INDEX_FIELD_NAME).setOrder(SortOrder.DESCENDING)), Mockito.eq(0), Mockito.eq(20), Mockito.any(Authentication.class) diff --git a/datahub-web-react/src/app/domain/CreateDomainModal.tsx b/datahub-web-react/src/app/domain/CreateDomainModal.tsx index 7338ca2238..75436a463f 100644 --- a/datahub-web-react/src/app/domain/CreateDomainModal.tsx +++ b/datahub-web-react/src/app/domain/CreateDomainModal.tsx @@ -18,49 +18,57 @@ const ClickableTag = styled(Tag)` type Props = { onClose: () => void; - onCreate: (id: string | undefined, name: string, description: string) => void; + onCreate: (urn: string, id: string | undefined, name: string, description: string) => void; }; const SUGGESTED_DOMAIN_NAMES = ['Engineering', 'Marketing', 'Sales', 'Product']; +const ID_FIELD_NAME = 'id'; +const NAME_FIELD_NAME = 'name'; +const DESCRIPTION_FIELD_NAME = 'description'; + export default function CreateDomainModal({ onClose, onCreate }: Props) { - const [stagedName, setStagedName] = useState(''); - const [stagedDescription, setStagedDescription] = useState(''); - const [stagedId, setStagedId] = useState(undefined); const [createDomainMutation] = useCreateDomainMutation(); const [createButtonEnabled, setCreateButtonEnabled] = useState(true); const [form] = Form.useForm(); + const setStagedName = (name) => { + form.setFieldsValue({ + name, + }); + }; + const onCreateDomain = () => { createDomainMutation({ variables: { input: { - id: stagedId, - name: stagedName, - description: stagedDescription, + id: form.getFieldValue(ID_FIELD_NAME), + name: form.getFieldValue(NAME_FIELD_NAME), + description: form.getFieldValue(DESCRIPTION_FIELD_NAME), }, }, }) - .then(({ errors }) => { + .then(({ data, errors }) => { if (!errors) { analytics.event({ type: EventType.CreateDomainEvent, }); + message.success({ + content: `Created domain!`, + duration: 3, + }); + onCreate( + data?.createDomain || '', + form.getFieldValue(ID_FIELD_NAME), + form.getFieldValue(NAME_FIELD_NAME), + form.getFieldValue(DESCRIPTION_FIELD_NAME), + ); + form.resetFields(); } }) .catch((e) => { message.destroy(); message.error({ content: `Failed to create Domain!: \n ${e.message || ''}`, duration: 3 }); - }) - .finally(() => { - message.success({ - content: `Created domain!`, - duration: 3, - }); - onCreate(stagedId, stagedName, stagedDescription); - setStagedName(''); - setStagedDescription(''); - setStagedId(undefined); }); onClose(); }; @@ -97,7 +105,7 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) { Name}> Give your new Domain a name. - setStagedName(event.target.value)} - /> + {SUGGESTED_DOMAIN_NAMES.map((name) => { - return setStagedName(name)}>{name}; + return ( + setStagedName(name)}> + {name} + + ); })} @@ -124,12 +132,12 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) { An optional description for your new domain. You can change this later. - - setStagedDescription(event.target.value)} - /> + + @@ -142,23 +150,19 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) { creation. ({ validator(_, value) { if (value && groupIdTextValidation(value)) { return Promise.resolve(); } - return Promise.reject(new Error('Please enter correct Domain name')); + return Promise.reject(new Error('Please enter a valid Domain id')); }, }), ]} > - setStagedId(event.target.value)} - /> + diff --git a/datahub-web-react/src/app/domain/DomainListItem.tsx b/datahub-web-react/src/app/domain/DomainListItem.tsx index 6b5ed080ec..27b71ddaf1 100644 --- a/datahub-web-react/src/app/domain/DomainListItem.tsx +++ b/datahub-web-react/src/app/domain/DomainListItem.tsx @@ -1,14 +1,14 @@ import React from 'react'; import styled from 'styled-components'; -import { Col, List, Row, Tag, Tooltip, Typography } from 'antd'; +import { DeleteOutlined, MoreOutlined } from '@ant-design/icons'; +import { Col, Dropdown, List, Menu, message, Modal, Row, Tag, Tooltip, Typography } from 'antd'; import { Link } from 'react-router-dom'; import { IconStyleType } from '../entity/Entity'; import { Domain, EntityType } from '../../types.generated'; import { useEntityRegistry } from '../useEntityRegistry'; import AvatarsGroup from '../shared/avatar/AvatarsGroup'; -import EntityDropdown from '../entity/shared/EntityDropdown'; -import { EntityMenuItems } from '../entity/shared/EntityDropdown/EntityDropdown'; import { getElasticCappedTotalValueText } from '../entity/shared/constants'; +import { useDeleteDomainMutation } from '../../graphql/domain.generated'; const DomainItemContainer = styled(Row)` display: flex; @@ -45,6 +45,10 @@ const AvatarGroupWrapper = styled.div` margin-right: 10px; `; +const MenuIcon = styled(MoreOutlined)<{ fontSize?: number }>` + font-size: ${(props) => props.fontSize || '24'}px; +`; + type Props = { domain: Domain; onDelete?: () => void; @@ -56,6 +60,39 @@ export default function DomainListItem({ domain, onDelete }: Props) { const logoIcon = entityRegistry.getIcon(EntityType.Domain, 12, IconStyleType.ACCENT); const owners = domain.ownership?.owners; const totalEntitiesText = getElasticCappedTotalValueText(domain.entities?.total || 0); + const [deleteDomainMutation] = useDeleteDomainMutation(); + + const deleteDomain = () => { + deleteDomainMutation({ + variables: { + urn: domain.urn, + }, + }) + .then(({ errors }) => { + if (!errors) { + message.success('Deleted Domain!'); + onDelete?.(); + } + }) + .catch(() => { + message.destroy(); + message.error({ content: `Failed to delee Domain!: An unknown error occurred.`, duration: 3 }); + }); + }; + + const onConfirmDelete = () => { + Modal.confirm({ + title: `Delete Domain '${displayName}'`, + content: `Are you sure you want to remove this ${entityRegistry.getEntityName(EntityType.Domain)}?`, + onOk() { + deleteDomain(); + }, + onCancel() {}, + okText: 'Yes', + maskClosable: true, + closable: true, + }); + }; return ( @@ -79,14 +116,18 @@ export default function DomainListItem({ domain, onDelete }: Props) { )} - + + +  Delete + + + } + > + + diff --git a/datahub-web-react/src/app/domain/DomainsList.tsx b/datahub-web-react/src/app/domain/DomainsList.tsx index 528c7013e8..ea6bf5e577 100644 --- a/datahub-web-react/src/app/domain/DomainsList.tsx +++ b/datahub-web-react/src/app/domain/DomainsList.tsx @@ -13,6 +13,7 @@ import DomainListItem from './DomainListItem'; import { SearchBar } from '../search/SearchBar'; import { useEntityRegistry } from '../useEntityRegistry'; import { scrollToTop } from '../shared/searchUtils'; +import { addToListDomainsCache, removeFromListDomainsCache } from './utils'; const DomainsContainer = styled.div``; @@ -51,12 +52,11 @@ export const DomainsList = () => { const [page, setPage] = useState(1); const [isCreatingDomain, setIsCreatingDomain] = useState(false); - const [removedUrns, setRemovedUrns] = useState([]); const pageSize = DEFAULT_PAGE_SIZE; const start = (page - 1) * pageSize; - const { loading, error, data, refetch } = useListDomainsQuery({ + const { loading, error, data, client, refetch } = useListDomainsQuery({ variables: { input: { start, @@ -64,15 +64,12 @@ export const DomainsList = () => { query, }, }, - fetchPolicy: 'no-cache', + fetchPolicy: 'cache-first', }); const totalDomains = data?.listDomains?.total || 0; const lastResultIndex = start + pageSize > totalDomains ? totalDomains : start + pageSize; - const domains = (data?.listDomains?.domains || []).sort( - (a, b) => (b.entities?.total || 0) - (a.entities?.total || 0), - ); - const filteredDomains = domains.filter((domain) => !removedUrns.includes(domain.urn)); + const domains = data?.listDomains?.domains || []; const onChangePage = (newPage: number) => { scrollToTop(); @@ -80,24 +77,21 @@ export const DomainsList = () => { }; const handleDelete = (urn: string) => { - // Hack to deal with eventual consistency. - const newRemovedUrns = [...removedUrns, urn]; - setRemovedUrns(newRemovedUrns); + removeFromListDomainsCache(client, urn, page, pageSize, query); setTimeout(function () { refetch?.(); - }, 3000); + }, 2000); }; + return ( <> {!data && loading && } {error && } -
- -
+ { locale={{ emptyText: , }} - dataSource={filteredDomains} + dataSource={domains} renderItem={(item: any) => ( - handleDelete(item.urn)} /> + handleDelete(item.urn)} + /> )} /> {lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex} - {' '} + of {totalDomains} { {isCreatingDomain && ( setIsCreatingDomain(false)} - onCreate={() => { - // Hack to deal with eventual consistency. - setTimeout(function () { - refetch?.(); - }, 2000); + onCreate={(urn, _, name, description) => { + addToListDomainsCache( + client, + { + urn, + properties: { + name, + description, + }, + ownership: null, + entities: null, + }, + pageSize, + query, + ); + setTimeout(() => refetch(), 2000); }} /> )} diff --git a/datahub-web-react/src/app/domain/utils.ts b/datahub-web-react/src/app/domain/utils.ts new file mode 100644 index 0000000000..847d237f78 --- /dev/null +++ b/datahub-web-react/src/app/domain/utils.ts @@ -0,0 +1,81 @@ +import { ListDomainsDocument, ListDomainsQuery } from '../../graphql/domain.generated'; + +/** + * Add an entry to the list domains cache. + */ +export const addToListDomainsCache = (client, newDomain, pageSize, query) => { + // Read the data from our cache for this query. + const currData: ListDomainsQuery | null = client.readQuery({ + query: ListDomainsDocument, + variables: { + input: { + start: 0, + count: pageSize, + query, + }, + }, + }); + + // Add our new domain into the existing list. + const newDomains = [newDomain, ...(currData?.listDomains?.domains || [])]; + + // Write our data back to the cache. + client.writeQuery({ + query: ListDomainsDocument, + variables: { + input: { + start: 0, + count: pageSize, + query, + }, + }, + data: { + listDomains: { + start: 0, + count: (currData?.listDomains?.count || 0) + 1, + total: (currData?.listDomains?.total || 0) + 1, + domains: newDomains, + }, + }, + }); +}; + +/** + * Remove an entry from the list domains cache. + */ +export const removeFromListDomainsCache = (client, urn, page, pageSize, query) => { + // Read the data from our cache for this query. + const currData: ListDomainsQuery | null = client.readQuery({ + query: ListDomainsDocument, + variables: { + input: { + start: (page - 1) * pageSize, + count: pageSize, + query, + }, + }, + }); + + // Remove the domain from the existing domain set. + const newDomains = [...(currData?.listDomains?.domains || []).filter((domain) => domain.urn !== urn)]; + + // Write our data back to the cache. + client.writeQuery({ + query: ListDomainsDocument, + variables: { + input: { + start: (page - 1) * pageSize, + count: pageSize, + query, + }, + }, + data: { + listDomains: { + start: currData?.listDomains?.start || 0, + count: (currData?.listDomains?.count || 1) - 1, + total: (currData?.listDomains?.total || 1) - 1, + domains: newDomains, + }, + }, + }); +}; diff --git a/datahub-web-react/src/graphql/domain.graphql b/datahub-web-react/src/graphql/domain.graphql index 4f5312aad1..d72ff336bf 100644 --- a/datahub-web-react/src/graphql/domain.graphql +++ b/datahub-web-react/src/graphql/domain.graphql @@ -33,7 +33,6 @@ query listDomains($input: ListDomainsInput!) { total domains { urn - id properties { name description diff --git a/li-utils/src/main/java/com/linkedin/metadata/Constants.java b/li-utils/src/main/java/com/linkedin/metadata/Constants.java index 8df40b09f6..8980f6b651 100644 --- a/li-utils/src/main/java/com/linkedin/metadata/Constants.java +++ b/li-utils/src/main/java/com/linkedin/metadata/Constants.java @@ -199,6 +199,7 @@ public class Constants { public static final String DOMAIN_KEY_ASPECT_NAME = "domainKey"; public static final String DOMAIN_PROPERTIES_ASPECT_NAME = "domainProperties"; public static final String DOMAINS_ASPECT_NAME = "domains"; + public static final String DOMAIN_CREATED_TIME_INDEX_FIELD_NAME = "createdTime"; // Assertion public static final String ASSERTION_KEY_ASPECT_NAME = "assertionKey"; diff --git a/metadata-models/src/main/pegasus/com/linkedin/domain/DomainProperties.pdl b/metadata-models/src/main/pegasus/com/linkedin/domain/DomainProperties.pdl index d68af27701..5a0b8657ec 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/domain/DomainProperties.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/domain/DomainProperties.pdl @@ -1,5 +1,7 @@ namespace com.linkedin.domain +import com.linkedin.common.AuditStamp + /** * Information about a Domain */ @@ -23,4 +25,14 @@ record DomainProperties { */ description: optional string + /** + * Created Audit stamp + */ + @Searchable = { + "/time": { + "fieldName": "createdTime", + "fieldType": "DATETIME" + } + } + created: optional AuditStamp }