mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-25 17:08:29 +00:00
feat(tags): editing tags from react client on datasets, schemas, charts & dashboards (#2248)
This commit is contained in:
parent
728a742528
commit
039fe597f7
@ -194,6 +194,9 @@ public class GmsGraphQLEngine {
|
||||
private static void configureMutationResolvers(final RuntimeWiring.Builder builder) {
|
||||
builder.type("Mutation", typeWiring -> typeWiring
|
||||
.dataFetcher("updateDataset", new AuthenticatedResolver<>(new MutableTypeResolver<>(DATASET_TYPE)))
|
||||
.dataFetcher("updateTag", new AuthenticatedResolver<>(new MutableTypeResolver<>(TAG_TYPE)))
|
||||
.dataFetcher("updateChart", new AuthenticatedResolver<>(new MutableTypeResolver<>(CHART_TYPE)))
|
||||
.dataFetcher("updateDashboard", new AuthenticatedResolver<>(new MutableTypeResolver<>(DASHBOARD_TYPE)))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -8,12 +8,15 @@ import com.linkedin.datahub.graphql.generated.AutoCompleteResults;
|
||||
import com.linkedin.datahub.graphql.generated.BrowsePath;
|
||||
import com.linkedin.datahub.graphql.generated.BrowseResults;
|
||||
import com.linkedin.datahub.graphql.generated.Chart;
|
||||
import com.linkedin.datahub.graphql.generated.ChartUpdateInput;
|
||||
import com.linkedin.datahub.graphql.generated.EntityType;
|
||||
import com.linkedin.datahub.graphql.generated.FacetFilterInput;
|
||||
import com.linkedin.datahub.graphql.generated.SearchResults;
|
||||
import com.linkedin.datahub.graphql.resolvers.ResolverUtils;
|
||||
import com.linkedin.datahub.graphql.types.BrowsableEntityType;
|
||||
import com.linkedin.datahub.graphql.types.MutableType;
|
||||
import com.linkedin.datahub.graphql.types.SearchableEntityType;
|
||||
import com.linkedin.datahub.graphql.types.chart.mappers.ChartUpdateInputMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.BrowsePathsMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.BrowseResultMetadataMapper;
|
||||
@ -22,6 +25,7 @@ import com.linkedin.datahub.graphql.types.mappers.SearchResultsMapper;
|
||||
import com.linkedin.metadata.configs.ChartSearchConfig;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.metadata.query.BrowseResult;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@ -35,7 +39,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static com.linkedin.datahub.graphql.Constants.BROWSE_PATH_DELIMITER;
|
||||
|
||||
public class ChartType implements SearchableEntityType<Chart>, BrowsableEntityType<Chart> {
|
||||
public class ChartType implements SearchableEntityType<Chart>, BrowsableEntityType<Chart>, MutableType<ChartUpdateInput> {
|
||||
|
||||
private final Charts _chartsClient;
|
||||
private static final ChartSearchConfig CHART_SEARCH_CONFIG = new ChartSearchConfig();
|
||||
@ -44,6 +48,11 @@ public class ChartType implements SearchableEntityType<Chart>, BrowsableEntityTy
|
||||
_chartsClient = chartsClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<ChartUpdateInput> inputClass() {
|
||||
return ChartUpdateInput.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EntityType type() {
|
||||
return EntityType.CHART;
|
||||
@ -139,4 +148,17 @@ public class ChartType implements SearchableEntityType<Chart>, BrowsableEntityTy
|
||||
throw new RuntimeException(String.format("Failed to retrieve chart with urn %s, invalid urn", urnStr));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Chart update(@Nonnull ChartUpdateInput input, @Nonnull QueryContext context) throws Exception {
|
||||
final com.linkedin.dashboard.Chart partialChart = ChartUpdateInputMapper.map(input);
|
||||
|
||||
try {
|
||||
_chartsClient.update(ChartUrn.createFromString(input.getUrn()), partialChart);
|
||||
} catch (RemoteInvocationException e) {
|
||||
throw new RuntimeException(String.format("Failed to write entity with urn %s", input.getUrn()), e);
|
||||
}
|
||||
|
||||
return load(input.getUrn(), context);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.linkedin.datahub.graphql.types.chart.mappers;
|
||||
|
||||
import com.linkedin.common.GlobalTags;
|
||||
import com.linkedin.common.TagAssociationArray;
|
||||
import com.linkedin.dashboard.Chart;
|
||||
import com.linkedin.datahub.graphql.generated.ChartUpdateInput;
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.TagAssociationUpdateMapper;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ChartUpdateInputMapper implements ModelMapper<ChartUpdateInput, Chart> {
|
||||
public static final ChartUpdateInputMapper INSTANCE = new ChartUpdateInputMapper();
|
||||
|
||||
public static Chart map(@Nonnull final ChartUpdateInput chartUpdateInput) {
|
||||
return INSTANCE.apply(chartUpdateInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Chart apply(@Nonnull final ChartUpdateInput chartUpdateInput) {
|
||||
final Chart result = new Chart();
|
||||
|
||||
if (chartUpdateInput.getGlobalTags() != null) {
|
||||
final GlobalTags globalTags = new GlobalTags();
|
||||
globalTags.setTags(
|
||||
new TagAssociationArray(
|
||||
chartUpdateInput.getGlobalTags().getTags().stream().map(
|
||||
element -> TagAssociationUpdateMapper.map(element)
|
||||
).collect(Collectors.toList())
|
||||
)
|
||||
);
|
||||
result.setGlobalTags(globalTags);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@ -8,12 +8,15 @@ import com.linkedin.datahub.graphql.generated.AutoCompleteResults;
|
||||
import com.linkedin.datahub.graphql.generated.BrowsePath;
|
||||
import com.linkedin.datahub.graphql.generated.BrowseResults;
|
||||
import com.linkedin.datahub.graphql.generated.Dashboard;
|
||||
import com.linkedin.datahub.graphql.generated.DashboardUpdateInput;
|
||||
import com.linkedin.datahub.graphql.generated.EntityType;
|
||||
import com.linkedin.datahub.graphql.generated.FacetFilterInput;
|
||||
import com.linkedin.datahub.graphql.generated.SearchResults;
|
||||
import com.linkedin.datahub.graphql.resolvers.ResolverUtils;
|
||||
import com.linkedin.datahub.graphql.types.BrowsableEntityType;
|
||||
import com.linkedin.datahub.graphql.types.MutableType;
|
||||
import com.linkedin.datahub.graphql.types.SearchableEntityType;
|
||||
import com.linkedin.datahub.graphql.types.dashboard.mappers.DashboardUpdateInputMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.BrowsePathsMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.BrowseResultMetadataMapper;
|
||||
@ -22,6 +25,7 @@ import com.linkedin.datahub.graphql.types.mappers.SearchResultsMapper;
|
||||
import com.linkedin.metadata.configs.DashboardSearchConfig;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.metadata.query.BrowseResult;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
@ -35,7 +39,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import static com.linkedin.datahub.graphql.Constants.BROWSE_PATH_DELIMITER;
|
||||
|
||||
public class DashboardType implements SearchableEntityType<Dashboard>, BrowsableEntityType<Dashboard> {
|
||||
public class DashboardType implements SearchableEntityType<Dashboard>, BrowsableEntityType<Dashboard>, MutableType<DashboardUpdateInput> {
|
||||
|
||||
private final Dashboards _dashboardsClient;
|
||||
private static final DashboardSearchConfig DASHBOARDS_SEARCH_CONFIG = new DashboardSearchConfig();
|
||||
@ -44,6 +48,11 @@ public class DashboardType implements SearchableEntityType<Dashboard>, Browsable
|
||||
_dashboardsClient = dashboardsClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<DashboardUpdateInput> inputClass() {
|
||||
return DashboardUpdateInput.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public EntityType type() {
|
||||
return EntityType.DASHBOARD;
|
||||
@ -139,4 +148,16 @@ public class DashboardType implements SearchableEntityType<Dashboard>, Browsable
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dashboard update(@Nonnull DashboardUpdateInput input, @Nonnull QueryContext context) throws Exception {
|
||||
final com.linkedin.dashboard.Dashboard partialDashboard = DashboardUpdateInputMapper.map(input);
|
||||
|
||||
try {
|
||||
_dashboardsClient.update(DashboardUrn.createFromString(input.getUrn()), partialDashboard);
|
||||
} catch (RemoteInvocationException e) {
|
||||
throw new RuntimeException(String.format("Failed to write entity with urn %s", input.getUrn()), e);
|
||||
}
|
||||
|
||||
return load(input.getUrn(), context);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
package com.linkedin.datahub.graphql.types.dashboard.mappers;
|
||||
|
||||
import com.linkedin.common.GlobalTags;
|
||||
import com.linkedin.common.TagAssociationArray;
|
||||
import com.linkedin.dashboard.Dashboard;
|
||||
import com.linkedin.datahub.graphql.generated.DashboardUpdateInput;
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.TagAssociationUpdateMapper;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DashboardUpdateInputMapper implements ModelMapper<DashboardUpdateInput, Dashboard> {
|
||||
public static final DashboardUpdateInputMapper INSTANCE = new DashboardUpdateInputMapper();
|
||||
|
||||
public static Dashboard map(@Nonnull final DashboardUpdateInput dashboardUpdateInput) {
|
||||
return INSTANCE.apply(dashboardUpdateInput);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dashboard apply(@Nonnull final DashboardUpdateInput dashboardUpdateInput) {
|
||||
final Dashboard result = new Dashboard();
|
||||
|
||||
if (dashboardUpdateInput.getGlobalTags() != null) {
|
||||
final GlobalTags globalTags = new GlobalTags();
|
||||
globalTags.setTags(
|
||||
new TagAssociationArray(
|
||||
dashboardUpdateInput.getGlobalTags().getTags().stream().map(
|
||||
element -> TagAssociationUpdateMapper.map(element)
|
||||
).collect(Collectors.toList())
|
||||
)
|
||||
);
|
||||
result.setGlobalTags(globalTags);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@ -167,6 +167,13 @@ public class DatasetType implements SearchableEntityType<Dataset>, BrowsableEnti
|
||||
partialDataset.getDeprecation().setActor(actor, SetMode.IGNORE_NULL);
|
||||
}
|
||||
|
||||
if (partialDataset.hasEditableSchemaMetadata()) {
|
||||
partialDataset.getEditableSchemaMetadata().setLastModified(auditStamp);
|
||||
if (!partialDataset.getEditableSchemaMetadata().hasCreated()) {
|
||||
partialDataset.getEditableSchemaMetadata().setCreated(auditStamp);
|
||||
}
|
||||
}
|
||||
|
||||
partialDataset.setLastModified(auditStamp);
|
||||
|
||||
try {
|
||||
|
||||
@ -44,6 +44,9 @@ public class DatasetMapper implements ModelMapper<com.linkedin.dataset.Dataset,
|
||||
if (dataset.hasSchemaMetadata()) {
|
||||
result.setSchema(SchemaMetadataMapper.map(dataset.getSchemaMetadata()));
|
||||
}
|
||||
if (dataset.hasEditableSchemaMetadata()) {
|
||||
result.setEditableSchemaMetadata(EditableSchemaMetadataMapper.map(dataset.getEditableSchemaMetadata()));
|
||||
}
|
||||
if (dataset.hasPlatformNativeType()) {
|
||||
result.setPlatformNativeType(Enum.valueOf(PlatformNativeType.class, dataset.getPlatformNativeType().name()));
|
||||
}
|
||||
|
||||
@ -2,12 +2,20 @@ package com.linkedin.datahub.graphql.types.dataset.mappers;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
import com.linkedin.common.GlobalTags;
|
||||
import com.linkedin.common.TagAssociationArray;
|
||||
import com.linkedin.datahub.graphql.generated.DatasetUpdateInput;
|
||||
import com.linkedin.datahub.graphql.types.common.mappers.InstitutionalMemoryUpdateMapper;
|
||||
import com.linkedin.datahub.graphql.types.common.mappers.OwnershipUpdateMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.TagAssociationUpdateMapper;
|
||||
import com.linkedin.dataset.Dataset;
|
||||
import com.linkedin.dataset.DatasetDeprecation;
|
||||
import com.linkedin.schema.EditableSchemaFieldInfo;
|
||||
import com.linkedin.schema.EditableSchemaFieldInfoArray;
|
||||
import com.linkedin.schema.EditableSchemaMetadata;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class DatasetUpdateInputMapper implements ModelMapper<DatasetUpdateInput, Dataset> {
|
||||
|
||||
@ -36,9 +44,53 @@ public class DatasetUpdateInputMapper implements ModelMapper<DatasetUpdateInput,
|
||||
}
|
||||
|
||||
if (datasetUpdateInput.getInstitutionalMemory() != null) {
|
||||
result.setInstitutionalMemory(InstitutionalMemoryUpdateMapper.map(datasetUpdateInput.getInstitutionalMemory()));
|
||||
result.setInstitutionalMemory(
|
||||
InstitutionalMemoryUpdateMapper.map(datasetUpdateInput.getInstitutionalMemory()));
|
||||
}
|
||||
|
||||
if (datasetUpdateInput.getGlobalTags() != null) {
|
||||
final GlobalTags globalTags = new GlobalTags();
|
||||
globalTags.setTags(
|
||||
new TagAssociationArray(
|
||||
datasetUpdateInput.getGlobalTags().getTags().stream().map(
|
||||
element -> TagAssociationUpdateMapper.map(element)
|
||||
).collect(Collectors.toList())
|
||||
)
|
||||
);
|
||||
result.setGlobalTags(globalTags);
|
||||
}
|
||||
|
||||
if (datasetUpdateInput.getEditableSchemaMetadata() != null) {
|
||||
final EditableSchemaMetadata editableSchemaMetadata = new EditableSchemaMetadata();
|
||||
editableSchemaMetadata.setEditableSchemaFieldInfo(
|
||||
new EditableSchemaFieldInfoArray(
|
||||
datasetUpdateInput.getEditableSchemaMetadata().getEditableSchemaFieldInfo().stream().map(
|
||||
element -> mapSchemaFieldInfo(element)
|
||||
).collect(Collectors.toList())));
|
||||
result.setEditableSchemaMetadata(editableSchemaMetadata);
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private EditableSchemaFieldInfo mapSchemaFieldInfo(
|
||||
final com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfoUpdate schemaFieldInfo
|
||||
) {
|
||||
final EditableSchemaFieldInfo output = new EditableSchemaFieldInfo();
|
||||
|
||||
if (schemaFieldInfo.getDescription() != null) {
|
||||
output.setDescription(schemaFieldInfo.getDescription());
|
||||
}
|
||||
output.setFieldPath(schemaFieldInfo.getFieldPath());
|
||||
|
||||
if (schemaFieldInfo.getGlobalTags() != null) {
|
||||
final GlobalTags globalTags = new GlobalTags();
|
||||
globalTags.setTags(new TagAssociationArray(schemaFieldInfo.getGlobalTags().getTags().stream().map(
|
||||
element -> TagAssociationUpdateMapper.map(element)).collect(Collectors.toList())));
|
||||
output.setGlobalTags(globalTags);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
package com.linkedin.datahub.graphql.types.dataset.mappers;
|
||||
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.GlobalTagsMapper;
|
||||
import com.linkedin.schema.EditableSchemaFieldInfo;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
|
||||
public class EditableSchemaFieldInfoMapper implements ModelMapper<EditableSchemaFieldInfo, com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfo> {
|
||||
|
||||
public static final EditableSchemaFieldInfoMapper INSTANCE = new EditableSchemaFieldInfoMapper();
|
||||
|
||||
public static com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfo map(@Nonnull final EditableSchemaFieldInfo fieldInfo) {
|
||||
return INSTANCE.apply(fieldInfo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfo apply(@Nonnull final EditableSchemaFieldInfo input) {
|
||||
final com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfo result = new com.linkedin.datahub.graphql.generated.EditableSchemaFieldInfo();
|
||||
if (input.hasDescription()) {
|
||||
result.setDescription((input.getDescription()));
|
||||
}
|
||||
if (input.hasFieldPath()) {
|
||||
result.setFieldPath((input.getFieldPath()));
|
||||
}
|
||||
if (input.hasGlobalTags()) {
|
||||
result.setGlobalTags(GlobalTagsMapper.map(input.getGlobalTags()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.linkedin.datahub.graphql.types.dataset.mappers;
|
||||
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
import com.linkedin.schema.EditableSchemaMetadata;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class EditableSchemaMetadataMapper implements ModelMapper<EditableSchemaMetadata, com.linkedin.datahub.graphql.generated.EditableSchemaMetadata> {
|
||||
|
||||
public static final EditableSchemaMetadataMapper INSTANCE = new EditableSchemaMetadataMapper();
|
||||
|
||||
public static com.linkedin.datahub.graphql.generated.EditableSchemaMetadata map(@Nonnull final EditableSchemaMetadata metadata) {
|
||||
return INSTANCE.apply(metadata);
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.linkedin.datahub.graphql.generated.EditableSchemaMetadata apply(@Nonnull final EditableSchemaMetadata input) {
|
||||
final com.linkedin.datahub.graphql.generated.EditableSchemaMetadata result = new com.linkedin.datahub.graphql.generated.EditableSchemaMetadata();
|
||||
result.setEditableSchemaFieldInfo(input.getEditableSchemaFieldInfo().stream().map(EditableSchemaFieldInfoMapper::map).collect(Collectors.toList()));
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,12 +1,36 @@
|
||||
package com.linkedin.datahub.graphql.types.tag;
|
||||
|
||||
import com.linkedin.common.AuditStamp;
|
||||
import com.linkedin.common.Owner;
|
||||
import com.linkedin.common.OwnerArray;
|
||||
import com.linkedin.common.Ownership;
|
||||
import com.linkedin.common.OwnershipSource;
|
||||
import com.linkedin.common.OwnershipSourceType;
|
||||
import com.linkedin.common.OwnershipType;
|
||||
import com.linkedin.common.urn.CorpuserUrn;
|
||||
import com.linkedin.common.urn.TagUrn;
|
||||
import com.linkedin.data.template.SetMode;
|
||||
import com.linkedin.datahub.graphql.QueryContext;
|
||||
import com.linkedin.datahub.graphql.generated.AutoCompleteResults;
|
||||
import com.linkedin.datahub.graphql.generated.EntityType;
|
||||
import com.linkedin.datahub.graphql.generated.FacetFilterInput;
|
||||
import com.linkedin.datahub.graphql.generated.SearchResults;
|
||||
import com.linkedin.datahub.graphql.generated.Tag;
|
||||
import com.linkedin.datahub.graphql.generated.TagUpdate;
|
||||
import com.linkedin.datahub.graphql.resolvers.ResolverUtils;
|
||||
import com.linkedin.datahub.graphql.types.MutableType;
|
||||
import com.linkedin.datahub.graphql.types.mappers.AutoCompleteResultsMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.SearchResultsMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.TagMapper;
|
||||
import com.linkedin.datahub.graphql.types.tag.mappers.TagUpdateMapper;
|
||||
import com.linkedin.metadata.configs.TagSearchConfig;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
import com.linkedin.tag.client.Tags;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -14,9 +38,10 @@ import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class TagType implements com.linkedin.datahub.graphql.types.EntityType<Tag> {
|
||||
public class TagType implements com.linkedin.datahub.graphql.types.SearchableEntityType<Tag>, MutableType<TagUpdate> {
|
||||
|
||||
private static final String DEFAULT_AUTO_COMPLETE_FIELD = "name";
|
||||
private static final TagSearchConfig TAG_SEARCH_CONFIG = new TagSearchConfig();
|
||||
|
||||
private final Tags _tagClient;
|
||||
|
||||
@ -34,6 +59,11 @@ public class TagType implements com.linkedin.datahub.graphql.types.EntityType<Ta
|
||||
return EntityType.TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<TagUpdate> inputClass() {
|
||||
return TagUpdate.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Tag> batchLoad(final List<String> urns, final QueryContext context) {
|
||||
|
||||
@ -59,6 +89,65 @@ public class TagType implements com.linkedin.datahub.graphql.types.EntityType<Ta
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public SearchResults search(@Nonnull String query,
|
||||
@Nullable List<FacetFilterInput> filters,
|
||||
int start,
|
||||
int count,
|
||||
@Nonnull QueryContext context) throws Exception {
|
||||
final Map<String, String> facetFilters = ResolverUtils.buildFacetFilters(filters, TAG_SEARCH_CONFIG.getFacetFields());
|
||||
final CollectionResponse<com.linkedin.tag.Tag> searchResult = _tagClient.search(query, null, facetFilters, null, start, count);
|
||||
return SearchResultsMapper.map(searchResult, TagMapper::map);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AutoCompleteResults autoComplete(@Nonnull String query,
|
||||
@Nullable String field,
|
||||
@Nullable List<FacetFilterInput> filters,
|
||||
int limit,
|
||||
@Nonnull QueryContext context) throws Exception {
|
||||
final Map<String, String> facetFilters = ResolverUtils.buildFacetFilters(filters, TAG_SEARCH_CONFIG.getFacetFields());
|
||||
final AutoCompleteResult result = _tagClient.autocomplete(query, field, facetFilters, limit);
|
||||
return AutoCompleteResultsMapper.map(result);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public Tag update(@Nonnull TagUpdate input, @Nonnull QueryContext context) throws Exception {
|
||||
// TODO: Verify that updater is owner.
|
||||
final CorpuserUrn actor = new CorpuserUrn(context.getActor());
|
||||
final com.linkedin.tag.Tag partialTag = TagUpdateMapper.map(input);
|
||||
|
||||
// Create Audit Stamp
|
||||
final AuditStamp auditStamp = new AuditStamp();
|
||||
auditStamp.setActor(actor, SetMode.IGNORE_NULL);
|
||||
auditStamp.setTime(System.currentTimeMillis());
|
||||
|
||||
if (partialTag.hasOwnership()) {
|
||||
partialTag.getOwnership().setLastModified(auditStamp);
|
||||
} else {
|
||||
final Ownership ownership = new Ownership();
|
||||
final Owner owner = new Owner();
|
||||
owner.setOwner(actor);
|
||||
owner.setType(OwnershipType.DATAOWNER);
|
||||
owner.setSource(new OwnershipSource().setType(OwnershipSourceType.SERVICE));
|
||||
|
||||
ownership.setOwners(new OwnerArray(owner));
|
||||
ownership.setLastModified(auditStamp);
|
||||
partialTag.setOwnership(ownership);
|
||||
}
|
||||
|
||||
partialTag.setLastModified(auditStamp);
|
||||
|
||||
try {
|
||||
_tagClient.update(TagUrn.createFromString(input.getUrn()), partialTag);
|
||||
} catch (RemoteInvocationException e) {
|
||||
throw new RuntimeException(String.format("Failed to write entity with urn %s", input.getUrn()), e);
|
||||
}
|
||||
|
||||
return load(input.getUrn(), context);
|
||||
}
|
||||
|
||||
private TagUrn getTagUrn(final String urnStr) {
|
||||
try {
|
||||
return TagUrn.createFromString(urnStr);
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package com.linkedin.datahub.graphql.types.tag.mappers;
|
||||
|
||||
import com.linkedin.common.TagAssociation;
|
||||
import com.linkedin.common.urn.TagUrn;
|
||||
import com.linkedin.datahub.graphql.generated.TagAssociationUpdate;
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
public class TagAssociationUpdateMapper implements ModelMapper<TagAssociationUpdate, TagAssociation> {
|
||||
|
||||
public static final TagAssociationUpdateMapper INSTANCE = new TagAssociationUpdateMapper();
|
||||
|
||||
public static TagAssociation map(@Nonnull final TagAssociationUpdate tagAssociationUpdate) {
|
||||
return INSTANCE.apply(tagAssociationUpdate);
|
||||
}
|
||||
|
||||
public TagAssociation apply(final TagAssociationUpdate tagAssociationUpdate) {
|
||||
final TagAssociation output = new TagAssociation();
|
||||
try {
|
||||
output.setTag(TagUrn.createFromString(tagAssociationUpdate.getTag().getUrn()));
|
||||
} catch (URISyntaxException e) {
|
||||
throw new RuntimeException(String.format("Failed to update tag with urn %s, invalid urn",
|
||||
tagAssociationUpdate.getTag().getUrn()));
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.linkedin.datahub.graphql.types.tag.mappers;
|
||||
|
||||
import com.linkedin.common.urn.TagUrn;
|
||||
import com.linkedin.datahub.graphql.generated.TagUpdate;
|
||||
import com.linkedin.datahub.graphql.types.common.mappers.OwnershipUpdateMapper;
|
||||
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class TagUpdateMapper implements ModelMapper<TagUpdate, com.linkedin.tag.Tag> {
|
||||
|
||||
public static final TagUpdateMapper INSTANCE = new TagUpdateMapper();
|
||||
|
||||
public static com.linkedin.tag.Tag map(@Nonnull final TagUpdate tagUpdate) {
|
||||
return INSTANCE.apply(tagUpdate);
|
||||
}
|
||||
|
||||
@Override
|
||||
public com.linkedin.tag.Tag apply(@Nonnull final TagUpdate tagUpdate) {
|
||||
final com.linkedin.tag.Tag result = new com.linkedin.tag.Tag();
|
||||
result.setUrn((new TagUrn(tagUpdate.getName())));
|
||||
result.setName(tagUpdate.getName());
|
||||
if (tagUpdate.getDescription() != null) {
|
||||
result.setDescription(tagUpdate.getDescription());
|
||||
}
|
||||
if (tagUpdate.getOwnership() != null) {
|
||||
result.setOwnership(OwnershipUpdateMapper.map(tagUpdate.getOwnership()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -108,6 +108,9 @@ type Query {
|
||||
|
||||
type Mutation {
|
||||
updateDataset(input: DatasetUpdateInput!): Dataset
|
||||
updateChart(input: ChartUpdateInput!): Chart
|
||||
updateDashboard(input: DashboardUpdateInput!): Dashboard
|
||||
updateTag(input: TagUpdate!): Tag
|
||||
}
|
||||
|
||||
type AuditStamp {
|
||||
@ -193,6 +196,11 @@ type Dataset implements Entity {
|
||||
"""
|
||||
schema: Schema
|
||||
|
||||
"""
|
||||
Editable schema metadata of the dataset
|
||||
"""
|
||||
editableSchemaMetadata: EditableSchemaMetadata
|
||||
|
||||
"""
|
||||
Status metadata of the dataset
|
||||
"""
|
||||
@ -473,7 +481,28 @@ type SchemaField {
|
||||
"""
|
||||
recursive: Boolean!
|
||||
"""
|
||||
The structured tags associated with the field
|
||||
Tags associated with the field
|
||||
"""
|
||||
globalTags: GlobalTags
|
||||
}
|
||||
|
||||
type EditableSchemaMetadata {
|
||||
editableSchemaFieldInfo: [EditableSchemaFieldInfo!]!
|
||||
}
|
||||
|
||||
type EditableSchemaFieldInfo {
|
||||
"""
|
||||
Flattened name of a field identifying the field the editable info is applied to
|
||||
"""
|
||||
fieldPath: String!
|
||||
|
||||
"""
|
||||
Edited description of the field
|
||||
"""
|
||||
description: String
|
||||
|
||||
"""
|
||||
Tags associated with the field
|
||||
"""
|
||||
globalTags: GlobalTags
|
||||
}
|
||||
@ -1052,6 +1081,30 @@ type BrowsePath {
|
||||
path: [String!]!
|
||||
}
|
||||
|
||||
input ChartUpdateInput {
|
||||
"""
|
||||
The chart urn
|
||||
"""
|
||||
urn: String!
|
||||
|
||||
"""
|
||||
Update to global tags
|
||||
"""
|
||||
globalTags: GlobalTagsUpdate
|
||||
}
|
||||
|
||||
input DashboardUpdateInput {
|
||||
"""
|
||||
The dashboard urn
|
||||
"""
|
||||
urn: String!
|
||||
|
||||
"""
|
||||
Update to global tags
|
||||
"""
|
||||
globalTags: GlobalTagsUpdate
|
||||
}
|
||||
|
||||
input DatasetUpdateInput {
|
||||
"""
|
||||
The dataset urn
|
||||
@ -1072,6 +1125,61 @@ input DatasetUpdateInput {
|
||||
Update to institutional memory, ie documentation
|
||||
"""
|
||||
institutionalMemory: InstitutionalMemoryUpdate
|
||||
|
||||
"""
|
||||
Update to global tags
|
||||
"""
|
||||
globalTags: GlobalTagsUpdate
|
||||
|
||||
"""
|
||||
Update to editable schema metadata of the dataset
|
||||
"""
|
||||
editableSchemaMetadata: EditableSchemaMetadataUpdate
|
||||
}
|
||||
|
||||
input EditableSchemaMetadataUpdate {
|
||||
editableSchemaFieldInfo: [EditableSchemaFieldInfoUpdate!]!
|
||||
}
|
||||
|
||||
input EditableSchemaFieldInfoUpdate {
|
||||
"""
|
||||
Flattened name of a field identifying the field the editable info is applied to
|
||||
"""
|
||||
fieldPath: String!
|
||||
|
||||
"""
|
||||
Edited description of the field
|
||||
"""
|
||||
description: String
|
||||
|
||||
"""
|
||||
Tags associated with the field
|
||||
"""
|
||||
globalTags: GlobalTagsUpdate
|
||||
}
|
||||
|
||||
input GlobalTagsUpdate {
|
||||
tags: [TagAssociationUpdate!]
|
||||
}
|
||||
|
||||
input TagAssociationUpdate {
|
||||
tag: TagUpdate!
|
||||
}
|
||||
|
||||
input TagUpdate {
|
||||
urn: String!
|
||||
|
||||
name: String!
|
||||
|
||||
"""
|
||||
Description of the tag
|
||||
"""
|
||||
description: String
|
||||
|
||||
"""
|
||||
Ownership metadata of the tag
|
||||
"""
|
||||
ownership: OwnershipUpdate
|
||||
}
|
||||
|
||||
input OwnershipUpdate {
|
||||
|
||||
@ -233,6 +233,7 @@ const dataset3 = {
|
||||
],
|
||||
},
|
||||
schema: null,
|
||||
editableSchemaMetadata: null,
|
||||
deprecation: null,
|
||||
} as Dataset;
|
||||
|
||||
@ -677,6 +678,83 @@ export const mocks = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: GetSearchResultsDocument,
|
||||
variables: {
|
||||
input: {
|
||||
type: 'CORP_USER',
|
||||
query: 'tags:abc-sample-tag',
|
||||
start: 0,
|
||||
count: 1,
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
__typename: 'Query',
|
||||
search: {
|
||||
__typename: 'SearchResults',
|
||||
start: 0,
|
||||
count: 0,
|
||||
total: 2,
|
||||
entities: [],
|
||||
facets: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: GetSearchResultsDocument,
|
||||
variables: {
|
||||
input: {
|
||||
type: 'DATASET',
|
||||
query: 'tags:abc-sample-tag',
|
||||
start: 0,
|
||||
count: 1,
|
||||
filters: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
result: {
|
||||
data: {
|
||||
__typename: 'Query',
|
||||
search: {
|
||||
__typename: 'SearchResults',
|
||||
start: 0,
|
||||
count: 1,
|
||||
total: 1,
|
||||
entities: [
|
||||
{
|
||||
__typename: 'Dataset',
|
||||
...dataset3,
|
||||
},
|
||||
],
|
||||
facets: [
|
||||
{
|
||||
field: 'origin',
|
||||
aggregations: [
|
||||
{
|
||||
value: 'PROD',
|
||||
count: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: 'platform',
|
||||
aggregations: [
|
||||
{ value: 'hdfs', count: 1 },
|
||||
{ value: 'mysql', count: 1 },
|
||||
{ value: 'kafka', count: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as GetSearchResultsQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
request: {
|
||||
query: GetTagDocument,
|
||||
|
||||
@ -5,9 +5,10 @@ import { Chart, GlobalTags } from '../../../../types.generated';
|
||||
import { Ownership as OwnershipView } from '../../shared/Ownership';
|
||||
import { EntityProfile } from '../../../shared/EntityProfile';
|
||||
import ChartHeader from './ChartHeader';
|
||||
import { useGetChartQuery } from '../../../../graphql/chart.generated';
|
||||
import { GetChartDocument, useGetChartQuery, useUpdateChartMutation } from '../../../../graphql/chart.generated';
|
||||
import ChartSources from './ChartSources';
|
||||
import { Message } from '../../../shared/Message';
|
||||
import TagGroup from '../../../shared/tags/TagGroup';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
padding: 32px 100px;
|
||||
@ -22,6 +23,20 @@ const ENABLED_TAB_TYPES = [TabType.Ownership, TabType.Sources];
|
||||
|
||||
export default function ChartProfile({ urn }: { urn: string }) {
|
||||
const { loading, error, data } = useGetChartQuery({ variables: { urn } });
|
||||
const [updateChart] = useUpdateChartMutation({
|
||||
update(cache, { data: newChart }) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
chart() {
|
||||
cache.writeQuery({
|
||||
query: GetChartDocument,
|
||||
data: { chart: { ...newChart?.updateChart } },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (error || (!loading && !error && !data)) {
|
||||
return <Alert type="error" message={error?.message || 'Entity failed to load'} />;
|
||||
@ -64,7 +79,14 @@ export default function ChartProfile({ urn }: { urn: string }) {
|
||||
{loading && <Message type="loading" content="Loading..." style={{ marginTop: '10%' }} />}
|
||||
{data && data.chart && (
|
||||
<EntityProfile
|
||||
tags={data.chart?.globalTags as GlobalTags}
|
||||
tags={
|
||||
<TagGroup
|
||||
editableTags={data.chart?.globalTags as GlobalTags}
|
||||
canAdd
|
||||
canRemove
|
||||
updateTags={(globalTags) => updateChart({ variables: { input: { urn, globalTags } } })}
|
||||
/>
|
||||
}
|
||||
title={data.chart.info?.name || ''}
|
||||
tabs={getTabs(data.chart as Chart)}
|
||||
header={getHeader(data.chart as Chart)}
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import { Alert } from 'antd';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { useGetDashboardQuery } from '../../../../graphql/dashboard.generated';
|
||||
import {
|
||||
GetDashboardDocument,
|
||||
useGetDashboardQuery,
|
||||
useUpdateDashboardMutation,
|
||||
} from '../../../../graphql/dashboard.generated';
|
||||
import { Dashboard, GlobalTags } from '../../../../types.generated';
|
||||
import { Ownership as OwnershipView } from '../../shared/Ownership';
|
||||
import { EntityProfile } from '../../../shared/EntityProfile';
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import DashboardCharts from './DashboardCharts';
|
||||
import { Message } from '../../../shared/Message';
|
||||
import TagGroup from '../../../shared/tags/TagGroup';
|
||||
|
||||
const PageContainer = styled.div`
|
||||
padding: 32px 100px;
|
||||
@ -25,6 +30,20 @@ const ENABLED_TAB_TYPES = [TabType.Ownership, TabType.Charts];
|
||||
*/
|
||||
export default function DashboardProfile({ urn }: { urn: string }) {
|
||||
const { loading, error, data } = useGetDashboardQuery({ variables: { urn } });
|
||||
const [updateDashboard] = useUpdateDashboardMutation({
|
||||
update(cache, { data: newDashboard }) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
dashboard() {
|
||||
cache.writeQuery({
|
||||
query: GetDashboardDocument,
|
||||
data: { dashboard: { ...newDashboard?.updateDashboard } },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (error || (!loading && !error && !data)) {
|
||||
return <Alert type="error" message={error?.message || 'Entity failed to load'} />;
|
||||
@ -68,7 +87,16 @@ export default function DashboardProfile({ urn }: { urn: string }) {
|
||||
{data && data.dashboard && (
|
||||
<EntityProfile
|
||||
title={data.dashboard.info?.name || ''}
|
||||
tags={data.dashboard?.globalTags as GlobalTags}
|
||||
tags={
|
||||
<TagGroup
|
||||
editableTags={data.dashboard?.globalTags as GlobalTags}
|
||||
canAdd
|
||||
canRemove
|
||||
updateTags={(globalTags) =>
|
||||
updateDashboard({ variables: { input: { urn, globalTags } } })
|
||||
}
|
||||
/>
|
||||
}
|
||||
tabs={getTabs(data.dashboard as Dashboard)}
|
||||
header={getHeader(data.dashboard as Dashboard)}
|
||||
/>
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Alert } from 'antd';
|
||||
import { useGetDatasetQuery, useUpdateDatasetMutation } from '../../../../graphql/dataset.generated';
|
||||
import {
|
||||
useGetDatasetQuery,
|
||||
useUpdateDatasetMutation,
|
||||
GetDatasetDocument,
|
||||
} from '../../../../graphql/dataset.generated';
|
||||
import { Ownership as OwnershipView } from '../../shared/Ownership';
|
||||
import SchemaView from './schema/Schema';
|
||||
import { EntityProfile } from '../../../shared/EntityProfile';
|
||||
@ -10,6 +14,7 @@ import PropertiesView from './Properties';
|
||||
import DocumentsView from './Documentation';
|
||||
import DatasetHeader from './DatasetHeader';
|
||||
import { Message } from '../../../shared/Message';
|
||||
import TagGroup from '../../../shared/tags/TagGroup';
|
||||
|
||||
export enum TabType {
|
||||
Ownership = 'Ownership',
|
||||
@ -27,7 +32,20 @@ const EMPTY_ARR: never[] = [];
|
||||
*/
|
||||
export const DatasetProfile = ({ urn }: { urn: string }): JSX.Element => {
|
||||
const { loading, error, data } = useGetDatasetQuery({ variables: { urn } });
|
||||
const [updateDataset] = useUpdateDatasetMutation();
|
||||
const [updateDataset] = useUpdateDatasetMutation({
|
||||
update(cache, { data: newDataset }) {
|
||||
cache.modify({
|
||||
fields: {
|
||||
dataset() {
|
||||
cache.writeQuery({
|
||||
query: GetDatasetDocument,
|
||||
data: { dataset: newDataset?.updateDataset },
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (error || (!loading && !error && !data)) {
|
||||
return <Alert type="error" message={error?.message || 'Entity failed to load'} />;
|
||||
@ -42,12 +60,21 @@ export const DatasetProfile = ({ urn }: { urn: string }): JSX.Element => {
|
||||
properties,
|
||||
institutionalMemory,
|
||||
schema,
|
||||
editableSchemaMetadata,
|
||||
}: Dataset) => {
|
||||
return [
|
||||
{
|
||||
name: TabType.Schema,
|
||||
path: TabType.Schema.toLowerCase(),
|
||||
content: <SchemaView schema={schema} />,
|
||||
content: (
|
||||
<SchemaView
|
||||
schema={schema}
|
||||
editableSchemaMetadata={editableSchemaMetadata}
|
||||
updateEditableSchema={(update) =>
|
||||
updateDataset({ variables: { input: { urn, editableSchemaMetadata: update } } })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: TabType.Ownership,
|
||||
@ -93,7 +120,14 @@ export const DatasetProfile = ({ urn }: { urn: string }): JSX.Element => {
|
||||
{data && data.dataset && (
|
||||
<EntityProfile
|
||||
title={data.dataset.name}
|
||||
tags={data.dataset?.globalTags as GlobalTags}
|
||||
tags={
|
||||
<TagGroup
|
||||
editableTags={data.dataset?.globalTags as GlobalTags}
|
||||
canAdd
|
||||
canRemove
|
||||
updateTags={(globalTags) => updateDataset({ variables: { input: { urn, globalTags } } })}
|
||||
/>
|
||||
}
|
||||
tabs={getTabs(data.dataset as Dataset)}
|
||||
header={getHeader(data.dataset as Dataset)}
|
||||
/>
|
||||
|
||||
@ -2,13 +2,13 @@ import React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import Schema from '../schema/Schema';
|
||||
import TestPageContainer from '../../../../../utils/test-utils/TestPageContainer';
|
||||
import { sampleSchema } from '../stories/sampleSchema';
|
||||
import { sampleSchema, sampleSchemaWithTags } from '../stories/sampleSchema';
|
||||
|
||||
describe('Schema', () => {
|
||||
it('renders', () => {
|
||||
const { getByText, queryAllByTestId } = render(
|
||||
<TestPageContainer>
|
||||
<Schema schema={sampleSchema} />
|
||||
<Schema schema={sampleSchema} updateEditableSchema={jest.fn()} />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(getByText('name')).toBeInTheDocument();
|
||||
@ -21,7 +21,7 @@ describe('Schema', () => {
|
||||
it('renders raw', () => {
|
||||
const { getByText, queryAllByTestId } = render(
|
||||
<TestPageContainer>
|
||||
<Schema schema={sampleSchema} />
|
||||
<Schema schema={sampleSchema} updateEditableSchema={jest.fn()} />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
|
||||
@ -40,4 +40,13 @@ describe('Schema', () => {
|
||||
expect(queryAllByTestId('icon-STRING')).toHaveLength(2);
|
||||
expect(queryAllByTestId('schema-raw-view')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders tags', () => {
|
||||
const { getByText } = render(
|
||||
<TestPageContainer>
|
||||
<Schema schema={sampleSchemaWithTags} updateEditableSchema={jest.fn()} />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(getByText('Legacy')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,12 +1,25 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { Button, Table, Typography } from 'antd';
|
||||
import { AlignType } from 'rc-table/lib/interface';
|
||||
import styled from 'styled-components';
|
||||
import { FetchResult } from '@apollo/client';
|
||||
|
||||
import TypeIcon from './TypeIcon';
|
||||
import { Schema, SchemaFieldDataType, GlobalTags } from '../../../../../types.generated';
|
||||
import TagGroup from '../../../../shared/TagGroup';
|
||||
import {
|
||||
Schema,
|
||||
SchemaFieldDataType,
|
||||
GlobalTags,
|
||||
EditableSchemaMetadata,
|
||||
SchemaField,
|
||||
EditableSchemaMetadataUpdate,
|
||||
GlobalTagsUpdate,
|
||||
EditableSchemaFieldInfo,
|
||||
EditableSchemaFieldInfoUpdate,
|
||||
} from '../../../../../types.generated';
|
||||
import TagGroup from '../../../../shared/tags/TagGroup';
|
||||
import { UpdateDatasetMutation } from '../../../../../graphql/dataset.generated';
|
||||
import { convertTagsForUpdate } from '../../../../shared/tags/utils/convertTagsForUpdate';
|
||||
|
||||
const ViewRawButtonContainer = styled.div`
|
||||
display: flex;
|
||||
@ -16,6 +29,10 @@ const ViewRawButtonContainer = styled.div`
|
||||
|
||||
export type Props = {
|
||||
schema?: Schema | null;
|
||||
editableSchemaMetadata?: EditableSchemaMetadata | null;
|
||||
updateEditableSchema: (
|
||||
update: EditableSchemaMetadataUpdate,
|
||||
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>>>;
|
||||
};
|
||||
|
||||
const defaultColumns = [
|
||||
@ -44,21 +61,82 @@ const defaultColumns = [
|
||||
},
|
||||
];
|
||||
|
||||
const tagColumn = {
|
||||
title: 'Tags',
|
||||
dataIndex: 'globalTags',
|
||||
key: 'tag',
|
||||
render: (tags: GlobalTags) => {
|
||||
return <TagGroup globalTags={tags} />;
|
||||
},
|
||||
};
|
||||
function convertEditableSchemaMetadataForUpdate(
|
||||
editableSchemaMetadata: EditableSchemaMetadata | null | undefined,
|
||||
): EditableSchemaMetadataUpdate {
|
||||
return {
|
||||
editableSchemaFieldInfo:
|
||||
editableSchemaMetadata?.editableSchemaFieldInfo.map((editableSchemaFieldInfo) => ({
|
||||
fieldPath: editableSchemaFieldInfo?.fieldPath,
|
||||
description: editableSchemaFieldInfo?.description,
|
||||
globalTags: { tags: convertTagsForUpdate(editableSchemaFieldInfo?.globalTags?.tags || []) },
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function SchemaView({ schema }: Props) {
|
||||
const columns = useMemo(() => {
|
||||
const hasTags = schema?.fields?.some((field) => (field?.globalTags?.tags?.length || 0) > 0);
|
||||
export default function SchemaView({ schema, editableSchemaMetadata, updateEditableSchema }: Props) {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | undefined>(undefined);
|
||||
|
||||
return [...defaultColumns, ...(hasTags ? [tagColumn] : [])];
|
||||
}, [schema]);
|
||||
const onUpdateTags = (update: GlobalTagsUpdate, record?: EditableSchemaFieldInfo) => {
|
||||
if (!record) return Promise.resolve();
|
||||
const newFieldInfo: EditableSchemaFieldInfoUpdate = {
|
||||
fieldPath: record?.fieldPath,
|
||||
description: record?.description,
|
||||
globalTags: update,
|
||||
};
|
||||
|
||||
let existingMetadataAsUpdate = convertEditableSchemaMetadataForUpdate(editableSchemaMetadata);
|
||||
|
||||
if (existingMetadataAsUpdate.editableSchemaFieldInfo.some((field) => field.fieldPath === record?.fieldPath)) {
|
||||
// if we already have a record for this field, update the record
|
||||
existingMetadataAsUpdate = {
|
||||
editableSchemaFieldInfo: existingMetadataAsUpdate.editableSchemaFieldInfo.map((fieldUpdate) => {
|
||||
if (fieldUpdate.fieldPath === record?.fieldPath) {
|
||||
return newFieldInfo;
|
||||
}
|
||||
return fieldUpdate;
|
||||
}),
|
||||
};
|
||||
} else {
|
||||
// otherwise add a new record
|
||||
existingMetadataAsUpdate.editableSchemaFieldInfo.push(newFieldInfo);
|
||||
}
|
||||
return updateEditableSchema(existingMetadataAsUpdate);
|
||||
};
|
||||
|
||||
const tagGroupRender = (tags: GlobalTags, record: SchemaField, rowIndex: number | undefined) => {
|
||||
const relevantEditableFieldInfo = editableSchemaMetadata?.editableSchemaFieldInfo.find(
|
||||
(candidateEditableFieldInfo) => candidateEditableFieldInfo.fieldPath === record.fieldPath,
|
||||
);
|
||||
return (
|
||||
<TagGroup
|
||||
uneditableTags={tags}
|
||||
editableTags={relevantEditableFieldInfo?.globalTags}
|
||||
canRemove
|
||||
canAdd={hoveredIndex === rowIndex}
|
||||
onOpenModal={() => setHoveredIndex(undefined)}
|
||||
updateTags={(update) =>
|
||||
onUpdateTags(update, relevantEditableFieldInfo || { fieldPath: record.fieldPath })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tagColumn = {
|
||||
width: 450,
|
||||
title: 'Tags',
|
||||
dataIndex: 'globalTags',
|
||||
key: 'tag',
|
||||
render: tagGroupRender,
|
||||
onCell: (record: SchemaField, rowIndex: number | undefined) => ({
|
||||
onMouseEnter: () => {
|
||||
setHoveredIndex(rowIndex);
|
||||
},
|
||||
onMouseLeave: () => {
|
||||
setHoveredIndex(undefined);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
@ -79,7 +157,12 @@ export default function SchemaView({ schema }: Props) {
|
||||
</pre>
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Table pagination={false} dataSource={schema?.fields} columns={columns} rowKey="fieldPath" />
|
||||
<Table
|
||||
pagination={false}
|
||||
dataSource={schema?.fields}
|
||||
columns={[...defaultColumns, tagColumn]}
|
||||
rowKey="fieldPath"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,20 +1,10 @@
|
||||
import { volcano, lime, geekblue, purple, gold, yellow } from '@ant-design/colors';
|
||||
import { Schema, SchemaField, SchemaFieldDataType } from '../../../../../types.generated';
|
||||
import { EntityType, Schema, SchemaField, SchemaFieldDataType } from '../../../../../types.generated';
|
||||
|
||||
// Extending the schema type with an option for tags
|
||||
export type TaggedSchemaField = {
|
||||
tags: Tag[];
|
||||
} & SchemaField;
|
||||
|
||||
const TAGS = {
|
||||
pii: { name: 'PII', color: volcano[1], category: 'Privacy' },
|
||||
financial: { name: 'Financial', color: gold[1], category: 'Privacy' },
|
||||
address: { name: 'Address', color: lime[1], category: 'Descriptor', descriptor: true },
|
||||
shipping: { name: 'Shipping', color: yellow[1], category: 'Privacy' },
|
||||
name: { name: 'Name', color: purple[1], category: 'Descriptor', descriptor: true },
|
||||
euro: { name: 'Currency', value: 'Euros', color: geekblue[1], category: 'Descriptor', descriptor: true },
|
||||
};
|
||||
|
||||
export type Tag = {
|
||||
name: string;
|
||||
value?: string;
|
||||
@ -114,6 +104,18 @@ export const sampleSchemaWithTags: Schema = {
|
||||
type: SchemaFieldDataType.Number,
|
||||
nativeDataType: 'number',
|
||||
recursive: false,
|
||||
globalTags: {
|
||||
tags: [
|
||||
{
|
||||
tag: {
|
||||
urn: 'urn:li:tag:Legacy',
|
||||
name: 'Legacy',
|
||||
description: 'this is a legacy dataset',
|
||||
type: EntityType.Tag,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldPath: 'name',
|
||||
@ -122,7 +124,6 @@ export const sampleSchemaWithTags: Schema = {
|
||||
type: SchemaFieldDataType.String,
|
||||
nativeDataType: 'string',
|
||||
recursive: false,
|
||||
tags: [TAGS.name, TAGS.pii],
|
||||
} as SchemaField,
|
||||
{
|
||||
fieldPath: 'shipping_address',
|
||||
@ -131,7 +132,6 @@ export const sampleSchemaWithTags: Schema = {
|
||||
type: SchemaFieldDataType.String,
|
||||
nativeDataType: 'string',
|
||||
recursive: false,
|
||||
tags: [TAGS.address, TAGS.pii, TAGS.shipping],
|
||||
} as SchemaField,
|
||||
{
|
||||
fieldPath: 'count',
|
||||
@ -148,7 +148,6 @@ export const sampleSchemaWithTags: Schema = {
|
||||
type: SchemaFieldDataType.Number,
|
||||
nativeDataType: 'number',
|
||||
recursive: false,
|
||||
tags: [TAGS.euro],
|
||||
} as SchemaField,
|
||||
{
|
||||
fieldPath: 'was_returned',
|
||||
@ -173,7 +172,6 @@ export const sampleSchemaWithTags: Schema = {
|
||||
type: SchemaFieldDataType.Struct,
|
||||
nativeDataType: 'struct',
|
||||
recursive: false,
|
||||
tags: [TAGS.financial],
|
||||
} as SchemaField,
|
||||
],
|
||||
};
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { grey } from '@ant-design/colors';
|
||||
import { Alert, Avatar, Card, Space, Tooltip, Typography } from 'antd';
|
||||
import { Alert, Avatar, Button, Card, Tooltip, Typography } from 'antd';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useGetTagQuery } from '../../../graphql/tag.generated';
|
||||
import defaultAvatar from '../../../images/default_avatar.png';
|
||||
import { EntityType } from '../../../types.generated';
|
||||
import { useGetAllEntitySearchResults } from '../../../utils/customGraphQL/useGetAllEntitySearchResults';
|
||||
import { navigateToSearchUrl } from '../../search/utils/navigateToSearchUrl';
|
||||
import { Message } from '../../shared/Message';
|
||||
import { useEntityRegistry } from '../../useEntityRegistry';
|
||||
|
||||
@ -22,7 +24,21 @@ const LoadingMessage = styled(Message)`
|
||||
const TitleLabel = styled(Typography.Text)`
|
||||
&&& {
|
||||
color: ${grey[2]};
|
||||
font-size: 13;
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
const CreatedByLabel = styled(Typography.Text)`
|
||||
&&& {
|
||||
color: ${grey[2]};
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StatsLabel = styled(Typography.Text)`
|
||||
&&& {
|
||||
color: ${grey[2]};
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -32,6 +48,29 @@ const TitleText = styled(Typography.Title)`
|
||||
}
|
||||
`;
|
||||
|
||||
const HeaderLayout = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const StatsBox = styled.div`
|
||||
width: 180px;
|
||||
justify-content: left;
|
||||
`;
|
||||
|
||||
const StatText = styled(Typography.Text)`
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
const EmptyStatsText = styled(Typography.Text)`
|
||||
font-size: 15px;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
const TagSearchButton = styled(Button)`
|
||||
margin-left: -16px;
|
||||
`;
|
||||
|
||||
type TagPageParams = {
|
||||
urn: string;
|
||||
};
|
||||
@ -43,6 +82,24 @@ export default function TagProfile() {
|
||||
const { urn } = useParams<TagPageParams>();
|
||||
const { loading, error, data } = useGetTagQuery({ variables: { urn } });
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const history = useHistory();
|
||||
|
||||
const allSearchResultsByType = useGetAllEntitySearchResults({
|
||||
query: `tags:${data?.tag?.name}`,
|
||||
start: 0,
|
||||
count: 1,
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const statsLoading = Object.keys(allSearchResultsByType).some((type) => {
|
||||
return allSearchResultsByType[type].loading;
|
||||
});
|
||||
|
||||
const someStats =
|
||||
!statsLoading &&
|
||||
Object.keys(allSearchResultsByType).some((type) => {
|
||||
return allSearchResultsByType[type]?.data.search.total > 0;
|
||||
});
|
||||
|
||||
if (error || (!loading && !error && !data)) {
|
||||
return <Alert type="error" message={error?.message || 'Entity failed to load'} />;
|
||||
@ -53,30 +110,76 @@ export default function TagProfile() {
|
||||
{loading && <LoadingMessage type="loading" content="Loading..." />}
|
||||
<Card
|
||||
title={
|
||||
<>
|
||||
<Space direction="vertical" size="middle">
|
||||
<HeaderLayout>
|
||||
<div>
|
||||
<div>
|
||||
<TitleLabel>Tag</TitleLabel>
|
||||
<TitleText>{data?.tag?.name}</TitleText>
|
||||
</div>
|
||||
<Avatar.Group maxCount={6} size="large">
|
||||
{data?.tag?.ownership?.owners?.map((owner) => (
|
||||
<Tooltip title={owner.owner.info?.fullName} key={owner.owner.urn}>
|
||||
<Link
|
||||
to={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${
|
||||
owner.owner.urn
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={owner.owner?.editableInfo?.pictureLink || defaultAvatar}
|
||||
data-testid={`avatar-tag-${owner.owner.urn}`}
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
</Space>
|
||||
</>
|
||||
<div>
|
||||
<div>
|
||||
<CreatedByLabel>Created by</CreatedByLabel>
|
||||
</div>
|
||||
<Avatar.Group maxCount={6} size="large">
|
||||
{data?.tag?.ownership?.owners?.map((owner) => (
|
||||
<Tooltip title={owner.owner.info?.fullName} key={owner.owner.urn}>
|
||||
<Link
|
||||
to={`/${entityRegistry.getPathName(EntityType.CorpUser)}/${
|
||||
owner.owner.urn
|
||||
}`}
|
||||
>
|
||||
<Avatar
|
||||
src={owner.owner?.editableInfo?.pictureLink || defaultAvatar}
|
||||
data-testid={`avatar-tag-${owner.owner.urn}`}
|
||||
/>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
</div>
|
||||
</div>
|
||||
<StatsBox>
|
||||
<StatsLabel>Applied to</StatsLabel>
|
||||
{statsLoading && (
|
||||
<div>
|
||||
<EmptyStatsText>Loading...</EmptyStatsText>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading && !someStats && (
|
||||
<div>
|
||||
<EmptyStatsText>No entities</EmptyStatsText>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading &&
|
||||
someStats &&
|
||||
Object.keys(allSearchResultsByType).map((type) => {
|
||||
if (allSearchResultsByType[type].data.search.total === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={type}>
|
||||
<TagSearchButton
|
||||
type="text"
|
||||
key={type}
|
||||
onClick={() =>
|
||||
navigateToSearchUrl({
|
||||
type: type as EntityType,
|
||||
query: `tags:${data?.tag?.name}`,
|
||||
history,
|
||||
entityRegistry,
|
||||
})
|
||||
}
|
||||
>
|
||||
<StatText data-testid={`stats-${type}`}>
|
||||
{allSearchResultsByType[type].data.search.total}{' '}
|
||||
{entityRegistry.getCollectionName(type as EntityType)}
|
||||
</StatText>
|
||||
</TagSearchButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</StatsBox>
|
||||
</HeaderLayout>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph strong style={{ color: grey[2], fontSize: 13 }}>
|
||||
|
||||
@ -44,4 +44,23 @@ describe('TagProfile', () => {
|
||||
'http://localhost/user/urn:li:corpuser:3',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders stats', async () => {
|
||||
const { getByTestId, queryByText } = render(
|
||||
<MockedProvider mocks={mocks} addTypename={false}>
|
||||
<TestPageContainer initialEntries={['/tag/urn:li:tag:abc-sample-tag']}>
|
||||
<Route path="/tag/:urn" render={() => <TagProfile />} />
|
||||
</TestPageContainer>
|
||||
</MockedProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(queryByText('abc-sample-tag')).toBeInTheDocument());
|
||||
|
||||
await waitFor(() => expect(queryByText('Loading')).not.toBeInTheDocument());
|
||||
|
||||
expect(getByTestId('stats-DATASET')).toBeInTheDocument();
|
||||
expect(getByTestId('stats-CORP_USER')).toBeInTheDocument();
|
||||
expect(queryByText('1 Datasets')).toBeInTheDocument();
|
||||
expect(queryByText('2 Users')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { EntityType, GlobalTags } from '../../types.generated';
|
||||
import defaultAvatar from '../../images/default_avatar.png';
|
||||
import { useEntityRegistry } from '../useEntityRegistry';
|
||||
import TagGroup from '../shared/TagGroup';
|
||||
import TagGroup from '../shared/tags/TagGroup';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@ -70,11 +70,7 @@ export default function DefaultPreviewCard({
|
||||
)}
|
||||
</Space>
|
||||
<Space direction="vertical" align="end" size={36} style={styles.rightColumn}>
|
||||
{tags && tags.tags?.length && (
|
||||
<Space>
|
||||
<TagGroup globalTags={tags} />
|
||||
</Space>
|
||||
)}
|
||||
{tags && tags.tags?.length && <TagGroup editableTags={tags} maxShow={3} />}
|
||||
<Space direction="vertical" size={12}>
|
||||
<Typography.Text strong>Owned By</Typography.Text>
|
||||
<Avatar.Group maxCount={4}>
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
import * as React from 'react';
|
||||
import { Col, Row, Divider, Layout, Space } from 'antd';
|
||||
import { Col, Row, Divider, Layout, Card, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
import { TagOutlined } from '@ant-design/icons';
|
||||
|
||||
import { RoutedTabs } from './RoutedTabs';
|
||||
import { GlobalTags } from '../../types.generated';
|
||||
import TagGroup from './TagGroup';
|
||||
|
||||
export interface EntityProfileProps {
|
||||
title: string;
|
||||
tags?: GlobalTags;
|
||||
tags?: React.ReactNode;
|
||||
header: React.ReactNode;
|
||||
tabs?: Array<{
|
||||
name: string;
|
||||
@ -17,8 +16,24 @@ export interface EntityProfileProps {
|
||||
}>;
|
||||
}
|
||||
|
||||
const TagsContainer = styled.div`
|
||||
margin-top: -8px;
|
||||
const FlexSpace = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
const TagsTitle = styled(Typography.Title)`
|
||||
font-size: 18px;
|
||||
`;
|
||||
|
||||
const TagCard = styled(Card)`
|
||||
margin-top: 24px;
|
||||
font-size: 18px;
|
||||
width: 450px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const TagIcon = styled(TagOutlined)`
|
||||
padding-right: 6px;
|
||||
`;
|
||||
|
||||
const defaultProps = {
|
||||
@ -35,23 +50,30 @@ export const EntityProfile = ({ title, tags, header, tabs }: EntityProfileProps)
|
||||
/* eslint-disable spaced-comment */
|
||||
return (
|
||||
<Layout.Content style={{ padding: '0px 100px' }}>
|
||||
<Row style={{ padding: '20px 0px 10px 0px' }}>
|
||||
<Col span={24}>
|
||||
<Space>
|
||||
<h1>{title}</h1>
|
||||
<TagsContainer>
|
||||
<TagGroup globalTags={tags} />
|
||||
</TagsContainer>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
{header}
|
||||
<Divider style={{ marginBottom: '0px' }} />
|
||||
<Row style={{ padding: '0px 0px 10px 0px' }}>
|
||||
<Col span={24}>
|
||||
<RoutedTabs defaultPath={defaultTabPath} tabs={tabs || []} />
|
||||
</Col>
|
||||
</Row>
|
||||
<div>
|
||||
<FlexSpace>
|
||||
<div>
|
||||
<Row style={{ padding: '20px 0px 10px 0px' }}>
|
||||
<Col span={24}>
|
||||
<h1>{title}</h1>
|
||||
</Col>
|
||||
</Row>
|
||||
{header}
|
||||
</div>
|
||||
<TagCard>
|
||||
<TagsTitle type="secondary" level={4}>
|
||||
<TagIcon /> Tags
|
||||
</TagsTitle>
|
||||
{tags}
|
||||
</TagCard>
|
||||
</FlexSpace>
|
||||
<Divider style={{ marginBottom: '0px' }} />
|
||||
<Row style={{ padding: '0px 0px 10px 0px' }}>
|
||||
<Col span={24}>
|
||||
<RoutedTabs defaultPath={defaultTabPath} tabs={tabs || []} />
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,23 +0,0 @@
|
||||
import { Space, Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useEntityRegistry } from '../useEntityRegistry';
|
||||
import { EntityType, GlobalTags } from '../../types.generated';
|
||||
|
||||
type Props = {
|
||||
globalTags?: GlobalTags | null;
|
||||
};
|
||||
|
||||
export default function TagGroup({ globalTags }: Props) {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
|
||||
return (
|
||||
<Space size="small">
|
||||
{globalTags?.tags?.map((tag) => (
|
||||
<Link to={`/${entityRegistry.getPathName(EntityType.Tag)}/${tag.tag.urn}`} key={tag.tag.urn}>
|
||||
<Tag color="blue">{tag.tag.name}</Tag>
|
||||
</Link>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
130
datahub-web-react/src/app/shared/tags/AddTagModal.tsx
Normal file
130
datahub-web-react/src/app/shared/tags/AddTagModal.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FetchResult } from '@apollo/client';
|
||||
import { Button, Modal, Select, Typography } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { UpdateDatasetMutation } from '../../../graphql/dataset.generated';
|
||||
import { useGetAutoCompleteResultsLazyQuery } from '../../../graphql/search.generated';
|
||||
import { GlobalTags, GlobalTagsUpdate, EntityType, TagAssociationUpdate } from '../../../types.generated';
|
||||
import { convertTagsForUpdate } from './utils/convertTagsForUpdate';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
|
||||
type AddTagModalProps = {
|
||||
globalTags?: GlobalTags | null;
|
||||
updateTags?: (
|
||||
update: GlobalTagsUpdate,
|
||||
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>>>;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TagSelect = styled(Select)`
|
||||
width: 480px;
|
||||
`;
|
||||
|
||||
const CREATE_TAG_VALUE = '____reserved____.createTagValue';
|
||||
|
||||
export default function AddTagModal({ updateTags, globalTags, visible, onClose }: AddTagModalProps) {
|
||||
const [getAutoCompleteResults, { loading, data: suggestionsData }] = useGetAutoCompleteResultsLazyQuery();
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [selectedTagValue, setSelectedTagValue] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [disableAdd, setDisableAdd] = useState(false);
|
||||
|
||||
const autoComplete = (query: string) => {
|
||||
getAutoCompleteResults({
|
||||
variables: {
|
||||
input: {
|
||||
type: EntityType.Tag,
|
||||
query,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const inputExistsInAutocomplete = suggestionsData?.autoComplete?.suggestions?.some(
|
||||
(result) => result.toLowerCase() === inputValue.toLowerCase(),
|
||||
);
|
||||
|
||||
const autocompleteOptions =
|
||||
suggestionsData?.autoComplete?.suggestions.map((result) => (
|
||||
<Select.Option value={result} key={result}>
|
||||
{result}
|
||||
</Select.Option>
|
||||
)) || [];
|
||||
|
||||
if (!inputExistsInAutocomplete && inputValue.length > 2 && !loading) {
|
||||
autocompleteOptions.push(
|
||||
<Select.Option value={CREATE_TAG_VALUE} key={CREATE_TAG_VALUE}>
|
||||
<Typography.Link> Create {inputValue}</Typography.Link>
|
||||
</Select.Option>,
|
||||
);
|
||||
}
|
||||
|
||||
const onOk = () => {
|
||||
if (!globalTags?.tags?.some((tag) => tag.tag.name === selectedTagValue)) {
|
||||
setDisableAdd(true);
|
||||
updateTags?.({
|
||||
tags: [
|
||||
...convertTagsForUpdate(globalTags?.tags || []),
|
||||
{ tag: { urn: `urn:li:tag:${selectedTagValue}`, name: selectedTagValue } },
|
||||
] as TagAssociationUpdate[],
|
||||
}).finally(() => {
|
||||
setDisableAdd(false);
|
||||
onClose();
|
||||
});
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (showCreateModal) {
|
||||
return (
|
||||
<CreateTagModal
|
||||
updateTags={updateTags}
|
||||
globalTags={globalTags}
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onBack={() => setShowCreateModal(false)}
|
||||
tagName={inputValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add tag"
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
okButtonProps={{ disabled: selectedTagValue.length === 0 }}
|
||||
okText="Add"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={onOk} disabled={selectedTagValue.length === 0 || disableAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagSelect
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="Find a tag"
|
||||
defaultActiveFirstOption={false}
|
||||
showArrow={false}
|
||||
filterOption={false}
|
||||
onSearch={(value: string) => {
|
||||
autoComplete(value);
|
||||
setInputValue(value);
|
||||
}}
|
||||
onSelect={(selected) =>
|
||||
selected === CREATE_TAG_VALUE ? setShowCreateModal(true) : setSelectedTagValue(String(selected))
|
||||
}
|
||||
notFoundContent={loading ? 'loading' : 'type at least 3 character to search'}
|
||||
>
|
||||
{autocompleteOptions}
|
||||
</TagSelect>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
87
datahub-web-react/src/app/shared/tags/CreateTagModal.tsx
Normal file
87
datahub-web-react/src/app/shared/tags/CreateTagModal.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FetchResult } from '@apollo/client';
|
||||
import { Button, Input, Modal, Space } from 'antd';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { UpdateDatasetMutation } from '../../../graphql/dataset.generated';
|
||||
import { useUpdateTagMutation } from '../../../graphql/tag.generated';
|
||||
import { GlobalTags, GlobalTagsUpdate, TagAssociationUpdate } from '../../../types.generated';
|
||||
import { convertTagsForUpdate } from './utils/convertTagsForUpdate';
|
||||
|
||||
type CreateTagModalProps = {
|
||||
globalTags?: GlobalTags | null;
|
||||
updateTags?: (
|
||||
update: GlobalTagsUpdate,
|
||||
) => Promise<FetchResult<UpdateDatasetMutation, Record<string, any>, Record<string, any>>>;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onBack: () => void;
|
||||
tagName: string;
|
||||
};
|
||||
|
||||
const FullWidthSpace = styled(Space)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export default function CreateTagModal({
|
||||
updateTags,
|
||||
globalTags,
|
||||
onClose,
|
||||
onBack,
|
||||
visible,
|
||||
tagName,
|
||||
}: CreateTagModalProps) {
|
||||
const [stagedDescription, setStagedDescription] = useState('');
|
||||
|
||||
const [updateTagMutation] = useUpdateTagMutation();
|
||||
const [disableCreate, setDisableCreate] = useState(false);
|
||||
|
||||
const onOk = () => {
|
||||
setDisableCreate(true);
|
||||
// first create the new tag
|
||||
updateTagMutation({
|
||||
variables: {
|
||||
input: {
|
||||
urn: `urn:li:tag:${tagName}`,
|
||||
name: tagName,
|
||||
description: stagedDescription,
|
||||
},
|
||||
},
|
||||
}).then(() => {
|
||||
// then apply the tag to the dataset
|
||||
updateTags?.({
|
||||
tags: [
|
||||
...convertTagsForUpdate(globalTags?.tags || []),
|
||||
{ tag: { urn: `urn:li:tag:${tagName}`, name: tagName } },
|
||||
] as TagAssociationUpdate[],
|
||||
}).finally(() => {
|
||||
// and finally close the modal
|
||||
setDisableCreate(false);
|
||||
onClose();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`Create ${tagName}`}
|
||||
visible={visible}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onBack}>Back</Button>
|
||||
<Button onClick={onOk} disabled={stagedDescription.length === 0 || disableCreate}>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FullWidthSpace direction="vertical">
|
||||
<Input.TextArea
|
||||
placeholder="Write a description for your new tag..."
|
||||
value={stagedDescription}
|
||||
onChange={(e) => setStagedDescription(e.target.value)}
|
||||
/>
|
||||
</FullWidthSpace>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
109
datahub-web-react/src/app/shared/tags/TagGroup.tsx
Normal file
109
datahub-web-react/src/app/shared/tags/TagGroup.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { Modal, Tag } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { useEntityRegistry } from '../../useEntityRegistry';
|
||||
import { EntityType, GlobalTags, GlobalTagsUpdate } from '../../../types.generated';
|
||||
import { convertTagsForUpdate } from './utils/convertTagsForUpdate';
|
||||
import AddTagModal from './AddTagModal';
|
||||
|
||||
type Props = {
|
||||
uneditableTags?: GlobalTags | null;
|
||||
editableTags?: GlobalTags | null;
|
||||
canRemove?: boolean;
|
||||
canAdd?: boolean;
|
||||
updateTags?: (update: GlobalTagsUpdate) => Promise<any>;
|
||||
onOpenModal?: () => void;
|
||||
maxShow?: number;
|
||||
};
|
||||
|
||||
const AddNewTag = styled(Tag)`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
export default function TagGroup({
|
||||
uneditableTags,
|
||||
editableTags,
|
||||
canRemove,
|
||||
canAdd,
|
||||
updateTags,
|
||||
onOpenModal,
|
||||
maxShow,
|
||||
}: Props) {
|
||||
const entityRegistry = useEntityRegistry();
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
|
||||
const removeTag = (urnToRemove: string) => {
|
||||
onOpenModal?.();
|
||||
const tagToRemove = editableTags?.tags?.find((tag) => tag.tag.urn === urnToRemove);
|
||||
const newTags = editableTags?.tags?.filter((tag) => tag.tag.urn !== urnToRemove);
|
||||
Modal.confirm({
|
||||
title: `Do you want to remove ${tagToRemove?.tag.name} tag?`,
|
||||
content: `Are you sure you want to remove the ${tagToRemove?.tag.name} tag?`,
|
||||
onOk() {
|
||||
updateTags?.({ tags: convertTagsForUpdate(newTags || []) });
|
||||
},
|
||||
onCancel() {},
|
||||
okText: 'Yes',
|
||||
maskClosable: true,
|
||||
closable: true,
|
||||
});
|
||||
};
|
||||
|
||||
let renderedTags = 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* uneditable tags are provided by ingestion pipelines exclusively */}
|
||||
{uneditableTags?.tags?.map((tag) => {
|
||||
renderedTags += 1;
|
||||
if (maxShow && renderedTags > maxShow) return null;
|
||||
return (
|
||||
<Link to={`/${entityRegistry.getPathName(EntityType.Tag)}/${tag.tag.urn}`} key={tag.tag.urn}>
|
||||
<Tag color="blue" closable={false}>
|
||||
{tag.tag.name}
|
||||
</Tag>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{/* editable tags may be provided by ingestion pipelines or the UI */}
|
||||
{editableTags?.tags?.map((tag) => {
|
||||
renderedTags += 1;
|
||||
if (maxShow && renderedTags > maxShow) return null;
|
||||
return (
|
||||
<Link to={`/${entityRegistry.getPathName(EntityType.Tag)}/${tag.tag.urn}`} key={tag.tag.urn}>
|
||||
<Tag
|
||||
color="blue"
|
||||
closable={canRemove}
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
removeTag(tag.tag.urn);
|
||||
}}
|
||||
>
|
||||
{tag.tag.name}
|
||||
</Tag>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{canAdd && (uneditableTags?.tags?.length || 0) + (editableTags?.tags?.length || 0) < 10 && (
|
||||
<>
|
||||
<AddNewTag color="success" onClick={() => setShowAddModal(true)}>
|
||||
+ Add Tag
|
||||
</AddNewTag>
|
||||
{showAddModal && (
|
||||
<AddTagModal
|
||||
globalTags={editableTags}
|
||||
updateTags={updateTags}
|
||||
visible
|
||||
onClose={() => {
|
||||
onOpenModal?.();
|
||||
setShowAddModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import TagGroup from '../TagGroup';
|
||||
import TestPageContainer from '../../../../utils/test-utils/TestPageContainer';
|
||||
import { EntityType } from '../../../../types.generated';
|
||||
|
||||
const legacyTag = {
|
||||
urn: 'urn:li:tag:legacy',
|
||||
name: 'Legacy',
|
||||
description: 'this element is outdated',
|
||||
type: EntityType.Tag,
|
||||
};
|
||||
|
||||
const ownershipTag = {
|
||||
urn: 'urn:li:tag:NeedsOwnership',
|
||||
name: 'NeedsOwnership',
|
||||
description: 'this element needs an owner',
|
||||
type: EntityType.Tag,
|
||||
};
|
||||
|
||||
const globalTags1 = {
|
||||
tags: [{ tag: legacyTag }],
|
||||
};
|
||||
|
||||
const globalTags2 = {
|
||||
tags: [{ tag: ownershipTag }],
|
||||
};
|
||||
|
||||
describe('TagGroup', () => {
|
||||
it('renders editable tags', async () => {
|
||||
const { getByText, getByLabelText, queryAllByLabelText, queryByText } = render(
|
||||
<TestPageContainer>
|
||||
<TagGroup editableTags={globalTags1} canRemove />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(queryByText('Add Tag')).not.toBeInTheDocument();
|
||||
expect(getByText('Legacy')).toBeInTheDocument();
|
||||
expect(queryAllByLabelText('close')).toHaveLength(1);
|
||||
fireEvent.click(getByLabelText('close'));
|
||||
await waitFor(() => expect(getByText('Do you want to remove Legacy tag?')).toBeInTheDocument());
|
||||
expect(getByText('Do you want to remove Legacy tag?')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(getByLabelText('Close'));
|
||||
|
||||
await waitFor(() => expect(queryByText('Do you want to remove Legacy tag?')).not.toBeInTheDocument());
|
||||
|
||||
expect(getByText('Legacy')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders uneditable tags', () => {
|
||||
const { getByText, queryByLabelText, queryByText } = render(
|
||||
<TestPageContainer>
|
||||
<TagGroup uneditableTags={globalTags2} />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(queryByText('Add Tag')).not.toBeInTheDocument();
|
||||
expect(getByText('NeedsOwnership')).toBeInTheDocument();
|
||||
expect(queryByLabelText('close')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders both together', () => {
|
||||
const { getByText, queryByText, queryAllByLabelText } = render(
|
||||
<TestPageContainer>
|
||||
<TagGroup uneditableTags={globalTags1} editableTags={globalTags2} canRemove />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(queryByText('Add Tag')).not.toBeInTheDocument();
|
||||
expect(getByText('Legacy')).toBeInTheDocument();
|
||||
expect(getByText('NeedsOwnership')).toBeInTheDocument();
|
||||
expect(queryAllByLabelText('close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('renders create tag', () => {
|
||||
const { getByText, queryByText } = render(
|
||||
<TestPageContainer>
|
||||
<TagGroup uneditableTags={globalTags1} editableTags={globalTags2} canRemove canAdd />
|
||||
</TestPageContainer>,
|
||||
);
|
||||
expect(queryByText('+ Add Tag')).toBeInTheDocument();
|
||||
expect(queryByText('Find a tag')).not.toBeInTheDocument();
|
||||
const AddTagButton = getByText('+ Add Tag');
|
||||
fireEvent.click(AddTagButton);
|
||||
expect(queryByText('Find a tag')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,7 @@
|
||||
import { TagAssociation, TagAssociationUpdate } from '../../../../types.generated';
|
||||
|
||||
export function convertTagsForUpdate(tags: TagAssociation[]): TagAssociationUpdate[] {
|
||||
return tags.map((tag) => ({
|
||||
tag: { urn: tag.tag.urn, name: tag.tag.name, description: tag.tag.description },
|
||||
}));
|
||||
}
|
||||
@ -96,3 +96,102 @@ query getChart($urn: String!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation updateChart($input: ChartUpdateInput!) {
|
||||
updateChart(input: $input) {
|
||||
urn
|
||||
type
|
||||
tool
|
||||
chartId
|
||||
info {
|
||||
name
|
||||
description
|
||||
inputs {
|
||||
urn
|
||||
name
|
||||
origin
|
||||
description
|
||||
platform {
|
||||
name
|
||||
info {
|
||||
logoUrl
|
||||
}
|
||||
}
|
||||
platformNativeType
|
||||
tags
|
||||
ownership {
|
||||
owners {
|
||||
owner {
|
||||
urn
|
||||
type
|
||||
username
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
title
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
editableInfo {
|
||||
pictureLink
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
}
|
||||
url
|
||||
type
|
||||
access
|
||||
lastRefreshed
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
created {
|
||||
time
|
||||
}
|
||||
}
|
||||
query {
|
||||
rawQuery
|
||||
type
|
||||
}
|
||||
ownership {
|
||||
owners {
|
||||
owner {
|
||||
urn
|
||||
type
|
||||
username
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
title
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
editableInfo {
|
||||
pictureLink
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
globalTags {
|
||||
tags {
|
||||
tag {
|
||||
urn
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,3 +87,93 @@ query getDashboard($urn: String!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation updateDashboard($input: DashboardUpdateInput!) {
|
||||
updateDashboard(input: $input) {
|
||||
urn
|
||||
type
|
||||
tool
|
||||
dashboardId
|
||||
info {
|
||||
name
|
||||
description
|
||||
charts {
|
||||
urn
|
||||
tool
|
||||
type
|
||||
info {
|
||||
name
|
||||
description
|
||||
}
|
||||
ownership {
|
||||
owners {
|
||||
owner {
|
||||
urn
|
||||
type
|
||||
username
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
title
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
editableInfo {
|
||||
pictureLink
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
}
|
||||
url
|
||||
access
|
||||
lastRefreshed
|
||||
created {
|
||||
time
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
ownership {
|
||||
owners {
|
||||
owner {
|
||||
urn
|
||||
type
|
||||
username
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
title
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
editableInfo {
|
||||
pictureLink
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
globalTags {
|
||||
tags {
|
||||
tag {
|
||||
urn
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,21 @@ fragment nonRecursiveDatasetFields on Dataset {
|
||||
}
|
||||
primaryKeys
|
||||
}
|
||||
editableSchemaMetadata {
|
||||
editableSchemaFieldInfo {
|
||||
fieldPath
|
||||
description
|
||||
globalTags {
|
||||
tags {
|
||||
tag {
|
||||
urn
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deprecation {
|
||||
actor
|
||||
deprecated
|
||||
@ -199,6 +214,21 @@ query getDataset($urn: String!) {
|
||||
}
|
||||
primaryKeys
|
||||
}
|
||||
editableSchemaMetadata {
|
||||
editableSchemaFieldInfo {
|
||||
fieldPath
|
||||
description
|
||||
globalTags {
|
||||
tags {
|
||||
tag {
|
||||
urn
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
deprecation {
|
||||
actor
|
||||
deprecated
|
||||
|
||||
@ -30,3 +30,36 @@ query getTag($urn: String!) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mutation updateTag($input: TagUpdate!) {
|
||||
updateTag(input: $input) {
|
||||
urn
|
||||
name
|
||||
description
|
||||
ownership {
|
||||
owners {
|
||||
owner {
|
||||
urn
|
||||
type
|
||||
username
|
||||
info {
|
||||
active
|
||||
displayName
|
||||
title
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
fullName
|
||||
}
|
||||
editableInfo {
|
||||
pictureLink
|
||||
}
|
||||
}
|
||||
type
|
||||
}
|
||||
lastModified {
|
||||
time
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,11 @@ Our open sourcing [blog post](https://engineering.linkedin.com/blog/2020/open-so
|
||||
- **Social actions**: likes, follows, bookmarks [*coming soon*]
|
||||
- **Compliance management**: field level tag based compliance editing [*coming soon*]
|
||||
- **Top users**: frequent users of a dataset [*coming soon*]
|
||||
|
||||
### Tags
|
||||
- **Globally defined**: Tags provided a standardized set of labels that can be shared across all your entities
|
||||
- **Supports entities and schemas**: Tags can be applied at the entity level or for datasets, attached to schema fields.
|
||||
- **Searchable** Entities can be searched and filtered by tag
|
||||
|
||||
### Users
|
||||
- **Search**: full-text & advanced search, search ranking
|
||||
|
||||
@ -8,6 +8,7 @@ import com.linkedin.common.Status
|
||||
import com.linkedin.common.Uri
|
||||
import com.linkedin.common.VersionTag
|
||||
import com.linkedin.schema.SchemaMetadata
|
||||
import com.linkedin.schema.EditableSchemaMetadata
|
||||
import com.linkedin.common.GlobalTags
|
||||
|
||||
/**
|
||||
@ -102,6 +103,11 @@ record Dataset includes DatasetKey, ChangeAuditStamps, VersionTag {
|
||||
*/
|
||||
schemaMetadata: optional SchemaMetadata
|
||||
|
||||
/**
|
||||
* Editable schema metadata of the dataset
|
||||
*/
|
||||
editableSchemaMetadata: optional EditableSchemaMetadata
|
||||
|
||||
/**
|
||||
* Status metadata of the dataset
|
||||
*/
|
||||
@ -116,4 +122,4 @@ record Dataset includes DatasetKey, ChangeAuditStamps, VersionTag {
|
||||
* List of global tags applied to the dataset
|
||||
*/
|
||||
globalTags: optional GlobalTags
|
||||
}
|
||||
}
|
||||
|
||||
@ -867,6 +867,44 @@
|
||||
},
|
||||
"doc" : "Schema metadata of the dataset",
|
||||
"optional" : true
|
||||
}, {
|
||||
"name" : "editableSchemaMetadata",
|
||||
"type" : {
|
||||
"type" : "record",
|
||||
"name" : "EditableSchemaMetadata",
|
||||
"namespace" : "com.linkedin.schema",
|
||||
"doc" : "EditableSchemaMetadata stores editable changes made to schema metadata. This separates changes made from\ningestion pipelines and edits in the UI to avoid accidental overwrites of user-provided data by ingestion pipelines.",
|
||||
"include" : [ "com.linkedin.common.ChangeAuditStamps" ],
|
||||
"fields" : [ {
|
||||
"name" : "editableSchemaFieldInfo",
|
||||
"type" : {
|
||||
"type" : "array",
|
||||
"items" : {
|
||||
"type" : "record",
|
||||
"name" : "EditableSchemaFieldInfo",
|
||||
"doc" : "SchemaField to describe metadata related to dataset schema.",
|
||||
"fields" : [ {
|
||||
"name" : "fieldPath",
|
||||
"type" : "string",
|
||||
"doc" : "FieldPath uniquely identifying the SchemaField this metadata is associated with"
|
||||
}, {
|
||||
"name" : "description",
|
||||
"type" : "string",
|
||||
"doc" : "Description",
|
||||
"optional" : true
|
||||
}, {
|
||||
"name" : "globalTags",
|
||||
"type" : "com.linkedin.common.GlobalTags",
|
||||
"doc" : "Tags associated with the field",
|
||||
"optional" : true
|
||||
} ]
|
||||
}
|
||||
},
|
||||
"doc" : "Client provided a list of fields from document schema."
|
||||
} ]
|
||||
},
|
||||
"doc" : "Editable schema metadata of the dataset",
|
||||
"optional" : true
|
||||
}, {
|
||||
"name" : "status",
|
||||
"type" : "com.linkedin.common.Status",
|
||||
@ -1039,7 +1077,7 @@
|
||||
"name" : "DatasetAspect",
|
||||
"namespace" : "com.linkedin.metadata.aspect",
|
||||
"doc" : "A union of all supported metadata aspects for a Dataset",
|
||||
"ref" : [ "com.linkedin.dataset.DatasetProperties", "com.linkedin.dataset.DatasetDeprecation", "com.linkedin.dataset.DatasetUpstreamLineage", "com.linkedin.dataset.UpstreamLineage", "com.linkedin.common.InstitutionalMemory", "com.linkedin.common.Ownership", "com.linkedin.common.Status", "com.linkedin.schema.SchemaMetadata", "com.linkedin.common.GlobalTags" ]
|
||||
"ref" : [ "com.linkedin.dataset.DatasetProperties", "com.linkedin.dataset.DatasetDeprecation", "com.linkedin.dataset.DatasetUpstreamLineage", "com.linkedin.dataset.UpstreamLineage", "com.linkedin.common.InstitutionalMemory", "com.linkedin.common.Ownership", "com.linkedin.common.Status", "com.linkedin.schema.SchemaMetadata", "com.linkedin.schema.EditableSchemaMetadata", "com.linkedin.common.GlobalTags" ]
|
||||
}, {
|
||||
"type" : "record",
|
||||
"name" : "AggregationMetadata",
|
||||
@ -1345,7 +1383,7 @@
|
||||
"validate" : {
|
||||
"com.linkedin.restli.common.EmptyRecordValidator" : { }
|
||||
}
|
||||
}, "com.linkedin.schema.ArrayType", "com.linkedin.schema.BinaryJsonSchema", "com.linkedin.schema.BooleanType", "com.linkedin.schema.BytesType", "com.linkedin.schema.DatasetFieldForeignKey", "com.linkedin.schema.DateType", "com.linkedin.schema.EnumType", "com.linkedin.schema.EspressoSchema", "com.linkedin.schema.FixedType", "com.linkedin.schema.ForeignKeySpec", "com.linkedin.schema.KafkaSchema", "com.linkedin.schema.KeyValueSchema", "com.linkedin.schema.MapType", "com.linkedin.schema.MySqlDDL", "com.linkedin.schema.NullType", "com.linkedin.schema.NumberType", "com.linkedin.schema.OracleDDL", "com.linkedin.schema.OrcSchema", "com.linkedin.schema.OtherSchema", "com.linkedin.schema.PrestoDDL", "com.linkedin.schema.RecordType", "com.linkedin.schema.SchemaField", "com.linkedin.schema.SchemaFieldDataType", "com.linkedin.schema.SchemaMetadata", "com.linkedin.schema.SchemaMetadataKey", "com.linkedin.schema.Schemaless", "com.linkedin.schema.StringType", "com.linkedin.schema.TimeType", "com.linkedin.schema.UnionType", "com.linkedin.schema.UrnForeignKey" ],
|
||||
}, "com.linkedin.schema.ArrayType", "com.linkedin.schema.BinaryJsonSchema", "com.linkedin.schema.BooleanType", "com.linkedin.schema.BytesType", "com.linkedin.schema.DatasetFieldForeignKey", "com.linkedin.schema.DateType", "com.linkedin.schema.EditableSchemaFieldInfo", "com.linkedin.schema.EditableSchemaMetadata", "com.linkedin.schema.EnumType", "com.linkedin.schema.EspressoSchema", "com.linkedin.schema.FixedType", "com.linkedin.schema.ForeignKeySpec", "com.linkedin.schema.KafkaSchema", "com.linkedin.schema.KeyValueSchema", "com.linkedin.schema.MapType", "com.linkedin.schema.MySqlDDL", "com.linkedin.schema.NullType", "com.linkedin.schema.NumberType", "com.linkedin.schema.OracleDDL", "com.linkedin.schema.OrcSchema", "com.linkedin.schema.OtherSchema", "com.linkedin.schema.PrestoDDL", "com.linkedin.schema.RecordType", "com.linkedin.schema.SchemaField", "com.linkedin.schema.SchemaFieldDataType", "com.linkedin.schema.SchemaMetadata", "com.linkedin.schema.SchemaMetadataKey", "com.linkedin.schema.Schemaless", "com.linkedin.schema.StringType", "com.linkedin.schema.TimeType", "com.linkedin.schema.UnionType", "com.linkedin.schema.UrnForeignKey" ],
|
||||
"schema" : {
|
||||
"name" : "datasets",
|
||||
"namespace" : "com.linkedin.dataset",
|
||||
|
||||
@ -9,18 +9,26 @@ import com.linkedin.common.urn.ChartUrn;
|
||||
import com.linkedin.dashboard.Chart;
|
||||
import com.linkedin.dashboard.ChartKey;
|
||||
import com.linkedin.data.template.StringArray;
|
||||
import com.linkedin.metadata.aspect.ChartAspect;
|
||||
import com.linkedin.metadata.configs.ChartSearchConfig;
|
||||
import com.linkedin.metadata.dao.ChartActionRequestBuilder;
|
||||
import com.linkedin.metadata.dao.utils.ModelUtils;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.metadata.query.BrowseResult;
|
||||
import com.linkedin.metadata.query.SortCriterion;
|
||||
import com.linkedin.metadata.restli.BaseBrowsableClient;
|
||||
import com.linkedin.metadata.snapshot.ChartSnapshot;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.client.BatchGetEntityRequest;
|
||||
import com.linkedin.restli.client.Client;
|
||||
import com.linkedin.restli.client.GetRequest;
|
||||
import com.linkedin.restli.client.Request;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
import com.linkedin.restli.common.ComplexResourceKey;
|
||||
import com.linkedin.restli.common.EmptyRecord;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@ -33,6 +41,7 @@ import static com.linkedin.metadata.dao.utils.QueryUtils.newFilter;
|
||||
public class Charts extends BaseBrowsableClient<Chart, ChartUrn> {
|
||||
|
||||
private static final ChartsRequestBuilders CHARTS_REQUEST_BUILDERS = new ChartsRequestBuilders();
|
||||
private static final ChartActionRequestBuilder CHARTS_ACTION_REQUEST_BUILDERS = new ChartActionRequestBuilder();
|
||||
private static final ChartSearchConfig CHARTS_SEARCH_CONFIG = new ChartSearchConfig();
|
||||
|
||||
public Charts(@Nonnull Client restliClient) {
|
||||
@ -144,6 +153,34 @@ public class Charts extends BaseBrowsableClient<Chart, ChartUrn> {
|
||||
return _client.sendRequest(requestBuilder.build()).getResponse().getEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Chart
|
||||
*/
|
||||
public void update(@Nonnull final ChartUrn urn, @Nonnull final Chart chart) throws RemoteInvocationException {
|
||||
Request request = CHARTS_ACTION_REQUEST_BUILDERS.createRequest(urn, toSnapshot(chart, urn));
|
||||
_client.sendRequest(request).getResponse();
|
||||
}
|
||||
|
||||
static ChartSnapshot toSnapshot(@Nonnull Chart chart, @Nonnull ChartUrn urn) {
|
||||
final List<ChartAspect> aspects = new ArrayList<>();
|
||||
if (chart.hasInfo()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(ChartAspect.class, chart.getInfo()));
|
||||
}
|
||||
if (chart.hasQuery()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(ChartAspect.class, chart.getQuery()));
|
||||
}
|
||||
if (chart.hasOwnership()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(ChartAspect.class, chart.getOwnership()));
|
||||
}
|
||||
if (chart.hasStatus()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(ChartAspect.class, chart.getStatus()));
|
||||
}
|
||||
if (chart.hasGlobalTags()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(ChartAspect.class, chart.getGlobalTags()));
|
||||
}
|
||||
return ModelUtils.newSnapshot(ChartSnapshot.class, urn, aspects);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private ComplexResourceKey<ChartKey, EmptyRecord> getKeyFromUrn(@Nonnull ChartUrn urn) {
|
||||
return new ComplexResourceKey<>(toChartKey(urn), new EmptyRecord());
|
||||
|
||||
@ -9,18 +9,26 @@ import com.linkedin.dashboard.DashboardsDoGetBrowsePathsRequestBuilder;
|
||||
import com.linkedin.dashboard.DashboardsFindBySearchRequestBuilder;
|
||||
import com.linkedin.dashboard.DashboardsRequestBuilders;
|
||||
import com.linkedin.data.template.StringArray;
|
||||
import com.linkedin.metadata.aspect.DashboardAspect;
|
||||
import com.linkedin.metadata.configs.DashboardSearchConfig;
|
||||
import com.linkedin.metadata.dao.DashboardActionRequestBuilder;
|
||||
import com.linkedin.metadata.dao.utils.ModelUtils;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.metadata.query.BrowseResult;
|
||||
import com.linkedin.metadata.query.SortCriterion;
|
||||
import com.linkedin.metadata.restli.BaseBrowsableClient;
|
||||
import com.linkedin.metadata.snapshot.DashboardSnapshot;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.client.BatchGetEntityRequest;
|
||||
import com.linkedin.restli.client.Client;
|
||||
import com.linkedin.restli.client.GetRequest;
|
||||
import com.linkedin.restli.client.Request;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
import com.linkedin.restli.common.ComplexResourceKey;
|
||||
import com.linkedin.restli.common.EmptyRecord;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@ -33,6 +41,7 @@ import static com.linkedin.metadata.dao.utils.QueryUtils.newFilter;
|
||||
public class Dashboards extends BaseBrowsableClient<Dashboard, DashboardUrn> {
|
||||
|
||||
private static final DashboardsRequestBuilders DASHBOARDS_REQUEST_BUILDERS = new DashboardsRequestBuilders();
|
||||
private static final DashboardActionRequestBuilder DASHBOARDS_ACTION_REQUEST_BUILDERS = new DashboardActionRequestBuilder();
|
||||
private static final DashboardSearchConfig DASHBOARDS_SEARCH_CONFIG = new DashboardSearchConfig();
|
||||
|
||||
public Dashboards(@Nonnull Client restliClient) {
|
||||
@ -144,6 +153,31 @@ public class Dashboards extends BaseBrowsableClient<Dashboard, DashboardUrn> {
|
||||
return _client.sendRequest(requestBuilder.build()).getResponse().getEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Dashboard
|
||||
*/
|
||||
public void update(@Nonnull final DashboardUrn urn, @Nonnull final Dashboard dashboard) throws RemoteInvocationException {
|
||||
Request request = DASHBOARDS_ACTION_REQUEST_BUILDERS.createRequest(urn, toSnapshot(dashboard, urn));
|
||||
_client.sendRequest(request).getResponse();
|
||||
}
|
||||
|
||||
static DashboardSnapshot toSnapshot(@Nonnull Dashboard dashboard, @Nonnull DashboardUrn urn) {
|
||||
final List<DashboardAspect> aspects = new ArrayList<>();
|
||||
if (dashboard.hasInfo()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DashboardAspect.class, dashboard.getInfo()));
|
||||
}
|
||||
if (dashboard.hasOwnership()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DashboardAspect.class, dashboard.getOwnership()));
|
||||
}
|
||||
if (dashboard.hasStatus()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DashboardAspect.class, dashboard.getStatus()));
|
||||
}
|
||||
if (dashboard.hasGlobalTags()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DashboardAspect.class, dashboard.getGlobalTags()));
|
||||
}
|
||||
return ModelUtils.newSnapshot(DashboardSnapshot.class, urn, aspects);
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
private ComplexResourceKey<DashboardKey, EmptyRecord> getKeyFromUrn(@Nonnull DashboardUrn urn) {
|
||||
return new ComplexResourceKey<>(toDashboardsKey(urn), new EmptyRecord());
|
||||
|
||||
@ -309,6 +309,12 @@ public class Datasets extends BaseBrowsableClient<Dataset, DatasetUrn> {
|
||||
if (dataset.hasRemoved()) {
|
||||
aspects.add(DatasetAspect.create(new Status().setRemoved(dataset.isRemoved())));
|
||||
}
|
||||
if (dataset.getGlobalTags() != null) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DatasetAspect.class, dataset.getGlobalTags()));
|
||||
}
|
||||
if (dataset.getEditableSchemaMetadata() != null) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DatasetAspect.class, dataset.getEditableSchemaMetadata()));
|
||||
}
|
||||
return ModelUtils.newSnapshot(DatasetSnapshot.class, datasetUrn, aspects);
|
||||
}
|
||||
|
||||
|
||||
@ -2,24 +2,31 @@ package com.linkedin.tag.client;
|
||||
|
||||
import com.linkedin.common.urn.TagUrn;
|
||||
import com.linkedin.data.template.StringArray;
|
||||
import com.linkedin.metadata.aspect.TagAspect;
|
||||
import com.linkedin.metadata.configs.TagSearchConfig;
|
||||
import com.linkedin.metadata.dao.TagActionRequestBuilders;
|
||||
import com.linkedin.metadata.dao.utils.ModelUtils;
|
||||
import com.linkedin.metadata.query.AutoCompleteResult;
|
||||
import com.linkedin.metadata.query.SortCriterion;
|
||||
import com.linkedin.metadata.restli.BaseSearchableClient;
|
||||
import com.linkedin.metadata.snapshot.TagSnapshot;
|
||||
import com.linkedin.r2.RemoteInvocationException;
|
||||
import com.linkedin.restli.client.BatchGetEntityRequest;
|
||||
import com.linkedin.restli.client.Client;
|
||||
import com.linkedin.restli.client.GetAllRequest;
|
||||
import com.linkedin.restli.client.GetRequest;
|
||||
import com.linkedin.restli.client.Request;
|
||||
import com.linkedin.restli.common.CollectionResponse;
|
||||
import com.linkedin.restli.common.ComplexResourceKey;
|
||||
import com.linkedin.restli.common.EmptyRecord;
|
||||
import com.linkedin.tag.Tag;
|
||||
import com.linkedin.tag.TagKey;
|
||||
import com.linkedin.tag.TagProperties;
|
||||
import com.linkedin.tag.TagsDoAutocompleteRequestBuilder;
|
||||
import com.linkedin.tag.TagsFindBySearchRequestBuilder;
|
||||
import com.linkedin.tag.TagsRequestBuilders;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@ -32,6 +39,7 @@ import static com.linkedin.metadata.dao.utils.QueryUtils.newFilter;
|
||||
public class Tags extends BaseSearchableClient<Tag> {
|
||||
|
||||
private static final TagsRequestBuilders TAGS_REQUEST_BUILDERS = new TagsRequestBuilders();
|
||||
private static final TagActionRequestBuilders TAGS_ACTION_REQUEST_BUILDERS = new TagActionRequestBuilders();
|
||||
private static final TagSearchConfig TAGS_SEARCH_CONFIG = new TagSearchConfig();
|
||||
|
||||
public Tags(@Nonnull Client restliClient) {
|
||||
@ -119,6 +127,28 @@ public class Tags extends BaseSearchableClient<Tag> {
|
||||
return _client.sendRequest(requestBuilder.build()).getResponse().getEntity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Tag
|
||||
*/
|
||||
public void update(@Nonnull final TagUrn urn, @Nonnull final Tag tag) throws RemoteInvocationException {
|
||||
Request request = TAGS_ACTION_REQUEST_BUILDERS.createRequest(urn, toSnapshot(tag, urn));
|
||||
_client.sendRequest(request).getResponse();
|
||||
}
|
||||
|
||||
static TagSnapshot toSnapshot(@Nonnull Tag tag, @Nonnull TagUrn tagUrn) {
|
||||
final List<TagAspect> aspects = new ArrayList<>();
|
||||
if (tag.hasDescription()) {
|
||||
TagProperties tagProperties = new TagProperties();
|
||||
tagProperties.setDescription((tag.getDescription()));
|
||||
tagProperties.setName((tag.getName()));
|
||||
aspects.add(ModelUtils.newAspectUnion(TagAspect.class, tagProperties));
|
||||
}
|
||||
if (tag.hasOwnership()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(TagAspect.class, tag.getOwnership()));
|
||||
}
|
||||
return ModelUtils.newSnapshot(TagSnapshot.class, tagUrn, aspects);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all {@link Tag} models of the tag
|
||||
*
|
||||
|
||||
@ -40,6 +40,7 @@ import com.linkedin.restli.server.annotations.PagingContextParam;
|
||||
import com.linkedin.restli.server.annotations.QueryParam;
|
||||
import com.linkedin.restli.server.annotations.RestLiCollection;
|
||||
import com.linkedin.restli.server.annotations.RestMethod;
|
||||
import com.linkedin.schema.EditableSchemaMetadata;
|
||||
import com.linkedin.schema.SchemaMetadata;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@ -155,8 +156,10 @@ public final class Datasets extends BaseBrowsableEntityResource<
|
||||
value.setUpstreamLineage((UpstreamLineage) aspect);
|
||||
} else if (aspect instanceof GlobalTags) {
|
||||
value.setGlobalTags(GlobalTags.class.cast(aspect));
|
||||
} else if (aspect instanceof EditableSchemaMetadata) {
|
||||
value.setEditableSchemaMetadata(EditableSchemaMetadata.class.cast(aspect));
|
||||
}
|
||||
});
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -191,6 +194,9 @@ public final class Datasets extends BaseBrowsableEntityResource<
|
||||
if (dataset.hasGlobalTags()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DatasetAspect.class, dataset.getGlobalTags()));
|
||||
}
|
||||
if (dataset.hasEditableSchemaMetadata()) {
|
||||
aspects.add(ModelUtils.newAspectUnion(DatasetAspect.class, dataset.getEditableSchemaMetadata()));
|
||||
}
|
||||
return ModelUtils.newSnapshot(DatasetSnapshot.class, datasetUrn, aspects);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package com.linkedin.metadata.dao;
|
||||
|
||||
import com.linkedin.common.urn.TagUrn;
|
||||
import com.linkedin.metadata.snapshot.TagSnapshot;
|
||||
|
||||
/**
|
||||
* An action request builder for tag entities.
|
||||
*/
|
||||
public class TagActionRequestBuilders extends BaseActionRequestBuilder<TagSnapshot, TagUrn> {
|
||||
|
||||
private static final String BASE_URI_TEMPLATE = "tags";
|
||||
|
||||
public TagActionRequestBuilders() {
|
||||
super(TagSnapshot.class, TagUrn.class, BASE_URI_TEMPLATE);
|
||||
}
|
||||
}
|
||||
@ -118,6 +118,27 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"com.linkedin.pegasus2avro.schema.EditableSchemaMetadata": {
|
||||
"created": {
|
||||
"time": 1581407189000,
|
||||
"actor": "urn:li:corpuser:jdoe",
|
||||
"impersonator": null
|
||||
},
|
||||
"lastModified": {
|
||||
"time": 1581407189000,
|
||||
"actor": "urn:li:corpuser:jdoe",
|
||||
"impersonator": null
|
||||
},
|
||||
"deleted": null,
|
||||
"editableSchemaFieldInfo": [
|
||||
{
|
||||
"fieldPath": "field_foo",
|
||||
"globalTags": { "tags": [{ "tag": "urn:li:tag:Legacy" }] }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"com.linkedin.pegasus2avro.schema.SchemaMetadata": {
|
||||
"schemaName": "SampleKafkaSchema",
|
||||
@ -156,6 +177,7 @@
|
||||
}
|
||||
},
|
||||
"nativeDataType": "string",
|
||||
"globalTags": { "tags": [{ "tag": "urn:li:tag:NeedsDocumentation" }] },
|
||||
"recursive": false
|
||||
},
|
||||
{
|
||||
@ -475,7 +497,7 @@
|
||||
},
|
||||
"proposedDelta": null
|
||||
},
|
||||
{
|
||||
{
|
||||
"auditHeader": null,
|
||||
"proposedSnapshot": {
|
||||
"com.linkedin.pegasus2avro.metadata.snapshot.ChartSnapshot": {
|
||||
@ -756,5 +778,38 @@
|
||||
}
|
||||
},
|
||||
"proposedDelta": null
|
||||
},
|
||||
{
|
||||
"auditHeader": null,
|
||||
"proposedSnapshot": {
|
||||
"com.linkedin.pegasus2avro.metadata.snapshot.TagSnapshot": {
|
||||
"urn": "urn:li:tag:NeedsDocumentation",
|
||||
"aspects": [
|
||||
{
|
||||
"com.linkedin.pegasus2avro.tag.TagProperties": {
|
||||
"name": "NeedsDocumentation",
|
||||
"description": "Indicates the data element needs documentation"
|
||||
}
|
||||
},
|
||||
{
|
||||
"com.linkedin.pegasus2avro.common.Ownership": {
|
||||
"owners": [
|
||||
{
|
||||
"owner": "urn:li:corpuser:jdoe",
|
||||
"type": "DATAOWNER",
|
||||
"source": null
|
||||
}
|
||||
],
|
||||
"lastModified": {
|
||||
"time": 1581407189000,
|
||||
"actor": "urn:li:corpuser:jdoe",
|
||||
"impersonator": null
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"proposedDelta": null
|
||||
}
|
||||
]
|
||||
|
||||
@ -8,6 +8,7 @@ import com.linkedin.dataset.DatasetProperties
|
||||
import com.linkedin.dataset.DatasetUpstreamLineage
|
||||
import com.linkedin.dataset.UpstreamLineage
|
||||
import com.linkedin.schema.SchemaMetadata
|
||||
import com.linkedin.schema.EditableSchemaMetadata
|
||||
import com.linkedin.common.GlobalTags
|
||||
|
||||
/**
|
||||
@ -22,5 +23,6 @@ typeref DatasetAspect = union[
|
||||
Ownership,
|
||||
Status,
|
||||
SchemaMetadata,
|
||||
EditableSchemaMetadata,
|
||||
GlobalTags
|
||||
]
|
||||
]
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
namespace com.linkedin.schema
|
||||
|
||||
import com.linkedin.common.GlobalTags
|
||||
|
||||
/**
|
||||
* SchemaField to describe metadata related to dataset schema.
|
||||
*/
|
||||
record EditableSchemaFieldInfo {
|
||||
/**
|
||||
* FieldPath uniquely identifying the SchemaField this metadata is associated with
|
||||
*/
|
||||
fieldPath: string
|
||||
|
||||
/**
|
||||
* Description
|
||||
*/
|
||||
description: optional string
|
||||
|
||||
/**
|
||||
* Tags associated with the field
|
||||
*/
|
||||
globalTags: optional GlobalTags
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
namespace com.linkedin.schema
|
||||
|
||||
import com.linkedin.common.ChangeAuditStamps
|
||||
|
||||
/**
|
||||
* EditableSchemaMetadata stores editable changes made to schema metadata. This separates changes made from
|
||||
* ingestion pipelines and edits in the UI to avoid accidental overwrites of user-provided data by ingestion pipelines.
|
||||
*/
|
||||
record EditableSchemaMetadata includes ChangeAuditStamps {
|
||||
/**
|
||||
* Client provided a list of fields from document schema.
|
||||
*/
|
||||
editableSchemaFieldInfo: array[EditableSchemaFieldInfo]
|
||||
}
|
||||
@ -47,4 +47,4 @@ record SchemaField {
|
||||
* Tags associated with the field
|
||||
*/
|
||||
globalTags: optional GlobalTags
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,4 +54,4 @@ record SchemaMetadata includes SchemaMetadataKey, ChangeAuditStamps {
|
||||
* Map captures all the references schema makes to external datasets. Map key is ForeignKeySpecName typeref.
|
||||
*/
|
||||
foreignKeysSpecs: optional map[string, ForeignKeySpec]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user