From 43c8460fe528f70ccb3d9aaa8582d619de0c03e7 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Mon, 9 Dec 2024 21:34:04 -0600 Subject: [PATCH 01/18] fix(plugin-logging): adjust error logging in plugin registry (#12064) --- .../metadata/models/registry/PluginEntityRegistryLoader.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoader.java b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoader.java index 4f2e5a106ae792..531537852109b0 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoader.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/registry/PluginEntityRegistryLoader.java @@ -6,7 +6,6 @@ import com.linkedin.metadata.models.registry.config.LoadStatus; import com.linkedin.util.Pair; import java.io.File; -import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.nio.file.Files; @@ -204,8 +203,8 @@ private void loadOneRegistry( loadResultBuilder.plugins(entityRegistry.getPluginFactory().getPluginLoadResult()); log.info("Loaded registry {} successfully", entityRegistry); - } catch (RuntimeException | EntityRegistryException | IOException e) { - log.debug("{}: Failed to load registry {} with {}", this, registryName, e.getMessage()); + } catch (Exception | EntityRegistryException e) { + log.error("{}: Failed to load registry {} with {}", this, registryName, e.getMessage(), e); StringWriter sw = new StringWriter(); PrintWriter pw = new PrintWriter(sw); e.printStackTrace(pw); From 638a0e370ec595edbfe12099c560675dc9510ccf Mon Sep 17 00:00:00 2001 From: Chakru <161002324+chakru-r@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:31:35 +0530 Subject: [PATCH 02/18] build(metadata-events): fix shell interpreter mismatch in build script (#12066) --- metadata-events/mxe-schemas/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/metadata-events/mxe-schemas/build.gradle b/metadata-events/mxe-schemas/build.gradle index ab0ea8b649e9d4..6dfe69a420242f 100644 --- a/metadata-events/mxe-schemas/build.gradle +++ b/metadata-events/mxe-schemas/build.gradle @@ -25,7 +25,7 @@ task copyOriginalAvsc(type: Copy, dependsOn: generateAvroSchema) { } task renameNamespace(type: Exec, dependsOn: copyOriginalAvsc) { - commandLine 'sh', './rename-namespace.sh' + commandLine 'bash', './rename-namespace.sh' } build.dependsOn renameNamespace @@ -34,4 +34,4 @@ clean { project.delete('src/main/pegasus') project.delete('src/mainGeneratedAvroSchema/avro') project.delete('src/renamed/avro') -} \ No newline at end of file +} From 0a2ac70d38377a77eecf70c84b606f013594258f Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Mon, 9 Dec 2024 23:15:07 -0600 Subject: [PATCH 03/18] fix(entity-service): handle no-op system-metadata batches (#12055) --- .../metadata/aspect/batch/AspectsBatch.java | 19 +- .../metadata/aspect/batch/BatchItem.java | 7 + .../test/metadata/aspect/batch/TestMCL.java | 21 + .../test/metadata/aspect/batch/TestMCP.java | 38 ++ .../metadata/entity/EntityAspect.java | 25 + .../entity/ebean/batch/AspectsBatchImpl.java | 40 +- .../entity/ebean/batch/ChangeItemImpl.java | 13 +- .../entity/ebean/batch/DeleteItemImpl.java | 6 + .../entity/ebean/batch/MCLItemImpl.java | 6 + .../entity/ebean/batch/PatchItemImpl.java | 9 +- .../entity/ebean/batch/ProposedItem.java | 27 + .../ebean/batch/AspectsBatchImplTest.java | 10 +- .../metadata/entity/EntityServiceImpl.java | 197 ++++--- .../metadata/entity/TransactionContext.java | 6 + .../entity/cassandra/CassandraAspectDao.java | 4 +- .../metadata/entity/ebean/EbeanAspectDao.java | 4 +- .../metadata/AspectGenerationUtils.java | 6 +- .../entity/EbeanEntityServiceTest.java | 378 ------------- .../metadata/entity/EntityServiceTest.java | 518 +++++++++++++++++- .../ebean/batch/ChangeItemImplTest.java | 41 ++ .../SchemaFieldSideEffectTest.java | 16 +- .../src/main/resources/application.yaml | 2 +- .../metadata/utils/GenericRecordUtils.java | 19 + 23 files changed, 927 insertions(+), 485 deletions(-) create mode 100644 metadata-io/src/test/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImplTest.java diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java index 30f5dce379a077..6ce6a9a5730385 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/AspectsBatch.java @@ -28,10 +28,12 @@ public interface AspectsBatch { Collection getItems(); + Collection getInitialItems(); + RetrieverContext getRetrieverContext(); /** - * Returns MCP items. Could be patch, upsert, etc. + * Returns MCP items. Could be one of patch, upsert, etc. * * @return batch items */ @@ -160,13 +162,24 @@ static Stream applyMCLSideEffects( } default boolean containsDuplicateAspects() { - return getItems().stream() - .map(i -> String.format("%s_%s", i.getClass().getName(), i.hashCode())) + return getInitialItems().stream() + .map(i -> String.format("%s_%s", i.getClass().getSimpleName(), i.hashCode())) .distinct() .count() != getItems().size(); } + default Map> duplicateAspects() { + return getInitialItems().stream() + .collect( + Collectors.groupingBy( + i -> String.format("%s_%s", i.getClass().getSimpleName(), i.hashCode()))) + .entrySet() + .stream() + .filter(entry -> entry.getValue() != null && entry.getValue().size() > 1) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + default Map> getUrnAspectsMap() { return getItems().stream() .map(aspect -> Pair.of(aspect.getUrn().toString(), aspect.getAspectName())) diff --git a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java index a6dfbc277e12ec..7f0a849a0eda1d 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/aspect/batch/BatchItem.java @@ -23,4 +23,11 @@ public interface BatchItem extends ReadItem { */ @Nonnull ChangeType getChangeType(); + + /** + * Determines if this item is a duplicate of another item in terms of the operation it represents + * to the database.Each implementation can define what constitutes a duplicate based on its + * specific fields which are persisted. + */ + boolean isDatabaseDuplicateOf(BatchItem other); } diff --git a/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCL.java b/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCL.java index 7dd889c48b8747..6643a9de58562b 100644 --- a/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCL.java +++ b/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCL.java @@ -4,10 +4,12 @@ import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.MCLItem; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; import com.linkedin.mxe.MetadataChangeLog; +import java.util.Objects; import javax.annotation.Nonnull; import lombok.Builder; import lombok.Getter; @@ -29,4 +31,23 @@ public class TestMCL implements MCLItem { public String getAspectName() { return getAspectSpec().getName(); } + + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TestMCL testMCL = (TestMCL) o; + return Objects.equals(metadataChangeLog, testMCL.metadataChangeLog); + } + + @Override + public int hashCode() { + return Objects.hashCode(metadataChangeLog); + } } diff --git a/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCP.java b/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCP.java index e562390a959a74..5b714bdbf0b478 100644 --- a/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCP.java +++ b/entity-registry/src/testFixtures/java/com/linkedin/test/metadata/aspect/batch/TestMCP.java @@ -6,6 +6,7 @@ import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.DataTemplateUtil; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.ReadItem; @@ -21,6 +22,7 @@ import java.net.URISyntaxException; import java.util.Collection; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -140,4 +142,40 @@ public Map getHeaders() { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))) .orElse(headers); } + + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + TestMCP testMCP = (TestMCP) o; + return urn.equals(testMCP.urn) + && DataTemplateUtil.areEqual(recordTemplate, testMCP.recordTemplate) + && Objects.equals(systemAspect, testMCP.systemAspect) + && Objects.equals(previousSystemAspect, testMCP.previousSystemAspect) + && Objects.equals(auditStamp, testMCP.auditStamp) + && Objects.equals(changeType, testMCP.changeType) + && Objects.equals(metadataChangeProposal, testMCP.metadataChangeProposal); + } + + @Override + public int hashCode() { + int result = urn.hashCode(); + result = 31 * result + Objects.hashCode(recordTemplate); + result = 31 * result + Objects.hashCode(systemAspect); + result = 31 * result + Objects.hashCode(previousSystemAspect); + result = 31 * result + Objects.hashCode(auditStamp); + result = 31 * result + Objects.hashCode(changeType); + result = 31 * result + Objects.hashCode(metadataChangeProposal); + return result; + } } diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/EntityAspect.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/EntityAspect.java index 976db4133c0043..2b67d5e92f833c 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/EntityAspect.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/EntityAspect.java @@ -52,6 +52,26 @@ public class EntityAspect { private String createdFor; + @Override + public String toString() { + return "EntityAspect{" + + "urn='" + + urn + + '\'' + + ", aspect='" + + aspect + + '\'' + + ", version=" + + version + + ", metadata='" + + metadata + + '\'' + + ", systemMetadata='" + + systemMetadata + + '\'' + + '}'; + } + /** * Provide a typed EntityAspect without breaking the existing public contract with generic types. */ @@ -144,6 +164,11 @@ public EnvelopedAspect toEnvelopedAspects() { return envelopedAspect; } + @Override + public String toString() { + return entityAspect.toString(); + } + public static class EntitySystemAspectBuilder { private EntityAspect.EntitySystemAspect build() { diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java index c0d65640df2378..1af9fc1565a456 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImpl.java @@ -1,6 +1,7 @@ package com.linkedin.metadata.entity.ebean.batch; import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; @@ -15,7 +16,9 @@ import com.linkedin.metadata.models.EntitySpec; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.util.Pair; +import java.util.ArrayList; import java.util.Collection; +import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; @@ -29,12 +32,23 @@ import lombok.extern.slf4j.Slf4j; @Slf4j -@Getter @Builder(toBuilder = true) public class AspectsBatchImpl implements AspectsBatch { @Nonnull private final Collection items; - @Nonnull private final RetrieverContext retrieverContext; + @Nonnull private final Collection nonRepeatedItems; + @Getter @Nonnull private final RetrieverContext retrieverContext; + + @Override + @Nonnull + public Collection getItems() { + return nonRepeatedItems; + } + + @Override + public Collection getInitialItems() { + return items; + } /** * Convert patches to upserts, apply hooks at the aspect and batch level. @@ -207,14 +221,32 @@ public AspectsBatchImplBuilder mcps( return this; } + private static List filterRepeats(Collection items) { + List result = new ArrayList<>(); + Map, T> last = new HashMap<>(); + + for (T item : items) { + Pair urnAspect = Pair.of(item.getUrn(), item.getAspectName()); + // Check if this item is a duplicate of the previous + if (!last.containsKey(urnAspect) || !item.isDatabaseDuplicateOf(last.get(urnAspect))) { + result.add(item); + } + last.put(urnAspect, item); + } + + return result; + } + public AspectsBatchImpl build() { + this.nonRepeatedItems = filterRepeats(this.items); + ValidationExceptionCollection exceptions = - AspectsBatch.validateProposed(this.items, this.retrieverContext); + AspectsBatch.validateProposed(this.nonRepeatedItems, this.retrieverContext); if (!exceptions.isEmpty()) { throw new IllegalArgumentException("Failed to validate MCP due to: " + exceptions); } - return new AspectsBatchImpl(this.items, this.retrieverContext); + return new AspectsBatchImpl(this.items, this.nonRepeatedItems, this.retrieverContext); } } diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImpl.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImpl.java index 6f45a36d1daf46..64263859e4aadb 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImpl.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImpl.java @@ -3,11 +3,13 @@ import com.datahub.util.exception.ModelConversionException; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.DataTemplateUtil; import com.linkedin.data.template.RecordTemplate; import com.linkedin.data.template.StringMap; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; import com.linkedin.metadata.aspect.SystemAspect; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.ChangeMCP; import com.linkedin.metadata.aspect.batch.MCPItem; import com.linkedin.metadata.aspect.patch.template.common.GenericPatchTemplate; @@ -269,6 +271,11 @@ private static RecordTemplate convertToRecordTemplate( } } + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -280,13 +287,15 @@ public boolean equals(Object o) { ChangeItemImpl that = (ChangeItemImpl) o; return urn.equals(that.urn) && aspectName.equals(that.aspectName) + && changeType.equals(that.changeType) && Objects.equals(systemMetadata, that.systemMetadata) - && recordTemplate.equals(that.recordTemplate); + && Objects.equals(auditStamp, that.auditStamp) + && DataTemplateUtil.areEqual(recordTemplate, that.recordTemplate); } @Override public int hashCode() { - return Objects.hash(urn, aspectName, systemMetadata, recordTemplate); + return Objects.hash(urn, aspectName, changeType, systemMetadata, auditStamp, recordTemplate); } @Override diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/DeleteItemImpl.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/DeleteItemImpl.java index 9c1ded284fa0bd..40bcb0fa8ed2d1 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/DeleteItemImpl.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/DeleteItemImpl.java @@ -6,6 +6,7 @@ import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; import com.linkedin.metadata.aspect.SystemAspect; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.ChangeMCP; import com.linkedin.metadata.entity.EntityApiUtils; import com.linkedin.metadata.entity.EntityAspect; @@ -115,6 +116,11 @@ public DeleteItemImpl build(AspectRetriever aspectRetriever) { } } + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLItemImpl.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLItemImpl.java index a5afd4651ed2c4..85923a28a64be5 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLItemImpl.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/MCLItemImpl.java @@ -5,6 +5,7 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.MCLItem; import com.linkedin.metadata.aspect.batch.MCPItem; import com.linkedin.metadata.entity.AspectUtils; @@ -158,6 +159,11 @@ private static Pair convertToRecordTemplate( } } + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/PatchItemImpl.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/PatchItemImpl.java index ec0a8422e3c4a2..2543d99ac6af37 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/PatchItemImpl.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/PatchItemImpl.java @@ -14,6 +14,7 @@ import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; import com.linkedin.metadata.aspect.AspectRetriever; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.MCPItem; import com.linkedin.metadata.aspect.batch.PatchMCP; import com.linkedin.metadata.aspect.patch.template.AspectTemplateEngine; @@ -216,6 +217,11 @@ public static JsonPatch convertToJsonPatch(MetadataChangeProposal mcp) { } } + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -228,12 +234,13 @@ public boolean equals(Object o) { return urn.equals(that.urn) && aspectName.equals(that.aspectName) && Objects.equals(systemMetadata, that.systemMetadata) + && auditStamp.equals(that.auditStamp) && patch.equals(that.patch); } @Override public int hashCode() { - return Objects.hash(urn, aspectName, systemMetadata, patch); + return Objects.hash(urn, aspectName, systemMetadata, auditStamp, patch); } @Override diff --git a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ProposedItem.java b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ProposedItem.java index 88187ef159f233..370f1f6f073e65 100644 --- a/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ProposedItem.java +++ b/metadata-io/metadata-io-api/src/main/java/com/linkedin/metadata/entity/ebean/batch/ProposedItem.java @@ -4,6 +4,7 @@ import com.linkedin.common.urn.Urn; import com.linkedin.data.template.RecordTemplate; import com.linkedin.events.metadata.ChangeType; +import com.linkedin.metadata.aspect.batch.BatchItem; import com.linkedin.metadata.aspect.batch.MCPItem; import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.EntitySpec; @@ -86,6 +87,32 @@ public ChangeType getChangeType() { return metadataChangeProposal.getChangeType(); } + @Override + public boolean isDatabaseDuplicateOf(BatchItem other) { + return equals(other); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + ProposedItem that = (ProposedItem) o; + return metadataChangeProposal.equals(that.metadataChangeProposal) + && auditStamp.equals(that.auditStamp); + } + + @Override + public int hashCode() { + int result = metadataChangeProposal.hashCode(); + result = 31 * result + auditStamp.hashCode(); + return result; + } + public static class ProposedItemBuilder { public ProposedItem build() { // Ensure systemMetadata diff --git a/metadata-io/metadata-io-api/src/test/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImplTest.java b/metadata-io/metadata-io-api/src/test/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImplTest.java index 96f535f2295aa4..9f57d36f800de3 100644 --- a/metadata-io/metadata-io-api/src/test/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImplTest.java +++ b/metadata-io/metadata-io-api/src/test/java/com/linkedin/metadata/entity/ebean/batch/AspectsBatchImplTest.java @@ -6,6 +6,7 @@ import static org.testng.Assert.assertEquals; import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; import com.linkedin.common.FabricType; import com.linkedin.common.Status; import com.linkedin.common.urn.DataPlatformUrn; @@ -220,6 +221,7 @@ public void toUpsertBatchItemsPatchItemTest() { @Test public void toUpsertBatchItemsProposedItemTest() { + AuditStamp auditStamp = AuditStampUtils.createDefaultAuditStamp(); List testItems = List.of( ProposedItem.builder() @@ -239,7 +241,7 @@ public void toUpsertBatchItemsProposedItemTest() { ByteString.copyString( "{\"foo\":\"bar\"}", StandardCharsets.UTF_8))) .setSystemMetadata(new SystemMetadata())) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .auditStamp(auditStamp) .build(), ProposedItem.builder() .entitySpec(testRegistry.getEntitySpec(DATASET_ENTITY_NAME)) @@ -258,7 +260,7 @@ public void toUpsertBatchItemsProposedItemTest() { ByteString.copyString( "{\"foo\":\"bar\"}", StandardCharsets.UTF_8))) .setSystemMetadata(new SystemMetadata())) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .auditStamp(auditStamp) .build()); AspectsBatchImpl testBatch = @@ -280,7 +282,7 @@ public void toUpsertBatchItemsProposedItemTest() { testRegistry .getEntitySpec(DATASET_ENTITY_NAME) .getAspectSpec(STATUS_ASPECT_NAME)) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .auditStamp(auditStamp) .systemMetadata(testItems.get(0).getSystemMetadata().setVersion("1")) .recordTemplate(new Status().setRemoved(false)) .build(mockAspectRetriever), @@ -295,7 +297,7 @@ public void toUpsertBatchItemsProposedItemTest() { testRegistry .getEntitySpec(DATASET_ENTITY_NAME) .getAspectSpec(STATUS_ASPECT_NAME)) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .auditStamp(auditStamp) .systemMetadata(testItems.get(1).getSystemMetadata().setVersion("1")) .recordTemplate(new Status().setRemoved(false)) .build(mockAspectRetriever))), diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index bf3481205fb5ab..059a6b7ed0aea3 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -854,7 +854,7 @@ private List ingestAspectsToLocalDB( boolean overwrite) { if (inputBatch.containsDuplicateAspects()) { - log.warn(String.format("Batch contains duplicates: %s", inputBatch)); + log.warn("Batch contains duplicates: {}", inputBatch.duplicateAspects()); MetricUtils.counter(EntityServiceImpl.class, "batch_with_duplicate").inc(); } @@ -968,39 +968,20 @@ private List ingestAspectsToLocalDB( writeItem.getAspectSpec(), databaseAspect); - final UpdateAspectResult result; /* This condition is specifically for an older conditional write ingestAspectIfNotPresent() overwrite is always true otherwise */ if (overwrite || databaseAspect == null) { - result = - Optional.ofNullable( - ingestAspectToLocalDB( - txContext, writeItem, databaseSystemAspect)) - .map( - optResult -> - optResult.toBuilder().request(writeItem).build()) - .orElse(null); - - } else { - RecordTemplate oldValue = databaseSystemAspect.getRecordTemplate(); - SystemMetadata oldMetadata = databaseSystemAspect.getSystemMetadata(); - result = - UpdateAspectResult.builder() - .urn(writeItem.getUrn()) - .request(writeItem) - .oldValue(oldValue) - .newValue(oldValue) - .oldSystemMetadata(oldMetadata) - .newSystemMetadata(oldMetadata) - .operation(MetadataAuditOperation.UPDATE) - .auditStamp(writeItem.getAuditStamp()) - .maxVersion(databaseAspect.getVersion()) - .build(); + return Optional.ofNullable( + ingestAspectToLocalDB( + txContext, writeItem, databaseSystemAspect)) + .map( + optResult -> optResult.toBuilder().request(writeItem).build()) + .orElse(null); } - return result; + return null; }) .filter(Objects::nonNull) .collect(Collectors.toList()); @@ -1051,7 +1032,8 @@ This condition is specifically for an older conditional write ingestAspectIfNotP } } else { MetricUtils.counter(EntityServiceImpl.class, "batch_empty_transaction").inc(); - log.warn("Empty transaction detected. {}", inputBatch); + // This includes no-op batches. i.e. patch removing non-existent items + log.debug("Empty transaction detected"); } return upsertResults; @@ -1150,7 +1132,7 @@ public RecordTemplate ingestAspectIfNotPresent( .build(); List ingested = ingestAspects(opContext, aspectsBatch, true, false); - return ingested.stream().findFirst().get().getNewValue(); + return ingested.stream().findFirst().map(UpdateAspectResult::getNewValue).orElse(null); } /** @@ -2525,6 +2507,14 @@ private UpdateAspectResult ingestAspectToLocalDB( @Nonnull final ChangeMCP writeItem, @Nullable final EntityAspect.EntitySystemAspect databaseAspect) { + if (writeItem.getRecordTemplate() == null) { + log.error( + "Unexpected write of null aspect with name {}, urn {}", + writeItem.getAspectName(), + writeItem.getUrn()); + return null; + } + // Set the "last run id" to be the run id provided with the new system metadata. This will be // stored in index // for all aspects that have a run id, regardless of whether they change. @@ -2533,9 +2523,6 @@ private UpdateAspectResult ingestAspectToLocalDB( .setLastRunId(writeItem.getSystemMetadata().getRunId(GetMode.NULL), SetMode.IGNORE_NULL); // 2. Compare the latest existing and new. - final RecordTemplate databaseValue = - databaseAspect == null ? null : databaseAspect.getRecordTemplate(); - final EntityAspect.EntitySystemAspect previousBatchAspect = (EntityAspect.EntitySystemAspect) writeItem.getPreviousSystemAspect(); final RecordTemplate previousValue = @@ -2544,45 +2531,86 @@ private UpdateAspectResult ingestAspectToLocalDB( // 3. If there is no difference between existing and new, we just update // the lastObserved in system metadata. RunId should stay as the original runId if (previousValue != null - && DataTemplateUtil.areEqual(databaseValue, writeItem.getRecordTemplate())) { + && DataTemplateUtil.areEqual(previousValue, writeItem.getRecordTemplate())) { - SystemMetadata latestSystemMetadata = previousBatchAspect.getSystemMetadata(); - latestSystemMetadata.setLastObserved(writeItem.getSystemMetadata().getLastObserved()); - latestSystemMetadata.setLastRunId( - writeItem.getSystemMetadata().getLastRunId(GetMode.NULL), SetMode.IGNORE_NULL); - - previousBatchAspect - .getEntityAspect() - .setSystemMetadata(RecordUtils.toJsonString(latestSystemMetadata)); - - log.info( - "Ingesting aspect with name {}, urn {}", - previousBatchAspect.getAspectName(), - previousBatchAspect.getUrn()); - aspectDao.saveAspect(txContext, previousBatchAspect.getEntityAspect(), false); - - // metrics - aspectDao.incrementWriteMetrics( - previousBatchAspect.getAspectName(), - 1, - previousBatchAspect.getMetadataRaw().getBytes(StandardCharsets.UTF_8).length); + Optional latestSystemMetadataDiff = + systemMetadataDiff( + txContext, + previousBatchAspect.getSystemMetadata(), + writeItem.getSystemMetadata(), + databaseAspect == null ? null : databaseAspect.getSystemMetadata()); + + if (latestSystemMetadataDiff.isPresent()) { + // Update previous version since that is what is re-written + previousBatchAspect + .getEntityAspect() + .setSystemMetadata(RecordUtils.toJsonString(latestSystemMetadataDiff.get())); + + // Inserts & update order is not guaranteed, flush the insert for potential updates within + // same tx + if (databaseAspect == null && txContext != null) { + conditionalLogLevel( + txContext, + String.format( + "Flushing for systemMetadata update aspect with name %s, urn %s", + writeItem.getAspectName(), writeItem.getUrn())); + txContext.flush(); + } - return UpdateAspectResult.builder() - .urn(writeItem.getUrn()) - .oldValue(previousValue) - .newValue(previousValue) - .oldSystemMetadata(previousBatchAspect.getSystemMetadata()) - .newSystemMetadata(latestSystemMetadata) - .operation(MetadataAuditOperation.UPDATE) - .auditStamp(writeItem.getAuditStamp()) - .maxVersion(0) - .build(); + conditionalLogLevel( + txContext, + String.format( + "Update aspect with name %s, urn %s, txContext: %s, databaseAspect: %s, newAspect: %s", + previousBatchAspect.getAspectName(), + previousBatchAspect.getUrn(), + txContext != null, + databaseAspect == null ? null : databaseAspect.getEntityAspect(), + previousBatchAspect.getEntityAspect())); + aspectDao.saveAspect(txContext, previousBatchAspect.getEntityAspect(), false); + + // metrics + aspectDao.incrementWriteMetrics( + previousBatchAspect.getAspectName(), + 1, + previousBatchAspect.getMetadataRaw().getBytes(StandardCharsets.UTF_8).length); + + return UpdateAspectResult.builder() + .urn(writeItem.getUrn()) + .oldValue(previousValue) + .newValue(previousValue) + .oldSystemMetadata(previousBatchAspect.getSystemMetadata()) + .newSystemMetadata(latestSystemMetadataDiff.get()) + .operation(MetadataAuditOperation.UPDATE) + .auditStamp(writeItem.getAuditStamp()) + .maxVersion(0) + .build(); + } else { + MetricUtils.counter(EntityServiceImpl.class, "batch_with_noop_sysmetadata").inc(); + return null; + } } // 4. Save the newValue as the latest version - if (!DataTemplateUtil.areEqual(databaseValue, writeItem.getRecordTemplate())) { - log.debug( - "Ingesting aspect with name {}, urn {}", writeItem.getAspectName(), writeItem.getUrn()); + if (writeItem.getRecordTemplate() != null + && !DataTemplateUtil.areEqual(previousValue, writeItem.getRecordTemplate())) { + conditionalLogLevel( + txContext, + String.format( + "Insert aspect with name %s, urn %s", writeItem.getAspectName(), writeItem.getUrn())); + + // Inserts & update order is not guaranteed, flush the insert for potential updates within + // same tx + if (databaseAspect == null + && !ASPECT_LATEST_VERSION.equals(writeItem.getNextAspectVersion()) + && txContext != null) { + conditionalLogLevel( + txContext, + String.format( + "Flushing for update aspect with name %s, urn %s", + writeItem.getAspectName(), writeItem.getUrn())); + txContext.flush(); + } + String newValueStr = EntityApiUtils.toJsonAspect(writeItem.getRecordTemplate()); long versionOfOld = aspectDao.saveLatestAspect( @@ -2630,4 +2658,41 @@ private static boolean shouldAspectEmitChangeLog(@Nonnull final AspectSpec aspec aspectSpec.getRelationshipFieldSpecs(); return relationshipFieldSpecs.stream().anyMatch(RelationshipFieldSpec::isLineageRelationship); } + + private static Optional systemMetadataDiff( + @Nullable TransactionContext txContext, + @Nullable SystemMetadata previous, + @Nonnull SystemMetadata current, + @Nullable SystemMetadata database) { + + SystemMetadata latestSystemMetadata = GenericRecordUtils.copy(previous, SystemMetadata.class); + + latestSystemMetadata.setLastRunId(previous.getRunId(), SetMode.REMOVE_IF_NULL); + latestSystemMetadata.setLastObserved(current.getLastObserved(), SetMode.IGNORE_NULL); + latestSystemMetadata.setRunId(current.getRunId(), SetMode.REMOVE_IF_NULL); + + if (!DataTemplateUtil.areEqual(latestSystemMetadata, previous) + && !DataTemplateUtil.areEqual(latestSystemMetadata, database)) { + + conditionalLogLevel( + txContext, + String.format( + "systemMetdataDiff: %s != %s AND %s", + RecordUtils.toJsonString(latestSystemMetadata), + previous == null ? null : RecordUtils.toJsonString(previous), + database == null ? null : RecordUtils.toJsonString(database))); + + return Optional.of(latestSystemMetadata); + } + + return Optional.empty(); + } + + private static void conditionalLogLevel(@Nullable TransactionContext txContext, String message) { + if (txContext != null && txContext.getFailedAttempts() > 1) { + log.warn(message); + } else { + log.debug(message); + } + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/TransactionContext.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/TransactionContext.java index 69f2f1c8981c03..6897c9152e9a25 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/TransactionContext.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/TransactionContext.java @@ -66,4 +66,10 @@ public void commitAndContinue() { } success(); } + + public void flush() { + if (tx != null) { + tx.flush(); + } + } } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraAspectDao.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraAspectDao.java index 9e7387947a9547..a00482acda62e2 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraAspectDao.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/cassandra/CassandraAspectDao.java @@ -590,7 +590,7 @@ public long saveLatestAspect( // Save oldValue as the largest version + 1 long largestVersion = ASPECT_LATEST_VERSION; BatchStatement batch = BatchStatement.newInstance(BatchType.UNLOGGED); - if (oldAspectMetadata != null && oldTime != null) { + if (!ASPECT_LATEST_VERSION.equals(nextVersion) && oldTime != null) { largestVersion = nextVersion; final EntityAspect aspect = new EntityAspect( @@ -616,7 +616,7 @@ public long saveLatestAspect( newTime, newActor, newImpersonator); - batch = batch.add(generateSaveStatement(aspect, oldAspectMetadata == null)); + batch = batch.add(generateSaveStatement(aspect, ASPECT_LATEST_VERSION.equals(nextVersion))); _cqlSession.execute(batch); return largestVersion; } diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java index 6233bf5e0e35cf..729d0e61cb2c00 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/ebean/EbeanAspectDao.java @@ -165,7 +165,7 @@ public long saveLatestAspect( } // Save oldValue as the largest version + 1 long largestVersion = ASPECT_LATEST_VERSION; - if (oldAspectMetadata != null && oldTime != null) { + if (!ASPECT_LATEST_VERSION.equals(nextVersion) && oldTime != null) { largestVersion = nextVersion; saveAspect( txContext, @@ -191,7 +191,7 @@ public long saveLatestAspect( newTime, newSystemMetadata, ASPECT_LATEST_VERSION, - oldAspectMetadata == null); + ASPECT_LATEST_VERSION.equals(nextVersion)); return largestVersion; } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/AspectGenerationUtils.java b/metadata-io/src/test/java/com/linkedin/metadata/AspectGenerationUtils.java index 346a1eef845923..395c040f288111 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/AspectGenerationUtils.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/AspectGenerationUtils.java @@ -34,19 +34,19 @@ public static SystemMetadata createSystemMetadata() { } @Nonnull - public static SystemMetadata createSystemMetadata(long nextAspectVersion) { + public static SystemMetadata createSystemMetadata(int nextAspectVersion) { return createSystemMetadata( 1625792689, "run-123", "run-123", String.valueOf(nextAspectVersion)); } @Nonnull - public static SystemMetadata createSystemMetadata(long lastObserved, @Nonnull String runId) { + public static SystemMetadata createSystemMetadata(int lastObserved, @Nonnull String runId) { return createSystemMetadata(lastObserved, runId, runId, null); } @Nonnull public static SystemMetadata createSystemMetadata( - long lastObserved, + int lastObserved, // for test comparison must be int @Nonnull String runId, @Nonnull String lastRunId, @Nullable String version) { diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java index a1000fd02abfe1..aa42545fa0e46f 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EbeanEntityServiceTest.java @@ -1,10 +1,6 @@ package com.linkedin.metadata.entity; -import static com.linkedin.metadata.Constants.APP_SOURCE; import static com.linkedin.metadata.Constants.CORP_USER_ENTITY_NAME; -import static com.linkedin.metadata.Constants.DATASET_ENTITY_NAME; -import static com.linkedin.metadata.Constants.GLOBAL_TAGS_ASPECT_NAME; -import static com.linkedin.metadata.Constants.METADATA_TESTS_SOURCE; import static com.linkedin.metadata.Constants.STATUS_ASPECT_NAME; import static org.mockito.Mockito.mock; import static org.testng.Assert.assertEquals; @@ -12,36 +8,27 @@ import static org.testng.Assert.assertTrue; import com.linkedin.common.AuditStamp; -import com.linkedin.common.GlobalTags; import com.linkedin.common.Status; -import com.linkedin.common.TagAssociation; -import com.linkedin.common.TagAssociationArray; -import com.linkedin.common.urn.TagUrn; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.data.template.DataTemplateUtil; import com.linkedin.data.template.RecordTemplate; -import com.linkedin.data.template.StringMap; import com.linkedin.entity.EnvelopedAspect; import com.linkedin.identity.CorpUserInfo; import com.linkedin.metadata.AspectGenerationUtils; import com.linkedin.metadata.Constants; import com.linkedin.metadata.EbeanTestUtils; -import com.linkedin.metadata.aspect.patch.GenericJsonPatch; -import com.linkedin.metadata.aspect.patch.PatchOperationType; import com.linkedin.metadata.config.EbeanConfiguration; import com.linkedin.metadata.config.PreProcessHooks; import com.linkedin.metadata.entity.ebean.EbeanAspectDao; import com.linkedin.metadata.entity.ebean.EbeanRetentionService; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.entity.ebean.batch.ChangeItemImpl; -import com.linkedin.metadata.entity.ebean.batch.PatchItemImpl; import com.linkedin.metadata.event.EventProducer; import com.linkedin.metadata.key.CorpUserKey; import com.linkedin.metadata.models.registry.EntityRegistryException; import com.linkedin.metadata.query.ListUrnsResult; import com.linkedin.metadata.service.UpdateIndicesService; -import com.linkedin.metadata.utils.AuditStampUtils; import com.linkedin.metadata.utils.PegasusUtils; import com.linkedin.mxe.MetadataChangeProposal; import com.linkedin.mxe.SystemMetadata; @@ -64,7 +51,6 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.stream.Collectors; import java.util.stream.IntStream; -import java.util.stream.Stream; import org.apache.commons.lang3.tuple.Triple; import org.testng.Assert; import org.testng.annotations.BeforeMethod; @@ -396,360 +382,6 @@ public void testSystemMetadataDuplicateKey() throws Exception { "Expected version 0 with systemMeta version 3 accounting for the the collision"); } - @Test - public void testBatchDuplicate() throws Exception { - Urn entityUrn = UrnUtils.getUrn("urn:li:corpuser:batchDuplicateTest"); - SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); - ChangeItemImpl item1 = - ChangeItemImpl.builder() - .urn(entityUrn) - .aspectName(STATUS_ASPECT_NAME) - .recordTemplate(new Status().setRemoved(true)) - .systemMetadata(systemMetadata.copy()) - .auditStamp(TEST_AUDIT_STAMP) - .build(TestOperationContexts.emptyAspectRetriever(null)); - ChangeItemImpl item2 = - ChangeItemImpl.builder() - .urn(entityUrn) - .aspectName(STATUS_ASPECT_NAME) - .recordTemplate(new Status().setRemoved(false)) - .systemMetadata(systemMetadata.copy()) - .auditStamp(TEST_AUDIT_STAMP) - .build(TestOperationContexts.emptyAspectRetriever(null)); - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(item1, item2)) - .build(), - false, - true); - - // List aspects urns - ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 2); - - assertEquals(batch.getStart().intValue(), 0); - assertEquals(batch.getCount().intValue(), 1); - assertEquals(batch.getTotal().intValue(), 1); - assertEquals(batch.getEntities().size(), 1); - assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); - - EnvelopedAspect envelopedAspect = - _entityServiceImpl.getLatestEnvelopedAspect( - opContext, CORP_USER_ENTITY_NAME, entityUrn, STATUS_ASPECT_NAME); - assertEquals( - envelopedAspect.getSystemMetadata().getVersion(), - "2", - "Expected version 2 accounting for duplicates"); - assertEquals( - envelopedAspect.getValue().toString(), - "{removed=false}", - "Expected 2nd item to be the latest"); - } - - @Test - public void testBatchPatchWithTrailingNoOp() throws Exception { - Urn entityUrn = - UrnUtils.getUrn( - "urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchWithTrailingNoOp,PROD)"); - TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); - Urn tag2 = UrnUtils.getUrn("urn:li:tag:tag2"); - Urn tagOther = UrnUtils.getUrn("urn:li:tag:other"); - - SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); - - ChangeItemImpl initialAspectTag1 = - ChangeItemImpl.builder() - .urn(entityUrn) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .recordTemplate( - new GlobalTags() - .setTags(new TagAssociationArray(new TagAssociation().setTag(tag1)))) - .systemMetadata(systemMetadata.copy()) - .auditStamp(TEST_AUDIT_STAMP) - .build(TestOperationContexts.emptyAspectRetriever(null)); - - PatchItemImpl patchAdd2 = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) - .build() - .getJsonPatch()) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - PatchItemImpl patchRemoveNonExistent = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.REMOVE, tagOther))) - .build() - .getJsonPatch()) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - // establish base entity - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(initialAspectTag1)) - .build(), - false, - true); - - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(patchAdd2, patchRemoveNonExistent)) - .build(), - false, - true); - - // List aspects urns - ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); - - assertEquals(batch.getStart().intValue(), 0); - assertEquals(batch.getCount().intValue(), 1); - assertEquals(batch.getTotal().intValue(), 1); - assertEquals(batch.getEntities().size(), 1); - assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); - - EnvelopedAspect envelopedAspect = - _entityServiceImpl.getLatestEnvelopedAspect( - opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); - assertEquals( - envelopedAspect.getSystemMetadata().getVersion(), - "3", - "Expected version 3. 1 - Initial, + 1 add, 1 remove"); - assertEquals( - new GlobalTags(envelopedAspect.getValue().data()) - .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), - Set.of(tag1, tag2), - "Expected both tags"); - } - - @Test - public void testBatchPatchAdd() throws Exception { - Urn entityUrn = - UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchAdd,PROD)"); - TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); - TagUrn tag2 = TagUrn.createFromString("urn:li:tag:tag2"); - TagUrn tag3 = TagUrn.createFromString("urn:li:tag:tag3"); - - SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); - - ChangeItemImpl initialAspectTag1 = - ChangeItemImpl.builder() - .urn(entityUrn) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .recordTemplate( - new GlobalTags() - .setTags(new TagAssociationArray(new TagAssociation().setTag(tag1)))) - .systemMetadata(systemMetadata.copy()) - .auditStamp(TEST_AUDIT_STAMP) - .build(TestOperationContexts.emptyAspectRetriever(null)); - - PatchItemImpl patchAdd3 = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag3))) - .build() - .getJsonPatch()) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - PatchItemImpl patchAdd2 = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) - .build() - .getJsonPatch()) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - PatchItemImpl patchAdd1 = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag1))) - .build() - .getJsonPatch()) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - // establish base entity - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(initialAspectTag1)) - .build(), - false, - true); - - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(patchAdd3, patchAdd2, patchAdd1)) - .build(), - false, - true); - - // List aspects urns - ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); - - assertEquals(batch.getStart().intValue(), 0); - assertEquals(batch.getCount().intValue(), 1); - assertEquals(batch.getTotal().intValue(), 1); - assertEquals(batch.getEntities().size(), 1); - assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); - - EnvelopedAspect envelopedAspect = - _entityServiceImpl.getLatestEnvelopedAspect( - opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); - assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "4", "Expected version 4"); - assertEquals( - new GlobalTags(envelopedAspect.getValue().data()) - .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), - Set.of(tag1, tag2, tag3), - "Expected all tags"); - } - - @Test - public void testBatchPatchAddDuplicate() throws Exception { - Urn entityUrn = - UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchAdd,PROD)"); - List initialTags = - List.of( - TagUrn.createFromString("urn:li:tag:__default_large_table"), - TagUrn.createFromString("urn:li:tag:__default_low_queries"), - TagUrn.createFromString("urn:li:tag:__default_low_changes"), - TagUrn.createFromString("urn:li:tag:!10TB+ tables")) - .stream() - .map(tag -> new TagAssociation().setTag(tag)) - .collect(Collectors.toList()); - TagUrn tag2 = TagUrn.createFromString("urn:li:tag:$ 1TB+"); - - SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); - - SystemMetadata patchSystemMetadata = new SystemMetadata(); - patchSystemMetadata.setLastObserved(systemMetadata.getLastObserved() + 1); - patchSystemMetadata.setProperties(new StringMap(Map.of(APP_SOURCE, METADATA_TESTS_SOURCE))); - - ChangeItemImpl initialAspectTag1 = - ChangeItemImpl.builder() - .urn(entityUrn) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .recordTemplate(new GlobalTags().setTags(new TagAssociationArray(initialTags))) - .systemMetadata(systemMetadata.copy()) - .auditStamp(TEST_AUDIT_STAMP) - .build(TestOperationContexts.emptyAspectRetriever(null)); - - PatchItemImpl patchAdd2 = - PatchItemImpl.builder() - .urn(entityUrn) - .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) - .aspectName(GLOBAL_TAGS_ASPECT_NAME) - .aspectSpec( - _testEntityRegistry - .getEntitySpec(DATASET_ENTITY_NAME) - .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) - .patch( - GenericJsonPatch.builder() - .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) - .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) - .build() - .getJsonPatch()) - .systemMetadata(patchSystemMetadata) - .auditStamp(AuditStampUtils.createDefaultAuditStamp()) - .build(_testEntityRegistry); - - // establish base entity - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(initialAspectTag1)) - .build(), - false, - true); - - _entityServiceImpl.ingestAspects( - opContext, - AspectsBatchImpl.builder() - .retrieverContext(opContext.getRetrieverContext().get()) - .items(List.of(patchAdd2, patchAdd2)) // duplicate - .build(), - false, - true); - - // List aspects urns - ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); - - assertEquals(batch.getStart().intValue(), 0); - assertEquals(batch.getCount().intValue(), 1); - assertEquals(batch.getTotal().intValue(), 1); - assertEquals(batch.getEntities().size(), 1); - assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); - - EnvelopedAspect envelopedAspect = - _entityServiceImpl.getLatestEnvelopedAspect( - opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); - assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "3", "Expected version 3"); - assertEquals( - new GlobalTags(envelopedAspect.getValue().data()) - .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), - Stream.concat(initialTags.stream().map(TagAssociation::getTag), Stream.of(tag2)) - .collect(Collectors.toSet()), - "Expected all tags"); - } - @Test public void dataGeneratorThreadingTest() { DataGenerator dataGenerator = new DataGenerator(opContext, _entityServiceImpl); @@ -976,14 +608,4 @@ public void run() { } } } - - private static GenericJsonPatch.PatchOp tagPatchOp(PatchOperationType op, Urn tagUrn) { - GenericJsonPatch.PatchOp patchOp = new GenericJsonPatch.PatchOp(); - patchOp.setOp(op.getValue()); - patchOp.setPath(String.format("/tags/%s", tagUrn)); - if (PatchOperationType.ADD.equals(op)) { - patchOp.setValue(Map.of("tag", tagUrn.toString())); - } - return patchOp; - } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java index 654c448fdec946..18d277cacbbe26 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java @@ -11,14 +11,18 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import com.linkedin.common.AuditStamp; +import com.linkedin.common.GlobalTags; import com.linkedin.common.Owner; import com.linkedin.common.OwnerArray; import com.linkedin.common.Ownership; import com.linkedin.common.OwnershipType; import com.linkedin.common.Status; +import com.linkedin.common.TagAssociation; +import com.linkedin.common.TagAssociationArray; import com.linkedin.common.UrnArray; import com.linkedin.common.VersionedUrn; import com.linkedin.common.urn.CorpuserUrn; +import com.linkedin.common.urn.TagUrn; import com.linkedin.common.urn.TupleKey; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; @@ -42,8 +46,11 @@ import com.linkedin.metadata.aspect.CorpUserAspect; import com.linkedin.metadata.aspect.CorpUserAspectArray; import com.linkedin.metadata.aspect.VersionedAspect; +import com.linkedin.metadata.aspect.patch.GenericJsonPatch; +import com.linkedin.metadata.aspect.patch.PatchOperationType; import com.linkedin.metadata.entity.ebean.batch.AspectsBatchImpl; import com.linkedin.metadata.entity.ebean.batch.ChangeItemImpl; +import com.linkedin.metadata.entity.ebean.batch.PatchItemImpl; import com.linkedin.metadata.entity.restoreindices.RestoreIndicesArgs; import com.linkedin.metadata.entity.validation.ValidationApiUtils; import com.linkedin.metadata.entity.validation.ValidationException; @@ -52,10 +59,12 @@ import com.linkedin.metadata.models.AspectSpec; import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.metadata.models.registry.EntityRegistryException; +import com.linkedin.metadata.query.ListUrnsResult; import com.linkedin.metadata.run.AspectRowSummary; import com.linkedin.metadata.service.UpdateIndicesService; import com.linkedin.metadata.snapshot.CorpUserSnapshot; import com.linkedin.metadata.snapshot.Snapshot; +import com.linkedin.metadata.utils.AuditStampUtils; import com.linkedin.metadata.utils.EntityKeyUtils; import com.linkedin.metadata.utils.GenericRecordUtils; import com.linkedin.mxe.GenericAspect; @@ -605,6 +614,9 @@ public void testReingestLineageAspect() throws Exception { entityUrn, _testEntityRegistry.getEntitySpec(entityUrn.getEntityType()).getKeyAspectSpec()))); + SystemMetadata futureSystemMetadata = AspectGenerationUtils.createSystemMetadata(1); + futureSystemMetadata.setLastObserved(futureSystemMetadata.getLastObserved() + 1); + final MetadataChangeLog restateChangeLog = new MetadataChangeLog(); restateChangeLog.setEntityType(entityUrn.getEntityType()); restateChangeLog.setEntityUrn(entityUrn); @@ -612,10 +624,10 @@ public void testReingestLineageAspect() throws Exception { restateChangeLog.setAspectName(aspectName1); restateChangeLog.setCreated(TEST_AUDIT_STAMP); restateChangeLog.setAspect(aspect); - restateChangeLog.setSystemMetadata(AspectGenerationUtils.createSystemMetadata(1)); + restateChangeLog.setSystemMetadata(futureSystemMetadata); restateChangeLog.setPreviousAspectValue(aspect); restateChangeLog.setPreviousSystemMetadata( - simulatePullFromDB(initialSystemMetadata, SystemMetadata.class)); + simulatePullFromDB(futureSystemMetadata, SystemMetadata.class)); restateChangeLog.setEntityKeyAspect( GenericRecordUtils.serializeAspect( EntityKeyUtils.convertUrnToEntityKey( @@ -636,11 +648,7 @@ public void testReingestLineageAspect() throws Exception { clearInvocations(_mockProducer); _entityServiceImpl.ingestAspects( - opContext, - entityUrn, - pairToIngest, - TEST_AUDIT_STAMP, - AspectGenerationUtils.createSystemMetadata()); + opContext, entityUrn, pairToIngest, TEST_AUDIT_STAMP, futureSystemMetadata); verify(_mockProducer, times(1)) .produceMetadataChangeLog( @@ -682,6 +690,12 @@ public void testReingestLineageProposal() throws Exception { initialChangeLog.setAspect(genericAspect); initialChangeLog.setSystemMetadata(metadata1); + SystemMetadata futureSystemMetadata = AspectGenerationUtils.createSystemMetadata(1); + futureSystemMetadata.setLastObserved(futureSystemMetadata.getLastObserved() + 1); + + MetadataChangeProposal mcp2 = new MetadataChangeProposal(mcp1.data().copy()); + mcp2.getSystemMetadata().setLastObserved(futureSystemMetadata.getLastObserved()); + final MetadataChangeLog restateChangeLog = new MetadataChangeLog(); restateChangeLog.setEntityType(entityUrn.getEntityType()); restateChangeLog.setEntityUrn(entityUrn); @@ -689,9 +703,10 @@ public void testReingestLineageProposal() throws Exception { restateChangeLog.setAspectName(aspectName1); restateChangeLog.setCreated(TEST_AUDIT_STAMP); restateChangeLog.setAspect(genericAspect); - restateChangeLog.setSystemMetadata(AspectGenerationUtils.createSystemMetadata(1)); + restateChangeLog.setSystemMetadata(futureSystemMetadata); restateChangeLog.setPreviousAspectValue(genericAspect); - restateChangeLog.setPreviousSystemMetadata(simulatePullFromDB(metadata1, SystemMetadata.class)); + restateChangeLog.setPreviousSystemMetadata( + simulatePullFromDB(futureSystemMetadata, SystemMetadata.class)); Map latestAspects = _entityServiceImpl.getLatestAspectsForUrn( @@ -706,7 +721,7 @@ public void testReingestLineageProposal() throws Exception { // unless invocations are cleared clearInvocations(_mockProducer); - _entityServiceImpl.ingestProposal(opContext, mcp1, TEST_AUDIT_STAMP, false); + _entityServiceImpl.ingestProposal(opContext, mcp2, TEST_AUDIT_STAMP, false); verify(_mockProducer, times(1)) .produceMetadataChangeLog( @@ -1390,7 +1405,7 @@ public void testIngestSameAspect() throws AssertionError { SystemMetadata metadata1 = AspectGenerationUtils.createSystemMetadata(1625792689, "run-123"); SystemMetadata metadata2 = AspectGenerationUtils.createSystemMetadata(1635792689, "run-456"); SystemMetadata metadata3 = - AspectGenerationUtils.createSystemMetadata(1635792689, "run-123", "run-456", "1"); + AspectGenerationUtils.createSystemMetadata(1635792689, "run-456", "run-123", "1"); List items = List.of( @@ -1482,6 +1497,9 @@ public void testIngestSameAspect() throws AssertionError { assertTrue( DataTemplateUtil.areEqual( + EntityApiUtils.parseSystemMetadata(readAspectDao2.getSystemMetadata()), metadata3), + String.format( + "Expected %s == %s", EntityApiUtils.parseSystemMetadata(readAspectDao2.getSystemMetadata()), metadata3)); verify(_mockProducer, times(0)) @@ -2179,6 +2197,474 @@ public void testExists() throws Exception { Set.of(existentUrn, noStatusUrn, softDeletedUrn)); } + @Test + public void testBatchDuplicate() throws Exception { + Urn entityUrn = UrnUtils.getUrn("urn:li:corpuser:batchDuplicateTest"); + SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); + ChangeItemImpl item1 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(STATUS_ASPECT_NAME) + .recordTemplate(new Status().setRemoved(true)) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + ChangeItemImpl item2 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(STATUS_ASPECT_NAME) + .recordTemplate(new Status().setRemoved(false)) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(item1, item2)) + .build(), + false, + true); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 2); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, CORP_USER_ENTITY_NAME, entityUrn, STATUS_ASPECT_NAME); + assertEquals( + envelopedAspect.getSystemMetadata().getVersion(), + "2", + "Expected version 2 after accounting for sequential duplicates"); + assertEquals( + envelopedAspect.getValue().toString(), + "{removed=false}", + "Expected 2nd item to be the latest"); + } + + @Test + public void testBatchPatchWithTrailingNoOp() throws Exception { + Urn entityUrn = + UrnUtils.getUrn( + "urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchWithTrailingNoOp,PROD)"); + TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); + Urn tag2 = UrnUtils.getUrn("urn:li:tag:tag2"); + Urn tagOther = UrnUtils.getUrn("urn:li:tag:other"); + + SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); + + ChangeItemImpl initialAspectTag1 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .recordTemplate( + new GlobalTags() + .setTags(new TagAssociationArray(new TagAssociation().setTag(tag1)))) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + + PatchItemImpl patchAdd2 = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + PatchItemImpl patchRemoveNonExistent = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.REMOVE, tagOther))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + // establish base entity + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(initialAspectTag1)) + .build(), + false, + true); + + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(patchAdd2, patchRemoveNonExistent)) + .build(), + false, + true); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); + assertEquals( + envelopedAspect.getSystemMetadata().getVersion(), + "2", + "Expected version 3. 1 - Initial, + 1 add, 1 remove"); + assertEquals( + new GlobalTags(envelopedAspect.getValue().data()) + .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), + Set.of(tag1, tag2), + "Expected both tags"); + } + + @Test + public void testBatchPatchAdd() throws Exception { + Urn entityUrn = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchAdd,PROD)"); + TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); + TagUrn tag2 = TagUrn.createFromString("urn:li:tag:tag2"); + TagUrn tag3 = TagUrn.createFromString("urn:li:tag:tag3"); + + SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); + + ChangeItemImpl initialAspectTag1 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .recordTemplate( + new GlobalTags() + .setTags(new TagAssociationArray(new TagAssociation().setTag(tag1)))) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + + PatchItemImpl patchAdd3 = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag3))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + PatchItemImpl patchAdd2 = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + PatchItemImpl patchAdd1 = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag1))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + // establish base entity + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(initialAspectTag1)) + .build(), + false, + true); + + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(patchAdd3, patchAdd2, patchAdd1)) + .build(), + false, + true); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); + assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "3", "Expected version 4"); + assertEquals( + new GlobalTags(envelopedAspect.getValue().data()) + .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), + Set.of(tag1, tag2, tag3), + "Expected all tags"); + } + + @Test + public void testBatchPatchAddDuplicate() throws Exception { + Urn entityUrn = + UrnUtils.getUrn("urn:li:dataset:(urn:li:dataPlatform:snowflake,testBatchPatchAdd,PROD)"); + List initialTags = + List.of( + TagUrn.createFromString("urn:li:tag:__default_large_table"), + TagUrn.createFromString("urn:li:tag:__default_low_queries"), + TagUrn.createFromString("urn:li:tag:__default_low_changes"), + TagUrn.createFromString("urn:li:tag:!10TB+ tables")) + .stream() + .map(tag -> new TagAssociation().setTag(tag)) + .collect(Collectors.toList()); + TagUrn tag2 = TagUrn.createFromString("urn:li:tag:$ 1TB+"); + + SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); + + SystemMetadata patchSystemMetadata = new SystemMetadata(); + patchSystemMetadata.setLastObserved(systemMetadata.getLastObserved() + 1); + patchSystemMetadata.setProperties(new StringMap(Map.of(APP_SOURCE, METADATA_TESTS_SOURCE))); + + ChangeItemImpl initialAspectTag1 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .recordTemplate(new GlobalTags().setTags(new TagAssociationArray(initialTags))) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + + PatchItemImpl patchAdd2 = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag2))) + .build() + .getJsonPatch()) + .systemMetadata(patchSystemMetadata) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + // establish base entity + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(initialAspectTag1)) + .build(), + false, + true); + + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(patchAdd2, patchAdd2)) // duplicate + .build(), + false, + true); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); + assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "2", "Expected version 2"); + assertEquals( + new GlobalTags(envelopedAspect.getValue().data()) + .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), + Stream.concat(initialTags.stream().map(TagAssociation::getTag), Stream.of(tag2)) + .collect(Collectors.toSet()), + "Expected all tags"); + } + + @Test + public void testPatchRemoveNonExistent() throws Exception { + Urn entityUrn = + UrnUtils.getUrn( + "urn:li:dataset:(urn:li:dataPlatform:snowflake,testPatchRemoveNonExistent,PROD)"); + TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); + + PatchItemImpl patchRemove = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.REMOVE, tag1))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + List results = + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(patchRemove)) + .build(), + false, + true); + + assertEquals(results.size(), 4, "Expected default aspects + empty globalTags"); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); + assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "1", "Expected version 4"); + assertEquals( + new GlobalTags(envelopedAspect.getValue().data()) + .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), + Set.of(), + "Expected empty tags"); + } + + @Test + public void testPatchAddNonExistent() throws Exception { + Urn entityUrn = + UrnUtils.getUrn( + "urn:li:dataset:(urn:li:dataPlatform:snowflake,testPatchAddNonExistent,PROD)"); + TagUrn tag1 = TagUrn.createFromString("urn:li:tag:tag1"); + + PatchItemImpl patchAdd = + PatchItemImpl.builder() + .urn(entityUrn) + .entitySpec(_testEntityRegistry.getEntitySpec(DATASET_ENTITY_NAME)) + .aspectName(GLOBAL_TAGS_ASPECT_NAME) + .aspectSpec( + _testEntityRegistry + .getEntitySpec(DATASET_ENTITY_NAME) + .getAspectSpec(GLOBAL_TAGS_ASPECT_NAME)) + .patch( + GenericJsonPatch.builder() + .arrayPrimaryKeys(Map.of("properties", List.of("tag"))) + .patch(List.of(tagPatchOp(PatchOperationType.ADD, tag1))) + .build() + .getJsonPatch()) + .auditStamp(AuditStampUtils.createDefaultAuditStamp()) + .build(_testEntityRegistry); + + List results = + _entityServiceImpl.ingestAspects( + opContext, + AspectsBatchImpl.builder() + .retrieverContext(opContext.getRetrieverContext().get()) + .items(List.of(patchAdd)) + .build(), + false, + true); + + assertEquals(results.size(), 4, "Expected default aspects + globalTags"); + + // List aspects urns + ListUrnsResult batch = _entityServiceImpl.listUrns(opContext, entityUrn.getEntityType(), 0, 1); + + assertEquals(batch.getStart().intValue(), 0); + assertEquals(batch.getCount().intValue(), 1); + assertEquals(batch.getTotal().intValue(), 1); + assertEquals(batch.getEntities().size(), 1); + assertEquals(entityUrn.toString(), batch.getEntities().get(0).toString()); + + EnvelopedAspect envelopedAspect = + _entityServiceImpl.getLatestEnvelopedAspect( + opContext, DATASET_ENTITY_NAME, entityUrn, GLOBAL_TAGS_ASPECT_NAME); + assertEquals(envelopedAspect.getSystemMetadata().getVersion(), "1", "Expected version 4"); + assertEquals( + new GlobalTags(envelopedAspect.getValue().data()) + .getTags().stream().map(TagAssociation::getTag).collect(Collectors.toSet()), + Set.of(tag1), + "Expected all tags"); + } + @Nonnull protected com.linkedin.entity.Entity createCorpUserEntity(Urn entityUrn, String email) throws Exception { @@ -2210,4 +2696,14 @@ protected Pair getAspectRecor RecordUtils.toRecordTemplate(clazz, objectMapper.writeValueAsString(aspect)); return new Pair<>(AspectGenerationUtils.getAspectName(aspect), recordTemplate); } + + private static GenericJsonPatch.PatchOp tagPatchOp(PatchOperationType op, Urn tagUrn) { + GenericJsonPatch.PatchOp patchOp = new GenericJsonPatch.PatchOp(); + patchOp.setOp(op.getValue()); + patchOp.setPath(String.format("/tags/%s", tagUrn)); + if (PatchOperationType.ADD.equals(op)) { + patchOp.setValue(Map.of("tag", tagUrn.toString())); + } + return patchOp; + } } diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImplTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImplTest.java new file mode 100644 index 00000000000000..3f6b301e72aa5a --- /dev/null +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/ebean/batch/ChangeItemImplTest.java @@ -0,0 +1,41 @@ +package com.linkedin.metadata.entity.ebean.batch; + +import static com.linkedin.metadata.Constants.STATUS_ASPECT_NAME; +import static org.testng.Assert.assertFalse; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.Status; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.metadata.AspectGenerationUtils; +import com.linkedin.mxe.SystemMetadata; +import io.datahubproject.test.metadata.context.TestOperationContexts; +import org.testng.annotations.Test; + +public class ChangeItemImplTest { + private static final AuditStamp TEST_AUDIT_STAMP = AspectGenerationUtils.createAuditStamp(); + + @Test + public void testBatchDuplicate() throws Exception { + Urn entityUrn = UrnUtils.getUrn("urn:li:corpuser:batchDuplicateTest"); + SystemMetadata systemMetadata = AspectGenerationUtils.createSystemMetadata(); + ChangeItemImpl item1 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(STATUS_ASPECT_NAME) + .recordTemplate(new Status().setRemoved(true)) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + ChangeItemImpl item2 = + ChangeItemImpl.builder() + .urn(entityUrn) + .aspectName(STATUS_ASPECT_NAME) + .recordTemplate(new Status().setRemoved(false)) + .systemMetadata(systemMetadata.copy()) + .auditStamp(TEST_AUDIT_STAMP) + .build(TestOperationContexts.emptyAspectRetriever(null)); + + assertFalse(item1.isDatabaseDuplicateOf(item2)); + } +} diff --git a/metadata-io/src/test/java/com/linkedin/metadata/schemafields/sideeffects/SchemaFieldSideEffectTest.java b/metadata-io/src/test/java/com/linkedin/metadata/schemafields/sideeffects/SchemaFieldSideEffectTest.java index 6139776702c715..1661f5f02ee593 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/schemafields/sideeffects/SchemaFieldSideEffectTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/schemafields/sideeffects/SchemaFieldSideEffectTest.java @@ -151,7 +151,7 @@ public void schemaMetadataToSchemaFieldKeyTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)")) .aspectName(SCHEMA_FIELD_ALIASES_ASPECT) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -172,7 +172,7 @@ public void schemaMetadataToSchemaFieldKeyTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_name)")) .aspectName(SCHEMA_FIELD_ALIASES_ASPECT) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -248,7 +248,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)")) .aspectName(STATUS_ASPECT_NAME) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -263,7 +263,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_name)")) .aspectName(STATUS_ASPECT_NAME) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -324,7 +324,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)")) .aspectName(STATUS_ASPECT_NAME) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -339,7 +339,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_name)")) .aspectName(STATUS_ASPECT_NAME) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -354,7 +354,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_id)")) .aspectName(SCHEMA_FIELD_ALIASES_ASPECT) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY @@ -375,7 +375,7 @@ public void statusToSchemaFieldStatusTest() { UrnUtils.getUrn( "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,fct_users_created,PROD),user_name)")) .aspectName(SCHEMA_FIELD_ALIASES_ASPECT) - .changeType(changeType) + .changeType(ChangeType.UPSERT) .entitySpec(TEST_REGISTRY.getEntitySpec(SCHEMA_FIELD_ENTITY_NAME)) .aspectSpec( TEST_REGISTRY diff --git a/metadata-service/configuration/src/main/resources/application.yaml b/metadata-service/configuration/src/main/resources/application.yaml index 4945b36a251c26..15cd126408a7cc 100644 --- a/metadata-service/configuration/src/main/resources/application.yaml +++ b/metadata-service/configuration/src/main/resources/application.yaml @@ -159,7 +159,7 @@ ebean: autoCreateDdl: ${EBEAN_AUTOCREATE:false} postgresUseIamAuth: ${EBEAN_POSTGRES_USE_AWS_IAM_AUTH:false} locking: - enabled: ${EBEAN_LOCKING_ENABLED:true} + enabled: ${EBEAN_LOCKING_ENABLED:false} durationSeconds: ${EBEAN_LOCKING_DURATION_SECONDS:60} maximumLocks: ${EBEAN_LOCKING_MAXIMUM_LOCKS:20000} diff --git a/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java b/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java index fafca9b1139731..993edc44daeff1 100644 --- a/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java +++ b/metadata-utils/src/main/java/com/linkedin/metadata/utils/GenericRecordUtils.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.common.urn.Urn; import com.linkedin.data.ByteString; +import com.linkedin.data.DataMap; import com.linkedin.data.template.RecordTemplate; import com.linkedin.entity.Aspect; import com.linkedin.entity.EntityResponse; @@ -13,6 +14,8 @@ import com.linkedin.metadata.models.registry.EntityRegistry; import com.linkedin.mxe.GenericAspect; import com.linkedin.mxe.GenericPayload; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.nio.charset.StandardCharsets; import java.util.Map; import java.util.stream.Collectors; @@ -23,6 +26,22 @@ public class GenericRecordUtils { private GenericRecordUtils() {} + public static T copy(T input, Class clazz) { + try { + if (input == null) { + return null; + } + Constructor constructor = clazz.getConstructor(DataMap.class); + return constructor.newInstance(input.data().copy()); + } catch (CloneNotSupportedException + | InvocationTargetException + | NoSuchMethodException + | InstantiationException + | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + /** Deserialize the given value into the aspect based on the input aspectSpec */ @Nonnull public static RecordTemplate deserializeAspect( From 61fffb2a81eb958cedbca29f6cc53091efe00f0e Mon Sep 17 00:00:00 2001 From: Chakru <161002324+chakru-r@users.noreply.github.com> Date: Tue, 10 Dec 2024 19:57:11 +0530 Subject: [PATCH 04/18] build(coverage): rename python coverage reports (#12071) --- gradle/coverage/python-coverage.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/coverage/python-coverage.gradle b/gradle/coverage/python-coverage.gradle index 0ab921dfb21ffd..23d6e37387ed83 100644 --- a/gradle/coverage/python-coverage.gradle +++ b/gradle/coverage/python-coverage.gradle @@ -1,7 +1,7 @@ //coverage related args to be passed to pytest ext.get_coverage_args = { test_name = "" -> - def coverage_file_name = "pycov-${project.name}${test_name}.xml" + def coverage_file_name = "coverage-${project.name}${test_name}.xml" /* Tools that aggregate and analyse coverage tools search for the coverage result files. Keeping them under one folder From 57b12bd9cb9689638a34932b239540981f95fd6d Mon Sep 17 00:00:00 2001 From: sagar-salvi-apptware <159135491+sagar-salvi-apptware@users.noreply.github.com> Date: Tue, 10 Dec 2024 22:06:01 +0530 Subject: [PATCH 05/18] fix(ingest): replace sqllineage/sqlparse with our SQL parser (#12020) --- docs/how/updating-datahub.md | 23 ++- metadata-ingestion-modules/gx-plugin/setup.py | 12 +- .../gx-plugin/src/datahub_gx_plugin/action.py | 24 ++- .../docs/sources/redash/redash.md | 7 +- metadata-ingestion/setup.py | 23 +-- .../src/datahub/ingestion/source/mode.py | 23 --- .../src/datahub/ingestion/source/redash.py | 76 ++------- .../datahub/ingestion/source/unity/usage.py | 31 ++-- .../utilities/sql_lineage_parser_impl.py | 160 ------------------ .../src/datahub/utilities/sql_parser.py | 94 ---------- .../src/datahub/utilities/sql_parser_base.py | 21 --- .../tests/unit/test_redash_source.py | 6 +- .../tests/unit/utilities/test_utilities.py | 65 +++++-- 13 files changed, 130 insertions(+), 435 deletions(-) delete mode 100644 metadata-ingestion/src/datahub/utilities/sql_lineage_parser_impl.py delete mode 100644 metadata-ingestion/src/datahub/utilities/sql_parser.py delete mode 100644 metadata-ingestion/src/datahub/utilities/sql_parser_base.py diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index bcc89332cc1c1b..d8fe06abad6252 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -19,19 +19,21 @@ This file documents any backwards-incompatible changes in DataHub and assists pe ## Next - #11560 - The PowerBI ingestion source configuration option include_workspace_name_in_dataset_urn determines whether the workspace name is included in the PowerBI dataset's URN.
PowerBI allows to have identical name of semantic model and their tables across the workspace, It will overwrite the semantic model in-case of multi-workspace ingestion.
- Entity urn with `include_workspace_name_in_dataset_urn: false` - ``` - urn:li:dataset:(urn:li:dataPlatform:powerbi,[.].,) - ``` + Entity urn with `include_workspace_name_in_dataset_urn: false` - Entity urn with `include_workspace_name_in_dataset_urn: true` - ``` - urn:li:dataset:(urn:li:dataPlatform:powerbi,[.]...,) - ``` + ``` + urn:li:dataset:(urn:li:dataPlatform:powerbi,[.].,) + ``` + + Entity urn with `include_workspace_name_in_dataset_urn: true` + + ``` + urn:li:dataset:(urn:li:dataPlatform:powerbi,[.]...,) + ``` The config `include_workspace_name_in_dataset_urn` is default to `false` for backward compatiblity, However, we recommend enabling this flag after performing the necessary cleanup. If stateful ingestion is enabled, running ingestion with the latest CLI version will handle the cleanup automatically. Otherwise, we recommend soft deleting all powerbi data via the DataHub CLI: - `datahub delete --platform powerbi --soft` and then re-ingest with the latest CLI version, ensuring the `include_workspace_name_in_dataset_urn` configuration is set to true. + `datahub delete --platform powerbi --soft` and then re-ingest with the latest CLI version, ensuring the `include_workspace_name_in_dataset_urn` configuration is set to true. - #11701: The Fivetran `sources_to_database` field is deprecated in favor of setting directly within `sources_to_platform_instance..database`. - #11742: For PowerBi ingestion, `use_powerbi_email` is now enabled by default when extracting ownership information. @@ -48,6 +50,9 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - #11619 - schema field/column paths can no longer be duplicated within the schema - #11570 - The `DatahubClientConfig`'s server field no longer defaults to `http://localhost:8080`. Be sure to explicitly set this. - #11570 - If a `datahub_api` is explicitly passed to a stateful ingestion config provider, it will be used. We previously ignored it if the pipeline context also had a graph object. +- #11518 - DataHub Garbage Collection: Various entities that are soft-deleted (after 10d) or are timeseries _entities_ (dataprocess, execution requests) will be removed automatically using logic in the `datahub-gc` ingestion source. +- #12020 - Removed `sql_parser` configuration from the Redash source, as Redash now exclusively uses the sqlglot-based parser for lineage extraction. +- #12020 - Removed `datahub.utilities.sql_parser`, `datahub.utilities.sql_parser_base` and `datahub.utilities.sql_lineage_parser_impl` module along with `SqlLineageSQLParser` and `DefaultSQLParser`. Use `create_lineage_sql_parsed_result` from `datahub.sql_parsing.sqlglot_lineage` module instead. - #11518 - DataHub Garbage Collection: Various entities that are soft-deleted (after 10d) or are timeseries *entities* (dataprocess, execution requests) will be removed automatically using logic in the `datahub-gc` ingestion diff --git a/metadata-ingestion-modules/gx-plugin/setup.py b/metadata-ingestion-modules/gx-plugin/setup.py index e87bbded96584e..73d5d1a9a02f18 100644 --- a/metadata-ingestion-modules/gx-plugin/setup.py +++ b/metadata-ingestion-modules/gx-plugin/setup.py @@ -15,15 +15,6 @@ def get_long_description(): rest_common = {"requests", "requests_file"} -# TODO: Can we move away from sqllineage and use sqlglot ?? -sqllineage_lib = { - "sqllineage==1.3.8", - # We don't have a direct dependency on sqlparse but it is a dependency of sqllineage. - # There have previously been issues from not pinning sqlparse, so it's best to pin it. - # Related: https://github.com/reata/sqllineage/issues/361 and https://github.com/reata/sqllineage/pull/360 - "sqlparse==0.4.4", -} - _version: str = package_metadata["__version__"] _self_pin = ( f"=={_version}" @@ -43,8 +34,7 @@ def get_long_description(): # https://github.com/ipython/traitlets/issues/741 "traitlets<5.2.2", *rest_common, - *sqllineage_lib, - f"acryl-datahub[datahub-rest]{_self_pin}", + f"acryl-datahub[datahub-rest,sql-parser]{_self_pin}", } mypy_stubs = { diff --git a/metadata-ingestion-modules/gx-plugin/src/datahub_gx_plugin/action.py b/metadata-ingestion-modules/gx-plugin/src/datahub_gx_plugin/action.py index 2ad301a38d0028..2d89d26997d1f3 100644 --- a/metadata-ingestion-modules/gx-plugin/src/datahub_gx_plugin/action.py +++ b/metadata-ingestion-modules/gx-plugin/src/datahub_gx_plugin/action.py @@ -34,8 +34,9 @@ ) from datahub.metadata.com.linkedin.pegasus2avro.common import DataPlatformInstance from datahub.metadata.schema_classes import PartitionSpecClass, PartitionTypeClass +from datahub.sql_parsing.sqlglot_lineage import create_lineage_sql_parsed_result from datahub.utilities._markupsafe_compat import MARKUPSAFE_PATCHED -from datahub.utilities.sql_parser import DefaultSQLParser +from datahub.utilities.urns.dataset_urn import DatasetUrn from great_expectations.checkpoint.actions import ValidationAction from great_expectations.core.batch import Batch from great_expectations.core.batch_spec import ( @@ -677,10 +678,23 @@ def get_dataset_partitions(self, batch_identifier, data_asset): query=query, customProperties=batchSpecProperties, ) - try: - tables = DefaultSQLParser(query).get_tables() - except Exception as e: - logger.warning(f"Sql parser failed on {query} with {e}") + + data_platform = get_platform_from_sqlalchemy_uri(str(sqlalchemy_uri)) + sql_parser_in_tables = create_lineage_sql_parsed_result( + query=query, + platform=data_platform, + env=self.env, + platform_instance=None, + default_db=None, + ) + tables = [ + DatasetUrn.from_string(table_urn).name + for table_urn in sql_parser_in_tables.in_tables + ] + if sql_parser_in_tables.debug_info.table_error: + logger.warning( + f"Sql parser failed on {query} with {sql_parser_in_tables.debug_info.table_error}" + ) tables = [] if len(set(tables)) != 1: diff --git a/metadata-ingestion/docs/sources/redash/redash.md b/metadata-ingestion/docs/sources/redash/redash.md index 8f8c5c85496a09..f23a523cebc913 100644 --- a/metadata-ingestion/docs/sources/redash/redash.md +++ b/metadata-ingestion/docs/sources/redash/redash.md @@ -1,5 +1,2 @@ -Note! The integration can use an SQL parser to try to parse the tables the chart depends on. This parsing is disabled by default, -but can be enabled by setting `parse_table_names_from_sql: true`. The default parser is based on the [`sqllineage`](https://pypi.org/project/sqllineage/) package. -As this package doesn't officially support all the SQL dialects that Redash supports, the result might not be correct. You can, however, implement a -custom parser and take it into use by setting the `sql_parser` configuration value. A custom SQL parser must inherit from `datahub.utilities.sql_parser.SQLParser` -and must be made available to Datahub by ,for example, installing it. The configuration then needs to be set to `module_name.ClassName` of the parser. +Note! The integration can use an SQL parser to try to parse the tables the chart depends on. This parsing is disabled by default, +but can be enabled by setting `parse_table_names_from_sql: true`. The parser is based on the [`sqlglot`](https://pypi.org/project/sqlglot/) package. diff --git a/metadata-ingestion/setup.py b/metadata-ingestion/setup.py index 5ae5438e212c5b..415871d30175f8 100644 --- a/metadata-ingestion/setup.py +++ b/metadata-ingestion/setup.py @@ -159,14 +159,6 @@ | classification_lib ) -sqllineage_lib = { - "sqllineage==1.3.8", - # We don't have a direct dependency on sqlparse but it is a dependency of sqllineage. - # There have previously been issues from not pinning sqlparse, so it's best to pin it. - # Related: https://github.com/reata/sqllineage/issues/361 and https://github.com/reata/sqllineage/pull/360 - "sqlparse==0.4.4", -} - aws_common = { # AWS Python SDK "boto3", @@ -216,7 +208,6 @@ "sqlalchemy-redshift>=0.8.3", "GeoAlchemy2", "redshift-connector>=2.1.0", - *sqllineage_lib, *path_spec_common, } @@ -464,9 +455,7 @@ # It's technically wrong for packages to depend on setuptools. However, it seems mlflow does it anyways. "setuptools", }, - "mode": {"requests", "python-liquid", "tenacity>=8.0.1"} - | sqllineage_lib - | sqlglot_lib, + "mode": {"requests", "python-liquid", "tenacity>=8.0.1"} | sqlglot_lib, "mongodb": {"pymongo[srv]>=3.11", "packaging"}, "mssql": sql_common | mssql_common, "mssql-odbc": sql_common | mssql_common | {"pyodbc"}, @@ -482,7 +471,7 @@ | pyhive_common | {"psycopg2-binary", "pymysql>=1.0.2"}, "pulsar": {"requests"}, - "redash": {"redash-toolbelt", "sql-metadata"} | sqllineage_lib, + "redash": {"redash-toolbelt", "sql-metadata"} | sqlglot_lib, "redshift": sql_common | redshift_common | usage_common @@ -503,9 +492,7 @@ "slack": slack, "superset": superset_common, "preset": superset_common, - # FIXME: I don't think tableau uses sqllineage anymore so we should be able - # to remove that dependency. - "tableau": {"tableauserverclient>=0.24.0"} | sqllineage_lib | sqlglot_lib, + "tableau": {"tableauserverclient>=0.24.0"} | sqlglot_lib, "teradata": sql_common | usage_common | sqlglot_lib @@ -527,9 +514,9 @@ ), "powerbi-report-server": powerbi_report_server, "vertica": sql_common | {"vertica-sqlalchemy-dialect[vertica-python]==0.0.8.2"}, - "unity-catalog": databricks | sql_common | sqllineage_lib, + "unity-catalog": databricks | sql_common, # databricks is alias for unity-catalog and needs to be kept in sync - "databricks": databricks | sql_common | sqllineage_lib, + "databricks": databricks | sql_common, "fivetran": snowflake_common | bigquery_common | sqlglot_lib, "qlik-sense": sqlglot_lib | {"requests", "websocket-client"}, "sigma": sqlglot_lib | {"requests"}, diff --git a/metadata-ingestion/src/datahub/ingestion/source/mode.py b/metadata-ingestion/src/datahub/ingestion/source/mode.py index e24cba9b193d31..c1ab9271ce13ae 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/mode.py +++ b/metadata-ingestion/src/datahub/ingestion/source/mode.py @@ -18,7 +18,6 @@ from requests.adapters import HTTPAdapter, Retry from requests.exceptions import ConnectionError from requests.models import HTTPBasicAuth, HTTPError -from sqllineage.runner import LineageRunner from tenacity import retry_if_exception_type, stop_after_attempt, wait_exponential import datahub.emitter.mce_builder as builder @@ -820,28 +819,6 @@ def _get_definition(self, definition_name): ) return None - @lru_cache(maxsize=None) - def _get_source_from_query(self, raw_query: str) -> set: - query = self._replace_definitions(raw_query) - parser = LineageRunner(query) - source_paths = set() - try: - for table in parser.source_tables: - sources = str(table).split(".") - source_schema, source_table = sources[-2], sources[-1] - if source_schema == "": - source_schema = str(self.config.default_schema) - - source_paths.add(f"{source_schema}.{source_table}") - except Exception as e: - self.report.report_failure( - title="Failed to Extract Lineage From Query", - message="Unable to retrieve lineage from Mode query.", - context=f"Query: {raw_query}, Error: {str(e)}", - ) - - return source_paths - def _get_datasource_urn( self, platform: str, diff --git a/metadata-ingestion/src/datahub/ingestion/source/redash.py b/metadata-ingestion/src/datahub/ingestion/source/redash.py index 581e32d29dceaf..f11d1944029ebb 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/redash.py +++ b/metadata-ingestion/src/datahub/ingestion/source/redash.py @@ -2,7 +2,7 @@ import math import sys from dataclasses import dataclass, field -from typing import Dict, Iterable, List, Optional, Set, Type +from typing import Dict, Iterable, List, Optional, Set import dateutil.parser as dp from packaging import version @@ -22,7 +22,6 @@ platform_name, support_status, ) -from datahub.ingestion.api.registry import import_path from datahub.ingestion.api.source import Source, SourceCapability, SourceReport from datahub.ingestion.api.workunit import MetadataWorkUnit from datahub.metadata.com.linkedin.pegasus2avro.common import ( @@ -39,9 +38,9 @@ ChartTypeClass, DashboardInfoClass, ) +from datahub.sql_parsing.sqlglot_lineage import create_lineage_sql_parsed_result from datahub.utilities.lossy_collections import LossyDict, LossyList from datahub.utilities.perf_timer import PerfTimer -from datahub.utilities.sql_parser_base import SQLParser from datahub.utilities.threaded_iterator_executor import ThreadedIteratorExecutor logger = logging.getLogger(__name__) @@ -270,10 +269,6 @@ class RedashConfig(ConfigModel): parse_table_names_from_sql: bool = Field( default=False, description="See note below." ) - sql_parser: str = Field( - default="datahub.utilities.sql_parser.DefaultSQLParser", - description="custom SQL parser. See note below for details.", - ) env: str = Field( default=DEFAULT_ENV, @@ -354,7 +349,6 @@ def __init__(self, ctx: PipelineContext, config: RedashConfig): self.api_page_limit = self.config.api_page_limit or math.inf self.parse_table_names_from_sql = self.config.parse_table_names_from_sql - self.sql_parser_path = self.config.sql_parser logger.info( f"Running Redash ingestion with parse_table_names_from_sql={self.parse_table_names_from_sql}" @@ -380,31 +374,6 @@ def create(cls, config_dict: dict, ctx: PipelineContext) -> Source: config = RedashConfig.parse_obj(config_dict) return cls(ctx, config) - @classmethod - def _import_sql_parser_cls(cls, sql_parser_path: str) -> Type[SQLParser]: - assert "." in sql_parser_path, "sql_parser-path must contain a ." - parser_cls = import_path(sql_parser_path) - - if not issubclass(parser_cls, SQLParser): - raise ValueError(f"must be derived from {SQLParser}; got {parser_cls}") - return parser_cls - - @classmethod - def _get_sql_table_names(cls, sql: str, sql_parser_path: str) -> List[str]: - parser_cls = cls._import_sql_parser_cls(sql_parser_path) - - try: - sql_table_names: List[str] = parser_cls(sql).get_tables() - except Exception as e: - logger.warning(f"Sql parser failed on {sql} with {e}") - return [] - - # Remove quotes from table names - sql_table_names = [t.replace('"', "") for t in sql_table_names] - sql_table_names = [t.replace("`", "") for t in sql_table_names] - - return sql_table_names - def _get_chart_data_source(self, data_source_id: Optional[int] = None) -> Dict: url = f"/api/data_sources/{data_source_id}" resp = self.client._get(url).json() @@ -441,14 +410,6 @@ def _get_database_name_based_on_datasource( return database_name - def _construct_datalineage_urn( - self, platform: str, database_name: str, sql_table_name: str - ) -> str: - full_dataset_name = get_full_qualified_name( - platform, database_name, sql_table_name - ) - return builder.make_dataset_urn(platform, full_dataset_name, self.config.env) - def _get_datasource_urns( self, data_source: Dict, sql_query_data: Dict = {} ) -> Optional[List[str]]: @@ -464,34 +425,23 @@ def _get_datasource_urns( # Getting table lineage from SQL parsing if self.parse_table_names_from_sql and data_source_syntax == "sql": dataset_urns = list() - try: - sql_table_names = self._get_sql_table_names( - query, self.sql_parser_path - ) - except Exception as e: + sql_parser_in_tables = create_lineage_sql_parsed_result( + query=query, + platform=platform, + env=self.config.env, + platform_instance=None, + default_db=database_name, + ) + # make sure dataset_urns is not empty list + dataset_urns = sql_parser_in_tables.in_tables + if sql_parser_in_tables.debug_info.table_error: self.report.queries_problem_parsing.add(str(query_id)) self.error( logger, "sql-parsing", - f"exception {e} in parsing query-{query_id}-datasource-{data_source_id}", + f"exception {sql_parser_in_tables.debug_info.table_error} in parsing query-{query_id}-datasource-{data_source_id}", ) - sql_table_names = [] - for sql_table_name in sql_table_names: - try: - dataset_urns.append( - self._construct_datalineage_urn( - platform, database_name, sql_table_name - ) - ) - except Exception: - self.report.queries_problem_parsing.add(str(query_id)) - self.warn( - logger, - "data-urn-invalid", - f"Problem making URN for {sql_table_name} parsed from query {query_id}", - ) - # make sure dataset_urns is not empty list return dataset_urns if len(dataset_urns) > 0 else None else: diff --git a/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py b/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py index 8c42ac81b98cf5..718818d9b347bf 100644 --- a/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py +++ b/metadata-ingestion/src/datahub/ingestion/source/unity/usage.py @@ -7,7 +7,6 @@ import pyspark from databricks.sdk.service.sql import QueryStatementType -from sqllineage.runner import LineageRunner from datahub.emitter.mcp import MetadataChangeProposalWrapper from datahub.ingestion.api.source_helpers import auto_empty_dataset_usage_statistics @@ -22,7 +21,9 @@ from datahub.ingestion.source.unity.report import UnityCatalogReport from datahub.ingestion.source.usage.usage_common import UsageAggregator from datahub.metadata.schema_classes import OperationClass +from datahub.sql_parsing.sqlglot_lineage import create_lineage_sql_parsed_result from datahub.sql_parsing.sqlglot_utils import get_query_fingerprint +from datahub.utilities.urns.dataset_urn import DatasetUrn logger = logging.getLogger(__name__) @@ -48,6 +49,7 @@ class UnityCatalogUsageExtractor: proxy: UnityCatalogApiProxy table_urn_builder: Callable[[TableReference], str] user_urn_builder: Callable[[str], str] + platform: str = "databricks" def __post_init__(self): self.usage_aggregator = UsageAggregator[TableReference](self.config) @@ -173,7 +175,7 @@ def _parse_query( self, query: Query, table_map: TableMap ) -> Optional[QueryTableInfo]: with self.report.usage_perf_report.sql_parsing_timer: - table_info = self._parse_query_via_lineage_runner(query.query_text) + table_info = self._parse_query_via_sqlglot(query.query_text) if table_info is None and query.statement_type == QueryStatementType.SELECT: with self.report.usage_perf_report.spark_sql_parsing_timer: table_info = self._parse_query_via_spark_sql_plan(query.query_text) @@ -191,26 +193,33 @@ def _parse_query( ), ) - def _parse_query_via_lineage_runner(self, query: str) -> Optional[StringTableInfo]: + def _parse_query_via_sqlglot(self, query: str) -> Optional[StringTableInfo]: try: - runner = LineageRunner(query) + sql_parser_in_tables = create_lineage_sql_parsed_result( + query=query, + default_db=None, + platform=self.platform, + env=self.config.env, + platform_instance=None, + ) + return GenericTableInfo( source_tables=[ - self._parse_sqllineage_table(table) - for table in runner.source_tables + self._parse_sqlglot_table(table) + for table in sql_parser_in_tables.in_tables ], target_tables=[ - self._parse_sqllineage_table(table) - for table in runner.target_tables + self._parse_sqlglot_table(table) + for table in sql_parser_in_tables.out_tables ], ) except Exception as e: - logger.info(f"Could not parse query via lineage runner, {query}: {e!r}") + logger.info(f"Could not parse query via sqlglot, {query}: {e!r}") return None @staticmethod - def _parse_sqllineage_table(sqllineage_table: object) -> str: - full_table_name = str(sqllineage_table) + def _parse_sqlglot_table(table_urn: str) -> str: + full_table_name = DatasetUrn.from_string(table_urn).name default_schema = "." if full_table_name.startswith(default_schema): return full_table_name[len(default_schema) :] diff --git a/metadata-ingestion/src/datahub/utilities/sql_lineage_parser_impl.py b/metadata-ingestion/src/datahub/utilities/sql_lineage_parser_impl.py deleted file mode 100644 index 5a8802c7a0a49c..00000000000000 --- a/metadata-ingestion/src/datahub/utilities/sql_lineage_parser_impl.py +++ /dev/null @@ -1,160 +0,0 @@ -import contextlib -import logging -import re -import unittest -import unittest.mock -from typing import Dict, List, Optional, Set - -from sqllineage.core.holders import Column, SQLLineageHolder -from sqllineage.exceptions import SQLLineageException - -from datahub.utilities.sql_parser_base import SQLParser, SqlParserException - -with contextlib.suppress(ImportError): - import sqlparse - from networkx import DiGraph - from sqllineage.core import LineageAnalyzer - - import datahub.utilities.sqllineage_patch -logger = logging.getLogger(__name__) - - -class SqlLineageSQLParserImpl(SQLParser): - _DATE_SWAP_TOKEN = "__d_a_t_e" - _HOUR_SWAP_TOKEN = "__h_o_u_r" - _TIMESTAMP_SWAP_TOKEN = "__t_i_m_e_s_t_a_m_p" - _DATA_SWAP_TOKEN = "__d_a_t_a" - _ADMIN_SWAP_TOKEN = "__a_d_m_i_n" - _MYVIEW_SQL_TABLE_NAME_TOKEN = "__my_view__.__sql_table_name__" - _MYVIEW_LOOKER_TOKEN = "my_view.SQL_TABLE_NAME" - - def __init__(self, sql_query: str, use_raw_names: bool = False) -> None: - super().__init__(sql_query) - original_sql_query = sql_query - self._use_raw_names = use_raw_names - - # SqlLineageParser makes mistakes on lateral flatten queries, use the prefix - if "lateral flatten" in sql_query: - sql_query = sql_query[: sql_query.find("lateral flatten")] - - # Replace reserved words that break SqlLineageParser - self.token_to_original: Dict[str, str] = { - self._DATE_SWAP_TOKEN: "date", - self._HOUR_SWAP_TOKEN: "hour", - self._TIMESTAMP_SWAP_TOKEN: "timestamp", - self._DATA_SWAP_TOKEN: "data", - self._ADMIN_SWAP_TOKEN: "admin", - } - for replacement, original in self.token_to_original.items(): - # Replace original tokens with replacement. Since table and column name can contain a hyphen('-'), - # also prevent original tokens appearing as part of these names with a hyphen from getting substituted. - sql_query = re.sub( - rf"((? List[str]: - result: List[str] = [] - if self._sql_holder is None: - logger.error("sql holder not present so cannot get tables") - return result - for table in self._sql_holder.source_tables: - table_normalized = re.sub( - r"^.", - "", - ( - str(table) - if not self._use_raw_names - else f"{table.schema.raw_name}.{table.raw_name}" - ), - ) - result.append(str(table_normalized)) - - # We need to revert TOKEN replacements - for token, replacement in self.token_to_original.items(): - result = [replacement if c == token else c for c in result] - result = [ - self._MYVIEW_LOOKER_TOKEN if c == self._MYVIEW_SQL_TABLE_NAME_TOKEN else c - for c in result - ] - - # Sort tables to make the list deterministic - result.sort() - - return result - - def get_columns(self) -> List[str]: - if self._sql_holder is None: - raise SqlParserException("sql holder not present so cannot get columns") - graph: DiGraph = self._sql_holder.graph # For mypy attribute checking - column_nodes = [n for n in graph.nodes if isinstance(n, Column)] - column_graph = graph.subgraph(column_nodes) - - target_columns = {column for column, deg in column_graph.out_degree if deg == 0} - - result: Set[str] = set() - for column in target_columns: - # Let's drop all the count(*) and similard columns which are expression actually if it does not have an alias - if not any(ele in column.raw_name for ele in ["*", "(", ")"]): - result.add(str(column.raw_name)) - - # Reverting back all the previously renamed words which confuses the parser - result = {"date" if c == self._DATE_SWAP_TOKEN else c for c in result} - result = { - "timestamp" if c == self._TIMESTAMP_SWAP_TOKEN else c for c in list(result) - } - - # swap back renamed date column - return list(result) diff --git a/metadata-ingestion/src/datahub/utilities/sql_parser.py b/metadata-ingestion/src/datahub/utilities/sql_parser.py deleted file mode 100644 index b88f8fd8c73029..00000000000000 --- a/metadata-ingestion/src/datahub/utilities/sql_parser.py +++ /dev/null @@ -1,94 +0,0 @@ -import logging -import multiprocessing -import traceback -from multiprocessing import Process, Queue -from typing import Any, List, Optional, Tuple - -from datahub.utilities.sql_lineage_parser_impl import SqlLineageSQLParserImpl -from datahub.utilities.sql_parser_base import SQLParser - -logger = logging.getLogger(__name__) - - -def sql_lineage_parser_impl_func_wrapper( - queue: Optional[multiprocessing.Queue], sql_query: str, use_raw_names: bool = False -) -> Optional[Tuple[List[str], List[str], Any]]: - """ - The wrapper function that computes the tables and columns using the SqlLineageSQLParserImpl - and puts the results on the shared IPC queue. This is used to isolate SqlLineageSQLParserImpl - functionality in a separate process, and hence protect our sources from memory leaks originating in - the sqllineage module. - :param queue: The shared IPC queue on to which the results will be put. - :param sql_query: The SQL query to extract the tables & columns from. - :param use_raw_names: Parameter used to ignore sqllineage's default lowercasing. - :return: None. - """ - exception_details: Optional[Tuple[BaseException, str]] = None - tables: List[str] = [] - columns: List[str] = [] - try: - parser = SqlLineageSQLParserImpl(sql_query, use_raw_names) - tables = parser.get_tables() - columns = parser.get_columns() - except BaseException as e: - exc_msg = traceback.format_exc() - exception_details = (e, exc_msg) - logger.debug(exc_msg) - - if queue is not None: - queue.put((tables, columns, exception_details)) - return None - else: - return (tables, columns, exception_details) - - -class SqlLineageSQLParser(SQLParser): - def __init__( - self, - sql_query: str, - use_external_process: bool = False, - use_raw_names: bool = False, - ) -> None: - super().__init__(sql_query, use_external_process) - if use_external_process: - self.tables, self.columns = self._get_tables_columns_process_wrapped( - sql_query, use_raw_names - ) - else: - return_tuple = sql_lineage_parser_impl_func_wrapper( - None, sql_query, use_raw_names - ) - if return_tuple is not None: - ( - self.tables, - self.columns, - some_exception, - ) = return_tuple - - @staticmethod - def _get_tables_columns_process_wrapped( - sql_query: str, use_raw_names: bool = False - ) -> Tuple[List[str], List[str]]: - # Invoke sql_lineage_parser_impl_func_wrapper in a separate process to avoid - # memory leaks from sqllineage module used by SqlLineageSQLParserImpl. This will help - # shield our sources like lookml & redash, that need to parse a large number of SQL statements, - # from causing significant memory leaks in the datahub cli during ingestion. - queue: multiprocessing.Queue = Queue() - process: multiprocessing.Process = Process( - target=sql_lineage_parser_impl_func_wrapper, - args=(queue, sql_query, use_raw_names), - ) - process.start() - tables, columns, exception_details = queue.get(block=True) - if exception_details is not None: - raise exception_details[0](f"Sub-process exception: {exception_details[1]}") - return tables, columns - - def get_tables(self) -> List[str]: - return self.tables - - def get_columns(self) -> List[str]: - return self.columns - - -DefaultSQLParser = SqlLineageSQLParser diff --git a/metadata-ingestion/src/datahub/utilities/sql_parser_base.py b/metadata-ingestion/src/datahub/utilities/sql_parser_base.py deleted file mode 100644 index 8fd5dfaf4978d1..00000000000000 --- a/metadata-ingestion/src/datahub/utilities/sql_parser_base.py +++ /dev/null @@ -1,21 +0,0 @@ -from abc import ABCMeta, abstractmethod -from typing import List - - -class SqlParserException(Exception): - """Raised when sql parser fails""" - - pass - - -class SQLParser(metaclass=ABCMeta): - def __init__(self, sql_query: str, use_external_process: bool = True) -> None: - self._sql_query = sql_query - - @abstractmethod - def get_tables(self) -> List[str]: - pass - - @abstractmethod - def get_columns(self) -> List[str]: - pass diff --git a/metadata-ingestion/tests/unit/test_redash_source.py b/metadata-ingestion/tests/unit/test_redash_source.py index 2982fe76c4d4e7..32ab200847dc6c 100644 --- a/metadata-ingestion/tests/unit/test_redash_source.py +++ b/metadata-ingestion/tests/unit/test_redash_source.py @@ -710,9 +710,9 @@ def test_get_chart_snapshot_parse_table_names_from_sql(mocked_data_source): ), chartUrl="http://localhost:5000/queries/4#10", inputs=[ - "urn:li:dataset:(urn:li:dataPlatform:mysql,Rfam.order_items,PROD)", - "urn:li:dataset:(urn:li:dataPlatform:mysql,Rfam.orders,PROD)", - "urn:li:dataset:(urn:li:dataPlatform:mysql,Rfam.staffs,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:mysql,rfam.order_items,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:mysql,rfam.orders,PROD)", + "urn:li:dataset:(urn:li:dataPlatform:mysql,rfam.staffs,PROD)", ], type="PIE", ) diff --git a/metadata-ingestion/tests/unit/utilities/test_utilities.py b/metadata-ingestion/tests/unit/utilities/test_utilities.py index 68da1bc1c01be2..91819bff41e629 100644 --- a/metadata-ingestion/tests/unit/utilities/test_utilities.py +++ b/metadata-ingestion/tests/unit/utilities/test_utilities.py @@ -1,8 +1,55 @@ import doctest +import re +from typing import List +from datahub.sql_parsing.schema_resolver import SchemaResolver +from datahub.sql_parsing.sqlglot_lineage import sqlglot_lineage from datahub.utilities.delayed_iter import delayed_iter from datahub.utilities.is_pytest import is_pytest_running -from datahub.utilities.sql_parser import SqlLineageSQLParser +from datahub.utilities.urns.dataset_urn import DatasetUrn + + +class SqlLineageSQLParser: + """ + It uses `sqlglot_lineage` to extract tables and columns, serving as a replacement for the `sqllineage` implementation, similar to BigQuery. + Reference: [BigQuery SQL Lineage Test](https://github.com/datahub-project/datahub/blob/master/metadata-ingestion/tests/unit/bigquery/test_bigquery_sql_lineage.py#L8). + """ + + _MYVIEW_SQL_TABLE_NAME_TOKEN = "__my_view__.__sql_table_name__" + _MYVIEW_LOOKER_TOKEN = "my_view.SQL_TABLE_NAME" + + def __init__(self, sql_query: str, platform: str = "bigquery") -> None: + # SqlLineageParser lowercarese tablenames and we need to replace Looker specific token which should be uppercased + sql_query = re.sub( + rf"(\${{{self._MYVIEW_LOOKER_TOKEN}}})", + rf"{self._MYVIEW_SQL_TABLE_NAME_TOKEN}", + sql_query, + ) + self.sql_query = sql_query + self.schema_resolver = SchemaResolver(platform=platform) + self.result = sqlglot_lineage(sql_query, self.schema_resolver) + + def get_tables(self) -> List[str]: + ans = [] + for urn in self.result.in_tables: + table_ref = DatasetUrn.from_string(urn) + ans.append(str(table_ref.name)) + + result = [ + self._MYVIEW_LOOKER_TOKEN if c == self._MYVIEW_SQL_TABLE_NAME_TOKEN else c + for c in ans + ] + # Sort tables to make the list deterministic + result.sort() + + return result + + def get_columns(self) -> List[str]: + ans = [] + for col_info in self.result.column_lineage or []: + for col_ref in col_info.upstreams: + ans.append(col_ref.column) + return ans def test_delayed_iter(): @@ -121,7 +168,7 @@ def test_sqllineage_sql_parser_get_columns_with_alias_and_count_star(): columns_list = SqlLineageSQLParser(sql_query).get_columns() columns_list.sort() - assert columns_list == ["a", "b", "count", "test"] + assert columns_list == ["a", "b", "c"] def test_sqllineage_sql_parser_get_columns_with_more_complex_join(): @@ -145,7 +192,7 @@ def test_sqllineage_sql_parser_get_columns_with_more_complex_join(): columns_list = SqlLineageSQLParser(sql_query).get_columns() columns_list.sort() - assert columns_list == ["bs", "pi", "pt", "pu", "v"] + assert columns_list == ["bs", "pi", "tt", "tt", "v"] def test_sqllineage_sql_parser_get_columns_complex_query_with_union(): @@ -198,7 +245,7 @@ def test_sqllineage_sql_parser_get_columns_complex_query_with_union(): columns_list = SqlLineageSQLParser(sql_query).get_columns() columns_list.sort() - assert columns_list == ["c", "date", "e", "u", "x"] + assert columns_list == ["c", "c", "e", "e", "e", "e", "u", "u", "x", "x"] def test_sqllineage_sql_parser_get_tables_from_templated_query(): @@ -239,7 +286,7 @@ def test_sqllineage_sql_parser_with_weird_lookml_query(): """ columns_list = SqlLineageSQLParser(sql_query).get_columns() columns_list.sort() - assert columns_list == ["aliased_platform", "country", "date"] + assert columns_list == [] def test_sqllineage_sql_parser_tables_from_redash_query(): @@ -276,13 +323,7 @@ def test_sqllineage_sql_parser_tables_with_special_names(): "hour-table", "timestamp-table", ] - expected_columns = [ - "column-admin", - "column-data", - "column-date", - "column-hour", - "column-timestamp", - ] + expected_columns: List[str] = [] assert sorted(SqlLineageSQLParser(sql_query).get_tables()) == expected_tables assert sorted(SqlLineageSQLParser(sql_query).get_columns()) == expected_columns From 1f389c1d36545e45ddf5ab6d4d243dafaa0d6029 Mon Sep 17 00:00:00 2001 From: david-leifker <114954101+david-leifker@users.noreply.github.com> Date: Tue, 10 Dec 2024 11:00:58 -0600 Subject: [PATCH 06/18] fix(entity-service): prevent mutation of systemMetdata on prev (#12081) --- .../metadata/entity/EntityServiceImpl.java | 30 ++++++++++++------- .../metadata/entity/EntityServiceTest.java | 5 ++-- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java index 059a6b7ed0aea3..d14990f93d22d9 100644 --- a/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java +++ b/metadata-io/src/main/java/com/linkedin/metadata/entity/EntityServiceImpl.java @@ -2536,16 +2536,12 @@ private UpdateAspectResult ingestAspectToLocalDB( Optional latestSystemMetadataDiff = systemMetadataDiff( txContext, + writeItem.getUrn(), previousBatchAspect.getSystemMetadata(), writeItem.getSystemMetadata(), databaseAspect == null ? null : databaseAspect.getSystemMetadata()); if (latestSystemMetadataDiff.isPresent()) { - // Update previous version since that is what is re-written - previousBatchAspect - .getEntityAspect() - .setSystemMetadata(RecordUtils.toJsonString(latestSystemMetadataDiff.get())); - // Inserts & update order is not guaranteed, flush the insert for potential updates within // same tx if (databaseAspect == null && txContext != null) { @@ -2560,13 +2556,25 @@ private UpdateAspectResult ingestAspectToLocalDB( conditionalLogLevel( txContext, String.format( - "Update aspect with name %s, urn %s, txContext: %s, databaseAspect: %s, newAspect: %s", + "Update aspect with name %s, urn %s, txContext: %s, databaseAspect: %s, newMetadata: %s newSystemMetadata: %s", previousBatchAspect.getAspectName(), previousBatchAspect.getUrn(), txContext != null, databaseAspect == null ? null : databaseAspect.getEntityAspect(), - previousBatchAspect.getEntityAspect())); - aspectDao.saveAspect(txContext, previousBatchAspect.getEntityAspect(), false); + previousBatchAspect.getEntityAspect().getMetadata(), + latestSystemMetadataDiff.get())); + + aspectDao.saveAspect( + txContext, + previousBatchAspect.getUrnRaw(), + previousBatchAspect.getAspectName(), + previousBatchAspect.getMetadataRaw(), + previousBatchAspect.getCreatedBy(), + null, + previousBatchAspect.getCreatedOn(), + RecordUtils.toJsonString(latestSystemMetadataDiff.get()), + previousBatchAspect.getVersion(), + false); // metrics aspectDao.incrementWriteMetrics( @@ -2661,13 +2669,14 @@ private static boolean shouldAspectEmitChangeLog(@Nonnull final AspectSpec aspec private static Optional systemMetadataDiff( @Nullable TransactionContext txContext, + @Nonnull Urn urn, @Nullable SystemMetadata previous, @Nonnull SystemMetadata current, @Nullable SystemMetadata database) { SystemMetadata latestSystemMetadata = GenericRecordUtils.copy(previous, SystemMetadata.class); - latestSystemMetadata.setLastRunId(previous.getRunId(), SetMode.REMOVE_IF_NULL); + latestSystemMetadata.setLastRunId(latestSystemMetadata.getRunId(), SetMode.REMOVE_IF_NULL); latestSystemMetadata.setLastObserved(current.getLastObserved(), SetMode.IGNORE_NULL); latestSystemMetadata.setRunId(current.getRunId(), SetMode.REMOVE_IF_NULL); @@ -2677,7 +2686,8 @@ private static Optional systemMetadataDiff( conditionalLogLevel( txContext, String.format( - "systemMetdataDiff: %s != %s AND %s", + "systemMetdataDiff urn %s, %s != %s AND %s", + urn, RecordUtils.toJsonString(latestSystemMetadata), previous == null ? null : RecordUtils.toJsonString(previous), database == null ? null : RecordUtils.toJsonString(database))); diff --git a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java index 18d277cacbbe26..4c42815a80f3f1 100644 --- a/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java +++ b/metadata-io/src/test/java/com/linkedin/metadata/entity/EntityServiceTest.java @@ -627,7 +627,7 @@ public void testReingestLineageAspect() throws Exception { restateChangeLog.setSystemMetadata(futureSystemMetadata); restateChangeLog.setPreviousAspectValue(aspect); restateChangeLog.setPreviousSystemMetadata( - simulatePullFromDB(futureSystemMetadata, SystemMetadata.class)); + simulatePullFromDB(initialSystemMetadata, SystemMetadata.class)); restateChangeLog.setEntityKeyAspect( GenericRecordUtils.serializeAspect( EntityKeyUtils.convertUrnToEntityKey( @@ -705,8 +705,7 @@ public void testReingestLineageProposal() throws Exception { restateChangeLog.setAspect(genericAspect); restateChangeLog.setSystemMetadata(futureSystemMetadata); restateChangeLog.setPreviousAspectValue(genericAspect); - restateChangeLog.setPreviousSystemMetadata( - simulatePullFromDB(futureSystemMetadata, SystemMetadata.class)); + restateChangeLog.setPreviousSystemMetadata(simulatePullFromDB(metadata1, SystemMetadata.class)); Map latestAspects = _entityServiceImpl.getLatestAspectsForUrn( From a68836e14c5b6a7dec42ff130c116a92d62127f2 Mon Sep 17 00:00:00 2001 From: Chakru <161002324+chakru-r@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:24:43 +0530 Subject: [PATCH 07/18] build(datahub-frontend): enable code-coverage (#12072) --- datahub-frontend/build.gradle | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/datahub-frontend/build.gradle b/datahub-frontend/build.gradle index 7750e169b11fbe..5cc5af50d217ba 100644 --- a/datahub-frontend/build.gradle +++ b/datahub-frontend/build.gradle @@ -4,8 +4,9 @@ plugins { id 'org.gradle.playframework' } -apply from: "../gradle/versioning/versioning.gradle" +apply from: '../gradle/versioning/versioning.gradle' apply from: './play.gradle' +apply from: '../gradle/coverage/java-coverage.gradle' ext { docker_repo = 'datahub-frontend-react' @@ -18,6 +19,13 @@ java { } } +test { + jacoco { + // jacoco instrumentation is failing when dealing with code of this dependency, excluding it. + excludes = ["com/gargoylesoftware/**"] + } +} + model { // Must specify the dependency here as "stage" is added by rule based model. tasks.myTar { From bcf230f87ffd1cf090d84bba3a394a7ac771a13a Mon Sep 17 00:00:00 2001 From: Chakru <161002324+chakru-r@users.noreply.github.com> Date: Wed, 11 Dec 2024 00:47:30 +0530 Subject: [PATCH 08/18] build(ci): codecov integration (#12073) --- .github/workflows/airflow-plugin.yml | 4 ++-- .github/workflows/build-and-test.yml | 10 ++++++++++ .github/workflows/dagster-plugin.yml | 4 ++-- .github/workflows/gx-plugin.yml | 4 ++-- .github/workflows/metadata-ingestion.yml | 4 ++-- .github/workflows/metadata-io.yml | 9 +++++++++ .github/workflows/prefect-plugin.yml | 4 ++-- 7 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.github/workflows/airflow-plugin.yml b/.github/workflows/airflow-plugin.yml index 1fdfc52857b011..eefa02be4f1af8 100644 --- a/.github/workflows/airflow-plugin.yml +++ b/.github/workflows/airflow-plugin.yml @@ -80,10 +80,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: airflow,airflow-${{ matrix.extra_pip_extras }} name: pytest-airflow-${{ matrix.python-version }}-${{ matrix.extra_pip_requirements }} diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index a5889b2d2f92de..1b10fe6e74372b 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -126,6 +126,16 @@ jobs: !**/binary/** - name: Ensure codegen is updated uses: ./.github/actions/ensure-codegen-updated + - name: Upload coverage to Codecov + if: always() + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./build/coverage-reports/ + fail_ci_if_error: false + flags: ${{ matrix.timezone }} + name: ${{ matrix.command }} + verbose: true quickstart-compose-validation: runs-on: ubuntu-latest diff --git a/.github/workflows/dagster-plugin.yml b/.github/workflows/dagster-plugin.yml index 37b6c93ec841ab..f512dcf8f3ffd4 100644 --- a/.github/workflows/dagster-plugin.yml +++ b/.github/workflows/dagster-plugin.yml @@ -66,10 +66,10 @@ jobs: **/junit.*.xml - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: dagster-${{ matrix.python-version }}-${{ matrix.extraPythonRequirement }} name: pytest-dagster diff --git a/.github/workflows/gx-plugin.yml b/.github/workflows/gx-plugin.yml index aa7c3f069c7654..595438bd6e4a90 100644 --- a/.github/workflows/gx-plugin.yml +++ b/.github/workflows/gx-plugin.yml @@ -70,10 +70,10 @@ jobs: **/junit.*.xml - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: gx-${{ matrix.python-version }}-${{ matrix.extraPythonRequirement }} name: pytest-gx diff --git a/.github/workflows/metadata-ingestion.yml b/.github/workflows/metadata-ingestion.yml index c0eafe891fb0aa..49def2a863c565 100644 --- a/.github/workflows/metadata-ingestion.yml +++ b/.github/workflows/metadata-ingestion.yml @@ -94,10 +94,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: ${{ always() && matrix.python-version == '3.10' }} - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: pytest-${{ matrix.command }} name: pytest-${{ matrix.command }} diff --git a/.github/workflows/metadata-io.yml b/.github/workflows/metadata-io.yml index 5ee2223d71b039..2225baecde64c6 100644 --- a/.github/workflows/metadata-io.yml +++ b/.github/workflows/metadata-io.yml @@ -81,6 +81,15 @@ jobs: !**/binary/** - name: Ensure codegen is updated uses: ./.github/actions/ensure-codegen-updated + - name: Upload coverage to Codecov + if: ${{ always()}} + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + directory: ./build/coverage-reports/ + fail_ci_if_error: false + name: metadata-io-test + verbose: true event-file: runs-on: ubuntu-latest diff --git a/.github/workflows/prefect-plugin.yml b/.github/workflows/prefect-plugin.yml index b0af00f92b7727..3c75e8fe9a62ff 100644 --- a/.github/workflows/prefect-plugin.yml +++ b/.github/workflows/prefect-plugin.yml @@ -67,10 +67,10 @@ jobs: !**/binary/** - name: Upload coverage to Codecov if: always() - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: . + directory: ./build/coverage-reports/ fail_ci_if_error: false flags: prefect,prefect-${{ matrix.extra_pip_extras }} name: pytest-prefect-${{ matrix.python-version }} From a290b24a7cdd67f2c53ad90e60b230043ee1ea2a Mon Sep 17 00:00:00 2001 From: RyanHolstien Date: Tue, 10 Dec 2024 13:25:41 -0600 Subject: [PATCH 09/18] fix(openapi): adds in previously ignored keep alive value (#12068) --- docs/how/updating-datahub.md | 1 + .../openapi/controller/GenericEntitiesController.java | 6 ++++-- .../io/datahubproject/openapi/v3/OpenAPIV3Generator.java | 7 +++++++ .../openapi/v3/controller/EntityController.java | 4 +++- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/how/updating-datahub.md b/docs/how/updating-datahub.md index d8fe06abad6252..073f68db230634 100644 --- a/docs/how/updating-datahub.md +++ b/docs/how/updating-datahub.md @@ -39,6 +39,7 @@ This file documents any backwards-incompatible changes in DataHub and assists pe - #11742: For PowerBi ingestion, `use_powerbi_email` is now enabled by default when extracting ownership information. - #12056: The DataHub Airflow plugin no longer supports Airflow 2.1 and Airflow 2.2. - #12056: The DataHub Airflow plugin now defaults to the v2 plugin implementation. +- OpenAPI Update: PIT Keep Alive parameter added to scroll. NOTE: This parameter requires the `pointInTimeCreationEnabled` feature flag to be enabled and the `elasticSearch.implementation` configuration to be `elasticsearch`. This feature is not supported for OpenSearch at this time and the parameter will not be respected without both of these set. ### Breaking Changes diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java index c17a4a6294f015..425646d4282149 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/controller/GenericEntitiesController.java @@ -199,7 +199,9 @@ public ResponseEntity getEntities( @RequestParam(value = "skipCache", required = false, defaultValue = "false") Boolean skipCache, @RequestParam(value = "includeSoftDelete", required = false, defaultValue = "false") - Boolean includeSoftDelete) + Boolean includeSoftDelete, + @RequestParam(value = "pitKeepAlive", required = false, defaultValue = "5m") + String pitKeepALive) throws URISyntaxException { EntitySpec entitySpec = entityRegistry.getEntitySpec(entityName); @@ -241,7 +243,7 @@ public ResponseEntity getEntities( null, sortCriteria, scrollId, - null, + pitKeepALive, count); if (!AuthUtil.isAPIAuthorizedResult(opContext, result)) { diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/OpenAPIV3Generator.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/OpenAPIV3Generator.java index d179ea8f3a0682..68ed316573f77a 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/OpenAPIV3Generator.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/OpenAPIV3Generator.java @@ -45,6 +45,7 @@ public class OpenAPIV3Generator { private static final String NAME_VERSION = "version"; private static final String NAME_SCROLL_ID = "scrollId"; private static final String NAME_INCLUDE_SOFT_DELETE = "includeSoftDelete"; + private static final String NAME_PIT_KEEP_ALIVE = "pitKeepAlive"; private static final String PROPERTY_VALUE = "value"; private static final String PROPERTY_URN = "urn"; private static final String PROPERTY_PATCH = "patch"; @@ -502,6 +503,12 @@ private static PathItem buildGenericListEntitiesPath() { .name(NAME_SKIP_CACHE) .description("Skip cache when listing entities.") .schema(new Schema().type(TYPE_BOOLEAN)._default(false)), + new Parameter() + .in(NAME_QUERY) + .name(NAME_PIT_KEEP_ALIVE) + .description( + "Point In Time keep alive, accepts a time based string like \"5m\" for five minutes.") + .schema(new Schema().type(TYPE_STRING)._default("5m")), new Parameter().$ref("#/components/parameters/PaginationCount" + MODEL_VERSION), new Parameter().$ref("#/components/parameters/ScrollId" + MODEL_VERSION), new Parameter().$ref("#/components/parameters/SortBy" + MODEL_VERSION), diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java index aa659b196f1872..5544fb845b2687 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v3/controller/EntityController.java @@ -146,6 +146,8 @@ public ResponseEntity scrollEntities( Boolean skipCache, @RequestParam(value = "includeSoftDelete", required = false, defaultValue = "false") Boolean includeSoftDelete, + @RequestParam(value = "pitKeepAlive", required = false, defaultValue = "5m") + String pitKeepALive, @RequestBody @Nonnull GenericEntityAspectsBodyV3 entityAspectsBody) throws URISyntaxException { @@ -202,7 +204,7 @@ public ResponseEntity scrollEntities( null, sortCriteria, scrollId, - null, + pitKeepALive, count); if (!AuthUtil.isAPIAuthorizedResult(opContext, result)) { From 84e50d8e76a1c44be39a0600d426b58e2b8c690b Mon Sep 17 00:00:00 2001 From: Chris Collins Date: Tue, 10 Dec 2024 15:06:57 -0500 Subject: [PATCH 10/18] feat(ui) Add alchemy component library to FE (#12054) --- datahub-web-react/.storybook/DocTemplate.mdx | 42 + datahub-web-react/.storybook/main.js | 25 + .../.storybook/manager-head.html | 33 + datahub-web-react/.storybook/manager.js | 15 + .../.storybook/preview-head.html | 33 + datahub-web-react/.storybook/preview.js | 84 + .../.storybook/storybook-logo.svg | 1 + .../.storybook/storybook-theme.css | 263 ++++ .../.storybook/storybook-theme.js | 47 + .../.storybook/styledComponents.ts | 36 + .../.storybook/webpack.config.js | 13 + datahub-web-react/package.json | 20 +- .../alchemy-components/.docs/Contributing.mdx | 43 + .../alchemy-components/.docs/DesignTokens.mdx | 63 + .../src/alchemy-components/.docs/Icons.mdx | 34 + .../src/alchemy-components/.docs/Intro.mdx | 14 + .../alchemy-components/.docs/StyleGuide.mdx | 209 +++ .../.docs/mdx-components/CodeBlock.tsx | 24 + .../.docs/mdx-components/CopyButton.tsx | 16 + .../.docs/mdx-components/GridList.tsx | 32 + .../mdx-components/IconGalleryWithSearch.tsx | 291 ++++ .../.docs/mdx-components/components.ts | 110 ++ .../.docs/mdx-components/index.ts | 6 + .../.docs/mdx-components/utils.ts | 15 + .../src/alchemy-components/README.mdx | 73 + .../components/Avatar/Avatar.stories.tsx | 133 ++ .../components/Avatar/Avatar.tsx | 40 + .../Avatar/_tests_/getNameInitials.test.ts | 34 + .../components/Avatar/components.ts | 51 + .../components/Avatar/index.ts | 1 + .../components/Avatar/types.ts | 10 + .../components/Avatar/utils.ts | 64 + .../components/Badge/Badge.stories.tsx | 102 ++ .../components/Badge/Badge.tsx | 29 + .../components/Badge/components.ts | 6 + .../components/Badge/index.ts | 1 + .../components/Badge/types.ts | 8 + .../components/Badge/utils.ts | 15 + .../components/BarChart/BarChart.stories.tsx | 90 ++ .../components/BarChart/BarChart.tsx | 152 ++ .../components/BarChart/components.tsx | 34 + .../components/BarChart/index.ts | 1 + .../components/BarChart/types.ts | 18 + .../components/BarChart/utils.ts | 26 + .../components/Button/Button.stories.tsx | 203 +++ .../components/Button/Button.tsx | 60 + .../components/Button/components.ts | 27 + .../components/Button/index.ts | 2 + .../components/Button/types.ts | 16 + .../components/Button/utils.ts | 238 +++ .../components/Card/Card.stories.tsx | 141 ++ .../components/Card/Card.tsx | 48 + .../components/Card/components.ts | 59 + .../components/Card/index.ts | 2 + .../components/Card/types.ts | 13 + .../components/Checkbox/Checkbox.stories.tsx | 156 ++ .../components/Checkbox/Checkbox.tsx | 103 ++ .../components/Checkbox/components.ts | 91 ++ .../components/Checkbox/index.ts | 2 + .../components/Checkbox/types.ts | 16 + .../components/Checkbox/utils.ts | 27 + .../components/Heading/Heading.stories.tsx | 98 ++ .../components/Heading/Heading.tsx | 38 + .../components/Heading/components.ts | 70 + .../components/Heading/index.ts | 2 + .../components/Heading/types.ts | 9 + .../components/Icon/Icon.stories.tsx | 131 ++ .../components/Icon/Icon.tsx | 59 + .../components/Icon/components.ts | 19 + .../components/Icon/constants.ts | 547 +++++++ .../components/Icon/index.ts | 3 + .../components/Icon/types.ts | 23 + .../components/Icon/utils.ts | 29 + .../components/Input/Input.stories.tsx | 177 +++ .../components/Input/Input.tsx | 97 ++ .../components/Input/components.ts | 92 ++ .../components/Input/index.ts | 2 + .../components/Input/types.ts | 22 + .../components/Input/utils.ts | 5 + .../LineChart/LineChart.stories.tsx | 96 ++ .../components/LineChart/LineChart.tsx | 178 +++ .../components/LineChart/components.tsx | 8 + .../components/LineChart/index.ts | 1 + .../components/LineChart/types.ts | 22 + .../PageTitle/PageTitle.stories.tsx | 71 + .../components/PageTitle/PageTitle.tsx | 17 + .../components/PageTitle/components.ts | 52 + .../components/PageTitle/index.ts | 1 + .../components/PageTitle/types.ts | 8 + .../components/PageTitle/utils.ts | 27 + .../components/Pills/Pill.stories.tsx | 126 ++ .../components/Pills/Pill.tsx | 42 + .../components/Pills/components.ts | 33 + .../components/Pills/index.ts | 1 + .../components/Pills/types.ts | 18 + .../components/Pills/utils.ts | 147 ++ .../components/Popover/Popover.tsx | 6 + .../components/Popover/index.ts | 1 + .../components/Radio/Radio.stories.tsx | 136 ++ .../components/Radio/Radio.tsx | 89 ++ .../components/Radio/components.ts | 83 + .../components/Radio/types.ts | 16 + .../components/Radio/utils.ts | 27 + .../components/Select/BasicSelect.tsx | 339 ++++ .../components/Select/Nested/NestedOption.tsx | 309 ++++ .../components/Select/Nested/NestedSelect.tsx | 312 ++++ .../components/Select/Nested/types.ts | 9 + .../components/Select/Select.stories.tsx | 431 +++++ .../components/Select/Select.tsx | 65 + .../components/Select/SimpleSelect.tsx | 299 ++++ .../components/Select/components.ts | 235 +++ .../components/Select/index.ts | 3 + .../components/Select/types.ts | 61 + .../components/Select/utils.ts | 125 ++ .../components/Switch/Switch.stories.tsx | 169 ++ .../components/Switch/Switch.tsx | 74 + .../components/Switch/components.ts | 118 ++ .../components/Switch/index.ts | 2 + .../components/Switch/types.ts | 21 + .../components/Switch/utils.ts | 97 ++ .../components/Table/Table.stories.tsx | 162 ++ .../components/Table/Table.tsx | 115 ++ .../components/Table/components.ts | 94 ++ .../components/Table/index.ts | 2 + .../components/Table/types.ts | 21 + .../components/Table/utils.ts | 73 + .../components/Text/Text.stories.tsx | 100 ++ .../components/Text/Text.tsx | 33 + .../components/Text/components.ts | 50 + .../components/Text/index.ts | 2 + .../components/Text/types.ts | 9 + .../components/TextArea/TextArea.stories.tsx | 159 ++ .../components/TextArea/TextArea.tsx | 80 + .../components/TextArea/components.ts | 106 ++ .../components/TextArea/index.ts | 2 + .../components/TextArea/types.ts | 15 + .../components/Tooltip/Tooltip.tsx | 6 + .../components/Tooltip/index.ts | 1 + .../components/commonStyles.ts | 23 + .../components/dataviz/utils.ts | 11 + .../src/alchemy-components/index.ts | 23 + .../theme/config/constants.ts | 1 + .../alchemy-components/theme/config/index.ts | 2 + .../alchemy-components/theme/config/types.ts | 47 + .../theme/foundations/blur.ts | 12 + .../theme/foundations/borders.ts | 9 + .../theme/foundations/breakpoints.ts | 10 + .../theme/foundations/colors.ts | 98 ++ .../theme/foundations/index.ts | 27 + .../theme/foundations/radius.ts | 9 + .../theme/foundations/shadows.ts | 16 + .../theme/foundations/sizes.ts | 7 + .../theme/foundations/spacing.ts | 12 + .../theme/foundations/transform.ts | 10 + .../theme/foundations/transition.ts | 32 + .../theme/foundations/typography.ts | 52 + .../theme/foundations/zIndex.ts | 17 + .../src/alchemy-components/theme/index.ts | 30 + .../theme/semantic-tokens.ts | 21 + .../src/alchemy-components/theme/utils.ts | 62 + datahub-web-react/src/fonts/Mulish-Black.ttf | Bin 0 -> 106576 bytes .../src/fonts/Mulish-BlackItalic.ttf | Bin 0 -> 110200 bytes datahub-web-react/src/fonts/Mulish-Bold.ttf | Bin 0 -> 106576 bytes .../src/fonts/Mulish-BoldItalic.ttf | Bin 0 -> 110260 bytes .../src/fonts/Mulish-ExtraBold.ttf | Bin 0 -> 106536 bytes .../src/fonts/Mulish-ExtraBoldItalic.ttf | Bin 0 -> 110256 bytes .../src/fonts/Mulish-ExtraLight.ttf | Bin 0 -> 106380 bytes .../src/fonts/Mulish-ExtraLightItalic.ttf | Bin 0 -> 110000 bytes datahub-web-react/src/fonts/Mulish-Italic.ttf | Bin 0 -> 110060 bytes datahub-web-react/src/fonts/Mulish-Light.ttf | Bin 0 -> 106572 bytes .../src/fonts/Mulish-LightItalic.ttf | Bin 0 -> 110192 bytes datahub-web-react/src/fonts/Mulish-Medium.ttf | Bin 0 -> 106568 bytes .../src/fonts/Mulish-MediumItalic.ttf | Bin 0 -> 110160 bytes .../src/fonts/Mulish-Regular.ttf | Bin 0 -> 106528 bytes .../src/fonts/Mulish-SemiBold.ttf | Bin 0 -> 106508 bytes .../src/fonts/Mulish-SemiBoldItalic.ttf | Bin 0 -> 110120 bytes datahub-web-react/tsconfig.json | 30 +- datahub-web-react/vite.config.ts | 28 + datahub-web-react/yarn.lock | 1384 ++++++++++++++++- 179 files changed, 11848 insertions(+), 14 deletions(-) create mode 100644 datahub-web-react/.storybook/DocTemplate.mdx create mode 100644 datahub-web-react/.storybook/main.js create mode 100644 datahub-web-react/.storybook/manager-head.html create mode 100644 datahub-web-react/.storybook/manager.js create mode 100644 datahub-web-react/.storybook/preview-head.html create mode 100644 datahub-web-react/.storybook/preview.js create mode 100644 datahub-web-react/.storybook/storybook-logo.svg create mode 100644 datahub-web-react/.storybook/storybook-theme.css create mode 100644 datahub-web-react/.storybook/storybook-theme.js create mode 100644 datahub-web-react/.storybook/styledComponents.ts create mode 100644 datahub-web-react/.storybook/webpack.config.js create mode 100644 datahub-web-react/src/alchemy-components/.docs/Contributing.mdx create mode 100644 datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx create mode 100644 datahub-web-react/src/alchemy-components/.docs/Icons.mdx create mode 100644 datahub-web-react/src/alchemy-components/.docs/Intro.mdx create mode 100644 datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts create mode 100644 datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/README.mdx create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Badge/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/components.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/BarChart/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Button/Button.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Button/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Button/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Button/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Button/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Card/Card.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Card/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Card/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Card/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Heading/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Heading/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Heading/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/constants.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Icon/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Input/Input.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Input/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Input/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Input/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Input/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/LineChart/components.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/LineChart/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/LineChart/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Pills/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Popover/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Radio/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Radio/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Radio/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/Select.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Select/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Select/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Switch/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Table/Table.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Table/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Table/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Table/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Table/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Text/Text.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Text/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Text/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Text/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/TextArea/TextArea.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/TextArea/components.ts create mode 100644 datahub-web-react/src/alchemy-components/components/TextArea/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/TextArea/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/Tooltip/Tooltip.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/Tooltip/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/commonStyles.ts create mode 100644 datahub-web-react/src/alchemy-components/components/dataviz/utils.ts create mode 100644 datahub-web-react/src/alchemy-components/index.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/config/constants.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/config/index.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/config/types.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/blur.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/borders.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/breakpoints.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/colors.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/index.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/radius.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/shadows.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/sizes.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/spacing.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/transform.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/transition.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/typography.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/foundations/zIndex.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/index.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/semantic-tokens.ts create mode 100644 datahub-web-react/src/alchemy-components/theme/utils.ts create mode 100644 datahub-web-react/src/fonts/Mulish-Black.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-BlackItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-Bold.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-BoldItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-ExtraBold.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-ExtraBoldItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-ExtraLight.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-ExtraLightItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-Italic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-Light.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-LightItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-Medium.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-MediumItalic.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-Regular.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-SemiBold.ttf create mode 100644 datahub-web-react/src/fonts/Mulish-SemiBoldItalic.ttf diff --git a/datahub-web-react/.storybook/DocTemplate.mdx b/datahub-web-react/.storybook/DocTemplate.mdx new file mode 100644 index 00000000000000..9ea1250075e11f --- /dev/null +++ b/datahub-web-react/.storybook/DocTemplate.mdx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { ThemeProvider } from 'styled-components'; +import { GlobalStyle } from './styledComponents'; + +import { Meta, Title, Subtitle, Description, Primary, Controls, Stories } from '@storybook/blocks'; +import { CodeBlock } from '../src/alchemy-components/.docs/mdx-components'; + +{/* + * 👇 The isTemplate property is required to tell Storybook that this is a template + * See https://storybook.js.org/docs/api/doc-block-meta + * to learn how to use +*/} + + + + + + + + + <Subtitle /> + + <div className="docsDescription"> + <Description /> + </div> + + <br /> + + ### Import + + <CodeBlock /> + + <br/> + + ### Customize + + <Primary /> + <Controls /> + + <Stories /> +</ThemeProvider> \ No newline at end of file diff --git a/datahub-web-react/.storybook/main.js b/datahub-web-react/.storybook/main.js new file mode 100644 index 00000000000000..2b92dffd88eb3a --- /dev/null +++ b/datahub-web-react/.storybook/main.js @@ -0,0 +1,25 @@ +// Docs for badges: https://storybook.js.org/addons/@geometricpanda/storybook-addon-badges + +export default { + framework: '@storybook/react-vite', + features: { + buildStoriesJson: true, + }, + core: { + disableTelemetry: true, + }, + stories: [ + '../src/alchemy-components/.docs/*.mdx', + '../src/alchemy-components/components/**/*.stories.@(js|jsx|mjs|ts|tsx)' + ], + addons: [ + '@storybook/addon-onboarding', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@geometricpanda/storybook-addon-badges', + ], + typescript: { + reactDocgen: 'react-docgen-typescript', + }, +} \ No newline at end of file diff --git a/datahub-web-react/.storybook/manager-head.html b/datahub-web-react/.storybook/manager-head.html new file mode 100644 index 00000000000000..98e6a2895f45c7 --- /dev/null +++ b/datahub-web-react/.storybook/manager-head.html @@ -0,0 +1,33 @@ +<style type="text/css"> + /* Regular */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); + } + + /* Medium */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); + } + + /* SemiBold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); + } + + /* Bold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); + } +</style> \ No newline at end of file diff --git a/datahub-web-react/.storybook/manager.js b/datahub-web-react/.storybook/manager.js new file mode 100644 index 00000000000000..6e9c62dd96c23f --- /dev/null +++ b/datahub-web-react/.storybook/manager.js @@ -0,0 +1,15 @@ +import './storybook-theme.css'; + +import { addons } from '@storybook/manager-api'; +import acrylTheme from './storybook-theme.js'; + +// Theme setup +addons.setConfig({ + theme: acrylTheme, +}); + +// Favicon +const link = document.createElement('link'); +link.setAttribute('rel', 'shortcut icon'); +link.setAttribute('href', 'https://www.acryldata.io/icons/favicon.ico'); +document.head.appendChild(link); \ No newline at end of file diff --git a/datahub-web-react/.storybook/preview-head.html b/datahub-web-react/.storybook/preview-head.html new file mode 100644 index 00000000000000..98e6a2895f45c7 --- /dev/null +++ b/datahub-web-react/.storybook/preview-head.html @@ -0,0 +1,33 @@ +<style type="text/css"> + /* Regular */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); + } + + /* Medium */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); + } + + /* SemiBold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); + } + + /* Bold */ + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); + } +</style> \ No newline at end of file diff --git a/datahub-web-react/.storybook/preview.js b/datahub-web-react/.storybook/preview.js new file mode 100644 index 00000000000000..a497ce7bccf3c8 --- /dev/null +++ b/datahub-web-react/.storybook/preview.js @@ -0,0 +1,84 @@ +import './storybook-theme.css'; +// FYI: import of antd styles required to show components based on it correctly +import 'antd/dist/antd.css'; + +import { BADGE, defaultBadgesConfig } from '@geometricpanda/storybook-addon-badges'; +import DocTemplate from './DocTemplate.mdx'; + +const preview = { + tags: ['!dev', 'autodocs'], + parameters: { + previewTabs: { + 'storybook/docs/panel': { index: -1 }, + }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/i, + }, + }, + options: { + storySort: { + method: 'alphabetical', + order: [ + // Order of Docs Pages + 'Introduction', + 'Style Guide', + 'Design Tokens', + 'Style Utilities', + 'Icons', + + // Order of Components + 'Layout', + 'Forms', + 'Data Display', + 'Feedback', + 'Typography', + 'Overlay', + 'Disclosure', + 'Navigation', + 'Media', + 'Other', + ], + locales: '', + }, + }, + docs: { + page: DocTemplate, + toc: { + disable: false, + }, + docs: { + source: { + format: true, + }, + }, + }, + + // Reconfig the premade badges with better titles + badgesConfig: { + stable: { + ...defaultBadgesConfig[BADGE.STABLE], + title: 'Stable', + tooltip: 'This component is stable but may have frequent changes. Use at own discretion.', + }, + productionReady: { + ...defaultBadgesConfig[BADGE.STABLE], + title: 'Production Ready', + tooltip: 'This component is production ready and has been tested in a production environment.', + }, + WIP: { + ...defaultBadgesConfig[BADGE.BETA], + title: 'WIP', + tooltip: 'This component is a work in progress and may not be fully functional or tested.', + }, + readyForDesignReview: { + ...defaultBadgesConfig[BADGE.NEEDS_REVISION], + title: 'Ready for Design Review', + tooltip: 'This component is ready for design review and feedback.', + }, + }, + }, +}; + +export default preview; diff --git a/datahub-web-react/.storybook/storybook-logo.svg b/datahub-web-react/.storybook/storybook-logo.svg new file mode 100644 index 00000000000000..5cc86813b59336 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-logo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.71 125.19"><defs><style>.cls-1{fill:#08303a;}.cls-2{fill:#11696b;}.cls-3{fill:#20d3bd;}</style></defs><g id="artwork"><path class="cls-1" d="M96.39,34.23,79.87,11.08a26.43,26.43,0,0,0-43,0L20.32,34.23A26.42,26.42,0,0,0,41.83,76h33A26.42,26.42,0,0,0,96.39,34.23ZM74.87,68h-33a18.42,18.42,0,0,1-15-29.12L43.35,15.72a18.43,18.43,0,0,1,30,0L89.87,38.88A18.42,18.42,0,0,1,74.87,68Z"/><path class="cls-2" d="M105.89,72.32,73,26.24a18,18,0,0,0-29.31,0L10.82,72.32a18,18,0,0,0,14.65,28.46H91.24a18,18,0,0,0,14.65-28.46ZM91.24,92.78H25.47A10,10,0,0,1,17.33,77L50.21,30.88a10,10,0,0,1,16.28,0L99.38,77A10,10,0,0,1,91.24,92.78Z"/><path class="cls-3" d="M114.83,109.26,66.56,41.61a10.07,10.07,0,0,0-16.41,0L1.88,109.26a10.08,10.08,0,0,0,8.2,15.93h96.55a10.08,10.08,0,0,0,8.2-15.93Zm-8.2,7.93H10.08a2.08,2.08,0,0,1-1.69-3.29L56.66,46.25a2.08,2.08,0,0,1,1.69-.87,2.05,2.05,0,0,1,1.69.87l48.28,67.65A2.08,2.08,0,0,1,106.63,117.19Z"/></g></svg> \ No newline at end of file diff --git a/datahub-web-react/.storybook/storybook-theme.css b/datahub-web-react/.storybook/storybook-theme.css new file mode 100644 index 00000000000000..edf93c57cf2086 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-theme.css @@ -0,0 +1,263 @@ +/* Storybook Theme CSS Overrides */ + +/* Regular */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf') format('truetype'); +} + +/* Medium */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf') format('truetype'); +} + +/* SemiBold */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf') format('truetype'); +} + +/* Bold */ +@font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf') format('truetype'); +} + +body { + font-family: 'Mulish', sans-serif !important; +} + +::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +*::-webkit-scrollbar-track { + background: rgba(193, 196, 208, 0); + border-radius: 10px; +} + +*::-webkit-scrollbar-thumb { + background: rgba(193, 196, 208, 0); + border-radius: 10px; + transition: 0.3s; +} + +*:hover::-webkit-scrollbar-track { + background: rgba(193, 196, 208, 0.3); +} + +*:hover::-webkit-scrollbar-thumb { + background: rgba(193, 196, 208, 0.8); +} + +.sbdocs-wrapper { + max-width: 95% !important; +} + +.sidebar-header img { + max-height: 25px !important; +} + +.sb-bar { + box-shadow: none !important; + border-bottom: 1px solid hsla(203, 50%, 30%, 0.15) !important; +} + +.sbdocs-preview, +.docblock-argstable-body, +.docblock-source { + box-shadow: none !important; + filter: none !important; +} + +.docblock-source { + max-width: 100% !important; + overflow: auto !important; + margin: 1rem 0 !important; +} + +.sidebar-item, +.sidebar-item[data-selected="true"] { + height: 32px !important; + display: flex !important; + align-items: center !important; + padding-right: 0 !important; + padding: 6px 12px !important; + font-size: 15px !important; + margin-bottom: 4px !important; + color: #000 !important; +} + +.sidebar-item:hover { + background-color: #eff8fc !important; +} + +.sidebar-item>a { + align-items: center !important; + gap: 8px !important; + padding: 0 !important; +} + +.sidebar-item[data-nodetype="group"] { + margin-top: 8px !important; +} + +.sidebar-item[data-nodetype="component"] { + padding-left: 8px !important; +} + +[data-nodetype="root"]>[data-action="collapse-root"]>div:first-child, +[data-nodetype="component"] div { + display: none !important; +} + +[data-nodetype="document"][data-parent-id], +[data-nodetype="story"][data-parent-id] { + padding: 0 !important; + margin-left: 16px !important; + height: 18px !important; + min-height: auto !important; + font-weight: 400 !important; +} + +[data-nodetype="document"][data-parent-id] svg, +[data-nodetype="story"][data-parent-id] svg { + display: none !important; +} + +[data-nodetype="document"][data-parent-id]::before, +[data-nodetype="story"][data-parent-id]::before { + content: '→' !important; +} + +[data-nodetype="document"][data-parent-id]:hover, +[data-nodetype="story"][data-parent-id]:hover, +[data-nodetype="document"][data-parent-id][data-selected="true"]:hover, +[data-nodetype="story"][data-parent-id][data-selected="true"]:hover { + background-color: #fff !important; + color: #4da1bf !important; +} + +[data-nodetype="document"][data-parent-id][data-selected="true"], +[data-nodetype="story"][data-parent-id][data-selected="true"] { + background-color: #fff !important; + height: 18px !important; + min-height: auto !important; + font-weight: 400 !important; +} + +.sbdocs-content div[id*=--sandbox]~div[id*=--sandbox]~div[id*=--sandbox], +li:has(a[href="#sandbox"]) { + display: none !important; +} + +[data-nodetype="document"]:not([data-parent-id]) { + padding-left: 0 !important; +} + +[data-nodetype="document"]:not([data-parent-id]) svg { + display: none !important; +} + +[data-nodetype="document"]:not([data-parent-id])>a { + font-size: 18px !important; + font-weight: 300 !important; +} + +[data-nodetype="component"][aria-expanded="true"], +[data-nodetype="document"][data-selected="true"] { + color: #000 !important; + background-color: transparent !important; + font-weight: 700 !important; +} + +[data-nodetype="root"][data-selected="true"] { + background-color: transparent !important; +} + +[data-nodetype="document"][data-selected="true"], +[data-nodetype="document"][data-parent-id][data-selected="true"] { + color: #4da1bf !important; +} + +.sidebar-subheading { + font-size: 12px !important; + font-weight: 600 !important; + letter-spacing: 1px !important; + color: #a9adbd !important; +} + +.sbdocs-wrapper { + padding: 2rem !important; +} + +table, +tr, +tbody>tr>* { + border-color: hsla(203, 50%, 30%, 0.15) !important; + background-color: transparent; +} + +:where(table:not(.sb-anchor, .sb-unstyled, .sb-unstyled table)) tr:nth-of-type(2n) { + background-color: transparent !important; +} + +tr { + border-top: 0 !important; +} + +th { + border: 0 !important; +} + +h2#stories { + display: none; +} + +.tabbutton { + border-bottom: none !important +} + +.tabbutton.tabbutton-active { + color: rgb(120, 201, 230) !important; +} + +.toc-wrapper { + margin-top: -2.5rem !important; + font-family: 'Mulish', sans-serif !important; +} + +/* Custom Doc Styles */ + +.custom-docs { + position: relative; +} + +.acrylBg { + position: fixed; + bottom: 0; + left: -20px; + background-repeat: repeat; + z-index: 0; +} + +.acrylBg img { + filter: invert(8); +} + +.custom-docs p, +.docsDescription p, +.custom-docs li { + font-size: 16px; + line-height: 1.75; +} \ No newline at end of file diff --git a/datahub-web-react/.storybook/storybook-theme.js b/datahub-web-react/.storybook/storybook-theme.js new file mode 100644 index 00000000000000..462bf2f03da944 --- /dev/null +++ b/datahub-web-react/.storybook/storybook-theme.js @@ -0,0 +1,47 @@ +import { create } from '@storybook/theming'; +import brandImage from './storybook-logo.svg'; + +import theme, { typography } from '../src/alchemy-components/theme'; + +export default create({ + // config + base: 'light', + brandTitle: 'Acryl Design System', + brandUrl: '/?path=/docs/', + brandImage: brandImage, + brandTarget: '_self', + + // styles + fontBase: typography.fontFamily, + fontCode: 'monospace', + + colorPrimary: theme.semanticTokens.colors.primary, + colorSecondary: theme.semanticTokens.colors.secondary, + + // UI + appBg: theme.semanticTokens.colors['body-bg'], + appContentBg: theme.semanticTokens.colors['body-bg'], + appPreviewBg: theme.semanticTokens.colors['body-bg'], + appBorderColor: theme.semanticTokens.colors['border-color'], + appBorderRadius: 4, + + // Text colors + textColor: theme.semanticTokens.colors['body-text'], + textInverseColor: theme.semanticTokens.colors['inverse-text'], + textMutedColor: theme.semanticTokens.colors['subtle-text'], + + // Toolbar default and active colors + barTextColor: theme.semanticTokens.colors['body-text'], + barSelectedColor: theme.semanticTokens.colors['subtle-bg'], + barHoverColor: theme.semanticTokens.colors['subtle-bg'], + barBg: theme.semanticTokens.colors['body-bg'], + + // Form colors + inputBg: theme.semanticTokens.colors['body-bg'], + inputBorder: theme.semanticTokens.colors['border-color'], + inputTextColor: theme.semanticTokens.colors['body-text'], + inputBorderRadius: 4, + + // Grid + gridCellSize: 6, +}); \ No newline at end of file diff --git a/datahub-web-react/.storybook/styledComponents.ts b/datahub-web-react/.storybook/styledComponents.ts new file mode 100644 index 00000000000000..5951c810d89985 --- /dev/null +++ b/datahub-web-react/.storybook/styledComponents.ts @@ -0,0 +1,36 @@ +import { createGlobalStyle } from 'styled-components'; + +import '../src/fonts/Mulish-Regular.ttf'; +import '../src/fonts/Mulish-Medium.ttf'; +import '../src/fonts/Mulish-SemiBold.ttf'; +import '../src/fonts/Mulish-Bold.ttf'; + +export const GlobalStyle = createGlobalStyle` + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 400; + src: url('../src/fonts/Mulish-Regular.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 500; + src: url('../src/fonts/Mulish-Medium.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 600; + src: url('../src/fonts/Mulish-SemiBold.ttf) format('truetype'); + } + @font-face { + font-family: 'Mulish'; + font-style: normal; + font-weight: 700; + src: url('../src/fonts/Mulish-Bold.ttf) format('truetype'); + } + body { + font-family: 'Mulish', sans-serif; + } +`; \ No newline at end of file diff --git a/datahub-web-react/.storybook/webpack.config.js b/datahub-web-react/.storybook/webpack.config.js new file mode 100644 index 00000000000000..22e4ec1de63050 --- /dev/null +++ b/datahub-web-react/.storybook/webpack.config.js @@ -0,0 +1,13 @@ +const path = require('path'); + +module.exports = { + module: { + loaders: [ + { + test: /\.(png|woff|woff2|eot|ttf|svg)$/, + loaders: ['file-loader'], + include: path.resolve(__dirname, '../'), + }, + ], + }, +}; \ No newline at end of file diff --git a/datahub-web-react/package.json b/datahub-web-react/package.json index dcaef6004d7022..31c10804482f0c 100644 --- a/datahub-web-react/package.json +++ b/datahub-web-react/package.json @@ -9,8 +9,12 @@ "@ant-design/colors": "^5.0.0", "@ant-design/icons": "^4.3.0", "@apollo/client": "^3.3.19", + "@fontsource/mulish": "^5.0.16", + "@geometricpanda/storybook-addon-badges": "^2.0.2", "@graphql-codegen/fragment-matcher": "^5.0.0", "@monaco-editor/react": "^4.3.1", + "@mui/icons-material": "^5.15.21", + "@mui/material": "^5.15.21", "@react-hook/window-size": "^3.0.7", "@react-spring/web": "^9.7.3", "@remirror/pm": "^2.0.3", @@ -30,6 +34,7 @@ "@uiw/react-md-editor": "^3.3.4", "@visx/axis": "^3.1.0", "@visx/curve": "^3.0.0", + "@visx/gradient": "^3.3.0", "@visx/group": "^3.0.0", "@visx/hierarchy": "^3.0.0", "@visx/legend": "^3.2.0", @@ -93,7 +98,9 @@ "format-check": "prettier --check src", "format": "prettier --write src", "type-check": "tsc --noEmit", - "type-watch": "tsc -w --noEmit" + "type-watch": "tsc -w --noEmit", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build" }, "browserslist": { "production": [ @@ -112,6 +119,16 @@ "@graphql-codegen/near-operation-file-preset": "^1.17.13", "@graphql-codegen/typescript-operations": "1.17.13", "@graphql-codegen/typescript-react-apollo": "2.2.1", + "@storybook/addon-essentials": "^8.1.11", + "@storybook/addon-interactions": "^8.1.11", + "@storybook/addon-links": "^8.1.11", + "@storybook/addon-onboarding": "^8.1.11", + "@storybook/blocks": "^8.1.11", + "@storybook/builder-vite": "^8.1.11", + "@storybook/manager-api": "^8.1.11", + "@storybook/react-vite": "^8.1.11", + "@storybook/test": "^8.1.11", + "@storybook/theming": "^8.1.11", "@types/graphql": "^14.5.0", "@types/query-string": "^6.3.0", "@types/styled-components": "^5.1.7", @@ -132,6 +149,7 @@ "less": "^4.2.0", "prettier": "^2.8.8", "source-map-explorer": "^2.5.2", + "storybook": "^8.1.11", "vite": "^4.5.5", "vite-plugin-babel-macros": "^1.0.6", "vite-plugin-static-copy": "^0.17.0", diff --git a/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx b/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx new file mode 100644 index 00000000000000..75a31d011903f8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Contributing.mdx @@ -0,0 +1,43 @@ +import { Meta } from '@storybook/blocks'; + +<Meta title="Contributing" /> + +<div className="custom-docs"> + ## Contributing + + Building and maintinging a design system is a collaborative effort. We welcome contributions from all team members, regardless of their role or experience level. This document outlines the process for contributing to the Acryl Component Library. + + ### Development + + To run Storybook locally, use the following command: + + ``` + yarn storybook + ``` + + Storybook will start a local development server and open a new browser window with the Storybook interface on port `6006`. When developing new components or updating existing ones, you can use Storybook to preview your changes in real-time. This will ensure that the component looks and behaves as expected before merging your changes. + + ### Crafting New Components + + When creating new components, make sure to follow the established design patterns and coding standards. This will help maintain consistency across all Acryl products and make it easier for other team members to understand and use your components. + + Design new components with <strong>reusability in mind</strong>. Components should be flexible, extensible, and easy to customize. Avoid hardcoding values and use props to pass data and styles to your components. This will make it easier to reuse the component in different contexts and scenarios. + + Our design team works exclusively in Figma, so if questions arise about the design or implementation of a component, please refer to the Figma files for more information. If you have any questions or need clarification, feel free to reach out to the design team for assistance. + + ### Pull Requests + + When submitting a pull request, please follow these guidelines: + + 1. Create a new branch for your changes. + 2. Make sure your code is well-documented and follows the established coding standards. + 3. Write clear and concise commit messages. + 4. Include a detailed description of the changes in your pull request. + + If applicable, include screenshots or GIFs to demonstrate the changes visually. This will help reviewers understand the context of your changes and provide more accurate feedback. If a Figma file exists, include a link to the file in the pull request description. + + ### Review Process + + All pull requests will be reviewed by the UI and design team to ensure that the changes align with the design system guidelines and best practices. The team will provide feedback and suggestions for improvement, and you may be asked to make additional changes before your pull request is merged. + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx b/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx new file mode 100644 index 00000000000000..0ebdebbf9db4cb --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/DesignTokens.mdx @@ -0,0 +1,63 @@ +import { Meta, Source } from '@storybook/blocks'; + +import theme from '@components/theme'; + +import { ColorCard, CopyButton } from './mdx-components'; + +<Meta title="Design Tokens" /> + +<div className="custom-docs"> + ## Design Tokens + + To streamline the design process and ensure consistency across all Acryl products, we use a set of design tokens that define the visual properties of our design system. These tokens include colors, typography, spacing, and other visual elements that can be used to create a cohesive user experience. + + ### Colors + + ```tsx + import theme from '@components/theme'; + + // Accessing a color via object path + <div style={{ color: theme.semanticTokens.colors.primary }}>Hello, World!</div> + + // Using CSS variables + <div style={{ color: 'var(--alch-color-primary)' }}>Hello, World!</div> + ``` + + <table style={{ width: '100%' }}> + <thead style={{ textAlign: 'left' }}> + <tr> + <th>Token Value</th> + <th>Selector</th> + <th>CSS Variable <small>(coming soon)</small></th> + </tr> + </thead> + <tbody> + {Object.keys(theme.semanticTokens.colors).map((color) => { + const objectKey = `colors['${color}']`; + const hexValue = theme.semanticTokens.colors[color]; + const cssVar = `--alch-color-${color}`; + + return ( + <tr key={color}> + <td> + <ColorCard color={hexValue} size="sm"> + <span className="colorChip" /> + <div> + <span className="colorValue">{color}</span> + <span className="hex">{hexValue}</span> + </div> + </ColorCard> + </td> + <td> + <span style={{ display: 'flex', alignItems: 'center', fontSize: 'inherit' }}> + {objectKey} <CopyButton text={objectKey} /> + </span> + </td> + <td>{cssVar}</td> + </tr> + ); + })} + </tbody> + </table> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/Icons.mdx b/datahub-web-react/src/alchemy-components/.docs/Icons.mdx new file mode 100644 index 00000000000000..e3f6ab68461196 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Icons.mdx @@ -0,0 +1,34 @@ +import { Meta, Source } from '@storybook/blocks'; + +import { AVAILABLE_ICONS } from '@components'; +import { IconGalleryWithSearch } from './mdx-components'; + +<Meta title="Icons" /> + +<div className="custom-docs"> + ## Icons + + Under the hood, we're utilizing the Material Design Icon Library. However, we've crafted out own resuable component to make it easier to use these icons in our application. + + <a href="/?path=/docs/media-icon--docs"> + View the component documentation to learn more + </a> + + In addition to using Materials Design Icons, we've also added a few custom icons to the library. You can access them through the same `<Icon />` component and are represented in the list of available options below. + + ```tsx + import { Icon } from '@components'; + + <Icon icon="AccountCircle" /> + ``` + + <br /> + + ### Gallery + + There are {AVAILABLE_ICONS.length} icons available. <br /> + Name values populate the `icon` prop on the `<Icon />` component. + + <IconGalleryWithSearch icons={AVAILABLE_ICONS} /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/Intro.mdx b/datahub-web-react/src/alchemy-components/.docs/Intro.mdx new file mode 100644 index 00000000000000..f81d08059c7b44 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/Intro.mdx @@ -0,0 +1,14 @@ +import { Meta, Description } from '@storybook/blocks'; +import ReadMe from '../README.mdx'; + +<Meta title="Introduction" /> + +<div className="custom-docs"> + <div className="acrylBg"> + <img src="https://www.acryldata.io/images/logo-pattern.svg" alt="Acryl Logo" /> + </div> + + {/* To simply, we're rendering the root readme here */} + <ReadMe /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx b/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx new file mode 100644 index 00000000000000..43199cbbca62d1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/StyleGuide.mdx @@ -0,0 +1,209 @@ +import { Meta, Source } from '@storybook/blocks'; + +import { Heading } from '@components'; +import { colors } from '@components/theme'; + +import { Grid, FlexGrid, ColorCard, CopyButton, Seperator } from './mdx-components'; + +import borderSource from '@components/theme/foundations/borders?raw'; +import colorsSource from '@components/theme/foundations/colors?raw'; +import typographySource from '@components/theme/foundations/typography?raw'; +import radiusSource from '@components/theme/foundations/radius?raw'; +import shadowsSource from '@components/theme/foundations/shadows?raw'; +import sizesSource from '@components/theme/foundations/sizes?raw'; +import spacingSource from '@components/theme/foundations/spacing?raw'; +import transitionSource from '@components/theme/foundations/transition?raw'; +import zIndexSource from '@components/theme/foundations/zIndex?raw'; + +<Meta title="Style Guide" /> + +<div className="custom-docs"> + ## Style Guide + + The purpose of this Style Guide is to establish a unified and cohesive design language that ensures a consistent user experience across all Acryl products. By adhering to these guidelines, we can maintain a high standard of design quality and improve the usability of our applications. + + ### Theme + + You can import the theme object into any component or file in your application and use it to style your components. The theme object is a single source of truth for your application's design system. + + ```tsx + import { typography, colors, spacing } from '@components/theme'; + ``` + + ### Colors + + Colors are managed via the `colors.ts` file in the `theme/foundations` directory. The colors are defined as a nested object with the following structure: + + <Source code={colorsSource} /> + + By default, all `500` values are considered the "default" value of that color range. For example, `gray.500` is the default gray color. The other values are used for shading and highlighting. Color values are defined in hex format and their values range between 25 and 1000. With 25 being the lighest and 1000 being the darkest. + + #### Black & White + <FlexGrid> + <ColorCard color={colors['black']}> + <span className="colorChip" /> + <div> + <span className="colorValue">Black</span> + <span className="hex">{colors['black']}</span> + </div> + </ColorCard> + <ColorCard color={colors['white']}> + <span className="colorChip" /> + <div> + <span className="colorValue">White</span> + <span className="hex">{colors['white']}</span> + </div> + </ColorCard> + </FlexGrid> + + <Seperator /> + + #### Gray + <Grid> + {Object.keys(colors.gray).map((color) => ( + <ColorCard key={color} color={colors['gray'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Gray {color} <CopyButton text={`gray.${color}`} /> + </span> + <span className="hex">{colors['gray'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Violet (Primary) + <Grid> + {Object.keys(colors.violet).map((color) => ( + <ColorCard key={color} color={colors['violet'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Violet {color} <CopyButton text={`violet.${color}`} /> + </span> + <span className="hex">{colors['violet'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Blue + <Grid> + {Object.keys(colors.blue).map((color) => ( + <ColorCard key={color} color={colors['blue'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Blue {color} <CopyButton text={`blue.${color}`} /> + </span> + <span className="hex">{colors['blue'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Green + <Grid> + {Object.keys(colors.green).map((color) => ( + <ColorCard key={color} color={colors['green'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Green {color} <CopyButton text={`green.${color}`} /> + </span> + <span className="hex">{colors['green'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Yellow + <Grid> + {Object.keys(colors.yellow).map((color) => ( + <ColorCard key={color} color={colors['yellow'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Yellow {color} <CopyButton text={`yellow.${color}`} /> + </span> + <span className="hex">{colors['yellow'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + <Seperator /> + + #### Red + <Grid> + {Object.keys(colors.red).map((color) => ( + <ColorCard key={color} color={colors['red'][color]}> + <span className="colorChip" /> + <div> + <span className="colorValue"> + Red {color} <CopyButton text={`red.${color}`} /> + </span> + <span className="hex">{colors['red'][color]}</span> + </div> + </ColorCard> + ))} + </Grid> + + ### Typography + + Font styles are managed via the `typography.ts` file in the `theme/foundations` directory. The primary font family in use is `Mulish`. The font styles are defined as a nested object with the following structure: + + <Source code={typographySource} /> + + ### Borders + + A set of border values defined by the border key. + + <Source code={borderSource} /> + + ### Border Radius + + A set smooth corner radius values defined by the radii key. + + <Source code={radiusSource} /> + + ### Shadows + + A set of shadow values defined by the shadows key. + + <Source code={shadowsSource} /> + + ## Sizes + + A set of size values defined by the sizes key. + + <Source code={sizesSource} /> + + ### Spacing + + A set of spacing values defined by the spacing key. + + <Source code={spacingSource} /> + + ### Transitions + + A set of transition values defined by the transition key. + + <Source code={transitionSource} /> + + ### Z-Index + + A set of z-index values defined by the zindex key. + + <Source code={zIndexSource} /> + +</div> diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx new file mode 100644 index 00000000000000..43b9ebfae64149 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CodeBlock.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import { Source, DocsContext } from '@storybook/blocks'; + +export const CodeBlock = () => { + const context = React.useContext(DocsContext); + + const { primaryStory } = context as any; + const component = context ? primaryStory.component.__docgenInfo.displayName : ''; + + if (!context || !primaryStory) return null; + + return ( + <div> + <Source + code={` + import { ${component} } from '@components'; + `} + format + dark + /> + </div> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx new file mode 100644 index 00000000000000..c81aa6ed442892 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/CopyButton.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import { Button, Icon } from '@components'; +import { copyToClipboard } from './utils'; + +interface Props { + text: string; +} + +export const CopyButton = ({ text }: Props) => ( + <div style={{ display: 'inline-block' }}> + <Button variant="text" color="gray" size="sm" onClick={() => copyToClipboard(text)}> + <Icon icon="ContentCopy" size="xs" /> + </Button> + </div> +); diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx new file mode 100644 index 00000000000000..5cb4bd27e521a4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/GridList.tsx @@ -0,0 +1,32 @@ +/* + Docs Only Component that helps to display a list of components in a grid layout. +*/ + +import React, { ReactNode } from 'react'; + +const styles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', +}; + +interface Props { + isVertical?: boolean; + width?: number | string; + children: ReactNode; +} + +export const GridList = ({ isVertical = false, width = '100%', children }: Props) => { + return ( + <div + style={{ + ...styles, + width, + flexDirection: isVertical ? 'column' : 'row', + }} + > + {children} + </div> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx b/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx new file mode 100644 index 00000000000000..d8751509bd6a72 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/IconGalleryWithSearch.tsx @@ -0,0 +1,291 @@ +import React, { useState } from 'react'; + +import { Icon, Button, ButtonProps } from '@components'; +import { IconGrid, IconGridItem, IconDisplayBlock } from './components'; + +interface Props { + icons: string[]; +} + +export const IconGalleryWithSearch = ({ icons }: Props) => { + const [iconSet, setIconSet] = useState(icons); + const [search, setSearch] = useState(''); + const [variant, setVariant] = useState('outline'); + + const filteredIcons = iconSet.filter((icon) => icon.toLowerCase().includes(search.toLowerCase())); + + const arrows = [ + 'ArrowBack', + 'ArrowCircleDown', + 'ArrowCircleLeft', + 'ArrowCircleRight', + 'ArrowCircleUp', + 'ArrowDownward', + 'ArrowForward', + 'ArrowOutward', + 'ArrowUpward', + 'CloseFullscreen', + 'Cached', + 'Code', + 'CodeOff', + 'CompareArrows', + 'Compress', + 'ChevronLeft', + 'ChevronRight', + 'DoubleArrow', + 'FastForward', + 'FastRewind', + 'FileDownload', + 'FileUpload', + 'ForkLeft', + 'ForkRight', + 'GetApp', + 'LastPage', + 'Launch', + 'Login', + 'Logout', + 'LowPriority', + 'ManageHistory', + 'Merge', + 'MergeType', + 'MoveUp', + 'MultipleStop', + 'OpenInFull', + 'Outbound', + 'Outbox', + 'Output', + 'PlayArrow', + 'PlayCircle', + 'Publish', + 'ReadMore', + 'ExitToApp', + 'Redo', + 'Refresh', + 'Replay', + 'ReplyAll', + 'Reply', + 'Restore', + 'SaveAlt', + 'Shortcut', + 'SkipNext', + 'SkipPrevious', + 'Start', + 'Straight', + 'SubdirectoryArrowLeft', + 'SubdirectoryArrowRight', + 'SwapHoriz', + 'SwapVert', + 'SwitchLeft', + 'SwitchRight', + 'SyncAlt', + 'SyncDisabled', + 'SyncLock', + 'Sync', + 'Shuffle', + 'SyncProblem', + 'TrendingDown', + 'TrendingFlat', + 'TrendingUp', + 'TurnLeft', + 'TurnRight', + 'TurnSlightLeft', + 'TurnSlightRight', + 'Undo', + 'UnfoldLessDouble', + 'UnfoldLess', + 'UnfoldMoreDouble', + 'UnfoldMore', + 'UpdateDisabled', + 'Update', + 'Upgrade', + 'Upload', + 'ZoomInMap', + 'ZoomOutMap', + ]; + + const dataViz = [ + 'AccountTree', + 'Analytics', + 'ArtTrack', + 'Article', + 'BackupTable', + 'BarChart', + 'BubbleChart', + 'Calculate', + 'Equalizer', + 'List', + 'FormatListBulleted', + 'FormatListNumbered', + 'Grading', + 'InsertChart', + 'Hub', + 'Insights', + 'Lan', + 'Leaderboard', + 'LegendToggle', + 'Map', + 'MultilineChart', + 'Nat', + 'PivotTableChart', + 'Poll', + 'Polyline', + 'QueryStats', + 'Radar', + 'Route', + 'Rule', + 'Schema', + 'Sort', + 'SortByAlpha', + 'ShowChart', + 'Source', + 'SsidChart', + 'StackedBarChart', + 'StackedLineChart', + 'Storage', + 'TableChart', + 'TableRows', + 'TableView', + 'Timeline', + 'ViewAgenda', + 'ViewArray', + 'ViewCarousel', + 'ViewColumn', + 'ViewComfy', + 'ViewCompact', + 'ViewCozy', + 'ViewDay', + 'ViewHeadline', + 'ViewKanban', + 'ViewList', + 'ViewModule', + 'ViewQuilt', + 'ViewSidebar', + 'ViewStream', + 'ViewTimeline', + 'ViewWeek', + 'Visibility', + 'VisibilityOff', + 'Webhook', + 'Window', + ]; + + const social = [ + 'AccountCircle', + 'Badge', + 'Campaign', + 'Celebration', + 'Chat', + 'ChatBubble', + 'CommentBank', + 'Comment', + 'CommentsDisabled', + 'Message', + 'ContactPage', + 'Contacts', + 'GroupAdd', + 'Group', + 'GroupRemove', + 'Groups', + 'Handshake', + 'ManageAccounts', + 'MoodBad', + 'SentimentDissatisfied', + 'SentimentNeutral', + 'SentimentSatisfied', + 'Mood', + 'NoAccounts', + 'People', + 'PersonAddAlt1', + 'PersonOff', + 'Person', + 'PersonRemoveAlt1', + 'PersonSearch', + 'SwitchAccount', + 'StarBorder', + 'StarHalf', + 'Star', + 'ThumbDown', + 'ThumbUp', + 'ThumbsUpDown', + 'Verified', + 'VerifiedUser', + ]; + + const notifs = [ + 'Mail', + 'Drafts', + 'MarkAsUnread', + 'Inbox', + 'Outbox', + 'MoveToInbox', + 'Unsubscribe', + 'Upcoming', + 'NotificationAdd', + 'NotificationImportant', + 'NotificationsActive', + 'NotificationsOff', + 'Notifications', + 'NotificationsPaused', + ]; + + const handleChangeSet = (set) => { + setIconSet(set); + setSearch(''); + }; + + const handleResetSet = () => { + setIconSet(icons); + setSearch(''); + }; + + const smButtonProps: ButtonProps = { + size: 'sm', + color: 'gray', + }; + + return ( + <> + <input + type="search" + value={search} + onChange={(e) => setSearch(e.target.value)} + placeholder="Search for an icon…" + style={{ width: '100%', padding: '0.5rem', marginBottom: '0.5rem' }} + /> + <div style={{ display: 'flex', justifyContent: 'space-between', gap: '8px' }}> + <div style={{ display: 'flex', gap: '8px' }}> + <Button onClick={handleResetSet} {...smButtonProps}> + All + </Button> + <Button onClick={() => handleChangeSet(arrows)} {...smButtonProps}> + Arrows + </Button> + <Button onClick={() => handleChangeSet(dataViz)} {...smButtonProps}> + Data Viz + </Button> + <Button onClick={() => handleChangeSet(social)} {...smButtonProps}> + Social + </Button> + <Button onClick={() => handleChangeSet(notifs)} {...smButtonProps}> + Notifications + </Button> + </div> + <div style={{ display: 'flex', gap: '8px' }}> + <Button onClick={() => setVariant(variant === 'outline' ? 'filled' : 'outline')} {...smButtonProps}> + Variant: {variant === 'filled' ? 'Filled' : 'Outline'} + </Button> + </div> + </div> + <IconGrid> + {filteredIcons.map((icon) => ( + <IconGridItem> + <IconDisplayBlock key={icon} title={icon}> + <Icon icon={icon} variant={variant as any} size="2xl" /> + </IconDisplayBlock> + <span>{icon}</span> + </IconGridItem> + ))} + </IconGrid> + </> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts new file mode 100644 index 00000000000000..28d428493b17b2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/components.ts @@ -0,0 +1,110 @@ +/* + Docs Only Components that helps to display information in info guides. +*/ + +import styled from 'styled-components'; + +import theme from '@components/theme'; + +export const Grid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; +`; + +export const FlexGrid = styled.div` + display: flex; + gap: 16px; +`; + +export const VerticalFlexGrid = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const Seperator = styled.div` + height: 16px; +`; + +export const ColorCard = styled.div<{ color: string; size?: string }>` + display: flex; + gap: 16px; + align-items: center; + + ${({ size }) => + size === 'sm' && + ` + gap: 8px; + `} + + & span { + display: block; + line-height: 1.3; + } + + & .colorChip { + background: ${({ color }) => color}; + width: 3rem; + height: 3rem; + + ${({ size }) => + size === 'sm' && + ` + width: 2rem; + height: 2rem; + border-radius: 4px; + `} + + border-radius: 8px; + box-shadow: rgba(0, 0, 0, 0.06) 0px 2px 4px 0px inset; + } + + & .colorValue { + display: flex; + align-items: center; + gap: 0; + font-weight: bold; + font-size: 14px; + } + + & .hex { + font-size: 11px; + opacity: 0.5; + text-transform: uppercase; + } +`; + +export const IconGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 16px; + margin-top: 20px; +`; + +export const IconGridItem = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + + border: 1px solid ${theme.semanticTokens.colors['border-color']}; + border-radius: 8px; + overflow: hidden; + + & span { + width: 100%; + border-top: 1px solid ${theme.semanticTokens.colors['border-color']}; + background-color: ${theme.semanticTokens.colors['subtle-bg']}; + text-align: center; + padding: 4px 8px; + font-size: 10px; + } +`; + +export const IconDisplayBlock = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 50px; +`; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts new file mode 100644 index 00000000000000..d1c1848d1eb378 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/index.ts @@ -0,0 +1,6 @@ +export * from './CodeBlock'; +export * from './CopyButton'; +export * from './GridList'; +export * from './IconGalleryWithSearch'; +export * from './components'; +export * from './utils'; diff --git a/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts b/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts new file mode 100644 index 00000000000000..d4fa47dc9e9674 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/.docs/mdx-components/utils.ts @@ -0,0 +1,15 @@ +/* + Docs related utils +*/ + +/** + * Copies the given text to the clipboard. + * @param {string} text - The text to be copied to the clipboard. + * @returns {Promise<void>} A promise that resolves when the text is copied. + */ +export const copyToClipboard = (text: string) => { + return navigator.clipboard + .writeText(text) + .then(() => console.log(`${text} copied to clipboard`)) + .catch(); +}; diff --git a/datahub-web-react/src/alchemy-components/README.mdx b/datahub-web-react/src/alchemy-components/README.mdx new file mode 100644 index 00000000000000..5373432c0ede03 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/README.mdx @@ -0,0 +1,73 @@ +# Alchemy Component Library + +This is a comprehensive library of accessible and reusable React components that streamlines the development of Acryl's applications and websites. The library offers a diverse range of components that can be easily combined to build complex user interfaces while adhering to accessibility best practices. + +### Component Usage + +It's easy to use the components availble in the library. Simply import the component and use it anywhere you're rendering React components. + +```tsx +import { Button } from '@components'; + +function YourComponent() { + return <Button>Click me!</Button>; +} +``` + +In addition to the components themselves, you can also import their types: + +```tsx +import type { ButtonProps } from '@components'; +``` + +### Theme Usage + +This component library comes with a complete theme utility that pre-defines all of our styling atoms and makes them accessible at `@components/theme`. + +```tsx +import { colors } from '@components/theme'; + +function YourComponent() { + return ( + <div style={{ bgColor: colors.green.400 }}> + This div has a green background! + </div> + ) +} +``` + +You can access the theme types at `@components/theme/types` and the theme config at `@components/theme/config`. + +### Writing Docs + +Our docs are generated using [Storybook](https://storybook.js.org/) and deployed to [Cloudfare](https://www.cloudflare.com/). + +- Storybook config is located at `.storybook` +- Static doc files are located at `alchemy-components/.docs` +- Component stories are located in each component directory: <br/>`alchemy-components/components/Component/Component.stories.tsx` + +Storybook serves as our playground for developing components. You can start it locally: + +```bash +yarn storybook +``` + +This launches the docs app at `localhost:6006` and enables everything you need to quickly develop and document components. + +### Contributing + +Building a component library is a collaboriate effort! We're aiming to provide a first-class experience, so here's a list of the standards we'll be looking for: + +- Consitent prop and variant naming conventions: <br /> + -- `variant` is used to define style types, such as `outline` or `filled`. <br /> + -- `color` is used to define the components color, such as `violet` or `blue`. <br /> + -- `size` is used to define the components size, such as `xs` or `4xl`. <br /> + -- Booleans are prefixed with `is`: `isLoading` or `isDisabled`. +- All style props have a correseponding theme type, ie. `FontSizeOptions`. +- All components have an export of default props. +- Styles are defined using `style objects` instead of `tagged template literals`. +- Stories are organized into the correct directory . + +### FAQs + +- **How are components being styled?** <br />Our components are built using [Styled Components](https://styled-components.com/) that dynamically generate styles based on variant selection. diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx new file mode 100644 index 00000000000000..09d0d37f15421a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,133 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { GridList } from '@src/alchemy-components/.docs/mdx-components'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Avatar, avatarDefaults } from './Avatar'; + +const IMAGE_URL = + 'https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/78/cb/e1/78cbe16d-28d9-057e-9f73-524c32eb5fe5/AppIcon-0-0-1x_U007emarketing-0-7-0-85-220.png/512x512bb.jpg'; + +// Auto Docs +const meta = { + title: 'Components / Avatar', + component: Avatar, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to render a user pill with picture and name', + }, + }, + + // Component-level argTypes + argTypes: { + name: { + description: 'Name of the user.', + table: { + defaultValue: { summary: `${avatarDefaults.name}` }, + }, + control: 'text', + }, + imageUrl: { + description: 'URL of the user image.', + control: 'text', + }, + onClick: { + description: 'On click function for the Avatar.', + }, + size: { + description: 'Size of the Avatar.', + table: { + defaultValue: { summary: `${avatarDefaults.size}` }, + }, + control: 'select', + }, + showInPill: { + description: 'Whether Avatar is shown in pill format with name.', + table: { + defaultValue: { summary: `${avatarDefaults.showInPill}` }, + }, + control: 'boolean', + }, + + isOutlined: { + description: 'Whether Avatar is outlined.', + table: { + defaultValue: { summary: `${avatarDefaults.isOutlined}` }, + }, + control: 'boolean', + }, + }, + + // Define defaults + args: { + name: 'John Doe', + size: 'default', + showInPill: false, + isOutlined: false, + }, +} satisfies Meta<typeof Avatar>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Avatar {...props} />, +}; + +export const sizes = () => ( + <GridList> + <Avatar name="John Doe" size="lg" /> + <Avatar name="John Doe" size="md" /> + <Avatar name="John Doe" size="default" /> + <Avatar name="John Doe" size="sm" /> + </GridList> +); + +export const withImage = () => ( + <GridList> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="lg" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="md" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="default" /> + <Avatar name="John Doe" imageUrl={IMAGE_URL} size="sm" /> + </GridList> +); + +export const pills = () => ( + <GridList isVertical> + <GridList> + <Avatar name="John Doe" size="lg" showInPill /> + <Avatar name="John Doe" size="md" showInPill /> + <Avatar name="John Doe" size="default" showInPill /> + <Avatar name="John Doe" size="sm" showInPill /> + </GridList> + <GridList> + <Avatar name="John Doe" size="lg" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="md" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="default" imageUrl={IMAGE_URL} showInPill /> + <Avatar name="John Doe" size="sm" imageUrl={IMAGE_URL} showInPill /> + </GridList> + </GridList> +); + +export const outlined = () => ( + <GridList> + <Avatar name="John Doe" size="lg" imageUrl={IMAGE_URL} isOutlined /> + <Avatar name="John Doe" size="lg" showInPill imageUrl={IMAGE_URL} isOutlined /> + </GridList> +); + +export const withOnClick = () => ( + <GridList> + <Avatar name="John Doe" onClick={() => window.alert('Avatar clicked')} /> + <Avatar name="John Doe" onClick={() => window.alert('Avatar clicked')} showInPill /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx new file mode 100644 index 00000000000000..9e5ec025e08e3d --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/Avatar.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { AvatarImage, AvatarImageWrapper, AvatarText, Container } from './components'; +import { AvatarProps } from './types'; +import getAvatarColor, { getNameInitials } from './utils'; + +export const avatarDefaults: AvatarProps = { + name: 'User name', + size: 'default', + showInPill: false, + isOutlined: false, +}; + +export const Avatar = ({ + name = avatarDefaults.name, + imageUrl, + size = avatarDefaults.size, + onClick, + showInPill = avatarDefaults.showInPill, + isOutlined = avatarDefaults.isOutlined, +}: AvatarProps) => { + const [hasError, setHasError] = useState(false); + + return ( + <Container onClick={onClick} $hasOnClick={!!onClick} $showInPill={showInPill}> + <AvatarImageWrapper + $color={getAvatarColor(name)} + $size={size} + $isOutlined={isOutlined} + $hasImage={!!imageUrl} + > + {!hasError && imageUrl ? ( + <AvatarImage src={imageUrl} onError={() => setHasError(true)} /> + ) : ( + <>{getNameInitials(name)} </> + )} + </AvatarImageWrapper> + {showInPill && <AvatarText $size={size}>{name}</AvatarText>} + </Container> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts b/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts new file mode 100644 index 00000000000000..54bb258acb0d81 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/_tests_/getNameInitials.test.ts @@ -0,0 +1,34 @@ +import { getNameInitials } from '../utils'; + +describe('get initials of the name', () => { + it('get initials of name with first name and last name', () => { + expect(getNameInitials('John Doe ')).toEqual('JD'); + }); + it('get initials of name with first name and last name in lower case', () => { + expect(getNameInitials('john doe')).toEqual('JD'); + }); + it('get initials of name with only first name', () => { + expect(getNameInitials('Robert')).toEqual('RO'); + }); + it('get initials of name with only first name in lower case', () => { + expect(getNameInitials('robert')).toEqual('RO'); + }); + it('get initials of name with three names', () => { + expect(getNameInitials('James Edward Brown')).toEqual('JB'); + }); + it('get initials of name with four names', () => { + expect(getNameInitials('Michael James Alexander Scott')).toEqual('MS'); + }); + it('get initials of name with a hyphen', () => { + expect(getNameInitials('Mary-Jane Watson')).toEqual('MW'); + }); + it('get initials of name with an apostrophe', () => { + expect(getNameInitials("O'Connor")).toEqual('OC'); + }); + it('get initials of name with a single letter', () => { + expect(getNameInitials('J')).toEqual('J'); + }); + it('get initials of name with an empty string', () => { + expect(getNameInitials('')).toEqual(''); + }); +}); diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/components.ts b/datahub-web-react/src/alchemy-components/components/Avatar/components.ts new file mode 100644 index 00000000000000..bcd23a8ab086c9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/components.ts @@ -0,0 +1,51 @@ +import { colors } from '@src/alchemy-components/theme'; +import { AvatarSizeOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; +import { getAvatarColorStyles, getAvatarNameSizes, getAvatarSizes } from './utils'; + +export const Container = styled.div<{ $hasOnClick: boolean; $showInPill?: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + border-radius: 20px; + border: ${(props) => props.$showInPill && `1px solid ${colors.gray[100]}`}; + padding: ${(props) => props.$showInPill && '3px 6px 3px 4px'}; + + ${(props) => + props.$hasOnClick && + ` + :hover { + cursor: pointer; + } + `} +`; + +export const AvatarImageWrapper = styled.div<{ + $color: string; + $size?: AvatarSizeOptions; + $isOutlined?: boolean; + $hasImage?: boolean; +}>` + ${(props) => getAvatarSizes(props.$size)} + + border-radius: 50%; + color: ${(props) => props.$color}; + border: ${(props) => props.$isOutlined && `1px solid ${colors.gray[1800]}`}; + display: flex; + align-items: center; + justify-content: center; + ${(props) => !props.$hasImage && getAvatarColorStyles(props.$color)} +`; + +export const AvatarImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +`; + +export const AvatarText = styled.span<{ $size?: AvatarSizeOptions }>` + color: ${colors.gray[1700]}; + font-weight: 600; + font-size: ${(props) => getAvatarNameSizes(props.$size)}; +`; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/index.ts b/datahub-web-react/src/alchemy-components/components/Avatar/index.ts new file mode 100644 index 00000000000000..d3fb6dfa7c09e1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from './Avatar'; diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/types.ts b/datahub-web-react/src/alchemy-components/components/Avatar/types.ts new file mode 100644 index 00000000000000..98c554b620dcbd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/types.ts @@ -0,0 +1,10 @@ +import { AvatarSizeOptions } from '@src/alchemy-components/theme/config'; + +export interface AvatarProps { + name: string; + imageUrl?: string; + onClick?: () => void; + size?: AvatarSizeOptions; + showInPill?: boolean; + isOutlined?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts b/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts new file mode 100644 index 00000000000000..46b2ee25488b89 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Avatar/utils.ts @@ -0,0 +1,64 @@ +import { colors } from '@src/alchemy-components/theme'; + +export const getNameInitials = (userName: string) => { + if (!userName) return ''; + const names = userName.trim().split(/[\s']+/); // Split by spaces or apostrophes + if (names.length === 1) { + const firstName = names[0]; + return firstName.length > 1 ? firstName[0]?.toUpperCase() + firstName[1]?.toUpperCase() : firstName[0]; + } + return names[0][0]?.toUpperCase() + names[names.length - 1][0]?.toUpperCase() || ''; +}; + +export function hashString(str: string) { + let hash = 0; + if (str.length === 0) { + return hash; + } + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + // eslint-disable-next-line + hash = (hash << 5) - hash + char; + // eslint-disable-next-line + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash); +} + +const colorMap = { + [colors.violet[500]]: { backgroundColor: colors.gray[1000], border: `1px solid ${colors.violet[1000]}` }, + [colors.blue[1000]]: { backgroundColor: colors.gray[1100], border: `1px solid ${colors.blue[200]}` }, + [colors.gray[600]]: { backgroundColor: colors.gray[1500], border: `1px solid ${colors.gray[100]}` }, +}; + +const avatarColors = Object.keys(colorMap); + +export const getAvatarColorStyles = (color) => { + return { + ...colorMap[color], + }; +}; + +export default function getAvatarColor(name: string) { + return avatarColors[hashString(name) % avatarColors.length]; +} + +export const getAvatarSizes = (size) => { + const sizeMap = { + sm: { width: '18px', height: '18px', fontSize: '8px' }, + md: { width: '24px', height: '24px', fontSize: '12px' }, + lg: { width: '28px', height: '28px', fontSize: '14px' }, + default: { width: '20px', height: '20px', fontSize: '10px' }, + }; + + return { + ...sizeMap[size], + }; +}; + +export const getAvatarNameSizes = (size) => { + if (size === 'lg') return '16px'; + if (size === 'sm') return '10px'; + if (size === 'md') return '14px'; + return '12px'; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx b/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx new file mode 100644 index 00000000000000..88d499226feafd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/Badge.stories.tsx @@ -0,0 +1,102 @@ +import React from 'react'; + +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { Badge, badgeDefault } from './Badge'; +import pillMeta from '../Pills/Pill.stories'; +import { omitKeys } from './utils'; + +const pillMetaArgTypes = omitKeys(pillMeta.argTypes, ['label']); +const pillMetaArgs = omitKeys(pillMeta.args, ['label']); + +const meta = { + title: 'Components / Badge', + component: Badge, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to get badge', + }, + }, + + // Component-level argTypes + argTypes: { + count: { + description: 'Count to show.', + table: { + defaultValue: { summary: `${badgeDefault.count}` }, + }, + control: { + type: 'number', + }, + }, + overflowCount: { + description: 'Max count to show.', + table: { + defaultValue: { summary: `${badgeDefault.overflowCount}` }, + }, + control: { + type: 'number', + }, + }, + showZero: { + description: 'Whether to show badge when `count` is zero.', + table: { + defaultValue: { summary: `${badgeDefault.showZero}` }, + }, + control: { + type: 'boolean', + }, + }, + ...pillMetaArgTypes, + }, + + // Define defaults + args: { + count: 100, + overflowCount: badgeDefault.overflowCount, + showZero: badgeDefault.showZero, + ...pillMetaArgs, + }, +} satisfies Meta<typeof Badge>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Badge {...props} />, +}; + +export const sizes = () => ( + <GridList> + <Badge count={100} /> + <Badge count={100} size="sm" /> + <Badge count={100} size="lg" /> + </GridList> +); + +export const colors = () => ( + <GridList> + <Badge count={100} /> + <Badge count={100} colorScheme="violet" /> + <Badge count={100} colorScheme="green" /> + <Badge count={100} colorScheme="red" /> + <Badge count={100} colorScheme="blue" /> + <Badge count={100} colorScheme="gray" /> + </GridList> +); + +export const withIcon = () => ( + <GridList> + <Badge count={100} leftIcon="AutoMode" /> + <Badge count={100} rightIcon="Close" /> + <Badge count={100} leftIcon="AutoMode" rightIcon="Close" /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx b/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx new file mode 100644 index 00000000000000..1c934ef120eee8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/Badge.tsx @@ -0,0 +1,29 @@ +import { Pill } from '@components'; +import React, { useMemo } from 'react'; + +import { BadgeProps } from './types'; +import { formatBadgeValue } from './utils'; +import { BadgeContainer } from './components'; + +export const badgeDefault: BadgeProps = { + count: 0, + overflowCount: 99, + showZero: false, +}; + +export function Badge({ + count = badgeDefault.count, + overflowCount = badgeDefault.overflowCount, + showZero = badgeDefault.showZero, + ...props +}: BadgeProps) { + const label = useMemo(() => formatBadgeValue(count, overflowCount), [count, overflowCount]); + + if (!showZero && count === 0) return null; + + return ( + <BadgeContainer title={`${count}`}> + <Pill label={label} {...props} /> + </BadgeContainer> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Badge/components.ts b/datahub-web-react/src/alchemy-components/components/Badge/components.ts new file mode 100644 index 00000000000000..a7791cd4f5ff88 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/components.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components'; + +export const BadgeContainer = styled.div({ + // Base root styles + display: 'inline-flex', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Badge/index.ts b/datahub-web-react/src/alchemy-components/components/Badge/index.ts new file mode 100644 index 00000000000000..26a9e305c7ffd5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/index.ts @@ -0,0 +1 @@ +export { Badge } from './Badge'; diff --git a/datahub-web-react/src/alchemy-components/components/Badge/types.ts b/datahub-web-react/src/alchemy-components/components/Badge/types.ts new file mode 100644 index 00000000000000..21348f2a083419 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/types.ts @@ -0,0 +1,8 @@ +import { HTMLAttributes } from 'react'; +import { PillProps } from '../Pills/types'; + +export interface BadgeProps extends HTMLAttributes<HTMLElement>, Omit<PillProps, 'label'> { + count: number; + overflowCount?: number; + showZero?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Badge/utils.ts b/datahub-web-react/src/alchemy-components/components/Badge/utils.ts new file mode 100644 index 00000000000000..e59ec2af998e74 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Badge/utils.ts @@ -0,0 +1,15 @@ +export const formatBadgeValue = (value: number, overflowCount?: number): string => { + if (overflowCount === undefined || value < overflowCount) return String(value); + + return `${overflowCount}+`; +}; + +export function omitKeys<T extends object, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> { + const { ...rest } = obj; + + keys.forEach((key) => { + delete rest[key]; + }); + + return rest; +} diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx new file mode 100644 index 00000000000000..1258ff398c0a7e --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import { BarChart } from './BarChart'; +import { getMockedProps } from './utils'; + +const meta = { + title: 'Charts / BarChart', + component: BarChart, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to show BarChart', + }, + }, + + // Component-level argTypes + argTypes: { + data: { + description: 'Array of datum to show', + }, + xAccessor: { + description: 'A function to convert datum to value of X', + }, + yAccessor: { + description: 'A function to convert datum to value of Y', + }, + renderTooltipContent: { + description: 'A function to replace default rendering of toolbar', + }, + margin: { + description: 'Add margins to chart', + }, + leftAxisTickFormat: { + description: 'A function to format labels of left axis', + }, + leftAxisTickLabelProps: { + description: 'Props for label of left axis', + }, + bottomAxisTickFormat: { + description: 'A function to format labels of bottom axis', + }, + bottomAxisTickLabelProps: { + description: 'Props for label of bottom axis', + }, + barColor: { + description: 'Color of bar', + control: { + type: 'color', + }, + }, + barSelectedColor: { + description: 'Color of selected bar', + control: { + type: 'color', + }, + }, + gridColor: { + description: "Color of grid's lines", + control: { + type: 'color', + }, + }, + renderGradients: { + description: 'A function to render different gradients that can be used as colors', + }, + }, + + // Define defaults + args: { + ...getMockedProps(), + renderTooltipContent: (datum) => <>DATUM: {JSON.stringify(datum)}</>, + }, +} satisfies Meta<typeof BarChart>; + +export default meta; + +type Story = StoryObj<typeof meta>; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => ( + <div style={{ width: '900px', height: '350px' }}> + <BarChart {...props} /> + </div> + ), +}; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx new file mode 100644 index 00000000000000..eb5465a1d1217b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/BarChart.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import { colors } from '@src/alchemy-components/theme'; +import { TickLabelProps } from '@visx/axis'; +import { LinearGradient } from '@visx/gradient'; +import { ParentSize } from '@visx/responsive'; +import { Axis, AxisScale, BarSeries, Grid, Tooltip, XYChart } from '@visx/xychart'; +import dayjs from 'dayjs'; +import { Popover } from '../Popover'; +import { ChartWrapper, StyledBarSeries } from './components'; +import { BarChartProps } from './types'; +import { abbreviateNumber } from '../dataviz/utils'; + +const commonTickLabelProps: TickLabelProps<any> = { + fontSize: 10, + fontFamily: 'Mulish', + fill: colors.gray[1700], +}; + +export const barChartDefault: BarChartProps<any> = { + data: [], + xAccessor: (datum) => datum?.x, + yAccessor: (datum) => datum?.y, + leftAxisTickFormat: abbreviateNumber, + leftAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'end', + }, + bottomAxisTickFormat: (value) => dayjs(value).format('DD MMM'), + bottomAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'middle', + verticalAnchor: 'start', + width: 20, + }, + barColor: 'url(#bar-gradient)', + barSelectedColor: colors.violet[500], + gridColor: '#e0e0e0', + renderGradients: () => <LinearGradient id="bar-gradient" from={colors.violet[500]} to="#917FFF" toOpacity={0.6} />, +}; + +export function BarChart<DatumType extends object = any>({ + data, + xAccessor = barChartDefault.xAccessor, + yAccessor = barChartDefault.yAccessor, + renderTooltipContent, + margin, + leftAxisTickFormat = barChartDefault.leftAxisTickFormat, + leftAxisTickLabelProps = barChartDefault.leftAxisTickLabelProps, + bottomAxisTickFormat = barChartDefault.bottomAxisTickFormat, + bottomAxisTickLabelProps = barChartDefault.bottomAxisTickLabelProps, + barColor = barChartDefault.barColor, + barSelectedColor = barChartDefault.barSelectedColor, + gridColor = barChartDefault.gridColor, + renderGradients = barChartDefault.renderGradients, +}: BarChartProps<DatumType>) { + const [hasSelectedBar, setHasSelectedBar] = useState<boolean>(false); + + // FYI: additional margins to show left and bottom axises + const internalMargin = { + top: (margin?.top ?? 0) + 30, + right: margin?.right ?? 0, + bottom: (margin?.bottom ?? 0) + 35, + left: (margin?.left ?? 0) + 40, + }; + + const accessors = { xAccessor, yAccessor }; + + return ( + <ChartWrapper> + <ParentSize> + {({ width, height }) => { + return ( + <XYChart + width={width} + height={height} + xScale={{ type: 'band', paddingInner: 0.4, paddingOuter: 0.1 }} + yScale={{ type: 'linear', nice: true, round: true }} + margin={internalMargin} + captureEvents={false} + > + {renderGradients?.()} + + <Axis + orientation="left" + hideAxisLine + hideTicks + tickFormat={leftAxisTickFormat} + tickLabelProps={leftAxisTickLabelProps} + /> + + <Axis + orientation="bottom" + numTicks={data.length} + tickFormat={bottomAxisTickFormat} + tickLabelProps={bottomAxisTickLabelProps} + hideAxisLine + hideTicks + /> + + <line + x1={internalMargin.left} + x2={internalMargin.left} + y1={0} + y2={height - internalMargin.bottom} + stroke={gridColor} + /> + + <Grid rows columns={false} stroke={gridColor} strokeWidth={1} lineStyle={{}} /> + + <StyledBarSeries + as={BarSeries<AxisScale, AxisScale, DatumType>} + $hasSelectedItem={hasSelectedBar} + $color={barColor} + $selectedColor={barSelectedColor} + dataKey="bar-seria-0" + data={data} + radius={4} + radiusTop + onBlur={() => setHasSelectedBar(false)} + onFocus={() => setHasSelectedBar(true)} + // Internally the library doesn't emmit these events if handlers are empty + // They are requred to show/hide/move tooltip + onPointerMove={() => null} + onPointerUp={() => null} + onPointerOut={() => null} + {...accessors} + /> + + <Tooltip<DatumType> + snapTooltipToDatumX + snapTooltipToDatumY + unstyled + applyPositionStyle + renderTooltip={({ tooltipData }) => { + return ( + tooltipData?.nearestDatum && ( + <Popover + open + placement="topLeft" + content={renderTooltipContent?.(tooltipData.nearestDatum.datum)} + /> + ) + ); + }} + /> + </XYChart> + ); + }} + </ParentSize> + </ChartWrapper> + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx b/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx new file mode 100644 index 00000000000000..aa8f1320ef21dd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/components.tsx @@ -0,0 +1,34 @@ +import { colors } from '@src/alchemy-components/theme'; +import { BarSeries } from '@visx/xychart'; +import styled from 'styled-components'; + +export const ChartWrapper = styled.div` + width: 100%; + height: 100%; + position: relative; +`; + +export const StyledBarSeries = styled(BarSeries)<{ + $hasSelectedItem?: boolean; + $color?: string; + $selectedColor?: string; +}>` + & { + cursor: pointer; + + fill: ${(props) => (props.$hasSelectedItem ? props.$selectedColor : props.$color) || colors.violet[500]}; + ${(props) => props.$hasSelectedItem && 'opacity: 0.3;'} + + :hover { + fill: ${(props) => props.$selectedColor || colors.violet[500]}; + filter: drop-shadow(0px -2px 5px rgba(33, 23, 95, 0.3)); + opacity: 1; + } + + :focus { + fill: ${(props) => props.$selectedColor || colors.violet[500]}; + outline: none; + opacity: 1; + } + } +`; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/index.ts b/datahub-web-react/src/alchemy-components/components/BarChart/index.ts new file mode 100644 index 00000000000000..fdfc3f3ab44a89 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/index.ts @@ -0,0 +1 @@ +export { BarChart } from './BarChart'; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/types.ts b/datahub-web-react/src/alchemy-components/components/BarChart/types.ts new file mode 100644 index 00000000000000..5fd7e2e63e2411 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/types.ts @@ -0,0 +1,18 @@ +import { TickFormatter, TickLabelProps } from '@visx/axis'; +import { Margin } from '@visx/xychart'; + +export type BarChartProps<DatumType extends object> = { + data: DatumType[]; + xAccessor: (datum: DatumType) => string | number; + yAccessor: (datum: DatumType) => number; + renderTooltipContent?: (datum: DatumType) => React.ReactNode; + margin?: Margin; + leftAxisTickFormat?: TickFormatter<DatumType>; + leftAxisTickLabelProps?: TickLabelProps<DatumType>; + bottomAxisTickFormat?: TickFormatter<DatumType>; + bottomAxisTickLabelProps?: TickLabelProps<DatumType>; + barColor?: string; + barSelectedColor?: string; + gridColor?: string; + renderGradients?: () => React.ReactNode; +}; diff --git a/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts b/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts new file mode 100644 index 00000000000000..0b592da7f59b08 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/BarChart/utils.ts @@ -0,0 +1,26 @@ +import dayjs from 'dayjs'; + +export function generateMockData(length = 30, maxValue = 50_000, minValue = 0) { + return Array(length) + .fill(0) + .map((_, index) => { + const date = dayjs() + .startOf('day') + .add(index - length, 'days') + .toDate(); + const value = Math.max(Math.random() * maxValue, minValue); + + return { + x: date, + y: value, + }; + }); +} + +export function getMockedProps() { + return { + data: generateMockData(), + xAccessor: (datum) => datum.x, + yAccessor: (datum) => Math.max(datum.y, 1000), + }; +} diff --git a/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx b/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx new file mode 100644 index 00000000000000..e2d7c2852da519 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/Button.stories.tsx @@ -0,0 +1,203 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '@components'; + +import { Button, buttonDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Forms / Button', + component: Button, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: + 'Buttons are used to trigger an action or event, such as submitting a form, opening a dialog, canceling an action, or performing a delete operation.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content of the Button.', + control: { + type: 'text', + }, + }, + variant: { + description: 'The variant of the Button.', + options: ['filled', 'outline', 'text'], + table: { + defaultValue: { summary: buttonDefaults.variant }, + }, + control: { + type: 'radio', + }, + }, + color: { + description: 'The color of the Button.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: buttonDefaults.color }, + }, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the Button.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: buttonDefaults.size }, + }, + control: { + type: 'select', + }, + }, + icon: { + description: 'The icon to display in the Button.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + iconPosition: { + description: 'The position of the icon in the Button.', + options: ['left', 'right'], + table: { + defaultValue: { summary: buttonDefaults.iconPosition }, + }, + control: { + type: 'radio', + }, + }, + isCircle: { + description: + 'Whether the Button should be a circle. If this is selected, the Button will ignore children content, so add an Icon to the Button.', + table: { + defaultValue: { summary: buttonDefaults?.isCircle?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isLoading: { + description: 'Whether the Button is in a loading state.', + table: { + defaultValue: { summary: buttonDefaults?.isLoading?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Button is disabled.', + table: { + defaultValue: { summary: buttonDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isActive: { + description: 'Whether the Button is active.', + table: { + defaultValue: { summary: buttonDefaults?.isActive?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + onClick: { + description: 'Function to call when the button is clicked', + table: { + defaultValue: { summary: 'undefined' }, + }, + action: 'clicked', + }, + }, + + // Define defaults + args: { + children: 'Button Content', + variant: buttonDefaults.variant, + color: buttonDefaults.color, + size: buttonDefaults.size, + icon: undefined, + iconPosition: buttonDefaults.iconPosition, + isCircle: buttonDefaults.isCircle, + isLoading: buttonDefaults.isLoading, + isDisabled: buttonDefaults.isDisabled, + isActive: buttonDefaults.isActive, + onClick: () => console.log('Button clicked'), + }, +} satisfies Meta<typeof Button>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Button {...props}>Button</Button>, +}; + +export const states = () => ( + <GridList> + <Button>Default</Button> + <Button isLoading>Loading State</Button> + <Button isActive>Active/Focus State</Button> + <Button isDisabled>Disabled State</Button> + </GridList> +); + +export const colors = () => ( + <GridList> + <Button>Violet Button</Button> + <Button color="green">Green Button</Button> + <Button color="red">Red Button</Button> + <Button color="blue">Blue Button</Button> + <Button color="gray">Gray Button</Button> + </GridList> +); + +export const sizes = () => ( + <GridList> + <Button size="sm">Small Button</Button> + <Button size="md">Regular Button</Button> + <Button size="lg">Large Button</Button> + <Button size="xl">XLarge Button</Button> + </GridList> +); + +export const withIcon = () => ( + <GridList> + <Button icon="Add">Icon Left</Button> + <Button icon="Add" iconPosition="right"> + Icon Right + </Button> + </GridList> +); + +export const circleShape = () => ( + <GridList> + <Button icon="Add" size="sm" isCircle /> + <Button icon="Add" isCircle /> + <Button icon="Add" size="lg" isCircle /> + </GridList> +); diff --git a/datahub-web-react/src/alchemy-components/components/Button/Button.tsx b/datahub-web-react/src/alchemy-components/components/Button/Button.tsx new file mode 100644 index 00000000000000..a727b0faf97a99 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/Button.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { LoadingOutlined } from '@ant-design/icons'; + +import { Icon } from '@components'; + +import { ButtonBase } from './components'; +import { ButtonProps } from './types'; + +export const buttonDefaults: ButtonProps = { + variant: 'filled', + color: 'violet', + size: 'md', + iconPosition: 'left', + isCircle: false, + isLoading: false, + isDisabled: false, + isActive: false, +}; + +export const Button = ({ + variant = buttonDefaults.variant, + color = buttonDefaults.color, + size = buttonDefaults.size, + icon, // default undefined + iconPosition = buttonDefaults.iconPosition, + isCircle = buttonDefaults.isCircle, + isLoading = buttonDefaults.isLoading, + isDisabled = buttonDefaults.isDisabled, + isActive = buttonDefaults.isActive, + children, + ...props +}: ButtonProps) => { + const sharedProps = { + variant, + color, + size, + isCircle, + isLoading, + isActive, + isDisabled, + disabled: isDisabled, + }; + + if (isLoading) { + return ( + <ButtonBase {...sharedProps} {...props}> + <LoadingOutlined rotate={10} /> {!isCircle && children} + </ButtonBase> + ); + } + + return ( + <ButtonBase {...sharedProps} {...props}> + {icon && iconPosition === 'left' && <Icon icon={icon} size={size} />} + {!isCircle && children} + {icon && iconPosition === 'right' && <Icon icon={icon} size={size} />} + </ButtonBase> + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Button/components.ts b/datahub-web-react/src/alchemy-components/components/Button/components.ts new file mode 100644 index 00000000000000..49fa9a12ede6e2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/components.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +import { spacing } from '@components/theme'; +import { ButtonProps } from './types'; +import { getButtonStyle } from './utils'; + +export const ButtonBase = styled.button( + // Dynamic styles + (props: ButtonProps) => ({ ...getButtonStyle(props as ButtonProps) }), + { + // Base root styles + display: 'flex', + alignItems: 'center', + gap: spacing.xsm, + cursor: 'pointer', + transition: `all 0.15s ease`, + + // For transitions between focus/active and hover states + outlineColor: 'transparent', + outlineStyle: 'solid', + + // Base Disabled styles + '&:disabled': { + cursor: 'not-allowed', + }, + }, +); diff --git a/datahub-web-react/src/alchemy-components/components/Button/index.ts b/datahub-web-react/src/alchemy-components/components/Button/index.ts new file mode 100644 index 00000000000000..745d8377f9fbb4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/index.ts @@ -0,0 +1,2 @@ +export { Button, buttonDefaults } from './Button'; +export type { ButtonProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Button/types.ts b/datahub-web-react/src/alchemy-components/components/Button/types.ts new file mode 100644 index 00000000000000..f510ff4c6c13c5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/types.ts @@ -0,0 +1,16 @@ +import { ButtonHTMLAttributes } from 'react'; + +import type { IconNames } from '@components'; +import type { SizeOptions, ColorOptions } from '@components/theme/config'; + +export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { + variant?: 'filled' | 'outline' | 'text'; + color?: ColorOptions; + size?: SizeOptions; + icon?: IconNames; + iconPosition?: 'left' | 'right'; + isCircle?: boolean; + isLoading?: boolean; + isDisabled?: boolean; + isActive?: boolean; +} diff --git a/datahub-web-react/src/alchemy-components/components/Button/utils.ts b/datahub-web-react/src/alchemy-components/components/Button/utils.ts new file mode 100644 index 00000000000000..c08f4f067304d1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Button/utils.ts @@ -0,0 +1,238 @@ +/* + * Button Style Utilities + */ + +import { typography, colors, shadows, radius, spacing } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { ButtonProps } from './types'; + +// Utility function to get color styles for button - does not generate CSS +const getButtonColorStyles = (variant, color) => { + const color500 = getColor(color, 500); // value of 500 shade + const isViolet = color === 'violet'; + + const base = { + // Backgrounds + bgColor: color500, + hoverBgColor: getColor(color, 600), + activeBgColor: getColor(color, 700), + disabledBgColor: getColor('gray', 100), + + // Borders + borderColor: color500, + activeBorderColor: getColor(color, 300), + disabledBorderColor: getColor('gray', 200), + + // Text + textColor: colors.white, + disabledTextColor: getColor('gray', 300), + }; + + // Specific color override for white + if (color === 'white') { + base.textColor = colors.black; + base.disabledTextColor = getColor('gray', 500); + } + + // Specific color override for gray + if (color === 'gray') { + base.textColor = getColor('gray', 500); + base.bgColor = getColor('gray', 100); + base.borderColor = getColor('gray', 100); + + base.hoverBgColor = getColor('gray', 100); + base.activeBgColor = getColor('gray', 200); + } + + // Override styles for outline variant + if (variant === 'outline') { + return { + ...base, + bgColor: colors.transparent, + borderColor: color500, + textColor: color500, + + hoverBgColor: isViolet ? getColor(color, 100) : getColor(color, 100), + activeBgColor: isViolet ? getColor(color, 100) : getColor(color, 200), + + disabledBgColor: 'transparent', + }; + } + + // Override styles for text variant + if (variant === 'text') { + return { + ...base, + textColor: color500, + + bgColor: colors.transparent, + borderColor: colors.transparent, + hoverBgColor: colors.transparent, + activeBgColor: colors.transparent, + disabledBgColor: colors.transparent, + disabledBorderColor: colors.transparent, + }; + } + + // Filled variable is the base style + return base; +}; + +// Generate color styles for button +const getButtonVariantStyles = (variant, color) => { + const variantStyles = { + filled: { + backgroundColor: color.bgColor, + border: `1px solid ${color.borderColor}`, + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + border: `1px solid ${color.hoverBgColor}`, + boxShadow: shadows.sm, + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + border: `1px solid ${color.disabledBorderColor}`, + color: color.disabledTextColor, + boxShadow: shadows.xs, + }, + }, + outline: { + backgroundColor: 'transparent', + border: `1px solid ${color.borderColor}`, + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + boxShadow: 'none', + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + border: `1px solid ${color.disabledBorderColor}`, + color: color.disabledTextColor, + boxShadow: shadows.xs, + }, + }, + text: { + backgroundColor: 'transparent', + border: 'none', + color: color.textColor, + '&:hover': { + backgroundColor: color.hoverBgColor, + }, + '&:disabled': { + backgroundColor: color.disabledBgColor, + color: color.disabledTextColor, + }, + }, + }; + + return variantStyles[variant]; +}; + +// Generate font styles for button +const getButtonFontStyles = (size) => { + const baseFontStyles = { + fontFamily: typography.fonts.body, + fontWeight: typography.fontWeights.normal, + lineHeight: typography.lineHeights.none, + }; + + const sizeStyles = { + sm: { + ...baseFontStyles, + fontSize: getFontSize(size), // 12px + }, + md: { + ...baseFontStyles, + fontSize: getFontSize(size), // 14px + }, + lg: { + ...baseFontStyles, + fontSize: getFontSize(size), // 16px + }, + xl: { + ...baseFontStyles, + fontSize: getFontSize(size), // 18px + }, + }; + + return sizeStyles[size]; +}; + +// Generate radii styles for button +const getButtonRadiiStyles = (isCircle) => { + if (isCircle) return { borderRadius: radius.full }; + return { borderRadius: radius.sm }; // radius is the same for all button sizes +}; + +// Generate padding styles for button +const getButtonPadding = (size, isCircle) => { + if (isCircle) return { padding: spacing.xsm }; + + const paddingStyles = { + sm: { + padding: '8px 12px', + }, + md: { + padding: '10px 12px', + }, + lg: { + padding: '10px 16px', + }, + xl: { + padding: '12px 20px', + }, + }; + + return paddingStyles[size]; +}; + +// Generate active styles for button +const getButtonActiveStyles = (styleColors) => ({ + borderColor: 'transparent', + backgroundColor: styleColors.activeBgColor, + // TODO: Figure out how to make the #fff interior border transparent + boxShadow: `0 0 0 2px #fff, 0 0 0 4px ${styleColors.activeBgColor}`, +}); + +// Generate loading styles for button +const getButtonLoadingStyles = () => ({ + pointerEvents: 'none', + opacity: 0.75, +}); + +/* + * Main function to generate styles for button + */ +export const getButtonStyle = (props: ButtonProps) => { + const { variant, color, size, isCircle, isActive, isLoading } = props; + + // Get map of colors + const colorStyles = getButtonColorStyles(variant, color) || ({} as any); + + // Define styles for button + const variantStyles = getButtonVariantStyles(variant, colorStyles); + const fontStyles = getButtonFontStyles(size); + const radiiStyles = getButtonRadiiStyles(isCircle); + const paddingStyles = getButtonPadding(size, isCircle); + + // Base of all generated styles + let styles = { + ...variantStyles, + ...fontStyles, + ...radiiStyles, + ...paddingStyles, + }; + + // Focus & Active styles are the same, but active styles are applied conditionally & override prevs styles + const activeStyles = { ...getButtonActiveStyles(colorStyles) }; + styles['&:focus'] = activeStyles; + styles['&:active'] = activeStyles; + if (isActive) styles = { ...styles, ...activeStyles }; + + // Loading styles + if (isLoading) styles = { ...styles, ...getButtonLoadingStyles() }; + + // Return generated styles + return styles; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx b/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx new file mode 100644 index 00000000000000..336831fd15cfab --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/Card.stories.tsx @@ -0,0 +1,141 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { GridList } from '@src/alchemy-components/.docs/mdx-components'; +import { colors } from '@src/alchemy-components/theme'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Card, cardDefaults } from '.'; +import { Icon } from '../Icon'; + +// Auto Docs +const meta = { + title: 'Components / Card', + component: Card, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render a card.', + }, + }, + + // Component-level argTypes + argTypes: { + title: { + description: 'The title of the card', + table: { + defaultValue: { summary: `${cardDefaults.title}` }, + }, + control: { + type: 'text', + }, + }, + subTitle: { + description: 'The subtitle of the card', + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon on the card', + control: { + type: 'text', + }, + }, + iconAlignment: { + description: 'Whether the alignment of icon is horizontal or vertical', + table: { + defaultValue: { summary: `${cardDefaults.iconAlignment}` }, + }, + control: { + type: 'select', + }, + }, + percent: { + description: 'The percent value on the pill of the card', + control: { + type: 'number', + }, + }, + button: { + description: 'The button on the card', + control: { + type: 'text', + }, + }, + width: { + description: 'The width of the card', + control: { + type: 'text', + }, + }, + onClick: { + description: 'The on click function for the card', + }, + }, + + // Define default args + args: { + title: 'Title', + subTitle: 'Subtitle', + iconAlignment: 'horizontal', + width: '150px', + }, +} satisfies Meta<typeof Card>; + +export default meta; + +// Stories + +type Story = StoryObj<typeof meta>; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => <Card {...props} />, +}; + +export const withChildren = () => ( + <Card title="Title" subTitle="Subtitle"> + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); + +export const withoutSubtitle = () => ( + <Card title="Title"> + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); + +export const withIcon = () => ( + <GridList> + <Card title="Title" subTitle="Subtitle" icon={<Icon icon="Cloud" color="gray" />} /> + <Card title="Title" subTitle="Subtitle" icon={<Icon icon="Cloud" color="gray" />} iconAlignment="vertical" /> + </GridList> +); + +export const withButton = () => ( + <Card + title="Title" + subTitle="Subtitle" + button={<Icon icon="Download" color="gray" size="2xl" />} + onClick={() => window.alert('Card clicked')} + /> +); + +export const withPercentPill = () => <Card title="Title" subTitle="Subtitle" percent={2} />; + +export const withAllTheElements = () => ( + <Card + title="Title" + subTitle="Subtitle" + percent={2} + icon={<Icon icon="Cloud" color="gray" />} + button={<Icon icon="Download" color="gray" size="2xl" />} + onClick={() => window.alert('Card clicked')} + > + <div style={{ backgroundColor: colors.gray[1000], padding: '8px 32px' }}>Children of the card (Swap me)</div> + </Card> +); diff --git a/datahub-web-react/src/alchemy-components/components/Card/Card.tsx b/datahub-web-react/src/alchemy-components/components/Card/Card.tsx new file mode 100644 index 00000000000000..55c581251bea99 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/Card.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { CardProps } from './types'; +import { CardContainer, Header, SubTitle, SubTitleContainer, Title, TitleContainer } from './components'; +import { Pill } from '../Pills'; + +export const cardDefaults: CardProps = { + title: 'Title', + iconAlignment: 'horizontal', +}; + +export const Card = ({ + title = cardDefaults.title, + iconAlignment = cardDefaults.iconAlignment, + subTitle, + percent, + button, + onClick, + icon, + children, + width, +}: CardProps) => { + return ( + <CardContainer hasButton={!!button} onClick={onClick} width={width}> + <Header iconAlignment={iconAlignment}> + {icon && <div>{icon}</div>} + <TitleContainer> + <Title> + {title} + {!!percent && ( + <Pill + label={`${Math.abs(percent)}%`} + size="sm" + colorScheme={percent < 0 ? 'red' : 'green'} + leftIcon={percent < 0 ? 'TrendingDown' : 'TrendingUp'} + clickable={false} + /> + )} + + + {subTitle} + {button} + + + + {children} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Card/components.ts b/datahub-web-react/src/alchemy-components/components/Card/components.ts new file mode 100644 index 00000000000000..bb3821fffc7f58 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/components.ts @@ -0,0 +1,59 @@ +import { colors, radius, spacing, typography } from '@src/alchemy-components/theme'; +import { IconAlignmentOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; + +export const CardContainer = styled.div<{ hasButton: boolean; width?: string }>(({ hasButton, width }) => ({ + border: `1px solid ${colors.gray[100]}`, + borderRadius: radius.lg, + padding: spacing.md, + minWidth: '150px', + boxShadow: '0px 1px 2px 0px rgba(33, 23, 95, 0.07)', + backgroundColor: colors.white, + display: 'flex', + flexDirection: 'column', + gap: spacing.md, + width, + + '&:hover': hasButton + ? { + border: `1px solid ${colors.violet[500]}`, + cursor: 'pointer', + } + : {}, +})); + +export const Header = styled.div<{ iconAlignment?: IconAlignmentOptions }>(({ iconAlignment }) => ({ + display: 'flex', + flexDirection: iconAlignment === 'horizontal' ? 'row' : 'column', + alignItems: iconAlignment === 'horizontal' ? 'center' : 'start', + gap: spacing.sm, + width: '100%', +})); + +export const TitleContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + gap: 2, + width: '100%', +}); + +export const Title = styled.div({ + fontSize: typography.fontSizes.lg, + fontWeight: typography.fontWeights.bold, + color: colors.gray[600], + display: 'flex', + alignItems: 'center', + gap: spacing.xsm, +}); + +export const SubTitleContainer = styled.div({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}); + +export const SubTitle = styled.div({ + fontSize: typography.fontSizes.md, + fontWeight: typography.fontWeights.normal, + color: colors.gray[1700], +}); diff --git a/datahub-web-react/src/alchemy-components/components/Card/index.ts b/datahub-web-react/src/alchemy-components/components/Card/index.ts new file mode 100644 index 00000000000000..b0eed059aafd8a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/index.ts @@ -0,0 +1,2 @@ +export { Card, cardDefaults } from './Card'; +export type { CardProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Card/types.ts b/datahub-web-react/src/alchemy-components/components/Card/types.ts new file mode 100644 index 00000000000000..e5b0e36f83e4ce --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Card/types.ts @@ -0,0 +1,13 @@ +import { IconAlignmentOptions } from '@src/alchemy-components/theme/config'; + +export interface CardProps { + title: string; + subTitle?: string; + percent?: number; + button?: React.ReactNode; + onClick?: () => void; + icon?: React.ReactNode; + iconAlignment?: IconAlignmentOptions; + children?: React.ReactNode; + width?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 00000000000000..e546c2ea526cbc --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,156 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { GridList } from '@components/.docs/mdx-components'; +import { Checkbox, checkboxDefaults, CheckboxGroup } from './Checkbox'; +import { CheckboxProps } from './types'; +import { Heading } from '../Heading'; + +const MOCK_CHECKBOXES: CheckboxProps[] = [ + { + label: 'Label 1', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 2', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 3', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, +]; + +const meta = { + title: 'Forms / Checkbox', + component: Checkbox, + parameters: { + layout: 'centered', + docs: { + subtitle: 'A component that is used to get user input in the state of a check box.', + }, + }, + argTypes: { + label: { + description: 'Label for the Checkbox.', + table: { + defaultValue: { summary: checkboxDefaults.label }, + }, + control: { + type: 'text', + }, + }, + error: { + description: 'Enforce error state on the Checkbox.', + table: { + defaultValue: { summary: checkboxDefaults.error }, + }, + control: { + type: 'text', + }, + }, + isChecked: { + description: 'Whether the Checkbox is checked.', + table: { + defaultValue: { summary: checkboxDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Checkbox is in disabled state.', + table: { + defaultValue: { summary: checkboxDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isIntermediate: { + description: 'Whether the Checkbox is in intermediate state.', + table: { + defaultValue: { summary: checkboxDefaults?.isIntermediate?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Checkbox is a required field.', + table: { + defaultValue: { summary: checkboxDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + label: checkboxDefaults.label, + error: checkboxDefaults.error, + isChecked: checkboxDefaults.isChecked, + isDisabled: checkboxDefaults.isDisabled, + isIntermediate: checkboxDefaults.isIntermediate, + isRequired: checkboxDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const states = () => ( + + + + + + +); + +export const intermediate = () => { + return ( + + + + + ); +}; + +export const disabledStates = () => ( + + + + + +); + +export const checkboxGroups = () => ( + +
+ Horizontal Checkbox Group + +
+
+ Vertical Checkbox Group + +
+
+); diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000000000..6ab4db74610e49 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/Checkbox.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { CheckboxProps, CheckboxGroupProps } from './types'; +import { + CheckboxBase, + CheckboxContainer, + CheckboxGroupContainer, + Checkmark, + HoverState, + Label, + Required, + StyledCheckbox, +} from './components'; + +export const checkboxDefaults: CheckboxProps = { + label: 'Label', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + setIsChecked: () => {}, +}; + +export const Checkbox = ({ + label = checkboxDefaults.label, + error = checkboxDefaults.error, + isChecked = checkboxDefaults.isChecked, + isDisabled = checkboxDefaults.isDisabled, + isIntermediate = checkboxDefaults.isIntermediate, + isRequired = checkboxDefaults.isRequired, + setIsChecked = checkboxDefaults.setIsChecked, + ...props +}: CheckboxProps) => { + const [checked, setChecked] = useState(isChecked || false); + const [isHovering, setIsHovering] = useState(false); + + useEffect(() => { + setChecked(isChecked || false); + }, [isChecked]); + + const id = props.id || `checkbox-${label}`; + + return ( + + + { + if (!isDisabled) { + setChecked(!checked); + setIsChecked?.(!checked); + } + }} + > + null} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + /> + + + + ); +}; + +export const CheckboxGroup = ({ isVertical, checkboxes }: CheckboxGroupProps) => { + if (!checkboxes.length) { + return <>; + } + + return ( + + {checkboxes.map((checkbox) => { + const props = { ...checkbox }; + return ( + + + + ); + })} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts new file mode 100644 index 00000000000000..6a4ad08c9c4ce6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/components.ts @@ -0,0 +1,91 @@ +import { borders, colors, spacing, transform, zIndices, radius } from '@components/theme'; +import styled from 'styled-components'; +import { getCheckboxColor, getCheckboxHoverBackgroundColor } from './utils'; +import { formLabelTextStyles } from '../commonStyles'; + +export const CheckboxContainer = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const Label = styled.div({ + ...formLabelTextStyles, +}); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const CheckboxBase = styled.div({ + position: 'relative', + width: '30px', + height: '30px', +}); + +export const StyledCheckbox = styled.input<{ + checked: boolean; + error: string; + disabled: boolean; +}>(({ error, checked, disabled }) => ({ + position: 'absolute', + opacity: 0, + height: 0, + width: 0, + '&:checked + div': { + backgroundColor: getCheckboxColor(checked, error, disabled, 'background'), + }, + '&:checked + div:after': { + display: 'block', + }, +})); + +export const Checkmark = styled.div<{ intermediate?: boolean; error: string; checked: boolean; disabled: boolean }>( + ({ intermediate, checked, error, disabled }) => ({ + position: 'absolute', + top: '4px', + left: '11px', + zIndex: zIndices.docked, + height: '18px', + width: '18px', + borderRadius: '3px', + border: `${borders['2px']} ${getCheckboxColor(checked, error, disabled, undefined)}`, + transition: 'all 0.2s ease-in-out', + cursor: 'pointer', + '&:after': { + content: '""', + position: 'absolute', + display: 'none', + left: !intermediate ? '6px' : '8px', + top: !intermediate ? '1px' : '3px', + width: !intermediate ? '5px' : '0px', + height: '10px', + border: 'solid white', + borderWidth: '0 3px 3px 0', + transform: !intermediate ? 'rotate(45deg)' : transform.rotate[90], + }, + }), +); + +export const HoverState = styled.div<{ isHovering: boolean; error: string; checked: boolean; disabled: boolean }>( + ({ isHovering, error, checked }) => ({ + width: '40px', + height: '40px', + backgroundColor: !isHovering ? 'transparent' : getCheckboxHoverBackgroundColor(checked, error), + position: 'absolute', + borderRadius: radius.full, + top: '-5px', + left: '2px', + zIndex: zIndices.hide, + }), +); + +export const CheckboxGroupContainer = styled.div<{ isVertical?: boolean }>(({ isVertical }) => ({ + display: 'flex', + flexDirection: isVertical ? 'column' : 'row', + justifyContent: 'center', + alignItems: 'center', + gap: spacing.md, + margin: spacing.xxsm, +})); diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts new file mode 100644 index 00000000000000..57e3d6d27856a5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox, CheckboxGroup, checkboxDefaults } from './Checkbox'; +export type { CheckboxProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts new file mode 100644 index 00000000000000..7ee10011689397 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/types.ts @@ -0,0 +1,16 @@ +import { InputHTMLAttributes } from 'react'; + +export interface CheckboxProps extends InputHTMLAttributes { + label: string; + error?: string; + isChecked?: boolean; + setIsChecked?: React.Dispatch>; + isDisabled?: boolean; + isIntermediate?: boolean; + isRequired?: boolean; +} + +export interface CheckboxGroupProps { + isVertical?: boolean; + checkboxes: CheckboxProps[]; +} diff --git a/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts new file mode 100644 index 00000000000000..edf5d24596e1b4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Checkbox/utils.ts @@ -0,0 +1,27 @@ +import theme, { colors } from '@components/theme'; + +const checkboxBackgroundDefault = { + default: colors.white, + checked: theme.semanticTokens.colors.primary, + error: theme.semanticTokens.colors.error, + disabled: colors.gray[300], +}; + +const checkboxHoverColors = { + default: colors.gray[100], + error: colors.red[100], + checked: colors.violet[100], +}; + +export function getCheckboxColor(checked: boolean, error: string, disabled: boolean, mode: 'background' | undefined) { + if (disabled) return checkboxBackgroundDefault.disabled; + if (error) return checkboxBackgroundDefault.error; + if (checked) return checkboxBackgroundDefault.checked; + return mode === 'background' ? checkboxBackgroundDefault.default : colors.gray[500]; +} + +export function getCheckboxHoverBackgroundColor(checked: boolean, error: string) { + if (error) return checkboxHoverColors.error; + if (checked) return checkboxHoverColors.checked; + return checkboxHoverColors.default; +} diff --git a/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx b/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx new file mode 100644 index 00000000000000..b8bd9f6420c006 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/Heading.stories.tsx @@ -0,0 +1,98 @@ +import React from 'react'; + +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { VerticalFlexGrid } from '@components/.docs/mdx-components'; +import { Heading, headingDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Typography / Heading', + component: Heading, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render semantic HTML heading elements.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content to display within the heading.', + table: { + type: { summary: 'string' }, + }, + control: { + type: 'text', + }, + }, + type: { + description: 'The type of heading to display.', + table: { + defaultValue: { summary: headingDefaults.type }, + }, + }, + size: { + description: 'Override the size of the heading.', + table: { + defaultValue: { summary: `${headingDefaults.size}` }, + }, + }, + color: { + description: 'Override the color of the heading.', + table: { + defaultValue: { summary: headingDefaults.color }, + }, + }, + weight: { + description: 'Override the weight of the heading.', + table: { + defaultValue: { summary: `${headingDefaults.weight}` }, + }, + }, + }, + + // Define defaults + args: { + children: 'The content to display within the heading.', + type: headingDefaults.type, + size: headingDefaults.size, + color: headingDefaults.color, + weight: headingDefaults.weight, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => {props.children}, +}; + +export const sizes: StoryFn = (props: any) => ( + + H1 {props.children} + H2 {props.children} + H3 {props.children} + H4 {props.children} + H5 {props.children} + H6 {props.children} + +); + +export const withLink = () => ( + + The content to display within the heading + +); diff --git a/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx b/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx new file mode 100644 index 00000000000000..6449ff512adacc --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/Heading.tsx @@ -0,0 +1,38 @@ +import React from 'react'; + +import { HeadingProps } from './types'; +import { H1, H2, H3, H4, H5, H6 } from './components'; + +export const headingDefaults: HeadingProps = { + type: 'h1', + color: 'inherit', + size: '2xl', + weight: 'medium', +}; + +export const Heading = ({ + type = headingDefaults.type, + size = headingDefaults.size, + color = headingDefaults.color, + weight = headingDefaults.weight, + children, +}: HeadingProps) => { + const sharedProps = { size, color, weight }; + + switch (type) { + case 'h1': + return

{children}

; + case 'h2': + return

{children}

; + case 'h3': + return

{children}

; + case 'h4': + return

{children}

; + case 'h5': + return
{children}
; + case 'h6': + return
{children}
; + default: + return

{children}

; + } +}; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/components.ts b/datahub-web-react/src/alchemy-components/components/Heading/components.ts new file mode 100644 index 00000000000000..beea5338585d83 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/components.ts @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +import { typography, colors } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { HeadingProps } from './types'; + +const headingStyles = { + H1: { + fontSize: typography.fontSizes['4xl'], + lineHeight: typography.lineHeights['2xl'], + }, + H2: { + fontSize: typography.fontSizes['3xl'], + lineHeight: typography.lineHeights.xl, + }, + H3: { + fontSize: typography.fontSizes['2xl'], + lineHeight: typography.lineHeights.lg, + }, + H4: { + fontSize: typography.fontSizes.xl, + lineHeight: typography.lineHeights.lg, + }, + H5: { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.md, + }, + H6: { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.xs, + }, +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.heading, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +// Prop Driven Styles +const propStyles = (props, isText = false) => { + const styles = {} as any; + if (props.size) styles.fontSize = getFontSize(props.size); + if (props.color) styles.color = getColor(props.color); + if (props.weight) styles.fontWeight = typography.fontWeights[props.weight]; + if (isText) styles.lineHeight = typography.lineHeights[props.size]; + return styles; +}; + +// Generate Headings +const headings = {} as any; + +['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].forEach((heading) => { + const component = styled[heading.toLowerCase()]; + headings[heading] = component({ ...baseStyles, ...headingStyles[heading] }, (props: HeadingProps) => ({ + ...propStyles(props as HeadingProps), + })); +}); + +export const { H1, H2, H3, H4, H5, H6 } = headings; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/index.ts b/datahub-web-react/src/alchemy-components/components/Heading/index.ts new file mode 100644 index 00000000000000..c414de6cc92f79 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/index.ts @@ -0,0 +1,2 @@ +export { Heading, headingDefaults } from './Heading'; +export type { HeadingProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Heading/types.ts b/datahub-web-react/src/alchemy-components/components/Heading/types.ts new file mode 100644 index 00000000000000..96fcf1ea292bf7 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Heading/types.ts @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react'; +import type { FontSizeOptions, FontColorOptions, FontWeightOptions } from '@components/theme/config'; + +export interface HeadingProps extends HTMLAttributes { + type?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + size?: FontSizeOptions; + color?: FontColorOptions; + weight?: FontWeightOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx b/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx new file mode 100644 index 00000000000000..3dcbd74ceb0b71 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/Icon.stories.tsx @@ -0,0 +1,131 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { Icon, iconDefaults, AVAILABLE_ICONS } from '.'; + +// Auto Docs +const meta = { + title: 'Media / Icon', + component: Icon, + + // Display Properties + parameters: { + layout: 'centered', + badges: ['productionReady'], + docs: { + subtitle: 'A singular component for rendering the icons used throughout the application.', + description: { + component: '👉 See the [Icons Gallery](/docs/icons--docs) for more information.', + }, + }, + }, + + // Component-level argTypes + argTypes: { + icon: { + description: `The name of the icon to display.`, + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + variant: { + description: 'The variant of the icon to display.', + defaultValue: 'outline', + options: ['outline', 'filled'], + table: { + defaultValue: { summary: iconDefaults.variant }, + }, + }, + size: { + description: 'The size of the icon to display.', + defaultValue: 'lg', + table: { + defaultValue: { summary: iconDefaults.size }, + }, + }, + color: { + description: 'The color of the icon to display.', + options: ['inherit', 'white', 'black', 'violet', 'green', 'red', 'blue', 'gray'], + type: 'string', + table: { + defaultValue: { summary: iconDefaults.color }, + }, + control: { + type: 'select', + }, + }, + rotate: { + description: 'The rotation of the icon. Applies a CSS transformation.', + table: { + defaultValue: { summary: iconDefaults.rotate }, + }, + }, + }, + + // Define defaults for required args + args: { + icon: iconDefaults.icon, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const filled = () => ( + + + + + +); + +export const sizes = () => ( + + + + + + + + + + +); + +export const colors = () => ( + + + + + + + + + +); + +export const rotation = () => ( + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx new file mode 100644 index 00000000000000..50c30d7203aed8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/Icon.tsx @@ -0,0 +1,59 @@ +import React from 'react'; + +import { getFontSize, getColor, getRotationTransform } from '@components/theme/utils'; + +import { IconProps } from './types'; +import { IconWrapper } from './components'; +import { getIconNames, getIconComponent } from './utils'; + +export const iconDefaults: IconProps = { + icon: 'AccountCircle', + variant: 'outline', + size: '4xl', + color: 'inherit', + rotate: '0', +}; + +export const Icon = ({ + icon, + variant = iconDefaults.variant, + size = iconDefaults.size, + color = iconDefaults.color, + rotate = iconDefaults.rotate, + ...props +}: IconProps) => { + const { filled, outlined } = getIconNames(); + + // Return early if no icon is provided + if (!icon) return null; + + // Get outlined icon component name + const isOutlined = variant === 'outline'; + const outlinedIconName = `${icon}Outlined`; + + // Warn if the icon does not have the specified variant + if (variant === 'outline' && !outlined.includes(outlinedIconName)) { + console.warn(`Icon "${icon}" does not have an outlined variant.`); + return null; + } + + // Warn if the icon does not have the specified variant + if (variant === 'filled' && !filled.includes(icon)) { + console.warn(`Icon "${icon}" does not have a filled variant.`); + return null; + } + + // Get outlined icon component + const IconComponent = getIconComponent(isOutlined ? outlinedIconName : icon); + + return ( + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/components.ts b/datahub-web-react/src/alchemy-components/components/Icon/components.ts new file mode 100644 index 00000000000000..82e9c9a8fcae00 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/components.ts @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +export const IconWrapper = styled.div<{ size: string; rotate?: string }>` + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: ${({ size }) => size}; + height: ${({ size }) => size}; + + & svg { + width: 100%; + height: 100%; + + transform: ${({ rotate }) => rotate}; + } +`; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/constants.ts b/datahub-web-react/src/alchemy-components/components/Icon/constants.ts new file mode 100644 index 00000000000000..25145a5970f0f2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/constants.ts @@ -0,0 +1,547 @@ +export const AVAILABLE_ICONS = [ + 'AccountCircle', + 'AccountTree', + 'AddCircle', + 'AddLink', + 'Add', + 'AddTask', + 'AddToPhotos', + 'Adjust', + 'AllInclusive', + 'Analytics', + 'Anchor', + 'Animation', + 'Announcement', + 'Api', + 'Approval', + 'Archive', + 'ArrowBack', + 'ArrowCircleDown', + 'ArrowCircleLeft', + 'ArrowCircleRight', + 'ArrowCircleUp', + 'ArrowDownward', + 'ArrowForward', + 'ArrowOutward', + 'ArrowUpward', + 'ArtTrack', + 'Article', + 'Assistant', + 'AttachFile', + 'Attachment', + 'AutoAwesome', + 'AutoFixHigh', + 'AutoFixOff', + 'AutoGraph', + 'AutoMode', + 'AutoStories', + 'AvTimer', + 'Backspace', + 'Backup', + 'BackupTable', + 'Badge', + 'Balance', + 'BarChart', + 'BatchPrediction', + 'Block', + 'Bolt', + 'Book', + 'BookmarkAdd', + 'BookmarkAdded', + 'BookmarkBorder', + 'Bookmark', + 'BookmarkRemove', + 'Bookmarks', + 'Brush', + 'BubbleChart', + 'BugReport', + 'BuildCircle', + 'Build', + 'BusinessCenter', + 'Business', + 'Cable', + 'Cached', + 'Calculate', + 'CalendarMonth', + 'CalendarToday', + 'CalendarViewDay', + 'Campaign', + 'Cancel', + 'CandlestickChart', + 'CardGiftcard', + 'CardMembership', + 'Cases', + 'Cast', + 'Category', + 'Celebration', + 'CellTower', + 'ChangeHistory', + 'ChatBubble', + 'Chat', + 'CheckBox', + 'CheckCircle', + 'Check', + 'Checklist', + 'ChevronLeft', + 'ChevronRight', + 'Class', + 'CloseFullscreen', + 'Close', + 'CloudCircle', + 'CloudDone', + 'CloudDownload', + 'CloudOff', + 'Cloud', + 'CloudQueue', + 'CloudSync', + 'CloudUpload', + 'CoPresent', + 'CodeOff', + 'Code', + 'ColorLens', + 'Colorize', + 'CommentBank', + 'Comment', + 'CommentsDisabled', + 'Commit', + 'CompareArrows', + 'Compare', + 'Compress', + 'Computer', + 'Construction', + 'ContactPage', + 'ContactSupport', + 'Contacts', + 'ContentCopy', + 'ContentCut', + 'Contrast', + 'ControlPoint', + 'Cookie', + 'CopyAll', + 'Copyright', + 'CorporateFare', + 'Cottage', + 'CreateNewFolder', + 'CrisisAlert', + 'Cyclone', + 'Dangerous', + 'DarkMode', + 'DashboardCustomize', + 'Dashboard', + 'DataArray', + 'DataObject', + 'DataThresholding', + 'DataUsage', + 'DatasetLinked', + 'Dataset', + 'DateRange', + 'DeleteForever', + 'Delete', + 'DeleteSweep', + 'Description', + 'Deselect', + 'DesignServices', + 'Details', + 'DeviceHub', + 'DeviceThermostat', + 'Diamond', + 'Difference', + 'DisabledByDefault', + 'DiscFull', + 'Discount', + 'DisplaySettings', + 'Diversity2', + 'Dns', + 'DoNotDisturb', + 'DocumentScanner', + 'DomainAdd', + 'DomainDisabled', + 'Domain', + 'DomainVerification', + 'DoneAll', + 'DonutLarge', + 'DonutSmall', + 'DoubleArrow', + 'DownloadDone', + 'DownloadForOffline', + 'Download', + 'Downloading', + 'Drafts', + 'DragHandle', + 'DragIndicator', + 'Draw', + 'DriveFileMove', + 'DriveFolderUpload', + 'DynamicFeed', + 'DynamicForm', + 'EditCalendar', + 'EditLocation', + 'EditNote', + 'EditOff', + 'Edit', + 'Eject', + 'ElectricBolt', + 'EmergencyShare', + 'EnhancedEncryption', + 'Equalizer', + 'Error', + 'EventAvailable', + 'EventBusy', + 'EventNote', + 'Event', + 'EventRepeat', + 'ExitToApp', + 'Expand', + 'ExploreOff', + 'Explore', + 'Exposure', + 'ExtensionOff', + 'Extension', + 'FastForward', + 'FastRewind', + 'FavoriteBorder', + 'Favorite', + 'FeaturedPlayList', + 'Feed', + 'Feedback', + 'FileCopy', + 'FileDownloadOff', + 'FileDownload', + 'FileOpen', + 'FilePresent', + 'FileUpload', + 'FilterAltOff', + 'FilterAlt', + 'FilterListOff', + 'FindInPage', + 'FindReplace', + 'FirstPage', + 'FitScreen', + 'FlagCircle', + 'Flag', + 'Flaky', + 'Flare', + 'FlashOff', + 'FlashOn', + 'FlightLand', + 'Flight', + 'FlightTakeoff', + 'FmdBad', + 'FmdGood', + 'FolderCopy', + 'FolderDelete', + 'FolderOff', + 'FolderOpen', + 'Folder', + 'FolderShared', + 'FolderSpecial', + 'FolderZip', + 'ForkLeft', + 'ForkRight', + 'FormatListBulleted', + 'FormatListNumbered', + 'Forum', + 'FullscreenExit', + 'Fullscreen', + 'Functions', + 'GetApp', + 'GppBad', + 'GppGood', + 'GppMaybe', + 'GpsFixed', + 'GpsNotFixed', + 'GpsOff', + 'Grading', + 'Grain', + 'GraphicEq', + 'Grid3x3', + 'Grid4x4', + 'GridGoldenratio', + 'GridOff', + 'GridOn', + 'GridView', + 'GroupAdd', + 'Group', + 'GroupRemove', + 'GroupWork', + 'Groups', + 'Handshake', + 'Handyman', + 'Hardware', + 'HealthAndSafety', + 'HelpCenter', + 'Help', + 'Hexagon', + 'HideSource', + 'Highlight', + 'History', + 'HistoryToggleOff', + 'Home', + 'Hub', + 'Image', + 'ImageSearch', + 'Inbox', + 'Info', + 'Input', + 'InsertChart', + 'InsertComment', + 'InsertDriveFile', + 'Insights', + 'Interests', + 'Inventory2', + 'Inventory', + 'KeyOff', + 'Key', + 'LabelImportant', + 'LabelOff', + 'Label', + 'Lan', + 'Landscape', + 'Language', + 'LastPage', + 'Launch', + 'LayersClear', + 'Layers', + 'Leaderboard', + 'LegendToggle', + 'LibraryAddCheck', + 'LibraryAdd', + 'LightMode', + 'Lightbulb', + 'LineAxis', + 'LineStyle', + 'LineWeight', + 'LinearScale', + 'LinkOff', + 'Link', + 'List', + 'LockOpen', + 'Lock', + 'LockReset', + 'Login', + 'Logout', + 'Loupe', + 'LowPriority', + 'Loyalty', + 'Mail', + 'ManageAccounts', + 'ManageHistory', + 'ManageSearch', + 'Map', + 'MapsUgc', + 'MarkAsUnread', + 'MeetingRoom', + 'Memory', + 'MenuBook', + 'MenuOpen', + 'Menu', + 'Merge', + 'MergeType', + 'Message', + 'MiscellaneousServices', + 'MoodBad', + 'Mood', + 'MoreHoriz', + 'MoreTime', + 'MoreVert', + 'MoveDown', + 'MoveToInbox', + 'MoveUp', + 'MultilineChart', + 'MultipleStop', + 'Nat', + 'NewReleases', + 'NightsStay', + 'NoAccounts', + 'NoEncryption', + 'NotStarted', + 'NoteAdd', + 'NotificationAdd', + 'NotificationImportant', + 'NotificationsActive', + 'NotificationsOff', + 'Notifications', + 'NotificationsPaused', + 'OpenInFull', + 'OpenInNew', + 'Outbound', + 'Outbox', + 'Output', + 'Pageview', + 'Password', + 'PauseCircle', + 'PendingActions', + 'Pending', + 'People', + 'PersonAddAlt1', + 'PersonOff', + 'Person', + 'PersonRemoveAlt1', + 'PersonSearch', + 'PinDrop', + 'PivotTableChart', + 'Place', + 'PlayArrow', + 'PlayCircle', + 'Policy', + 'Poll', + 'Polyline', + 'PostAdd', + 'Preview', + 'PrivacyTip', + 'PublicOff', + 'Public', + 'Publish', + 'PushPin', + 'QueryStats', + 'QuestionAnswer', + 'Queue', + 'Radar', + 'ReadMore', + 'Redo', + 'Refresh', + 'RemoveCircle', + 'Replay', + 'ReplyAll', + 'Reply', + 'Report', + 'ReportProblem', + 'Restore', + 'RocketLaunch', + 'Rocket', + 'Route', + 'RssFeed', + 'Rule', + 'RunningWithErrors', + 'SatelliteAlt', + 'SaveAlt', + 'Schedule', + 'Schema', + 'Science', + 'SearchOff', + 'Search', + 'Security', + 'Sell', + 'Sensors', + 'SentimentDissatisfied', + 'SentimentNeutral', + 'SentimentSatisfied', + 'Settings', + 'Share', + 'Shield', + 'ShortText', + 'Shortcut', + 'ShowChart', + 'Shuffle', + 'Signpost', + 'SkipNext', + 'SkipPrevious', + 'SortByAlpha', + 'Sort', + 'Source', + 'SpaceDashboard', + 'Speed', + 'SsidChart', + 'StackedBarChart', + 'StackedLineChart', + 'StarBorder', + 'StarHalf', + 'Star', + 'Start', + 'StickyNote2', + 'StopCircle', + 'Storage', + 'Storm', + 'Straight', + 'Stream', + 'Style', + 'SubdirectoryArrowLeft', + 'SubdirectoryArrowRight', + 'Subject', + 'Subscriptions', + 'SubtitlesOff', + 'Support', + 'SwapHoriz', + 'SwapVert', + 'SwitchAccount', + 'SwitchLeft', + 'SwitchRight', + 'SyncAlt', + 'SyncDisabled', + 'SyncLock', + 'Sync', + 'SyncProblem', + 'TableChart', + 'TableRows', + 'TableView', + 'Tag', + 'TaskAlt', + 'Terminal', + 'ThumbDown', + 'ThumbUp', + 'ThumbsUpDown', + 'Timelapse', + 'Timeline', + 'TipsAndUpdates', + 'Toc', + 'TrackChanges', + 'TrendingDown', + 'TrendingFlat', + 'TrendingUp', + 'Tune', + 'Tungsten', + 'TurnLeft', + 'TurnRight', + 'TurnSlightLeft', + 'TurnSlightRight', + 'Unarchive', + 'Undo', + 'UnfoldLessDouble', + 'UnfoldLess', + 'UnfoldMoreDouble', + 'UnfoldMore', + 'Unsubscribe', + 'Upcoming', + 'UpdateDisabled', + 'Update', + 'Upgrade', + 'UploadFile', + 'Upload', + 'Verified', + 'VerifiedUser', + 'ViewAgenda', + 'ViewArray', + 'ViewCarousel', + 'ViewColumn', + 'ViewComfy', + 'ViewCompact', + 'ViewCozy', + 'ViewDay', + 'ViewHeadline', + 'ViewKanban', + 'ViewList', + 'ViewModule', + 'ViewQuilt', + 'ViewSidebar', + 'ViewStream', + 'ViewTimeline', + 'ViewWeek', + 'VisibilityOff', + 'Visibility', + 'Warehouse', + 'Warning', + 'Webhook', + 'Whatshot', + 'Widgets', + 'Wifi', + 'Window', + 'WorkHistory', + 'WorkOff', + 'Work', + 'WorkspacePremium', + 'Workspaces', + 'Wysiwyg', + 'ZoomInMap', + 'ZoomIn', + 'ZoomOutMap', +]; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/index.ts b/datahub-web-react/src/alchemy-components/components/Icon/index.ts new file mode 100644 index 00000000000000..23ca0a7ef7da2f --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/index.ts @@ -0,0 +1,3 @@ +export { Icon, iconDefaults } from './Icon'; +export type { IconProps, IconNames } from './types'; +export { AVAILABLE_ICONS } from './constants'; diff --git a/datahub-web-react/src/alchemy-components/components/Icon/types.ts b/datahub-web-react/src/alchemy-components/components/Icon/types.ts new file mode 100644 index 00000000000000..f5a050e9338a71 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/types.ts @@ -0,0 +1,23 @@ +import { HTMLAttributes } from 'react'; + +import type { FontSizeOptions, FontColorOptions, RotationOptions } from '@components/theme/config'; +import { AVAILABLE_ICONS } from './constants'; + +// Utility function to create an enum from an array of strings +function createEnum(values: T[]): { [K in T]: K } { + return values.reduce((acc, value) => { + acc[value] = value; + return acc; + }, Object.create(null)); +} + +const names = createEnum(AVAILABLE_ICONS); +export type IconNames = keyof typeof names; + +export interface IconProps extends HTMLAttributes { + icon: IconNames; + variant?: 'filled' | 'outline'; + size?: FontSizeOptions; + color?: FontColorOptions; + rotate?: RotationOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Icon/utils.ts b/datahub-web-react/src/alchemy-components/components/Icon/utils.ts new file mode 100644 index 00000000000000..1137b3da28bc7a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Icon/utils.ts @@ -0,0 +1,29 @@ +import * as materialIcons from '@mui/icons-material'; + +export const getIconNames = () => { + // We only want "Filled" (mui default) and "Outlined" icons + const filtered = Object.keys(materialIcons).filter( + (key) => + !key.includes('Filled') && !key.includes('TwoTone') && !key.includes('Rounded') && !key.includes('Sharp'), + ); + + const filled: string[] = []; + const outlined: string[] = []; + + filtered.forEach((key) => { + if (key.includes('Outlined')) { + outlined.push(key); + } else if (!key.includes('Outlined')) { + filled.push(key); + } + }); + + return { + filled, + outlined, + }; +}; + +export const getIconComponent = (icon: string) => { + return materialIcons[icon]; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx new file mode 100644 index 00000000000000..053e952b62a2e9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.stories.tsx @@ -0,0 +1,177 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '../Icon'; + +import { Input, inputDefaults } from './Input'; + +const meta = { + title: 'Forms / Input', + component: Input, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in a single line field.', + }, + }, + + // Component-level argTypes + argTypes: { + value: { + description: 'Value for the Input.', + table: { + defaultValue: { summary: inputDefaults.value as string }, + }, + control: { + type: 'text', + }, + }, + label: { + description: 'Label for the Input.', + table: { + defaultValue: { summary: inputDefaults.label }, + }, + control: { + type: 'text', + }, + }, + placeholder: { + description: 'Placeholder for the Input.', + table: { + defaultValue: { summary: inputDefaults.placeholder }, + }, + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon to display in the Input.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + error: { + description: 'Enforce error state on the Input.', + table: { + defaultValue: { summary: inputDefaults.error }, + }, + control: { + type: 'text', + }, + }, + warning: { + description: 'Enforce warning state on the Input.', + table: { + defaultValue: { summary: inputDefaults.warning }, + }, + control: { + type: 'text', + }, + }, + isSuccess: { + description: 'Enforce success state on the Input.', + table: { + defaultValue: { summary: inputDefaults?.isSuccess?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Input is in disabled state.', + table: { + defaultValue: { summary: inputDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isInvalid: { + description: 'Whether the Input is an invalid state.', + table: { + defaultValue: { summary: inputDefaults?.isInvalid?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isReadOnly: { + description: 'Whether the Input is in readonly mode.', + table: { + defaultValue: { summary: inputDefaults?.isReadOnly?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isPassword: { + description: 'Whether the Input has a password type.', + table: { + defaultValue: { summary: inputDefaults?.isPassword?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Input is a required field.', + table: { + defaultValue: { summary: inputDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + value: inputDefaults.value, + label: inputDefaults.label, + placeholder: inputDefaults.placeholder, + icon: inputDefaults.icon, + error: inputDefaults.error, + warning: inputDefaults.warning, + isSuccess: inputDefaults.isSuccess, + isDisabled: inputDefaults.isDisabled, + isInvalid: inputDefaults.isInvalid, + isReadOnly: inputDefaults.isReadOnly, + isPassword: inputDefaults.isPassword, + isRequired: inputDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const status = () => ( + + + + + +); + +export const states = () => ( + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Input/Input.tsx b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx new file mode 100644 index 00000000000000..976fc47ffc5948 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/Input.tsx @@ -0,0 +1,97 @@ +import { Tooltip } from '@components'; +import React from 'react'; + +import { InputProps } from './types'; + +import { ErrorMessage, InputContainer, InputField, InputWrapper, Label, Required, WarningMessage } from './components'; + +import { Icon } from '../Icon'; +import { getInputType } from './utils'; + +export const inputDefaults: InputProps = { + value: '', + setValue: () => {}, + label: 'Label', + placeholder: 'Placeholder', + error: '', + warning: '', + isSuccess: false, + isDisabled: false, + isInvalid: false, + isReadOnly: false, + isPassword: false, + isRequired: false, + errorOnHover: false, + type: 'text', +}; + +export const Input = ({ + value = inputDefaults.value, + setValue = inputDefaults.setValue, + label = inputDefaults.label, + placeholder = inputDefaults.placeholder, + icon, // default undefined + error = inputDefaults.error, + warning = inputDefaults.warning, + isSuccess = inputDefaults.isSuccess, + isDisabled = inputDefaults.isDisabled, + isInvalid = inputDefaults.isInvalid, + isReadOnly = inputDefaults.isReadOnly, + isPassword = inputDefaults.isPassword, + isRequired = inputDefaults.isRequired, + errorOnHover = inputDefaults.errorOnHover, + type = inputDefaults.type, + id, + ...props +}: InputProps) => { + // Invalid state is always true if error is present + let invalid = isInvalid; + if (error) invalid = true; + + // Show/hide password text + const [showPassword, setShowPassword] = React.useState(false); + const passwordIcon = showPassword ? 'Visibility' : 'VisibilityOff'; + + // Input base props + const inputBaseProps = { + label, + isSuccess, + error, + warning, + isDisabled, + isInvalid: invalid, + }; + + return ( + + {label && ( + + )} + + {icon && } + setValue?.(e.target.value)} + type={getInputType(type, isPassword, showPassword)} + placeholder={placeholder} + readOnly={isReadOnly} + disabled={isDisabled} + required={isRequired} + id={id} + /> + {!isPassword && ( + + {invalid && } + {isSuccess && } + {warning && } + + )} + {isPassword && setShowPassword(!showPassword)} icon={passwordIcon} size="lg" />} + + {invalid && error && !errorOnHover && {error}} + {warning && {warning}} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Input/components.ts b/datahub-web-react/src/alchemy-components/components/Input/components.ts new file mode 100644 index 00000000000000..d1c337642d9cd8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/components.ts @@ -0,0 +1,92 @@ +import styled from 'styled-components'; + +import theme, { borders, colors, radius, spacing, typography } from '@components/theme'; +import { getStatusColors } from '@components/theme/utils'; + +import { + INPUT_MAX_HEIGHT, + formLabelTextStyles, + inputValueTextStyles, + inputPlaceholderTextStyles, +} from '../commonStyles'; + +import type { InputProps } from './types'; + +const defaultFlexStyles = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', +}; + +const defaultMessageStyles = { + marginTop: spacing.xxsm, + fontSize: typography.fontSizes.sm, +}; + +export const InputWrapper = styled.div({ + ...defaultFlexStyles, + alignItems: 'flex-start', + flexDirection: 'column', + width: '100%', +}); + +export const InputContainer = styled.div( + ({ isSuccess, warning, isDisabled, isInvalid }: InputProps) => ({ + border: `${borders['1px']} ${getStatusColors(isSuccess, warning, isInvalid)}`, + backgroundColor: isDisabled ? colors.gray[100] : colors.white, + paddingRight: spacing.md, + }), + { + ...defaultFlexStyles, + width: '100%', + maxHeight: INPUT_MAX_HEIGHT, + overflow: 'hidden', + borderRadius: radius.md, + flex: 1, + color: colors.gray[400], // 1st icon color + + '&:focus-within': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, + }, +); + +export const InputField = styled.input({ + padding: `${spacing.sm} ${spacing.md}`, + lineHeight: typography.lineHeights.normal, + maxHeight: INPUT_MAX_HEIGHT, + border: borders.none, + width: '100%', + + // Shared common input text styles + ...inputValueTextStyles(), + + '&::placeholder': { + ...inputPlaceholderTextStyles, + }, + + '&:focus': { + outline: 'none', + }, +}); + +export const Required = styled.span({ + color: colors.red[500], +}); + +export const Label = styled.div({ + ...formLabelTextStyles, + marginBottom: spacing.xsm, + textAlign: 'left', +}); + +export const ErrorMessage = styled.div({ + ...defaultMessageStyles, + color: theme.semanticTokens.colors.error, +}); + +export const WarningMessage = styled.div({ + ...defaultMessageStyles, + color: theme.semanticTokens.colors.warning, +}); diff --git a/datahub-web-react/src/alchemy-components/components/Input/index.ts b/datahub-web-react/src/alchemy-components/components/Input/index.ts new file mode 100644 index 00000000000000..336a9b4dd08e97 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/index.ts @@ -0,0 +1,2 @@ +export { Input, inputDefaults } from './Input'; +export type { InputProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Input/types.ts b/datahub-web-react/src/alchemy-components/components/Input/types.ts new file mode 100644 index 00000000000000..1b2abf132d3283 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/types.ts @@ -0,0 +1,22 @@ +import { InputHTMLAttributes } from 'react'; + +import { IconNames } from '../Icon'; + +export interface InputProps extends InputHTMLAttributes { + value?: string | number | readonly string[] | undefined; + setValue?: React.Dispatch>; + label: string; + placeholder?: string; + icon?: IconNames; + error?: string; + warning?: string; + isSuccess?: boolean; + isDisabled?: boolean; + isInvalid?: boolean; + isReadOnly?: boolean; + isPassword?: boolean; + isRequired?: boolean; + errorOnHover?: boolean; + id?: string; + type?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Input/utils.ts b/datahub-web-react/src/alchemy-components/components/Input/utils.ts new file mode 100644 index 00000000000000..142a93232485b3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Input/utils.ts @@ -0,0 +1,5 @@ +export const getInputType = (type?: string, isPassword?: boolean, showPassword?: boolean) => { + if (type) return type; + if (isPassword && !showPassword) return 'password'; + return 'text'; +}; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx new file mode 100644 index 00000000000000..8cce0369918a2e --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.stories.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import { LineChart } from './LineChart'; +import { getMockedProps } from '../BarChart/utils'; + +const meta = { + title: 'Charts / LineChart', + component: LineChart, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to show LineChart', + }, + }, + + // Component-level argTypes + argTypes: { + data: { + description: 'Array of datum to show', + }, + xAccessor: { + description: 'A function to convert datum to value of X', + }, + yAccessor: { + description: 'A function to convert datum to value of Y', + }, + renderTooltipContent: { + description: 'A function to replace default rendering of toolbar', + }, + margin: { + description: 'Add margins to chart', + }, + leftAxisTickFormat: { + description: 'A function to format labels of left axis', + }, + leftAxisTickLabelProps: { + description: 'Props for label of left axis', + }, + bottomAxisTickFormat: { + description: 'A function to format labels of bottom axis', + }, + bottomAxisTickLabelProps: { + description: 'Props for label of bottom axis', + }, + lineColor: { + description: 'Color of line on chart', + control: { + type: 'color', + }, + }, + areaColor: { + description: 'Color of area under line', + control: { + type: 'color', + }, + }, + gridColor: { + description: "Color of grid's lines", + control: { + type: 'color', + }, + }, + renderGradients: { + description: 'A function to render different gradients that can be used as colors', + }, + toolbarVerticalCrosshairStyle: { + description: "Styles of toolbar's vertical line", + }, + renderTooltipGlyph: { + description: 'A function to render a glyph', + }, + }, + + // Define defaults + args: { + ...getMockedProps(), + renderTooltipContent: (datum) => <>DATUM: {JSON.stringify(datum)}, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => ( +
+ +
+ ), +}; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx new file mode 100644 index 00000000000000..22580122ccf84f --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/LineChart.tsx @@ -0,0 +1,178 @@ +import { colors } from '@src/alchemy-components/theme'; +// import { abbreviateNumber } from '@src/app/dataviz/utils'; +import { TickLabelProps } from '@visx/axis'; +import { curveMonotoneX } from '@visx/curve'; +import { LinearGradient } from '@visx/gradient'; +import { ParentSize } from '@visx/responsive'; +import { AreaSeries, Axis, AxisScale, Grid, LineSeries, Tooltip, XYChart } from '@visx/xychart'; +import dayjs from 'dayjs'; +import React, { useState } from 'react'; +import { Popover } from '../Popover'; +import { ChartWrapper } from './components'; +import { LineChartProps } from './types'; +import { abbreviateNumber } from '../dataviz/utils'; + +const commonTickLabelProps: TickLabelProps = { + fontSize: 10, + fontFamily: 'Mulish', + fill: colors.gray[1700], +}; + +const GLYPH_DROP_SHADOW_FILTER = ` + drop-shadow(0px 1px 3px rgba(33, 23, 95, 0.30)) + drop-shadow(0px 2px 5px rgba(33, 23, 95, 0.25)) + drop-shadow(0px -2px 5px rgba(33, 23, 95, 0.25) +`; + +export const lineChartDefault: LineChartProps = { + data: [], + xAccessor: (datum) => datum?.x, + yAccessor: (datum) => datum?.y, + leftAxisTickFormat: abbreviateNumber, + leftAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'end', + }, + bottomAxisTickFormat: (x) => dayjs(x).format('D MMM'), + bottomAxisTickLabelProps: { + ...commonTickLabelProps, + textAnchor: 'middle', + verticalAnchor: 'start', + }, + lineColor: colors.violet[500], + areaColor: 'url(#line-gradient)', + gridColor: '#e0e0e0', + renderGradients: () => ( + + ), + toolbarVerticalCrosshairStyle: { + stroke: colors.white, + strokeWidth: 2, + filter: GLYPH_DROP_SHADOW_FILTER, + }, + renderTooltipGlyph: (props) => { + return ( + <> + + + + ); + }, +}; + +export function LineChart({ + data, + xAccessor = lineChartDefault.xAccessor, + yAccessor = lineChartDefault.yAccessor, + renderTooltipContent, + margin, + leftAxisTickFormat = lineChartDefault.leftAxisTickFormat, + leftAxisTickLabelProps = lineChartDefault.leftAxisTickLabelProps, + bottomAxisTickFormat = lineChartDefault.bottomAxisTickFormat, + bottomAxisTickLabelProps = lineChartDefault.bottomAxisTickLabelProps, + lineColor = lineChartDefault.lineColor, + areaColor = lineChartDefault.areaColor, + gridColor = lineChartDefault.gridColor, + renderGradients = lineChartDefault.renderGradients, + toolbarVerticalCrosshairStyle = lineChartDefault.toolbarVerticalCrosshairStyle, + renderTooltipGlyph = lineChartDefault.renderTooltipGlyph, +}: LineChartProps) { + const [showGrid, setShowGrid] = useState(false); + + // FYI: additional margins to show left and bottom axises + const internalMargin = { + top: (margin?.top ?? 0) + 30, + right: (margin?.right ?? 0) + 20, + bottom: (margin?.bottom ?? 0) + 35, + left: (margin?.left ?? 0) + 40, + }; + + const accessors = { xAccessor, yAccessor }; + + return ( + setShowGrid(true)} onMouseLeave={() => setShowGrid(false)}> + + {({ width, height }) => { + return ( + + {renderGradients?.()} + + + + + + + + {showGrid && ( + + )} + + + dataKey="line-chart-seria-01" + data={data} + fill={areaColor} + curve={curveMonotoneX} + {...accessors} + /> + + dataKey="line-chart-seria-01" + data={data} + stroke={lineColor} + curve={curveMonotoneX} + {...accessors} + /> + + + snapTooltipToDatumX + snapTooltipToDatumY + showVerticalCrosshair + applyPositionStyle + showSeriesGlyphs + verticalCrosshairStyle={toolbarVerticalCrosshairStyle} + renderGlyph={renderTooltipGlyph} + unstyled + renderTooltip={({ tooltipData }) => { + return ( + tooltipData?.nearestDatum && ( + + ) + ); + }} + /> + + ); + }} + + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx b/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx new file mode 100644 index 00000000000000..fb6c0cf1ced784 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/components.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const ChartWrapper = styled.div` + width: 100%; + height: 100%; + position: relative; + cursor: pointer; +`; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/index.ts b/datahub-web-react/src/alchemy-components/components/LineChart/index.ts new file mode 100644 index 00000000000000..7fca9300d578ca --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/index.ts @@ -0,0 +1 @@ +export { LineChart } from './LineChart'; diff --git a/datahub-web-react/src/alchemy-components/components/LineChart/types.ts b/datahub-web-react/src/alchemy-components/components/LineChart/types.ts new file mode 100644 index 00000000000000..cf45662ba7cf90 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/LineChart/types.ts @@ -0,0 +1,22 @@ +import { TickFormatter, TickLabelProps } from '@visx/axis'; +import { Margin } from '@visx/xychart'; +import { RenderTooltipGlyphProps } from '@visx/xychart/lib/components/Tooltip'; +import React from 'react'; + +export type LineChartProps = { + data: DatumType[]; + xAccessor: (datum: DatumType) => string | number; + yAccessor: (datum: DatumType) => number; + renderTooltipContent?: (datum: DatumType) => React.ReactNode; + margin?: Margin; + leftAxisTickFormat?: TickFormatter; + leftAxisTickLabelProps?: TickLabelProps; + bottomAxisTickFormat?: TickFormatter; + bottomAxisTickLabelProps?: TickLabelProps; + lineColor?: string; + areaColor?: string; + gridColor?: string; + renderGradients?: () => React.ReactNode; + toolbarVerticalCrosshairStyle?: React.SVGProps; + renderTooltipGlyph?: (props: RenderTooltipGlyphProps) => React.ReactNode | undefined; +}; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx new file mode 100644 index 00000000000000..7016ecbc7c90a0 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.stories.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { PageTitle } from '.'; + +// Auto Docs +const meta = { + title: 'Pages / Page Title', + component: PageTitle, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render the title and subtitle for a page.', + }, + }, + + // Component-level argTypes + argTypes: { + title: { + description: 'The title text', + }, + subTitle: { + description: 'The subtitle text', + }, + variant: { + description: 'The variant of header based on its usage', + }, + }, + + // Define default args + args: { + title: 'Automations', + subTitle: 'Create & manage automations', + variant: 'pageHeader', + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const withLink = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et + posuere dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis + rutrum. Morbi tempus velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel + mollis eros. + + } + /> +); + +export const sectionHeader = () => ( + +); diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx new file mode 100644 index 00000000000000..3dcf42ff2fc0e2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/PageTitle.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { PageTitleProps } from './types'; +import { Container, SubTitle, Title } from './components'; +import { Pill } from '../Pills'; + +export const PageTitle = ({ title, subTitle, pillLabel, variant = 'pageHeader' }: PageTitleProps) => { + return ( + + + {title} + {pillLabel ? <Pill label={pillLabel} size="sm" clickable={false} /> : null} + + + {subTitle ? {subTitle} : null} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts new file mode 100644 index 00000000000000..328323434e0403 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/components.ts @@ -0,0 +1,52 @@ +import styled from 'styled-components'; +import { typography, colors } from '@components/theme'; +import { getHeaderSubtitleStyles, getHeaderTitleStyles } from './utils'; + +// Text Styles +const titleStyles = { + display: 'flex', + alignItems: 'center', + gap: 8, + fontWeight: typography.fontWeights.bold, + color: colors.gray[600], +}; + +const subTitleStyles = { + fontWeight: typography.fontWeights.normal, + color: colors.gray[1700], +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.body, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +export const Container = styled.div` + display: flex; + flex-direction: column; + align-items: start; + justify-content: start; +`; + +export const Title = styled.div<{ variant: string }>(({ variant }) => ({ + ...baseStyles, + ...titleStyles, + ...getHeaderTitleStyles(variant), +})); + +export const SubTitle = styled.div<{ variant: string }>(({ variant }) => ({ + ...baseStyles, + ...subTitleStyles, + ...getHeaderSubtitleStyles(variant), +})); diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts new file mode 100644 index 00000000000000..2888306f7c9a66 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/index.ts @@ -0,0 +1 @@ +export { PageTitle } from './PageTitle'; diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts new file mode 100644 index 00000000000000..fb1e207d0bbd7b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/types.ts @@ -0,0 +1,8 @@ +import React from 'react'; + +export interface PageTitleProps { + title: string; + subTitle?: string | React.ReactNode; + pillLabel?: string; + variant?: 'pageHeader' | 'sectionHeader'; +} diff --git a/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts b/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts new file mode 100644 index 00000000000000..fe6d18688f31f1 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/PageTitle/utils.ts @@ -0,0 +1,27 @@ +import { typography } from '@components/theme'; + +export const getHeaderTitleStyles = (variant) => { + if (variant === 'sectionHeader') { + return { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.lg, + }; + } + return { + fontSize: typography.fontSizes['3xl'], + lineHeight: typography.lineHeights['3xl'], + }; +}; + +export const getHeaderSubtitleStyles = (variant) => { + if (variant === 'sectionHeader') { + return { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.md, + }; + } + return { + fontSize: typography.fontSizes.lg, + lineHeight: typography.lineHeights.lg, + }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx new file mode 100644 index 00000000000000..d5cdffef6d6bd3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.stories.tsx @@ -0,0 +1,126 @@ +import React from 'react'; + +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { AVAILABLE_ICONS } from '../Icon'; +import { Pill, pillDefault } from './Pill'; + +const meta = { + title: 'Components / Pill', + component: Pill, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.EXPERIMENTAL], + docs: { + subtitle: 'A component that is used to get pill', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the Pill.', + table: { + defaultValue: { summary: pillDefault.label }, + }, + control: { + type: 'text', + }, + }, + leftIcon: { + description: 'The icon to display in the Pill icon.', + type: 'string', + options: AVAILABLE_ICONS, + control: { + type: 'select', + }, + }, + rightIcon: { + description: 'The icon to display in the Pill icon.', + type: 'string', + options: AVAILABLE_ICONS, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the pill.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: pillDefault.size }, + }, + control: { + type: 'select', + }, + }, + variant: { + description: 'The size of the Pill.', + options: ['filled', 'outline'], + table: { + defaultValue: { summary: pillDefault.variant }, + }, + control: { + type: 'select', + }, + }, + colorScheme: { + description: 'The color of the Pill.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: pillDefault.color }, + }, + control: { + type: 'select', + }, + }, + }, + + // Define defaults + args: { + label: pillDefault.label, + leftIcon: pillDefault.leftIcon, + rightIcon: pillDefault.rightIcon, + size: pillDefault.size, + variant: pillDefault.variant, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const sizes = () => ( + + + + + +); + +export const colors = () => ( + + + + + + + + +); + +export const withIcon = () => ( + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx new file mode 100644 index 00000000000000..898ec89fce5957 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/Pill.tsx @@ -0,0 +1,42 @@ +import { Icon } from '@components'; +import React from 'react'; + +import { PillContainer, PillText } from './components'; +import { PillProps } from './types'; + +export const pillDefault: PillProps = { + label: 'Label', + size: 'md', + variant: 'filled', + clickable: true, +}; + +export function Pill({ + label = pillDefault.label, + size = pillDefault.size, + leftIcon, + rightIcon, + colorScheme, + variant = pillDefault.variant, + clickable = pillDefault.clickable, + id, + onClickRightIcon, + onClickLeftIcon, + onPillClick, +}: PillProps) { + return ( + + {leftIcon && } + {label} + {rightIcon && } + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/Pills/components.ts b/datahub-web-react/src/alchemy-components/components/Pills/components.ts new file mode 100644 index 00000000000000..79734561a92da6 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/components.ts @@ -0,0 +1,33 @@ +import { spacing } from '@components/theme'; +import styled from 'styled-components'; + +import { PillStyleProps } from './types'; +import { getPillStyle } from './utils'; + +export const PillContainer = styled.div( + // Dynamic styles + (props: PillStyleProps) => ({ ...getPillStyle(props as PillStyleProps) }), + { + // Base root styles + display: 'inline-flex', + alignItems: 'center', + gap: spacing.xxsm, + cursor: 'pointer', + padding: '0px 8px', + borderRadius: '200px', + maxWidth: '100%', + + // Base Disabled styles + '&:disabled': { + cursor: 'not-allowed', + }, + }, +); + +export const PillText = styled.span({ + maxWidth: '100%', + display: 'block', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Pills/index.ts b/datahub-web-react/src/alchemy-components/components/Pills/index.ts new file mode 100644 index 00000000000000..85a76193db2670 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/index.ts @@ -0,0 +1 @@ +export { Pill } from './Pill'; diff --git a/datahub-web-react/src/alchemy-components/components/Pills/types.ts b/datahub-web-react/src/alchemy-components/components/Pills/types.ts new file mode 100644 index 00000000000000..17d4d12465e1ef --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/types.ts @@ -0,0 +1,18 @@ +import { ColorOptions, SizeOptions, VariantOptions } from '@src/alchemy-components/theme/config'; +import { HTMLAttributes } from 'react'; + +export interface PillStyleProps { + colorScheme?: ColorOptions; // need to keep colorScheme because HTMLAttributes also have color property + variant?: VariantOptions; + size?: SizeOptions; + clickable?: boolean; +} + +export interface PillProps extends HTMLAttributes, PillStyleProps { + label: string; + rightIcon?: string; + leftIcon?: string; + onClickRightIcon?: (e: React.MouseEvent) => void; + onClickLeftIcon?: (e: React.MouseEvent) => void; + onPillClick?: (e: React.MouseEvent) => void; +} diff --git a/datahub-web-react/src/alchemy-components/components/Pills/utils.ts b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts new file mode 100644 index 00000000000000..832bf95640982b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Pills/utils.ts @@ -0,0 +1,147 @@ +import { colors, typography } from '@src/alchemy-components/theme'; +import { getColor, getFontSize } from '@src/alchemy-components/theme/utils'; +import { PillStyleProps } from './types'; + +// Utility function to get color styles for pill - does not generate CSS +const getPillColorStyles = (variant, color) => { + const defaultStyles = { + bgColor: getColor(color, 100), + hoverBgColor: getColor('gray', 100), + borderColor: '', + activeBorderColor: getColor('violet', 500), + textColor: getColor(color, 600), + }; + + const colorOverrides = { + violet: { + textColor: getColor(color, 500), + bgColor: getColor('gray', 1000), + borderColor: 'transparent', + hoverBgColor: getColor(color, 100), + activeBorderColor: getColor(color, 500), + }, + blue: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1100), + borderColor: 'transparent', + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + red: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1200), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + green: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1300), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + yellow: { + textColor: getColor(color, 1000), + bgColor: getColor('gray', 1400), + hoverBgColor: getColor(color, 1100), + activeBorderColor: getColor(color, 1000), + }, + }; + + const styles = colorOverrides[color] || defaultStyles; + + if (variant === 'outline') { + return { + bgColor: colors.transparent, + borderColor: getColor('gray', 1400), + textColor: getColor(color, 600), + }; + } + + return styles; +}; + +// Generate variant styles for pill +const getPillVariantStyles = (variant, colorStyles) => + ({ + filled: { + backgroundColor: colorStyles.bgColor, + border: `1px solid transparent`, + color: colorStyles.textColor, + '&:hover': { + backgroundColor: colorStyles.hoverBgColor, + }, + }, + outline: { + backgroundColor: 'transparent', + border: `1px solid ${colorStyles.borderColor}`, + color: colorStyles.textColor, + '&:hover': { + backgroundColor: colorStyles.hoverBgColor, + border: `1px solid transparent`, + }, + '&:disabled': { + border: `1px solid transparent`, + }, + }, + text: { + color: colorStyles.textColor, + }, + }[variant]); + +// Generate font styles for pill +const getPillFontStyles = (size) => { + const baseFontStyles = { + fontFamily: typography.fonts.body, + fontWeight: typography.fontWeights.normal, + lineHeight: typography.lineHeights.none, + }; + + const sizeMap = { + xs: { fontSize: getFontSize(size), lineHeight: '16px' }, + sm: { fontSize: getFontSize(size), lineHeight: '22px' }, + md: { fontSize: getFontSize(size), lineHeight: '24px' }, + lg: { fontSize: getFontSize(size), lineHeight: '30px' }, + xl: { fontSize: getFontSize(size), lineHeight: '34px' }, + }; + + return { + ...baseFontStyles, + ...sizeMap[size], + }; +}; + +// Generate active styles for pill +const getPillActiveStyles = (styleColors) => ({ + borderColor: styleColors.activeBorderColor, +}); + +/* + * Main function to generate styles for pill + */ +export const getPillStyle = (props: PillStyleProps) => { + const { variant, colorScheme = 'gray', size, clickable = true } = props; + + // Get map of colors + const colorStyles = getPillColorStyles(variant, colorScheme); + + // Define styles for pill + let styles = { + ...getPillVariantStyles(variant, colorStyles), + ...getPillFontStyles(size), + '&:focus': { + ...getPillActiveStyles(colorStyles), + outline: 'none', // Remove default browser focus outline if needed + }, + '&:active': { + ...getPillActiveStyles(colorStyles), + }, + }; + if (!clickable) { + styles = { + ...styles, + pointerEvents: 'none', + }; + } + + return styles; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx b/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx new file mode 100644 index 00000000000000..8f6ca61976b206 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Popover/Popover.tsx @@ -0,0 +1,6 @@ +import { Popover, PopoverProps } from 'antd'; +import * as React from 'react'; + +export default function DataHubPopover(props: PopoverProps & React.RefAttributes) { + return ; +} diff --git a/datahub-web-react/src/alchemy-components/components/Popover/index.ts b/datahub-web-react/src/alchemy-components/components/Popover/index.ts new file mode 100644 index 00000000000000..02df6c38e8c4ea --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Popover/index.ts @@ -0,0 +1 @@ +export { default as Popover } from './Popover'; diff --git a/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx b/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx new file mode 100644 index 00000000000000..cb3116d7b8941b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/Radio.stories.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { GridList } from '@components/.docs/mdx-components'; +import { Radio, radioDefaults, RadioGroup } from './Radio'; +import { Heading } from '../Heading'; +import { RadioProps } from './types'; + +const MOCK_RADIOS: RadioProps[] = [ + { + label: 'Label 1', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 2', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, + { + label: 'Label 3', + error: '', + isChecked: false, + isDisabled: false, + isIntermediate: false, + isRequired: false, + }, +]; + +const meta = { + title: 'Forms / Radio', + component: Radio, + parameters: { + layout: 'centered', + docs: { + subtitle: 'A component that is used to get user input in the state of a radio button.', + }, + }, + argTypes: { + label: { + description: 'Label for the Radio.', + table: { + defaultValue: { summary: radioDefaults.label }, + }, + control: { + type: 'text', + }, + }, + error: { + description: 'Enforce error state on the Radio.', + table: { + defaultValue: { summary: radioDefaults.error }, + }, + control: { + type: 'text', + }, + }, + isChecked: { + description: 'Whether the Radio is checked.', + table: { + defaultValue: { summary: radioDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Radio is in disabled state.', + table: { + defaultValue: { summary: radioDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Radio is a required field.', + table: { + defaultValue: { summary: radioDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + args: { + label: radioDefaults.label, + error: radioDefaults.error, + isChecked: radioDefaults.isChecked, + isDisabled: radioDefaults.isDisabled, + isRequired: radioDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const states = () => ( + + + + + + +); + +export const disabledStates = () => ( + + + + +); + +export const radioGroups = () => ( + +
+ Horizontal Radio Group + +
+
+ Vertical Radio Group + +
+
+); diff --git a/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx b/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx new file mode 100644 index 00000000000000..592c10ec88de8a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/Radio.tsx @@ -0,0 +1,89 @@ +import React, { useEffect, useState } from 'react'; +import { RadioGroupProps, RadioProps } from './types'; +import { + RadioWrapper, + Checkmark, + HiddenInput, + Label, + Required, + RadioLabel, + RadioBase, + RadioGroupContainer, +} from './components'; + +export const radioDefaults = { + label: 'Label', + error: '', + isChecked: false, + isDisabled: false, + isRequired: false, + isVertical: false, + setIsChecked: () => {}, +}; + +export const Radio = ({ + label = radioDefaults.label, + error = radioDefaults.error, + isChecked = radioDefaults.isChecked, + isDisabled = radioDefaults.isDisabled, + isRequired = radioDefaults.isRequired, + setIsChecked = radioDefaults.setIsChecked, + ...props +}: RadioProps) => { + const [checked, setChecked] = useState(isChecked || false); + + useEffect(() => { + setChecked(isChecked || false); + }, [isChecked]); + + const id = props.id || `checkbox-${label}`; + + return ( + + + { + setChecked(true); + setIsChecked?.(true); + }} + aria-label={label} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + + + {label && ( + + + + )} + + ); +}; + +export const RadioGroup = ({ isVertical, radios }: RadioGroupProps) => { + if (!radios.length) { + return <>; + } + + return ( + + {radios.map((checkbox) => { + const props = { ...checkbox }; + return ( + + + + ); + })} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Radio/components.ts b/datahub-web-react/src/alchemy-components/components/Radio/components.ts new file mode 100644 index 00000000000000..027971be179584 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/components.ts @@ -0,0 +1,83 @@ +import { borders, colors, radius, spacing } from '@components/theme'; +import styled from 'styled-components'; +import { formLabelTextStyles } from '../commonStyles'; +import { getRadioBorderColor, getRadioCheckmarkColor } from './utils'; + +export const RadioWrapper = styled.div<{ disabled: boolean; error: string }>(({ disabled, error }) => ({ + position: 'relative', + margin: '20px', + width: '20px', + height: '20px', + border: `${borders['2px']} ${getRadioBorderColor(disabled, error)}`, + backgroundColor: colors.white, + borderRadius: radius.full, + display: 'flex', + justifyContent: 'flex-start', + alignItems: 'center', + marginRight: '40px', + cursor: !disabled ? 'pointer' : 'none', + transition: 'border 0.3s ease, outline 0.3s ease', + '&:hover': { + border: `${borders['2px']} ${!disabled && !error ? colors.violet[500] : getRadioBorderColor(disabled, error)}`, + outline: !disabled && !error ? `${borders['2px']} ${colors.gray[200]}` : 'none', + }, +})); + +export const RadioBase = styled.div({}); + +export const Label = styled.div({ + ...formLabelTextStyles, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const RadioLabel = styled.div({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const RadioHoverState = styled.div({ + border: `${borders['2px']} ${colors.violet[500]}`, + width: 'calc(100% - -3px)', + height: 'calc(100% - -3px)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: radius.full, +}); + +export const Checkmark = styled.div<{ checked: boolean; disabled: boolean; error: string }>( + ({ checked, disabled, error }) => ({ + width: 'calc(100% - 6px)', + height: 'calc(100% - 6px)', + borderRadius: radius.full, + background: getRadioCheckmarkColor(checked, disabled, error), + display: checked ? 'inline-block' : 'none', + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }), +); + +export const HiddenInput = styled.input<{ checked: boolean }>({ + opacity: 0, + width: '20px', + height: '20px', +}); + +export const RadioGroupContainer = styled.div<{ isVertical?: boolean }>(({ isVertical }) => ({ + display: 'flex', + flexDirection: isVertical ? 'column' : 'row', + justifyContent: 'center', + alignItems: 'center', + gap: !isVertical ? spacing.md : spacing.none, + margin: !isVertical ? spacing.xxsm : spacing.none, +})); diff --git a/datahub-web-react/src/alchemy-components/components/Radio/types.ts b/datahub-web-react/src/alchemy-components/components/Radio/types.ts new file mode 100644 index 00000000000000..59fd15654f916a --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/types.ts @@ -0,0 +1,16 @@ +import { InputHTMLAttributes } from 'react'; + +export interface RadioProps extends InputHTMLAttributes { + label?: string; + error?: string; + isChecked?: boolean; + setIsChecked?: React.Dispatch>; + isDisabled?: boolean; + isIntermediate?: boolean; + isRequired?: boolean; +} + +export interface RadioGroupProps { + isVertical?: boolean; + radios: RadioProps[]; +} diff --git a/datahub-web-react/src/alchemy-components/components/Radio/utils.ts b/datahub-web-react/src/alchemy-components/components/Radio/utils.ts new file mode 100644 index 00000000000000..ed9dcc35d303b4 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Radio/utils.ts @@ -0,0 +1,27 @@ +import { colors } from '@components/theme'; + +const radioBorderColors = { + default: colors.gray[400], + disabled: colors.gray[300], + error: colors.red[500], +}; + +const radioCheckmarkColors = { + default: colors.white, + disabled: colors.gray[300], + checked: colors.violet[500], + error: colors.red[500], +}; + +export function getRadioBorderColor(disabled: boolean, error: string) { + if (disabled) return radioBorderColors.disabled; + if (error) return radioCheckmarkColors.error; + return radioBorderColors.default; +} + +export function getRadioCheckmarkColor(checked: boolean, disabled: boolean, error: string) { + if (disabled) return radioCheckmarkColors.disabled; + if (error) return radioCheckmarkColors.error; + if (checked) return radioCheckmarkColors.checked; + return radioCheckmarkColors.default; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx new file mode 100644 index 00000000000000..b49159ba38a758 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/BasicSelect.tsx @@ -0,0 +1,339 @@ +import { Button, Icon, Pill, Text } from '@components'; +import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionButtonsContainer, + Container, + DescriptionContainer, + Dropdown, + FooterBase, + LabelContainer, + LabelsWrapper, + OptionContainer, + OptionLabel, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectAllOption, + SelectBase, + SelectLabel, + SelectValue, + StyledCancelButton, + StyledCheckbox, + StyledClearButton, +} from './components'; +import { ActionButtonsProps, SelectLabelDisplayProps, SelectOption, SelectProps } from './types'; +import { getFooterButtonSize } from './utils'; + +const SelectLabelDisplay = ({ + selectedValues, + options, + placeholder, + isMultiSelect, + removeOption, + disabledValues, + showDescriptions, +}: SelectLabelDisplayProps) => { + const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)); + return ( + + {!!selectedOptions.length && + isMultiSelect && + selectedOptions.map((o) => { + const isDisabled = disabledValues?.includes(o.value); + return ( + { + e.stopPropagation(); + removeOption?.(o); + }} + clickable={!isDisabled} + /> + ); + })} + {!selectedValues.length && {placeholder}} + {!isMultiSelect && ( + <> + {selectedOptions[0]?.label} + {showDescriptions && !!selectedValues.length && ( + {selectedOptions[0]?.description} + )} + + )} + + ); +}; + +const SelectActionButtons = ({ + selectedValues, + isOpen, + isDisabled, + isReadOnly, + showClear, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {showClear && selectedValues.length > 0 && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +// Updated main component +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + isMultiSelect: false, + showClear: false, + placeholder: 'Select an option', + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const BasicSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onCancel, + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + showClear = selectDefaults.showClear, + size = selectDefaults.size, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = [], + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState(values); + const [tempValues, setTempValues] = useState(values); + const selectRef = useRef(null); + const [areAllSelected, setAreAllSelected] = useState(false); + + useEffect(() => { + if (values?.length > 0 && !isEqual(selectedValues, values)) { + setSelectedValues(values); + } + }, [values, selectedValues]); + + useEffect(() => { + setAreAllSelected(tempValues.length === options.length); + }, [options, tempValues]); + + const filteredOptions = useMemo( + () => options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), + [options, searchQuery], + ); + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setTempValues(selectedValues); + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly, selectedValues]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + const updatedValues = tempValues.includes(option.value) + ? tempValues.filter((val) => val !== option.value) + : [...tempValues, option.value]; + + setTempValues(isMultiSelect ? updatedValues : [option.value]); + }, + [tempValues, isMultiSelect], + ); + + const removeOption = useCallback( + (option: SelectOption) => { + const updatedValues = selectedValues.filter((val) => val !== option.value); + setSelectedValues(updatedValues); + }, + [selectedValues], + ); + + const handleUpdateClick = useCallback(() => { + setSelectedValues(tempValues); + setIsOpen(false); + if (onUpdate) { + onUpdate(tempValues); + } + }, [tempValues, onUpdate]); + + const handleCancelClick = useCallback(() => { + setIsOpen(false); + setTempValues(selectedValues); + if (onCancel) { + onCancel(); + } + }, [selectedValues, onCancel]); + + const handleClearSelection = useCallback(() => { + setSelectedValues([]); + setAreAllSelected(false); + setTempValues([]); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + const handleSelectAll = () => { + if (areAllSelected) { + setTempValues([]); + onUpdate?.([]); + } else { + const allValues = options.map((option) => option.value); + setTempValues(allValues); + onUpdate?.(allValues); + } + setAreAllSelected(!areAllSelected); + }; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + setSearchQuery(e.target.value)} + style={{ fontSize: size || 'md' }} + /> + + + )} + + {showSelectAll && isMultiSelect && ( + !(disabledValues.length === options.length) && handleSelectAll()} + isDisabled={disabledValues.length === options.length} + > + + {selectAllLabel} + + + + )} + {filteredOptions.map((option) => ( + !isMultiSelect && handleOptionChange(option)} + isSelected={tempValues.includes(option.value)} + isMultiSelect={isMultiSelect} + isDisabled={disabledValues?.includes(option.value)} + > + {isMultiSelect ? ( + + {option.label} + handleOptionChange(option)} + checked={tempValues.includes(option.value)} + disabled={disabledValues?.includes(option.value)} + /> + + ) : ( + + + {option.label} + + {!!option.description && ( + + {option.description} + + )} + + )} + + ))} + + + + Cancel + + + + + )} + + ); +}; + +export default BasicSelect; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx new file mode 100644 index 00000000000000..8a7d3670b2b1b9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx @@ -0,0 +1,309 @@ +import React, { useState, useMemo, useEffect } from 'react'; + +import { colors, Icon } from '@components'; +import theme from '@components/theme'; +import styled from 'styled-components'; +import { Checkbox } from 'antd'; + +import { OptionLabel } from '../components'; +import { SelectOption } from './types'; + +const ParentOption = styled.div` + display: flex; + align-items: center; +`; + +const ChildOptions = styled.div` + padding-left: 20px; +`; + +const StyledCheckbox = styled(Checkbox)<{ checked: boolean; indeterminate?: boolean }>` + .ant-checkbox-inner { + border: 1px solid ${colors.gray[300]} !important; + border-radius: 3px; + } + margin-left: auto; + ${(props) => + props.checked && + !props.indeterminate && + ` + .ant-checkbox-inner { + background-color: ${theme.semanticTokens.colors.primary}; + border-color: ${theme.semanticTokens.colors.primary} !important; + } + `} + ${(props) => + props.indeterminate && + ` + .ant-checkbox-inner { + &:after { + background-color: ${theme.semanticTokens.colors.primary}; + } + } + `} + ${(props) => + props.disabled && + ` + .ant-checkbox-inner { + background-color: ${colors.gray[200]} !important; + } + `} +`; + +function getChildrenRecursively( + directChildren: SelectOption[], + parentValueToOptions: { [parentValue: string]: SelectOption[] }, +) { + const visitedParents = new Set(); + let allChildren: SelectOption[] = []; + + function getChildren(parentValue: string) { + const newChildren = parentValueToOptions[parentValue] || []; + if (visitedParents.has(parentValue) || !newChildren.length) { + return; + } + + visitedParents.add(parentValue); + allChildren = [...allChildren, ...newChildren]; + newChildren.forEach((child) => getChildren(child.value || child.value)); + } + + directChildren.forEach((c) => getChildren(c.value || c.value)); + + return allChildren; +} + +interface OptionProps { + option: SelectOption; + selectedOptions: SelectOption[]; + parentValueToOptions: { [parentValue: string]: SelectOption[] }; + areParentsSelectable: boolean; + handleOptionChange: (node: SelectOption) => void; + addOptions: (nodes: SelectOption[]) => void; + removeOptions: (nodes: SelectOption[]) => void; + loadData?: (node: SelectOption) => void; + isMultiSelect?: boolean; + isLoadingParentChildList?: boolean; + setSelectedOptions: React.Dispatch>; +} + +export const NestedOption = ({ + option, + selectedOptions, + parentValueToOptions, + handleOptionChange, + addOptions, + removeOptions, + loadData, + isMultiSelect, + areParentsSelectable, + isLoadingParentChildList, + setSelectedOptions, +}: OptionProps) => { + const [autoSelectChildren, setAutoSelectChildren] = useState(false); + const [loadingParentUrns, setLoadingParentUrns] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const directChildren = useMemo( + () => parentValueToOptions[option.value] || [], + [parentValueToOptions, option.value], + ); + + const recursiveChildren = useMemo( + () => getChildrenRecursively(directChildren, parentValueToOptions), + [directChildren, parentValueToOptions], + ); + + const children = useMemo(() => [...directChildren, ...recursiveChildren], [directChildren, recursiveChildren]); + const selectableChildren = useMemo( + () => (areParentsSelectable ? children : children.filter((c) => !c.isParent)), + [areParentsSelectable, children], + ); + const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); + + useEffect(() => { + if (autoSelectChildren && selectableChildren.length) { + addOptions(selectableChildren); + setAutoSelectChildren(false); + } + }, [autoSelectChildren, selectableChildren, addOptions]); + + const areAllChildrenSelected = useMemo( + () => selectableChildren.every((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyChildrenSelected = useMemo( + () => selectableChildren.some((child) => selectedOptions.find((o) => o.value === child.value)), + [selectableChildren, selectedOptions], + ); + + const areAnyUnselectableChildrenUnexpanded = !!parentChildren.find( + (parent) => !selectableChildren.find((child) => child.parentValue === parent.value), + ); + + const isSelected = useMemo( + () => + !!selectedOptions.find((o) => o.value === option.value) || + (!areParentsSelectable && + !!option.isParent && + !!selectableChildren.length && + areAllChildrenSelected && + !areAnyUnselectableChildrenUnexpanded), + [ + selectedOptions, + areAllChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + areParentsSelectable, + option.isParent, + option.value, + selectableChildren.length, + ], + ); + + const isImplicitlySelected = useMemo( + () => !option.isParent && !!selectedOptions.find((o) => o.value === option.parentValue), + [selectedOptions, option.isParent, option.parentValue], + ); + + const isParentMissingChildren = useMemo(() => !!option.isParent && !children.length, [children, option.isParent]); + + const isPartialSelected = useMemo( + () => + (!areAllChildrenSelected && areAnyChildrenSelected) || + (isSelected && isParentMissingChildren) || + (isSelected && areAnyUnselectableChildrenUnexpanded) || + (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || + (isSelected && !!children.length && !areAnyChildrenSelected), + [ + isSelected, + children, + areAllChildrenSelected, + areAnyChildrenSelected, + areAnyUnselectableChildrenUnexpanded, + isParentMissingChildren, + ], + ); + + const selectOption = () => { + if (areParentsSelectable && option.isParent) { + const existingSelectedOptions = new Set(selectedOptions.map((opt) => opt.value)); + const existingChildSelectedOptions = + selectedOptions.filter((opt) => opt.parentValue === option.value) || []; + if (existingSelectedOptions.has(option.value)) { + removeOptions([option]); + } else { + // filter out the childrens of parent selection as we are allowing implicitly selection + const filteredOptions = selectedOptions.filter( + (selectedOption) => !existingChildSelectedOptions.find((o) => o.value === selectedOption.value), + ); + const newSelectedOptions = [...filteredOptions, option]; + + setSelectedOptions(newSelectedOptions); + } + } else if (isPartialSelected || (!isSelected && !areAnyChildrenSelected)) { + const optionsToAdd = option.isParent && !areParentsSelectable ? selectableChildren : [option]; + addOptions(optionsToAdd); + } else if (areAllChildrenSelected) { + removeOptions([option, ...selectableChildren]); + } else { + handleOptionChange(option); + } + }; + + // one loader variable for fetching data for expanded parents and their respective child nodes + useEffect(() => { + // once loading has been done just remove all the parent node urn + if (!isLoadingParentChildList) { + setLoadingParentUrns([]); + } + }, [isLoadingParentChildList]); + + return ( +
+ + { + e.preventDefault(); + if (isImplicitlySelected) { + return; + } + if (isParentMissingChildren) { + setLoadingParentUrns((previousIds) => [...previousIds, option.value]); + loadData?.(option); + } + if (option.isParent) { + setIsOpen(!isOpen); + } else { + selectOption(); + } + }} + isSelected={!isMultiSelect && isSelected} + // added hack to show cursor in wait untill we get the inline spinner + style={{ width: '100%', cursor: loadingParentUrns.includes(option.value) ? 'wait' : 'pointer' }} + > + {option.isParent && {option.label}} + {!option.isParent && <>{option.label}} + {option.isParent && ( + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + if (!isOpen && isParentMissingChildren) { + setLoadingParentUrns((previousIds) => [...previousIds, option.value]); + loadData?.(option); + } + }} + icon="ChevronLeft" + rotate={isOpen ? '90' : '270'} + size="xl" + color="gray" + style={{ cursor: 'pointer', marginLeft: '4px' }} + /> + )} + { + e.preventDefault(); + if (isImplicitlySelected) { + return; + } + e.stopPropagation(); + if (isParentMissingChildren) { + loadData?.(option); + if (!areParentsSelectable) { + setAutoSelectChildren(true); + } + } + selectOption(); + }} + disabled={isImplicitlySelected} + /> + + + {isOpen && ( + + {directChildren.map((child) => ( + + ))} + + )} +
+ ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx new file mode 100644 index 00000000000000..744c7bfcfec0d2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -0,0 +1,312 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import { Icon, Pill } from '@components'; + +import { + ActionButtonsContainer, + Container, + Dropdown, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectBase, + SelectLabel, + StyledClearButton, +} from '../components'; + +import { SelectSizeOptions } from '../types'; +import { NestedOption } from './NestedOption'; +import { SelectOption } from './types'; + +const NO_PARENT_VALUE = 'no_parent_value'; + +const LabelDisplayWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px; + max-height: 125px; + min-height: 16px; +`; + +interface SelectLabelDisplayProps { + selectedOptions: SelectOption[]; + placeholder: string; + handleOptionChange: (node: SelectOption) => void; +} + +const SelectLabelDisplay = ({ selectedOptions, placeholder, handleOptionChange }: SelectLabelDisplayProps) => { + return ( + + {!!selectedOptions.length && + selectedOptions.map((o) => ( + { + e.stopPropagation(); + handleOptionChange(o); + }} + /> + ))} + {!selectedOptions.length && {placeholder}} + + ); +}; + +export interface ActionButtonsProps { + fontSize?: SelectSizeOptions; + selectedOptions: SelectOption[]; + isOpen: boolean; + isDisabled: boolean; + isReadOnly: boolean; + handleClearSelection: () => void; +} + +const SelectActionButtons = ({ + selectedOptions, + isOpen, + isDisabled, + isReadOnly, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {!!selectedOptions.length && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +export interface SelectProps { + options: SelectOption[]; + label: string; + value?: string; + initialValues?: SelectOption[]; + onCancel?: () => void; + onUpdate?: (selectedValues: SelectOption[]) => void; + size?: SelectSizeOptions; + showSearch?: boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + isMultiSelect?: boolean; + areParentsSelectable?: boolean; + loadData?: (node: SelectOption) => void; + onSearch?: (query: string) => void; + width?: number | 'full'; + height?: number; + placeholder?: string; + searchPlaceholder?: string; + isLoadingParentChildList?: boolean; +} + +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + isMultiSelect: false, + width: 255, + height: 425, +}; + +export const NestedSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + initialValues = [], + onUpdate, + loadData, + onSearch, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + isMultiSelect = selectDefaults.isMultiSelect, + size = selectDefaults.size, + areParentsSelectable = true, + placeholder, + searchPlaceholder, + height = selectDefaults.height, + isLoadingParentChildList = false, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedOptions, setSelectedOptions] = useState(initialValues); + const selectRef = useRef(null); + + // TODO: handle searching inside of a nested component on the FE only + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly]); + + const handleSearch = useCallback( + (query: string) => { + setSearchQuery(query); + onSearch?.(query); + }, + [onSearch], + ); + + // Instead of calling the update function individually whenever selectedOptions changes, + // we use the useEffect hook to trigger the onUpdate function automatically when selectedOptions is updated. + useEffect(() => { + if (onUpdate) { + onUpdate(selectedOptions); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedOptions]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + let newSelectedOptions: SelectOption[]; + if (selectedOptions.find((o) => o.value === option.value)) { + newSelectedOptions = selectedOptions.filter((o) => o.value !== option.value); + } else { + newSelectedOptions = [...selectedOptions, option]; + } + setSelectedOptions(newSelectedOptions); + if (!isMultiSelect) { + setIsOpen(false); + } + }, + [selectedOptions, isMultiSelect], + ); + + const addOptions = useCallback( + (optionsToAdd: SelectOption[]) => { + const existingValues = new Set(selectedOptions.map((option) => option.value)); + const filteredOptionsToAdd = optionsToAdd.filter((option) => !existingValues.has(option.value)); + if (filteredOptionsToAdd.length) { + const newSelectedOptions = [...selectedOptions, ...filteredOptionsToAdd]; + setSelectedOptions(newSelectedOptions); + } + }, + [selectedOptions], + ); + + const removeOptions = useCallback( + (optionsToRemove: SelectOption[]) => { + const newValues = selectedOptions.filter( + (selectedOption) => !optionsToRemove.find((o) => o.value === selectedOption.value), + ); + setSelectedOptions(newValues); + }, + [selectedOptions], + ); + + const handleClearSelection = useCallback(() => { + setSelectedOptions([]); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + // generate map for options to quickly fetch children + const parentValueToOptions: { [parentValue: string]: SelectOption[] } = {}; + options.forEach((o) => { + const parentValue = o.parentValue || NO_PARENT_VALUE; + parentValueToOptions[parentValue] = parentValueToOptions[parentValue] + ? [...parentValueToOptions[parentValue], o] + : [o]; + }); + + const rootOptions = parentValueToOptions[NO_PARENT_VALUE] || []; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + handleSearch(e.target.value)} + style={{ fontSize: size || 'md', width: '100%' }} + /> + + + )} + + {rootOptions.map((option) => ( + + ))} + + + )} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts new file mode 100644 index 00000000000000..62d4541fce0d3d --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/types.ts @@ -0,0 +1,9 @@ +import { Entity } from '@src/types.generated'; + +export interface SelectOption { + value: string; + label: string; + parentValue?: string; + isParent?: boolean; + entity?: Entity; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx new file mode 100644 index 00000000000000..0ec20b15e771ab --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.stories.tsx @@ -0,0 +1,431 @@ +import { GridList } from '@components/.docs/mdx-components'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Select, selectDefaults } from './Select'; +import { SimpleSelect } from './SimpleSelect'; +import { SelectSizeOptions } from './types'; + +// Auto Docs +const meta: Meta = { + title: 'Forms / Select', + component: Select, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to select one or multiple input options from a dropdown list.', + }, + }, + + // Component-level argTypes + argTypes: { + options: { + description: 'Array of options for the Select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: JSON.stringify(selectDefaults.options) }, + }, + }, + label: { + description: 'Label for the Select component.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.label }, + }, + }, + values: { + description: 'Selected values for the Select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: selectDefaults.values?.toString() }, + }, + }, + showSearch: { + description: 'Whether to show the search input.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showSearch?.toString() }, + }, + }, + isDisabled: { + description: 'Whether the Select component is disabled.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isDisabled?.toString() }, + }, + }, + isReadOnly: { + description: 'Whether the Select component is read-only.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isReadOnly?.toString() }, + }, + }, + isRequired: { + description: 'Whether the Select component is required.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isRequired?.toString() }, + }, + }, + size: { + description: 'Size of the Select component.', + control: { + type: 'select', + options: ['sm', 'md', 'lg'], + }, + table: { + defaultValue: { summary: selectDefaults.size }, + }, + }, + width: { + description: 'Width of the Select component.', + control: { + type: 'number', + }, + table: { + defaultValue: { summary: `${selectDefaults.width}` }, + }, + }, + isMultiSelect: { + description: 'Whether the Select component allows multiple values to be selected.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.isMultiSelect?.toString() }, + }, + }, + placeholder: { + description: 'Placeholder for the Select component.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.placeholder }, + }, + }, + disabledValues: { + description: 'Disabled values for the multi-select component.', + control: { + type: 'object', + }, + table: { + defaultValue: { summary: selectDefaults.disabledValues?.toString() }, + }, + }, + showSelectAll: { + description: 'Whether the multi select component shows Select All button.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showSelectAll?.toString() }, + }, + }, + selectAllLabel: { + description: 'Label for Select All button.', + control: { + type: 'text', + }, + table: { + defaultValue: { summary: selectDefaults.selectAllLabel }, + }, + }, + showDescriptions: { + description: 'Whether to show descriptions with the select options.', + control: { + type: 'boolean', + }, + table: { + defaultValue: { summary: selectDefaults.showDescriptions?.toString() }, + }, + }, + }, + + // Define defaults + args: { + options: [ + { label: 'Option 1', value: '1' }, + { label: 'Option 2', value: '2' }, + { label: 'Option 3', value: '3' }, + ], + label: 'Select Label', + values: undefined, + showSearch: selectDefaults.showSearch, + isDisabled: selectDefaults.isDisabled, + isReadOnly: selectDefaults.isReadOnly, + isRequired: selectDefaults.isRequired, + onCancel: () => console.log('Cancel clicked'), + onUpdate: (selectedValues: string[]) => console.log('Update clicked', selectedValues), + size: 'md', // Default size + width: 255, + isMultiSelect: selectDefaults.isMultiSelect, + placeholder: selectDefaults.placeholder, + disabledValues: undefined, + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +const sizeOptions: SelectSizeOptions[] = ['sm', 'md', 'lg']; + +export const simpleSelectSandbox: Story = { + tags: ['dev'], + + render: (props) => ( + + ), +}; + +export const simpleSelectStates = () => ( + + <> + + + + + +); + +export const simpleSelectWithSearch = () => ( + +); + +export const simpleSelectWithMultiSelect = () => ( + +); + +export const simpleSelectWithDisabledValues = () => ( + +); + +export const simpleSelectWithSelectAll = () => ( + +); + +export const simpleSelectWithDescriptions = () => ( + +); + +export const simpleSelectSizes = () => ( + + {sizeOptions.map((size, index) => ( + + ))} + +); + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const BasicSelectSandbox: Story = { + tags: ['dev'], + + render: (props) => ( + + + + +); + +export const withSearch = () => ( + +); + +export const sizes = () => ( + + {sizeOptions.map((size, index) => ( + alert('Cancel clicked')} + onUpdate={(selectedValues) => alert(`Update clicked with values: ${selectedValues}`)} + size="md" + /> + +); diff --git a/datahub-web-react/src/alchemy-components/components/Select/Select.tsx b/datahub-web-react/src/alchemy-components/components/Select/Select.tsx new file mode 100644 index 00000000000000..da28f090565431 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/Select.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { BasicSelect } from './BasicSelect'; +import { SelectProps } from './types'; + +export const selectDefaults: SelectProps = { + options: [], + label: '', + showSearch: false, + values: undefined, + size: 'md', + isDisabled: false, + isReadOnly: false, + isRequired: false, + width: 255, + isMultiSelect: false, + placeholder: 'Select an option', + disabledValues: undefined, + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const Select = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onCancel, + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + size = selectDefaults.size, + width = selectDefaults.width, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = selectDefaults.disabledValues, + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + return ( + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx new file mode 100644 index 00000000000000..be1184cee9e9f5 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/SimpleSelect.tsx @@ -0,0 +1,299 @@ +import { Icon, Pill, Text } from '@components'; +import { isEqual } from 'lodash'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActionButtonsContainer, + Container, + DescriptionContainer, + Dropdown, + LabelContainer, + LabelsWrapper, + OptionContainer, + OptionLabel, + OptionList, + Placeholder, + SearchIcon, + SearchInput, + SearchInputContainer, + SelectAllOption, + SelectBase, + SelectLabel, + SelectValue, + StyledCheckbox, + StyledClearButton, +} from './components'; +import { ActionButtonsProps, SelectLabelDisplayProps, SelectOption, SelectProps } from './types'; + +const SelectLabelDisplay = ({ + selectedValues, + options, + placeholder, + isMultiSelect, + removeOption, + disabledValues, + showDescriptions, +}: SelectLabelDisplayProps) => { + const selectedOptions = options.filter((opt) => selectedValues.includes(opt.value)); + return ( + + {!!selectedOptions.length && + isMultiSelect && + selectedOptions.map((o) => { + const isDisabled = disabledValues?.includes(o.value); + return ( + { + e.stopPropagation(); + removeOption?.(o); + }} + clickable={!isDisabled} + /> + ); + })} + {!selectedValues.length && {placeholder}} + {!isMultiSelect && ( + <> + {selectedOptions[0]?.label} + {showDescriptions && !!selectedValues.length && ( + {selectedOptions[0]?.description} + )} + + )} + + ); +}; + +const SelectActionButtons = ({ + selectedValues, + isOpen, + isDisabled, + isReadOnly, + showClear, + handleClearSelection, + fontSize = 'md', +}: ActionButtonsProps) => { + return ( + + {showClear && selectedValues.length > 0 && !isDisabled && !isReadOnly && ( + + )} + + + ); +}; + +export const selectDefaults: SelectProps = { + options: [], + label: '', + size: 'md', + showSearch: false, + isDisabled: false, + isReadOnly: false, + isRequired: false, + showClear: true, + width: 255, + isMultiSelect: false, + placeholder: 'Select an option ', + showSelectAll: false, + selectAllLabel: 'Select All', + showDescriptions: false, +}; + +export const SimpleSelect = ({ + options = selectDefaults.options, + label = selectDefaults.label, + values = [], + onUpdate, + showSearch = selectDefaults.showSearch, + isDisabled = selectDefaults.isDisabled, + isReadOnly = selectDefaults.isReadOnly, + isRequired = selectDefaults.isRequired, + showClear = selectDefaults.showClear, + size = selectDefaults.size, + isMultiSelect = selectDefaults.isMultiSelect, + placeholder = selectDefaults.placeholder, + disabledValues = [], + showSelectAll = selectDefaults.showSelectAll, + selectAllLabel = selectDefaults.selectAllLabel, + optionListTestId, + showDescriptions = selectDefaults.showDescriptions, + ...props +}: SelectProps) => { + const [searchQuery, setSearchQuery] = useState(''); + const [isOpen, setIsOpen] = useState(false); + const [selectedValues, setSelectedValues] = useState(values); + const selectRef = useRef(null); + const [areAllSelected, setAreAllSelected] = useState(false); + + useEffect(() => { + if (values?.length > 0 && !isEqual(selectedValues, values)) { + setSelectedValues(values); + } + }, [values, selectedValues]); + + useEffect(() => { + setAreAllSelected(selectedValues.length === options.length); + }, [options, selectedValues]); + + const filteredOptions = useMemo( + () => options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), + [options, searchQuery], + ); + + const handleDocumentClick = useCallback((e: MouseEvent) => { + if (selectRef.current && !selectRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }, []); + + useEffect(() => { + document.addEventListener('click', handleDocumentClick); + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [handleDocumentClick]); + + const handleSelectClick = useCallback(() => { + if (!isDisabled && !isReadOnly) { + setIsOpen((prev) => !prev); + } + }, [isDisabled, isReadOnly]); + + const handleOptionChange = useCallback( + (option: SelectOption) => { + const updatedValues = selectedValues.includes(option.value) + ? selectedValues.filter((val) => val !== option.value) + : [...selectedValues, option.value]; + + setSelectedValues(isMultiSelect ? updatedValues : [option.value]); + if (onUpdate) { + onUpdate(isMultiSelect ? updatedValues : [option.value]); + } + if (!isMultiSelect) setIsOpen(false); + }, + [onUpdate, isMultiSelect, selectedValues], + ); + + const handleClearSelection = useCallback(() => { + setSelectedValues([]); + setAreAllSelected(false); + setIsOpen(false); + if (onUpdate) { + onUpdate([]); + } + }, [onUpdate]); + + const handleSelectAll = () => { + if (areAllSelected) { + setSelectedValues([]); + onUpdate?.([]); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onUpdate?.(allValues); + } + setAreAllSelected(!areAllSelected); + }; + + return ( + + {label && {label}} + + + + + {isOpen && ( + + {showSearch && ( + + setSearchQuery(e.target.value)} + style={{ fontSize: size || 'md' }} + /> + + + )} + + {showSelectAll && isMultiSelect && ( + !(disabledValues.length === options.length) && handleSelectAll()} + isDisabled={disabledValues.length === options.length} + > + + {selectAllLabel} + + + + )} + {filteredOptions.map((option) => ( + !isMultiSelect && handleOptionChange(option)} + isSelected={selectedValues.includes(option.value)} + isMultiSelect={isMultiSelect} + isDisabled={disabledValues?.includes(option.value)} + > + {isMultiSelect ? ( + + {option.label} + handleOptionChange(option)} + checked={selectedValues.includes(option.value)} + disabled={disabledValues?.includes(option.value)} + /> + + ) : ( + + + {option.label} + + {!!option.description && ( + + {option.description} + + )} + + )} + + ))} + + + )} + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Select/components.ts b/datahub-web-react/src/alchemy-components/components/Select/components.ts new file mode 100644 index 00000000000000..a360238fef4923 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/components.ts @@ -0,0 +1,235 @@ +import { Button, Icon } from '@components'; +import { borders, colors, radius, shadows, spacing, transition, typography } from '@components/theme'; +import { Checkbox } from 'antd'; +import styled from 'styled-components'; +import { formLabelTextStyles, inputPlaceholderTextStyles, inputValueTextStyles } from '../commonStyles'; +import { SelectSizeOptions, SelectStyleProps } from './types'; +import { getOptionLabelStyle, getSelectFontStyles, getSelectStyle } from './utils'; + +const sharedTransition = `${transition.property.colors} ${transition.easing['ease-in-out']} ${transition.duration.normal}`; + +/** + * Base Select component styling + */ +export const SelectBase = styled.div(({ isDisabled, isReadOnly, fontSize, isOpen }) => ({ + ...getSelectStyle({ isDisabled, isReadOnly, fontSize, isOpen }), + display: 'flex', + flexDirection: 'row' as const, + gap: spacing.xsm, + transition: sharedTransition, + justifyContent: 'space-between', + alignItems: 'center', + overflow: 'auto', + backgroundColor: isDisabled ? colors.gray[100] : 'white', +})); + +/** + * Styled components specific to the Basic version of the Select component + */ + +// Container for the Basic Select component +interface ContainerProps { + size: SelectSizeOptions; + width?: number | 'full'; +} + +export const Container = styled.div(({ size, width }) => ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + width: width === 'full' ? '100%' : `${width}px`, + gap: '4px', + transition: sharedTransition, + minWidth: '175px', + ...getSelectFontStyles(size), + ...inputValueTextStyles(size), +})); + +export const Dropdown = styled.div({ + position: 'absolute', + top: '100%', + left: 0, + right: 0, + borderRadius: radius.md, + background: colors.white, + zIndex: 1, + transition: sharedTransition, + boxShadow: shadows.dropdown, + padding: spacing.xsm, + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginTop: '4px', + maxHeight: '360px', + overflow: 'auto', +}); + +export const SearchInputContainer = styled.div({ + position: 'relative', + width: '100%', + display: 'flex', + justifyContent: 'center', +}); + +export const SearchInput = styled.input({ + width: '100%', + borderRadius: radius.md, + border: `1px solid ${colors.gray[200]}`, + color: colors.gray[500], + fontFamily: typography.fonts.body, + fontSize: typography.fontSizes.sm, + padding: spacing.xsm, + paddingRight: spacing.xlg, + + '&:focus': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, +}); + +export const SearchIcon = styled(Icon)({ + position: 'absolute', + right: spacing.sm, + top: '50%', + transform: 'translateY(-50%)', + pointerEvents: 'none', +}); + +// Styled components for SelectValue (Selected value display) +export const SelectValue = styled.span({ + ...inputValueTextStyles(), +}); + +export const Placeholder = styled.span({ + ...inputPlaceholderTextStyles, +}); + +export const ActionButtonsContainer = styled.div({ + display: 'flex', + gap: '6px', + flexDirection: 'row', + alignItems: 'center', +}); + +/** + * Components that can be reused to create new Select variants + */ + +export const FooterBase = styled.div({ + display: 'flex', + justifyContent: 'flex-end', + gap: spacing.sm, + paddingTop: spacing.sm, + borderTop: `1px solid ${colors.gray[100]}`, +}); + +export const OptionList = styled.div({ + display: 'flex', + flexDirection: 'column' as const, +}); + +export const LabelContainer = styled.div({ + display: 'flex', + justifyContent: 'space-between', + width: '100%', +}); + +export const OptionContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const DescriptionContainer = styled.span({ + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + width: '100%', + color: colors.gray[500], + lineHeight: 'normal', + fontSize: typography.fontSizes.sm, + marginTop: spacing.xxsm, +}); + +export const LabelsWrapper = styled.div({ + display: 'flex', + flexWrap: 'wrap', + gap: spacing.xxsm, + maxHeight: '150px', + maxWidth: 'calc(100% - 54px)', +}); + +export const OptionLabel = styled.label<{ isSelected: boolean; isMultiSelect?: boolean; isDisabled?: boolean }>( + ({ isSelected, isMultiSelect, isDisabled }) => ({ + ...getOptionLabelStyle(isSelected, isMultiSelect, isDisabled), + }), +); + +export const SelectAllOption = styled.div<{ isSelected: boolean; isDisabled?: boolean }>( + ({ isSelected, isDisabled }) => ({ + cursor: isDisabled ? 'not-allowed' : 'pointer', + padding: spacing.xsm, + color: isSelected ? colors.violet[700] : colors.gray[500], + fontWeight: typography.fontWeights.semiBold, + fontSize: typography.fontSizes.md, + display: 'flex', + alignItems: 'center', + }), +); + +export const SelectLabel = styled.label({ + ...formLabelTextStyles, + marginBottom: spacing.xxsm, + textAlign: 'left', +}); + +export const StyledCancelButton = styled(Button)({ + backgroundColor: colors.violet[100], + color: colors.violet[500], + borderColor: colors.violet[100], + + '&:hover': { + backgroundColor: colors.violet[200], + borderColor: colors.violet[200], + }, +}); + +export const StyledClearButton = styled(Button)({ + backgroundColor: colors.gray[200], + border: `1px solid ${colors.gray[200]}`, + color: colors.black, + padding: '1px', + + '&:hover': { + backgroundColor: colors.violet[100], + color: colors.violet[700], + borderColor: colors.violet[100], + boxShadow: shadows.none, + }, + + '&:focus': { + backgroundColor: colors.violet[100], + color: colors.violet[700], + boxShadow: `0 0 0 2px ${colors.white}, 0 0 0 4px ${colors.violet[50]}`, + }, +}); + +export const ClearIcon = styled.span({ + cursor: 'pointer', + marginLeft: '8px', +}); + +export const ArrowIcon = styled.span<{ isOpen: boolean }>(({ isOpen }) => ({ + marginLeft: 'auto', + border: 'solid black', + borderWidth: '0 1px 1px 0', + display: 'inline-block', + padding: '3px', + transform: isOpen ? 'rotate(-135deg)' : 'rotate(45deg)', +})); + +export const StyledCheckbox = styled(Checkbox)({ + '.ant-checkbox-checked:not(.ant-checkbox-disabled) .ant-checkbox-inner': { + backgroundColor: colors.violet[500], + borderColor: `${colors.violet[500]} !important`, + }, +}); diff --git a/datahub-web-react/src/alchemy-components/components/Select/index.ts b/datahub-web-react/src/alchemy-components/components/Select/index.ts new file mode 100644 index 00000000000000..eb469d0edc0046 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/index.ts @@ -0,0 +1,3 @@ +export { Select, selectDefaults } from './Select'; +export { SimpleSelect } from './SimpleSelect'; +export type { SelectProps, SelectOption } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Select/types.ts b/datahub-web-react/src/alchemy-components/components/Select/types.ts new file mode 100644 index 00000000000000..5ccde408b76999 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/types.ts @@ -0,0 +1,61 @@ +export type SelectSizeOptions = 'sm' | 'md' | 'lg'; + +export interface SelectOption { + value: string; + label: string; + description?: string; +} + +export interface SelectProps { + options: SelectOption[]; + label?: string; + values?: string[]; + onCancel?: () => void; + onUpdate?: (selectedValues: string[]) => void; + size?: SelectSizeOptions; + showSearch?: boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + showClear?: boolean; + width?: number | 'full'; + isMultiSelect?: boolean; + placeholder?: string; + disabledValues?: string[]; + showSelectAll?: boolean; + selectAllLabel?: string; + optionListTestId?: string; + showDescriptions?: boolean; +} + +export interface SelectStyleProps { + fontSize?: SelectSizeOptions; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + isOpen?: boolean; +} + +export interface ActionButtonsProps { + fontSize?: SelectSizeOptions; + selectedValues: string[]; + isOpen: boolean; + isDisabled: boolean; + isReadOnly: boolean; + showClear: boolean; + handleClearSelection: () => void; +} + +export interface SelectLabelDisplayProps { + selectedValues: string[]; + options: SelectOption[]; + placeholder: string; + isMultiSelect?: boolean; + removeOption?: (option: SelectOption) => void; + disabledValues?: string[]; + showDescriptions?: boolean; +} + +export interface SearchInputProps extends React.InputHTMLAttributes { + fontSize: SelectSizeOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/Select/utils.ts b/datahub-web-react/src/alchemy-components/components/Select/utils.ts new file mode 100644 index 00000000000000..d054dd8ff737ad --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Select/utils.ts @@ -0,0 +1,125 @@ +import { borders, colors, radius, spacing, typography } from '@components/theme'; +import { getFontSize } from '@components/theme/utils'; + +import { SelectStyleProps } from './types'; + +export const getOptionLabelStyle = (isSelected: boolean, isMultiSelect?: boolean, isDisabled?: boolean) => ({ + cursor: isDisabled ? 'not-allowed' : 'pointer', + padding: spacing.xsm, + borderRadius: radius.md, + lineHeight: typography.lineHeights.normal, + backgroundColor: isSelected && !isMultiSelect ? colors.violet[100] : 'transparent', + color: isSelected ? colors.violet[700] : colors.gray[500], + fontWeight: typography.fontWeights.medium, + fontSize: typography.fontSizes.md, + display: 'flex', + alignItems: 'center', + + '&:hover': { + backgroundColor: isSelected ? colors.violet[100] : colors.gray[100], + }, +}); + +export const getFooterButtonSize = (size) => { + return size === 'sm' ? 'sm' : 'md'; +}; + +export const getSelectFontStyles = (size) => { + const baseFontStyles = { + lineHeight: typography.lineHeights.none, + }; + + const sizeStyles = { + sm: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + md: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + lg: { + ...baseFontStyles, + fontSize: getFontSize(size), + }, + }; + + return sizeStyles[size]; +}; + +export const getSelectPadding = (size) => { + const paddingStyles = { + sm: { + padding: `${spacing.sm} ${spacing.xsm}`, + }, + md: { + padding: `${spacing.sm} ${spacing.md}`, + }, + lg: { + padding: `${spacing.md} ${spacing.sm}`, + }, + }; + + return paddingStyles[size]; +}; + +export const getSearchPadding = (size) => { + const paddingStyles = { + sm: { + padding: `${spacing.xxsm} ${spacing.xsm}`, + }, + md: { + padding: `${spacing.xsm} ${spacing.xsm}`, + }, + lg: { + padding: `${spacing.xsm} ${spacing.xsm}`, + }, + }; + + return paddingStyles[size]; +}; + +export const getSelectStyle = (props: SelectStyleProps) => { + const { isDisabled, isReadOnly, fontSize, isOpen } = props; + + const baseStyle = { + borderRadius: radius.md, + border: `1px solid ${colors.gray[200]}`, + fontFamily: typography.fonts.body, + color: isDisabled ? colors.gray[300] : colors.black, + cursor: isDisabled || isReadOnly ? 'not-allowed' : 'pointer', + backgroundColor: isDisabled ? colors.gray[100] : 'initial', + + '&::placeholder': { + color: colors.gray[400], + }, + + // Open Styles + ...(isOpen + ? { + borderColor: colors.violet[300], + boxShadow: `0px 0px 4px 0px rgba(83, 63, 209, 0.5)`, + outline: 'none', + } + : {}), + + // Hover Styles + ...(isDisabled || isReadOnly || isOpen + ? {} + : { + '&:hover': { + borderColor: colors.violet[200], + outline: `${borders['1px']} ${colors.violet[200]}`, + }, + }), + }; + + const fontStyles = getSelectFontStyles(fontSize); + const paddingStyles = getSelectPadding(fontSize); + + return { + ...baseStyle, + ...fontStyles, + ...paddingStyles, + }; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx b/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx new file mode 100644 index 00000000000000..7bb4ee2397cc63 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/Switch.stories.tsx @@ -0,0 +1,169 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; + +import { Switch, switchDefaults } from './Switch'; +import { AVAILABLE_ICONS } from '../Icon'; + +const meta = { + title: 'Forms / Switch', + component: Switch, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in the state of a toggle.', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the Switch.', + table: { + defaultValue: { summary: switchDefaults.label }, + }, + control: { + type: 'text', + }, + }, + labelPosition: { + description: 'The position of the label relative to the Switch.', + options: ['left', 'top'], + table: { + defaultValue: { summary: switchDefaults.labelPosition }, + }, + control: { + type: 'select', + }, + }, + icon: { + description: 'The icon to display in the Switch Slider.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + colorScheme: { + description: 'The color of the Switch.', + options: ['violet', 'green', 'red', 'blue', 'gray'], + table: { + defaultValue: { summary: switchDefaults.colorScheme }, + }, + control: { + type: 'select', + }, + }, + size: { + description: 'The size of the Button.', + options: ['sm', 'md', 'lg', 'xl'], + table: { + defaultValue: { summary: switchDefaults.size }, + }, + control: { + type: 'select', + }, + }, + isSquare: { + description: 'Whether the Switch is square in shape.', + table: { + defaultValue: { summary: switchDefaults?.isSquare?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isChecked: { + description: 'Whether the Switch is checked.', + table: { + defaultValue: { summary: switchDefaults?.isChecked?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Whether the Switch is in disabled state.', + table: { + defaultValue: { summary: switchDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Whether the Switch is a required field.', + table: { + defaultValue: { summary: switchDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + + // Define defaults + args: { + label: switchDefaults.label, + labelPosition: switchDefaults.labelPosition, + icon: switchDefaults.icon, + colorScheme: switchDefaults.colorScheme, + size: switchDefaults.size, + isSquare: switchDefaults.isSquare, + isChecked: switchDefaults.isChecked, + isDisabled: switchDefaults.isDisabled, + isRequired: switchDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const sizes = () => ( + + + + + + +); + +export const colors = () => ( + + + + + + + +); + +export const states = () => ( + + + + + +); + +export const types = () => ( + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx b/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx new file mode 100644 index 00000000000000..18a01386562ee9 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/Switch.tsx @@ -0,0 +1,74 @@ +import { Tooltip } from '@components'; +import React, { useEffect, useState } from 'react'; +import { IconContainer, Label, Required, Slider, StyledIcon, StyledInput, SwitchContainer } from './components'; +import { SwitchProps } from './types'; + +export const switchDefaults: SwitchProps = { + label: 'Label', + labelPosition: 'left', + colorScheme: 'violet', + size: 'md', + isSquare: false, + isChecked: false, + isDisabled: false, + isRequired: false, +}; + +export const Switch = ({ + label = switchDefaults.label, + labelPosition = switchDefaults.labelPosition, + icon, // undefined by default + colorScheme = switchDefaults.colorScheme, + size = switchDefaults.size, + isSquare = switchDefaults.isSquare, + isChecked = switchDefaults.isChecked, + isDisabled = switchDefaults.isDisabled, + isRequired = switchDefaults.isRequired, + labelHoverText, + disabledHoverText, + labelStyle, + ...props +}: SwitchProps) => { + const [checked, setChecked] = useState(isChecked); + + useEffect(() => { + setChecked(isChecked); + }, [isChecked]); + + const id = props.id || `switchToggle-${label}`; + + return ( + + + + + setChecked(!checked)} + customSize={size} + disabled={isDisabled} + colorScheme={colorScheme || 'violet'} + aria-labelledby={id} + aria-checked={checked} + {...props} + /> + + + + {icon && ( + + )} + + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/components.ts b/datahub-web-react/src/alchemy-components/components/Switch/components.ts new file mode 100644 index 00000000000000..1586c1cf9f32fd --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/components.ts @@ -0,0 +1,118 @@ +import styled from 'styled-components'; + +import { borders, colors, shadows, spacing, transition } from '@components/theme'; +import { ColorOptions, SizeOptions } from '@components/theme/config'; + +import { Icon } from '../Icon'; + +import { formLabelTextStyles } from '../commonStyles'; + +import { + getIconTransformPositionLeft, + getIconTransformPositionTop, + getInputHeight, + getSliderTransformPosition, + getToggleSize, +} from './utils'; + +import type { SwitchLabelPosition } from './types'; + +export const Label = styled.div({ + ...formLabelTextStyles, + display: 'flex', + alignItems: 'flex-start', +}); + +export const SwitchContainer = styled.label<{ labelPosition: SwitchLabelPosition; isDisabled?: boolean }>( + ({ labelPosition, isDisabled }) => ({ + display: 'flex', + flexDirection: labelPosition === 'top' ? 'column' : 'row', + alignItems: labelPosition === 'top' ? 'flex-start' : 'center', + gap: spacing.sm, + cursor: isDisabled ? 'not-allowed' : 'pointer', + width: 'max-content', + }), +); + +export const Slider = styled.div<{ size?: SizeOptions; isSquare?: boolean; isDisabled?: boolean }>( + ({ size, isSquare, isDisabled }) => ({ + '&:before': { + transition: `${transition.duration.normal} all`, + content: '""', + position: 'absolute', + minWidth: getToggleSize(size || 'md', 'slider'), // sliders width and height must be same + minHeight: getToggleSize(size || 'md', 'slider'), + borderRadius: !isSquare ? '35px' : '0px', + top: '50%', + left: spacing.xxsm, + transform: 'translate(0, -50%)', + backgroundColor: !isDisabled ? colors.white : colors.gray[200], + boxShadow: ` + 0px 1px 2px 0px rgba(16, 24, 40, 0.06), + 0px 1px 3px 0px rgba(16, 24, 40, 0.12) + `, + }, + borderRadius: !isSquare ? '32px' : '0px', + minWidth: getToggleSize(size || 'md', 'input'), + minHeight: getInputHeight(size || 'md'), + }), + { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + position: 'relative', + + backgroundColor: colors.gray[100], + padding: spacing.xxsm, + transition: `${transition.duration.normal} all`, + boxSizing: 'content-box', + }, +); + +export const Required = styled.span({ + color: colors.red[500], + marginLeft: spacing.xxsm, +}); + +export const StyledInput = styled.input<{ + customSize?: SizeOptions; + disabled?: boolean; + colorScheme: ColorOptions; + checked?: boolean; +}>` + opacity: 0; + position: absolute; + + &:checked + ${Slider} { + background-color: ${(props) => (!props.disabled ? colors[props.colorScheme][500] : colors.gray[100])}; + + &:before { + transform: ${({ customSize }) => getSliderTransformPosition(customSize || 'md')}; + } + } + + &:focus-within + ${Slider} { + border-color: ${(props) => (props.checked ? colors[props.colorScheme][200] : 'transparent')}; + outline: ${(props) => (props.checked ? `${borders['2px']} ${colors[props.colorScheme][200]}` : 'none')}; + box-shadow: ${(props) => (props.checked ? shadows.xs : 'none')}; + } +`; + +export const StyledIcon = styled(Icon)<{ checked?: boolean; size: SizeOptions }>( + ({ checked, size }) => ({ + left: getIconTransformPositionLeft(size, checked || false), + top: getIconTransformPositionTop(size), + }), + { + transition: `${transition.duration.normal} all`, + position: 'absolute', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.gray[500], + }, +); + +export const IconContainer = styled.div({ + position: 'relative', +}); diff --git a/datahub-web-react/src/alchemy-components/components/Switch/index.ts b/datahub-web-react/src/alchemy-components/components/Switch/index.ts new file mode 100644 index 00000000000000..0c48d2964887ec --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/index.ts @@ -0,0 +1,2 @@ +export { Switch, switchDefaults } from './Switch'; +export type { SwitchProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Switch/types.ts b/datahub-web-react/src/alchemy-components/components/Switch/types.ts new file mode 100644 index 00000000000000..e15c0f81b4a392 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/types.ts @@ -0,0 +1,21 @@ +import { ColorOptions, SizeOptions } from '@components/theme/config'; +import { InputHTMLAttributes } from 'react'; +import { CSSProperties } from 'styled-components'; +import { IconNames } from '../Icon'; + +export type SwitchLabelPosition = 'left' | 'top'; + +export interface SwitchProps extends Omit, 'size'> { + label: string; + labelPosition?: SwitchLabelPosition; + icon?: IconNames; + colorScheme?: ColorOptions; + size?: SizeOptions; + isSquare?: boolean; + isChecked?: boolean; + isDisabled?: boolean; + isRequired?: boolean; + labelHoverText?: string; + disabledHoverText?: string; + labelStyle?: CSSProperties; +} diff --git a/datahub-web-react/src/alchemy-components/components/Switch/utils.ts b/datahub-web-react/src/alchemy-components/components/Switch/utils.ts new file mode 100644 index 00000000000000..c0365baa348183 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Switch/utils.ts @@ -0,0 +1,97 @@ +import { SizeOptions } from '@components/theme/config'; + +const sliderSize = { + sm: '14px', + md: '16px', + lg: '18px', + xl: '20px', +}; + +const inputSize = { + sm: '35px', + md: '40px', + lg: '45px', + xl: '50px', +}; + +const translateSize = { + sm: '22px', + md: '24px', + lg: '26px', + xl: '28px', +}; + +const iconTransformPositionLeft = { + sm: { + checked: '5.5px', + unchecked: '-16.5px', + }, + md: { + checked: '5px', + unchecked: '-19px', + }, + lg: { + checked: '4.5px', + unchecked: '-21.5px', + }, + xl: { + checked: '4px', + unchecked: '-24px', + }, +}; + +const iconTransformPositionTop = { + sm: '-6px', + md: '-7px', + lg: '-8px', + xl: '-9px', +}; + +export const getToggleSize = (size: SizeOptions, mode: 'slider' | 'input'): string => { + if (size === 'sm') return mode === 'slider' ? sliderSize.sm : inputSize.sm; + if (size === 'md') return mode === 'slider' ? sliderSize.md : inputSize.md; + if (size === 'lg') return mode === 'slider' ? sliderSize.lg : inputSize.lg; + return mode === 'slider' ? sliderSize.xl : inputSize.xl; // xl +}; + +export const getInputHeight = (size: SizeOptions) => { + if (size === 'sm') return sliderSize.sm; + if (size === 'md') return sliderSize.md; + if (size === 'lg') return sliderSize.lg; + return sliderSize.xl; // xl +}; + +export const getSliderTransformPosition = (size: SizeOptions): string => { + if (size === 'sm') return `translate(${translateSize.sm}, -50%)`; + if (size === 'md') return `translate(${translateSize.md}, -50%)`; + if (size === 'lg') return `translate(${translateSize.lg}, -50%)`; + return `translate(${translateSize.xl}, -50%)`; // xl +}; + +export const getIconTransformPositionLeft = (size: SizeOptions, checked: boolean): string => { + if (size === 'sm') { + if (checked) return iconTransformPositionLeft.sm.checked; + return iconTransformPositionLeft.sm.unchecked; + } + + if (size === 'md') { + if (checked) return iconTransformPositionLeft.md.checked; + return iconTransformPositionLeft.md.unchecked; + } + + if (size === 'lg') { + if (checked) return iconTransformPositionLeft.lg.checked; + return iconTransformPositionLeft.lg.unchecked; + } + + // xl + if (checked) return iconTransformPositionLeft.xl.checked; + return iconTransformPositionLeft.xl.unchecked; +}; + +export const getIconTransformPositionTop = (size: SizeOptions): string => { + if (size === 'sm') return iconTransformPositionTop.sm; + if (size === 'md') return iconTransformPositionTop.md; + if (size === 'lg') return iconTransformPositionTop.lg; + return iconTransformPositionTop.xl; // xl +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx new file mode 100644 index 00000000000000..3a36b658978066 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.stories.tsx @@ -0,0 +1,162 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; +import { Table, tableDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Lists & Tables / Table', + component: Table, + + // Display Properties + parameters: { + layout: 'padded', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'This component allows users to render a table with different columns and their data', + }, + }, + + // Component-level argTypes + argTypes: { + columns: { + description: 'Array of column objects for the table header.', + control: 'object', + table: { + defaultValue: { summary: JSON.stringify(tableDefaults.columns) }, + }, + }, + data: { + description: 'Array of data rows for the table body.', + control: 'object', + table: { + defaultValue: { summary: JSON.stringify(tableDefaults.data) }, + }, + }, + showHeader: { + description: 'Whether to show the table header.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.showHeader?.toString() }, + }, + }, + isLoading: { + description: 'Whether the table is in loading state.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.isLoading?.toString() }, + }, + }, + isScrollable: { + description: 'Whether the table is scrollable.', + control: 'boolean', + table: { + defaultValue: { summary: tableDefaults.isScrollable?.toString() }, + }, + }, + maxHeight: { + description: 'Maximum height of the table container.', + control: 'text', + table: { + defaultValue: { summary: tableDefaults.maxHeight }, + }, + }, + }, + + // Define defaults + args: { + columns: [ + { title: 'Column 1', key: 'column1', dataIndex: 'column1' }, + { title: 'Column 2', key: 'column2', dataIndex: 'column2' }, + ], + data: [ + { column1: 'Row 1 Col 1', column2: 'Row 1 Col 2' }, + { column1: 'Row 2 Col 1', column2: 'Row 2 Col 2' }, + ], + showHeader: tableDefaults.showHeader, + isLoading: tableDefaults.isLoading, + isScrollable: tableDefaults.isScrollable, + maxHeight: tableDefaults.maxHeight, + }, +} satisfies Meta>; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const withScroll = () => ( +
+); + +export const withCustomColumnWidths = () => ( +
+); + +export const withColumnSorting = () => ( +
a.column1.localeCompare(b.column1), + }, + { title: 'Column 2', key: 'column2', dataIndex: 'column2' }, + { title: 'Column 3', key: 'column3', dataIndex: 'column3', sorter: (a, b) => a.column3 - b.column3 }, + ]} + data={[ + { column1: 'Row 2 Col 1', column2: 'Row 2 Col 2', column3: 3 }, + { column1: 'Row 1 Col 1', column2: 'Row 1 Col 2', column3: 2 }, + { column1: 'Row 3 Col 1', column2: 'Row 3 Col 2', column3: 1 }, + ]} + /> +); + +export const withoutHeader = () => ( +
+); diff --git a/datahub-web-react/src/alchemy-components/components/Table/Table.tsx b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx new file mode 100644 index 00000000000000..11e598f8d4e0f7 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/Table.tsx @@ -0,0 +1,115 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import { Text } from '@components'; +import React, { useState } from 'react'; +import { + BaseTable, + HeaderContainer, + LoadingContainer, + SortIcon, + SortIconsContainer, + TableCell, + TableContainer, + TableHeader, + TableHeaderCell, + TableRow, +} from './components'; +import { TableProps } from './types'; +import { getSortedData, handleActiveSort, renderCell, SortingState } from './utils'; + +export const tableDefaults: TableProps = { + columns: [], + data: [], + showHeader: true, + isLoading: false, + isScrollable: false, + maxHeight: '100%', +}; + +export const Table = ({ + columns = tableDefaults.columns, + data = tableDefaults.data, + showHeader = tableDefaults.showHeader, + isLoading = tableDefaults.isLoading, + isScrollable = tableDefaults.isScrollable, + maxHeight = tableDefaults.maxHeight, + ...props +}: TableProps) => { + const [sortColumn, setSortColumn] = useState(null); + const [sortOrder, setSortOrder] = useState(SortingState.ORIGINAL); + + const sortedData = getSortedData(columns, data, sortColumn, sortOrder); + + if (isLoading) { + return ( + + + Loading data... + + ); + } + + return ( + + + {showHeader && ( + + + {columns.map((column) => ( + + + {column.title} + {column.sorter && ( + + column.sorter && + handleActiveSort( + column.key, + sortColumn, + setSortColumn, + setSortOrder, + ) + } + > + + + + )} + + + ))} + + + )} + + {sortedData.map((row, index) => ( + + {columns.map((column) => { + return ( + + {renderCell(column, row, index)} + + ); + })} + + ))} + + + + ); +}; diff --git a/datahub-web-react/src/alchemy-components/components/Table/components.ts b/datahub-web-react/src/alchemy-components/components/Table/components.ts new file mode 100644 index 00000000000000..8908256a81ddf2 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/components.ts @@ -0,0 +1,94 @@ +import { Icon } from '@components'; +import { colors, radius, spacing, typography } from '@src/alchemy-components/theme'; +import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import styled from 'styled-components'; + +export const TableContainer = styled.div<{ isScrollable?: boolean; maxHeight?: string }>( + ({ isScrollable, maxHeight }) => ({ + borderRadius: radius.lg, + border: `1px solid ${colors.gray[1400]}`, + overflow: isScrollable ? 'auto' : 'hidden', + width: '100%', + maxHeight: maxHeight || '100%', + }), +); + +export const BaseTable = styled.table({ + borderCollapse: 'collapse', + width: '100%', +}); + +export const TableHeader = styled.thead({ + backgroundColor: colors.gray[1500], + borderRadius: radius.lg, + position: 'sticky', + top: 0, + zIndex: 100, +}); + +export const TableHeaderCell = styled.th<{ width?: string }>(({ width }) => ({ + padding: `${spacing.sm} ${spacing.md}`, + color: colors.gray[600], + fontSize: typography.fontSizes.sm, + fontWeight: typography.fontWeights.medium, + textAlign: 'start', + width: width || 'auto', +})); + +export const HeaderContainer = styled.div({ + display: 'flex', + alignItems: 'center', + gap: spacing.sm, +}); + +export const TableRow = styled.tr({ + '&:last-child': { + '& td': { + borderBottom: 'none', + }, + }, + + '& td:first-child': { + fontWeight: typography.fontWeights.medium, + color: colors.gray[600], + }, +}); + +export const TableCell = styled.td<{ width?: string; alignment?: AlignmentOptions }>(({ width, alignment }) => ({ + padding: spacing.md, + borderBottom: `1px solid ${colors.gray[1400]}`, + color: colors.gray[1700], + fontSize: typography.fontSizes.md, + fontWeight: typography.fontWeights.normal, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: width || 'unset', + textAlign: alignment || 'left', +})); + +export const SortIconsContainer = styled.div({ + display: 'flex', + flexDirection: 'column', +}); + +export const SortIcon = styled(Icon)<{ isActive?: boolean }>(({ isActive }) => ({ + margin: '-3px', + stroke: isActive ? colors.violet[600] : undefined, + + ':hover': { + cursor: 'pointer', + }, +})); + +export const LoadingContainer = styled.div({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + width: '100%', + gap: spacing.sm, + color: colors.violet[700], + fontSize: typography.fontSizes['3xl'], +}); diff --git a/datahub-web-react/src/alchemy-components/components/Table/index.ts b/datahub-web-react/src/alchemy-components/components/Table/index.ts new file mode 100644 index 00000000000000..986f467da74b8c --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/index.ts @@ -0,0 +1,2 @@ +export { Table, tableDefaults } from './Table'; +export type { Column, TableProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Table/types.ts b/datahub-web-react/src/alchemy-components/components/Table/types.ts new file mode 100644 index 00000000000000..b3e0357d5cf147 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/types.ts @@ -0,0 +1,21 @@ +import { AlignmentOptions } from '@src/alchemy-components/theme/config'; +import { TableHTMLAttributes } from 'react'; + +export interface Column { + title: string; + key: string; + dataIndex?: string; + render?: (record: T, index: number) => React.ReactNode; + width?: string; + sorter?: (a: T, b: T) => number; + alignment?: AlignmentOptions; +} + +export interface TableProps extends TableHTMLAttributes { + columns: Column[]; + data: T[]; + showHeader?: boolean; + isLoading?: boolean; + isScrollable?: boolean; + maxHeight?: string; +} diff --git a/datahub-web-react/src/alchemy-components/components/Table/utils.ts b/datahub-web-react/src/alchemy-components/components/Table/utils.ts new file mode 100644 index 00000000000000..c76494d32ca633 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Table/utils.ts @@ -0,0 +1,73 @@ +import { Column } from './types'; + +export enum SortingState { + ASCENDING = 'ascending', + DESCENDING = 'descending', + ORIGINAL = 'original', +} + +export const handleActiveSort = ( + key: string, + sortColumn: string | null, + setSortColumn: React.Dispatch>, + setSortOrder: React.Dispatch>, +) => { + if (sortColumn === key) { + // Toggle sort order + setSortOrder((prevOrder) => { + if (prevOrder === SortingState.ASCENDING) return SortingState.DESCENDING; + if (prevOrder === SortingState.DESCENDING) return SortingState.ORIGINAL; + return SortingState.ASCENDING; + }); + } else { + // Set new column and default sort order + setSortColumn(key); + setSortOrder(SortingState.ASCENDING); + } +}; + +export const getSortedData = ( + columns: Column[], + data: T[], + sortColumn: string | null, + sortOrder: SortingState, +) => { + if (sortOrder === SortingState.ORIGINAL || !sortColumn) { + return data; + } + + const activeColumn = columns.find((column) => column.key === sortColumn); + + // Sort based on the order and column sorter + if (activeColumn && activeColumn.sorter) { + return data.slice().sort((a, b) => { + return sortOrder === SortingState.ASCENDING ? activeColumn.sorter!(a, b) : activeColumn.sorter!(b, a); + }); + } + + return data; +}; + +export const renderCell = (column: Column, row: T, index: number) => { + const { render, dataIndex } = column; + + let cellData; + + if (dataIndex) { + cellData = row[dataIndex]; + + if (typeof dataIndex === 'string') { + cellData = dataIndex.split('.').reduce((acc, prop) => acc && acc[prop], row); + } + + if (Array.isArray(dataIndex)) { + cellData = dataIndex.reduce((acc, prop) => acc && acc[prop], row); + } + } + + if (render) { + return render(row, index); + } + + return cellData; +}; diff --git a/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx b/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx new file mode 100644 index 00000000000000..c82d468aaa08ce --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/Text.stories.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import type { Meta, StoryObj, StoryFn } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { VerticalFlexGrid } from '@components/.docs/mdx-components'; +import { Text, textDefaults } from '.'; + +// Auto Docs +const meta = { + title: 'Typography / Text', + component: Text, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'Used to render text and paragraphs within an interface.', + }, + }, + + // Component-level argTypes + argTypes: { + children: { + description: 'The content to display within the heading.', + table: { + type: { summary: 'string' }, + }, + }, + type: { + description: 'The type of text to display.', + table: { + defaultValue: { summary: textDefaults.type }, + }, + }, + size: { + description: 'Override the size of the text.', + table: { + defaultValue: { summary: `${textDefaults.size}` }, + }, + }, + color: { + description: 'Override the color of the text.', + table: { + defaultValue: { summary: textDefaults.color }, + }, + }, + weight: { + description: 'Override the weight of the heading.', + table: { + defaultValue: { summary: textDefaults.weight }, + }, + }, + }, + + // Define default args + args: { + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et posuere dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis rutrum. Morbi tempus velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel mollis eros.', + type: textDefaults.type, + size: textDefaults.size, + color: textDefaults.color, + weight: textDefaults.weight, + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => {props.children}, +}; + +export const sizes: StoryFn = (props: any) => ( + + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + {props.children} + +); + +export const withLink = () => ( + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas aliquet nulla id felis vehicula, et posuere + dui dapibus. Nullam rhoncus massa non tortor convallis, in blandit turpis rutrum. Morbi tempus + velit mauris, at mattis metus mattis sed. Nunc molestie efficitur lectus, vel mollis eros. + +); diff --git a/datahub-web-react/src/alchemy-components/components/Text/Text.tsx b/datahub-web-react/src/alchemy-components/components/Text/Text.tsx new file mode 100644 index 00000000000000..89122afbfcc8bf --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/Text.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import { TextProps } from './types'; +import { P, Div, Span } from './components'; + +export const textDefaults: TextProps = { + type: 'p', + color: 'inherit', + size: 'md', + weight: 'normal', +}; + +export const Text = ({ + type = textDefaults.type, + color = textDefaults.color, + size = textDefaults.size, + weight = textDefaults.weight, + children, + ...props +}: TextProps) => { + const sharedProps = { size, color, weight, ...props }; + + switch (type) { + case 'p': + return

{children}

; + case 'div': + return
{children}
; + case 'span': + return {children}; + default: + return

{children}

; + } +}; diff --git a/datahub-web-react/src/alchemy-components/components/Text/components.ts b/datahub-web-react/src/alchemy-components/components/Text/components.ts new file mode 100644 index 00000000000000..1d48497f39c9c8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/components.ts @@ -0,0 +1,50 @@ +import styled from 'styled-components'; + +import { typography, colors } from '@components/theme'; +import { getColor, getFontSize } from '@components/theme/utils'; +import { TextProps } from './types'; + +// Text Styles +const textStyles = { + fontSize: typography.fontSizes.md, + lineHeight: typography.lineHeights.md, + fontWeight: typography.fontWeights.normal, +}; + +// Default styles +const baseStyles = { + fontFamily: typography.fonts.body, + margin: 0, + + '& a': { + color: colors.violet[400], + textDecoration: 'none', + transition: 'color 0.15s ease', + + '&:hover': { + color: colors.violet[500], + }, + }, +}; + +// Prop Driven Styles +const propStyles = (props, isText = false) => { + const styles = {} as any; + if (props.size) styles.fontSize = getFontSize(props.size); + if (props.color) styles.color = getColor(props.color); + if (props.weight) styles.fontWeight = typography.fontWeights[props.weight]; + if (isText) styles.lineHeight = typography.lineHeights[props.size || 'md']; + return styles; +}; + +export const P = styled.p({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); + +export const Span = styled.span({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); + +export const Div = styled.div({ ...baseStyles, ...textStyles }, (props: TextProps) => ({ + ...propStyles(props as TextProps, true), +})); diff --git a/datahub-web-react/src/alchemy-components/components/Text/index.ts b/datahub-web-react/src/alchemy-components/components/Text/index.ts new file mode 100644 index 00000000000000..d4240105173d48 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/index.ts @@ -0,0 +1,2 @@ +export { Text, textDefaults } from './Text'; +export type { TextProps } from './types'; diff --git a/datahub-web-react/src/alchemy-components/components/Text/types.ts b/datahub-web-react/src/alchemy-components/components/Text/types.ts new file mode 100644 index 00000000000000..6a41929da12a9b --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/Text/types.ts @@ -0,0 +1,9 @@ +import { HTMLAttributes } from 'react'; +import type { FontSizeOptions, FontColorOptions, FontWeightOptions } from '@components/theme/config'; + +export interface TextProps extends HTMLAttributes { + type?: 'span' | 'p' | 'div'; + size?: FontSizeOptions; + color?: FontColorOptions; + weight?: FontWeightOptions; +} diff --git a/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx b/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx new file mode 100644 index 00000000000000..b244eefa6f2073 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/TextArea/TextArea.stories.tsx @@ -0,0 +1,159 @@ +import React from 'react'; + +import type { Meta, StoryObj } from '@storybook/react'; +import { BADGE } from '@geometricpanda/storybook-addon-badges'; + +import { GridList } from '@components/.docs/mdx-components'; + +import { TextArea, textAreaDefaults } from './TextArea'; +import { AVAILABLE_ICONS } from '../Icon'; + +// Auto Docs +const meta = { + title: 'Forms / Text Area', + component: TextArea, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE, 'readyForDesignReview'], + docs: { + subtitle: 'A component that is used to get user input in a text area field.', + }, + }, + + // Component-level argTypes + argTypes: { + label: { + description: 'Label for the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.label }, + }, + control: { + type: 'text', + }, + }, + placeholder: { + description: 'Placeholder for the Text Area.', + table: { + defaultValue: { summary: textAreaDefaults.placeholder }, + }, + control: { + type: 'text', + }, + }, + icon: { + description: 'The icon to display in the Text Area.', + type: 'string', + options: AVAILABLE_ICONS, + table: { + defaultValue: { summary: 'undefined' }, + }, + control: { + type: 'select', + }, + }, + error: { + description: 'Enforce error state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.error }, + }, + control: { + type: 'text', + }, + }, + warning: { + description: 'Enforce warning state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults.warning }, + }, + control: { + type: 'text', + }, + }, + isSuccess: { + description: 'Enforce success state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isSuccess?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isDisabled: { + description: 'Enforce disabled state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isDisabled?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isInvalid: { + description: 'Enforce invalid state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isInvalid?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isReadOnly: { + description: 'Enforce read only state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isReadOnly?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + isRequired: { + description: 'Enforce required state on the TextArea.', + table: { + defaultValue: { summary: textAreaDefaults?.isRequired?.toString() }, + }, + control: { + type: 'boolean', + }, + }, + }, + + // Define defaults + args: { + label: textAreaDefaults.label, + placeholder: textAreaDefaults.placeholder, + icon: textAreaDefaults.icon, + error: textAreaDefaults.error, + warning: textAreaDefaults.warning, + isSuccess: textAreaDefaults.isSuccess, + isDisabled: textAreaDefaults.isDisabled, + isInvalid: textAreaDefaults.isInvalid, + isReadOnly: textAreaDefaults.isReadOnly, + isRequired: textAreaDefaults.isRequired, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const sandbox: Story = { + tags: ['dev'], + render: (props) =>