refactor(ui): Adding apollo caching to manage domains page. (#6494)

This commit is contained in:
John Joyce 2022-11-23 12:31:31 -08:00 committed by GitHub
parent ebd685d40d
commit a400eb0d52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 286 additions and 81 deletions

View File

@ -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<CompletableFuture<Strin
proposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key));
proposal.setEntityType(Constants.DOMAIN_ENTITY_NAME);
proposal.setAspectName(Constants.DOMAIN_PROPERTIES_ASPECT_NAME);
proposal.setAspect(GenericRecordUtils.serializeAspect(mapDomainProperties(input)));
proposal.setAspect(GenericRecordUtils.serializeAspect(mapDomainProperties(input, context)));
proposal.setChangeType(ChangeType.UPSERT);
String domainUrn = _entityClient.ingestProposal(proposal, context.getAuthentication());
@ -78,10 +80,11 @@ public class CreateDomainResolver implements DataFetcher<CompletableFuture<Strin
});
}
private DomainProperties mapDomainProperties(final CreateDomainInput input) {
private DomainProperties mapDomainProperties(final CreateDomainInput input, final QueryContext context) {
final DomainProperties result = new DomainProperties();
result.setName(input.getName());
result.setDescription(input.getDescription(), SetMode.IGNORE_NULL);
result.setCreated(new AuditStamp().setActor(UrnUtils.getUrn(context.getActorUrn())).setTime(System.currentTimeMillis()));
return result;
}
}

View File

@ -10,17 +10,19 @@ import com.linkedin.datahub.graphql.generated.ListDomainsInput;
import com.linkedin.datahub.graphql.generated.ListDomainsResult;
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.SearchResult;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*;
import static com.linkedin.metadata.Constants.*;
/**
@ -56,7 +58,8 @@ public class ListDomainsResolver implements DataFetcher<CompletableFuture<ListDo
final SearchResult gmsResult = _entityClient.search(
Constants.DOMAIN_ENTITY_NAME,
query,
Collections.emptyMap(),
null,
new SortCriterion().setField(DOMAIN_CREATED_TIME_INDEX_FIELD_NAME).setOrder(SortOrder.DESCENDING),
start,
count,
context.getAuthentication());

View File

@ -0,0 +1,44 @@
package com.linkedin.datahub.graphql.resolvers.domain;
import com.linkedin.domain.DomainProperties;
import com.linkedin.metadata.utils.GenericRecordUtils;
import com.linkedin.mxe.GenericAspect;
import com.linkedin.mxe.MetadataChangeProposal;
import org.mockito.ArgumentMatcher;
public class CreateDomainProposalMatcher implements ArgumentMatcher<MetadataChangeProposal> {
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());
}
}

View File

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

View File

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

View File

@ -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<string | undefined>(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) {
<Form.Item label={<Typography.Text strong>Name</Typography.Text>}>
<Typography.Paragraph>Give your new Domain a name. </Typography.Paragraph>
<Form.Item
name="name"
name={NAME_FIELD_NAME}
rules={[
{
required: true,
@ -108,15 +116,15 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) {
]}
hasFeedback
>
<Input
placeholder="A name for your domain"
value={stagedName}
onChange={(event) => setStagedName(event.target.value)}
/>
<Input placeholder="A name for your domain" />
</Form.Item>
<SuggestedNamesGroup>
{SUGGESTED_DOMAIN_NAMES.map((name) => {
return <ClickableTag onClick={() => setStagedName(name)}>{name}</ClickableTag>;
return (
<ClickableTag key={name} onClick={() => setStagedName(name)}>
{name}
</ClickableTag>
);
})}
</SuggestedNamesGroup>
</Form.Item>
@ -124,12 +132,12 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) {
<Typography.Paragraph>
An optional description for your new domain. You can change this later.
</Typography.Paragraph>
<Form.Item name="description" rules={[{ whitespace: true }, { min: 1, max: 500 }]} hasFeedback>
<Input
placeholder="A description for your domain"
value={stagedDescription}
onChange={(event) => setStagedDescription(event.target.value)}
/>
<Form.Item
name={DESCRIPTION_FIELD_NAME}
rules={[{ whitespace: true }, { min: 1, max: 500 }]}
hasFeedback
>
<Input placeholder="A description for your domain" />
</Form.Item>
</Form.Item>
<Collapse ghost>
@ -142,23 +150,19 @@ export default function CreateDomainModal({ onClose, onCreate }: Props) {
creation.
</Typography.Paragraph>
<Form.Item
name="domainId"
name={ID_FIELD_NAME}
rules={[
() => ({
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'));
},
}),
]}
>
<Input
placeholder="engineering"
value={stagedId || ''}
onChange={(event) => setStagedId(event.target.value)}
/>
<Input placeholder="engineering" />
</Form.Item>
</Form.Item>
</Collapse.Panel>

View File

@ -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 (
<List.Item data-testid={domain.urn}>
@ -79,14 +116,18 @@ export default function DomainListItem({ domain, onDelete }: Props) {
<AvatarsGroup size={24} owners={owners} entityRegistry={entityRegistry} maxCount={4} />
</AvatarGroupWrapper>
)}
<EntityDropdown
urn={domain.urn}
entityType={EntityType.Domain}
entityData={domain}
menuItems={new Set([EntityMenuItems.DELETE])}
size={20}
onDeleteEntity={onDelete}
/>
<Dropdown
trigger={['click']}
overlay={
<Menu>
<Menu.Item onClick={onConfirmDelete}>
<DeleteOutlined /> &nbsp;Delete
</Menu.Item>
</Menu>
}
>
<MenuIcon fontSize={20} />
</Dropdown>
</DomainEndContainer>
</DomainItemContainer>
</List.Item>

View File

@ -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<string[]>([]);
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 && <Message type="loading" content="Loading domains..." />}
{error && <Message type="error" content="Failed to load domains! An unexpected error occurred." />}
<DomainsContainer>
<TabToolbar>
<div>
<Button type="text" onClick={() => setIsCreatingDomain(true)}>
<PlusOutlined /> New Domain
</Button>
</div>
<Button type="text" onClick={() => setIsCreatingDomain(true)}>
<PlusOutlined /> New Domain
</Button>
<SearchBar
initialQuery={query || ''}
placeholderText="Search domains..."
@ -121,16 +115,20 @@ export const DomainsList = () => {
locale={{
emptyText: <Empty description="No Domains!" image={Empty.PRESENTED_IMAGE_SIMPLE} />,
}}
dataSource={filteredDomains}
dataSource={domains}
renderItem={(item: any) => (
<DomainListItem domain={item as Domain} onDelete={() => handleDelete(item.urn)} />
<DomainListItem
key={item.urn}
domain={item as Domain}
onDelete={() => handleDelete(item.urn)}
/>
)}
/>
<DomainsPaginationContainer>
<PaginationInfo>
<b>
{lastResultIndex > 0 ? (page - 1) * pageSize + 1 : 0} - {lastResultIndex}
</b>{' '}
</b>
of <b>{totalDomains}</b>
</PaginationInfo>
<Pagination
@ -146,11 +144,22 @@ export const DomainsList = () => {
{isCreatingDomain && (
<CreateDomainModal
onClose={() => 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);
}}
/>
)}

View File

@ -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,
},
},
});
};

View File

@ -33,7 +33,6 @@ query listDomains($input: ListDomainsInput!) {
total
domains {
urn
id
properties {
name
description

View File

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

View File

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