From aa936884ec11534814250b85ee569d2a2015b6eb Mon Sep 17 00:00:00 2001 From: John Joyce Date: Tue, 1 Jul 2025 17:44:12 -0700 Subject: [PATCH 01/15] Improve Domains UX in few ways --- .../domain/CreateDomainResolver.java | 22 +- .../src/main/resources/entity.graphql | 6 + .../domain/CreateDomainResolverTest.java | 254 +++++++++++++++++- .../components/Select/Nested/NestedOption.tsx | 2 + .../components/Select/Nested/NestedSelect.tsx | 29 +- .../Select/Nested/useSelectOption.ts | 59 ++-- .../CreateNewDomainModal.tsx | 187 +++++++++++++ .../DomainDetailsSection.tsx | 87 ++++++ .../CreateNewDomainModal/DomainSelector.tsx | 136 ++++++++++ .../domainV2/CreateNewDomainModal/README.md | 73 +++++ .../domainV2/CreateNewDomainModal/index.ts | 1 + .../domainV2/CreateNewDomainModal/types.ts | 25 ++ .../nestedDomains/DomainsSidebarHeader.tsx | 10 +- .../shared/links/DomainColoredIcon.tsx | 14 +- .../src/app/sharedV2/owners/OwnersSection.tsx | 180 +++++++------ datahub-web-react/src/graphql/me.graphql | 1 + 16 files changed, 956 insertions(+), 130 deletions(-) create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/CreateNewDomainModal.tsx create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/DomainDetailsSection.tsx create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/DomainSelector.tsx create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/README.md create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/index.ts create mode 100644 datahub-web-react/src/app/domainV2/CreateNewDomainModal/types.ts diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java index ec2b0346288268..d7e90d523dd5dc 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolver.java @@ -4,6 +4,7 @@ import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; import static com.linkedin.metadata.Constants.*; +import com.google.common.collect.ImmutableList; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; @@ -16,6 +17,7 @@ import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; import com.linkedin.datahub.graphql.generated.CreateDomainInput; import com.linkedin.datahub.graphql.generated.OwnerEntityType; +import com.linkedin.datahub.graphql.generated.ResourceRefInput; import com.linkedin.datahub.graphql.resolvers.mutate.util.DomainUtils; import com.linkedin.datahub.graphql.resolvers.mutate.util.OwnerUtils; import com.linkedin.domain.DomainProperties; @@ -99,8 +101,24 @@ public CompletableFuture get(DataFetchingEnvironment environment) throws String domainUrn = _entityClient.ingestProposal(context.getOperationContext(), proposal, false); - OwnerUtils.addCreatorAsOwner( - context, domainUrn, OwnerEntityType.CORP_USER, _entityService); + + if (input.getOwners() != null && !input.getOwners().isEmpty()) { + input.getOwners().stream() + .forEach( + (ownerInput) -> + OwnerUtils.validateOwner( + context.getOperationContext(), ownerInput, _entityService)); + OwnerUtils.addOwnersToResources( + context.getOperationContext(), + input.getOwners(), + ImmutableList.of(new ResourceRefInput(domainUrn, null, null)), + UrnUtils.getUrn(context.getActorUrn()), + _entityService); + } else { + // No owners specified. Default to current user. + OwnerUtils.addCreatorAsOwner( + context, domainUrn, OwnerEntityType.CORP_USER, _entityService); + } return domainUrn; } catch (DataHubGraphQLException e) { throw e; diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index db0fd2c47cf890..a101620f78a659 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -11567,6 +11567,12 @@ input CreateDomainInput { Optional parent domain urn for the domain """ parentDomain: String + + """ + Optional - Add owners to the domain. + If not provided, we'll automatically assign the current actor as the owner. + """ + owners: [OwnerInput!] } """ diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java index c0d74225a9cf1d..26cf2a2c005379 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/domain/CreateDomainResolverTest.java @@ -4,13 +4,17 @@ import static com.linkedin.metadata.Constants.DOMAIN_PROPERTIES_ASPECT_NAME; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; import static org.testng.Assert.*; +import com.google.common.collect.ImmutableList; import com.linkedin.common.AuditStamp; import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.UrnUtils; import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.generated.CreateDomainInput; +import com.linkedin.datahub.graphql.generated.OwnerEntityType; +import com.linkedin.datahub.graphql.generated.OwnerInput; import com.linkedin.datahub.graphql.resolvers.mutate.util.DomainUtils; import com.linkedin.domain.DomainProperties; import com.linkedin.entity.Aspect; @@ -39,15 +43,34 @@ public class CreateDomainResolverTest { private static final Urn TEST_DOMAIN_URN = Urn.createFromTuple("domain", "test-id"); private static final Urn TEST_PARENT_DOMAIN_URN = Urn.createFromTuple("domain", "test-parent-id"); + private static final Urn TEST_OWNER_URN = UrnUtils.getUrn("urn:li:corpuser:test-owner"); + private static final Urn TEST_ACTOR_URN = UrnUtils.getUrn("urn:li:corpuser:test"); private static final CreateDomainInput TEST_INPUT = new CreateDomainInput( - "test-id", "test-name", "test-description", TEST_PARENT_DOMAIN_URN.toString()); + "test-id", "test-name", "test-description", TEST_PARENT_DOMAIN_URN.toString(), null); private static final CreateDomainInput TEST_INPUT_NO_PARENT_DOMAIN = - new CreateDomainInput("test-id", "test-name", "test-description", null); + new CreateDomainInput("test-id", "test-name", "test-description", null, null); - private static final Urn TEST_ACTOR_URN = UrnUtils.getUrn("urn:li:corpuser:test"); + private static final OwnerInput TEST_OWNER_INPUT = + new OwnerInput( + TEST_OWNER_URN.toString(), + OwnerEntityType.CORP_USER, + com.linkedin.datahub.graphql.generated.OwnershipType.TECHNICAL_OWNER, + null); + + private static final CreateDomainInput TEST_INPUT_WITH_OWNERS = + new CreateDomainInput( + "test-id", + "test-name", + "test-description", + TEST_PARENT_DOMAIN_URN.toString(), + ImmutableList.of(TEST_OWNER_INPUT)); + + private static final CreateDomainInput TEST_INPUT_NO_PARENT_WITH_OWNERS = + new CreateDomainInput( + "test-id", "test-name", "test-description", null, ImmutableList.of(TEST_OWNER_INPUT)); @Test public void testGetSuccess() throws Exception { @@ -60,6 +83,10 @@ public void testGetSuccess() throws Exception { Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_PARENT_DOMAIN_URN))).thenReturn(true); + // Mock the domain URN that will be returned from ingestProposal + Mockito.when(mockClient.ingestProposal(any(), any(), Mockito.eq(false))) + .thenReturn(TEST_DOMAIN_URN.toString()); + // Execute resolver QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); @@ -108,6 +135,10 @@ public void testGetSuccessNoParentDomain() throws Exception { Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_DOMAIN_URN))).thenReturn(false); + // Mock the domain URN that will be returned from ingestProposal + Mockito.when(mockClient.ingestProposal(any(), any(), Mockito.eq(false))) + .thenReturn(TEST_DOMAIN_URN.toString()); + QueryContext mockContext = getMockAllowContext(); DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_NO_PARENT_DOMAIN); @@ -143,6 +174,223 @@ public void testGetSuccessNoParentDomain() throws Exception { any(), Mockito.argThat(new CreateDomainProposalMatcher(proposal)), Mockito.eq(false)); } + @Test + public void testGetSuccessWithOwners() throws Exception { + // Create resolver + EntityClient mockClient = Mockito.mock(EntityClient.class); + EntityService mockService = getMockEntityService(); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService); + + Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_DOMAIN_URN))).thenReturn(false); + Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_PARENT_DOMAIN_URN))).thenReturn(true); + Mockito.when(mockService.exists(any(), Mockito.eq(TEST_OWNER_URN), eq(true))).thenReturn(true); + + // Mock the domain URN that will be returned from ingestProposal + Mockito.when(mockClient.ingestProposal(any(), any(), Mockito.eq(false))) + .thenReturn(TEST_DOMAIN_URN.toString()); + + // Mock ownership type URNs that OwnerUtils.addOwnersToResources checks for + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__technical_owner")), + Mockito.eq(true))) + .thenReturn(true); + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__none")), + Mockito.eq(true))) + .thenReturn(true); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT_WITH_OWNERS); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockClient.filter( + any(), + Mockito.eq(Constants.DOMAIN_ENTITY_NAME), + Mockito.eq( + DomainUtils.buildNameAndParentDomainFilter( + TEST_INPUT_WITH_OWNERS.getName(), TEST_PARENT_DOMAIN_URN)), + Mockito.eq(null), + Mockito.any(Integer.class), + Mockito.any(Integer.class))) + .thenReturn(new SearchResult().setEntities(new SearchEntityArray())); + + resolver.get(mockEnv).get(); + + // Verify domain creation + final DomainKey key = new DomainKey(); + key.setId("test-id"); + final MetadataChangeProposal domainProposal = new MetadataChangeProposal(); + domainProposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + domainProposal.setEntityType(Constants.DOMAIN_ENTITY_NAME); + DomainProperties props = new DomainProperties(); + props.setDescription("test-description"); + props.setName("test-name"); + props.setCreated(new AuditStamp().setActor(TEST_ACTOR_URN).setTime(0L)); + props.setParentDomain(TEST_PARENT_DOMAIN_URN); + domainProposal.setAspectName(Constants.DOMAIN_PROPERTIES_ASPECT_NAME); + domainProposal.setAspect(GenericRecordUtils.serializeAspect(props)); + domainProposal.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(), + Mockito.argThat(new CreateDomainProposalMatcher(domainProposal)), + Mockito.eq(false)); + + // Verify owners are added + Mockito.verify(mockService, Mockito.times(1)).ingestProposal(any(), any(), Mockito.eq(false)); + } + + @Test + public void testGetSuccessNoParentDomainWithOwners() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + EntityService mockService = getMockEntityService(); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService); + + Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_DOMAIN_URN))).thenReturn(false); + Mockito.when(mockService.exists(any(), Mockito.eq(TEST_OWNER_URN), eq(true))).thenReturn(true); + + // Mock the domain URN that will be returned from ingestProposal + Mockito.when(mockClient.ingestProposal(any(), any(), Mockito.eq(false))) + .thenReturn(TEST_DOMAIN_URN.toString()); + + // Mock ownership type URNs that OwnerUtils.addOwnersToResources checks for + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__technical_owner")), + Mockito.eq(true))) + .thenReturn(true); + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__none")), + Mockito.eq(true))) + .thenReturn(true); + + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))) + .thenReturn(TEST_INPUT_NO_PARENT_WITH_OWNERS); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockClient.filter( + any(), + Mockito.eq(Constants.DOMAIN_ENTITY_NAME), + Mockito.eq(DomainUtils.buildNameAndParentDomainFilter(TEST_INPUT.getName(), null)), + Mockito.eq(null), + Mockito.any(Integer.class), + Mockito.any(Integer.class))) + .thenReturn(new SearchResult().setEntities(new SearchEntityArray())); + + resolver.get(mockEnv).get(); + + // Verify domain creation + final DomainKey key = new DomainKey(); + key.setId("test-id"); + final MetadataChangeProposal domainProposal = new MetadataChangeProposal(); + domainProposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + domainProposal.setEntityType(Constants.DOMAIN_ENTITY_NAME); + DomainProperties props = new DomainProperties(); + props.setDescription("test-description"); + props.setName("test-name"); + props.setCreated(new AuditStamp().setActor(TEST_ACTOR_URN).setTime(0L)); + domainProposal.setAspectName(Constants.DOMAIN_PROPERTIES_ASPECT_NAME); + domainProposal.setAspect(GenericRecordUtils.serializeAspect(props)); + domainProposal.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(), + Mockito.argThat(new CreateDomainProposalMatcher(domainProposal)), + Mockito.eq(false)); + + // Verify owners are added - TODO Verify the contents. + Mockito.verify(mockService, Mockito.times(1)).ingestProposal(any(), any(), Mockito.eq(false)); + } + + @Test + public void testGetSuccessNoOwnersProvided() throws Exception { + // Create resolver + EntityClient mockClient = Mockito.mock(EntityClient.class); + EntityService mockService = getMockEntityService(); + CreateDomainResolver resolver = new CreateDomainResolver(mockClient, mockService); + + Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_DOMAIN_URN))).thenReturn(false); + Mockito.when(mockClient.exists(any(), Mockito.eq(TEST_PARENT_DOMAIN_URN))).thenReturn(true); + + // Mock the domain URN that will be returned from ingestProposal + Mockito.when(mockClient.ingestProposal(any(), any(), Mockito.eq(false))) + .thenReturn(TEST_DOMAIN_URN.toString()); + + // Mock ownership type URNs that OwnerUtils.addActorAsOwner checks for + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__technical_owner")), + Mockito.eq(true))) + .thenReturn(true); + Mockito.when( + mockService.exists( + any(), + Mockito.eq(UrnUtils.getUrn("urn:li:ownershipType:__system__none")), + Mockito.eq(true))) + .thenReturn(true); + + // Execute resolver + QueryContext mockContext = getMockAllowContext(); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + Mockito.when(mockEnv.getArgument(Mockito.eq("input"))).thenReturn(TEST_INPUT); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Mockito.when( + mockClient.filter( + any(), + Mockito.eq(Constants.DOMAIN_ENTITY_NAME), + Mockito.eq( + DomainUtils.buildNameAndParentDomainFilter( + TEST_INPUT.getName(), TEST_PARENT_DOMAIN_URN)), + Mockito.eq(null), + Mockito.any(Integer.class), + Mockito.any(Integer.class))) + .thenReturn(new SearchResult().setEntities(new SearchEntityArray())); + + resolver.get(mockEnv).get(); + + // Verify domain creation + final DomainKey key = new DomainKey(); + key.setId("test-id"); + final MetadataChangeProposal domainProposal = new MetadataChangeProposal(); + domainProposal.setEntityKeyAspect(GenericRecordUtils.serializeAspect(key)); + domainProposal.setEntityType(Constants.DOMAIN_ENTITY_NAME); + DomainProperties props = new DomainProperties(); + props.setDescription("test-description"); + props.setName("test-name"); + props.setCreated(new AuditStamp().setActor(TEST_ACTOR_URN).setTime(0L)); + props.setParentDomain(TEST_PARENT_DOMAIN_URN); + domainProposal.setAspectName(Constants.DOMAIN_PROPERTIES_ASPECT_NAME); + domainProposal.setAspect(GenericRecordUtils.serializeAspect(props)); + domainProposal.setChangeType(ChangeType.UPSERT); + + Mockito.verify(mockClient, Mockito.times(1)) + .ingestProposal( + any(), + Mockito.argThat(new CreateDomainProposalMatcher(domainProposal)), + Mockito.eq(false)); + + // Verify current user is added as owner when no owners provided + // The EntityService.ingestProposal is called with AspectsBatch, not individual proposals + Mockito.verify(mockService, Mockito.times(1)).ingestProposal(any(), any(), Mockito.eq(false)); + } + @Test public void testGetInvalidParent() throws Exception { EntityClient mockClient = Mockito.mock(EntityClient.class); 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 index 9dda4156032140..921d5bd4e742e8 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedOption.tsx @@ -105,6 +105,7 @@ export const NestedOption = ({ selectableChildren, areParentsSelectable, implicitlySelectChildren, + isMultiSelect: !!isMultiSelect, addOptions, removeOptions, setSelectedOptions, @@ -215,6 +216,7 @@ export const NestedOption = ({ areParentsSelectable={areParentsSelectable} setSelectedOptions={setSelectedOptions} implicitlySelectChildren={implicitlySelectChildren} + renderCustomOptionText={renderCustomOptionText} /> ))} 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 index f94a6678f0ce9a..af7b452f25ccd6 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/NestedSelect.tsx @@ -166,7 +166,13 @@ export const NestedSelect = o.value === option.value)) { newStagedOptions = stagedOptions.filter((o) => o.value !== option.value); } else { - newStagedOptions = [...stagedOptions, option]; + if (!isMultiSelect) { + // Single selection: replace all options with just this one + newStagedOptions = [option]; + } else { + // Multi selection: add to existing options + newStagedOptions = [...stagedOptions, option]; + } } setStagedOptions(newStagedOptions); if (!isMultiSelect) { @@ -178,14 +184,23 @@ export const NestedSelect = { - const existingValues = new Set(stagedOptions.map((option) => option.value)); - const filteredOptionsToAdd = optionsToAdd.filter((option) => !existingValues.has(option.value)); - if (filteredOptionsToAdd.length) { - const newStagedOptions = [...stagedOptions, ...filteredOptionsToAdd]; - setStagedOptions(newStagedOptions); + if (!isMultiSelect) { + // Single selection: take only the first option + const firstOption = optionsToAdd[0]; + if (firstOption) { + setStagedOptions([firstOption]); + } + } else { + // Multi selection: add to existing options + const existingValues = new Set(stagedOptions.map((option) => option.value)); + const filteredOptionsToAdd = optionsToAdd.filter((option) => !existingValues.has(option.value)); + if (filteredOptionsToAdd.length) { + const newStagedOptions = [...stagedOptions, ...filteredOptionsToAdd]; + setStagedOptions(newStagedOptions); + } } }, - [stagedOptions], + [stagedOptions, isMultiSelect], ); const removeOptions = useCallback( diff --git a/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts b/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts index 3e691660105e0b..904a48d03d8333 100644 --- a/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts +++ b/datahub-web-react/src/alchemy-components/components/Select/Nested/useSelectOption.ts @@ -13,6 +13,7 @@ interface Props { removeOptions: (nodes: OptionType[]) => void; setSelectedOptions: React.Dispatch>; handleOptionChange: (node: OptionType) => void; + isMultiSelect: boolean; } export default function useNestedOption({ @@ -26,6 +27,7 @@ export default function useNestedOption({ removeOptions, setSelectedOptions, handleOptionChange, + isMultiSelect, }: Props) { const parentChildren = useMemo(() => children.filter((c) => c.isParent), [children]); @@ -44,22 +46,8 @@ export default function useNestedOption({ ); 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, - ], + () => !!selectedOptions.find((o) => o.value === option.value), + [selectedOptions, option.value], ); const isImplicitlySelected = useMemo( @@ -74,16 +62,18 @@ export default function useNestedOption({ const isPartialSelected = useMemo( () => - (!areAllChildrenSelected && areAnyChildrenSelected) || - (isSelected && isParentMissingChildren) || - (isSelected && areAnyUnselectableChildrenUnexpanded) || - (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || - (isSelected && !!children.length && !areAnyChildrenSelected) || - (!isSelected && - areAllChildrenSelected && - !isParentMissingChildren && - option.isParent && - areParentsSelectable), + areParentsSelectable && !isMultiSelect + ? false + : (!areAllChildrenSelected && areAnyChildrenSelected) || + (isSelected && isParentMissingChildren) || + (isSelected && areAnyUnselectableChildrenUnexpanded) || + (areAnyUnselectableChildrenUnexpanded && areAnyChildrenSelected) || + (isSelected && !!children.length && !areAnyChildrenSelected) || + (!isSelected && + areAllChildrenSelected && + !isParentMissingChildren && + option.isParent && + areParentsSelectable), [ isSelected, children, @@ -93,6 +83,7 @@ export default function useNestedOption({ areAnyUnselectableChildrenUnexpanded, isParentMissingChildren, areParentsSelectable, + isMultiSelect, ], ); @@ -116,10 +107,18 @@ export default function useNestedOption({ if (areParentsSelectable && option.isParent && implicitlySelectChildren) { selectChildrenImplicitly(); } else if (isPartialSelected || (!isSelected && !areAnyChildrenSelected)) { - const optionsToAdd = - option.isParent && !areParentsSelectable ? selectableChildren : [option, ...selectableChildren]; - - addOptions(optionsToAdd); + if (!isMultiSelect) { + // Single selection behavior: replace all selections with just this option + setSelectedOptions([option]); + } else if (implicitlySelectChildren) { + // Multi-selection with implicit children: add parent + children or just children + const optionsToAdd = + option.isParent && !areParentsSelectable ? selectableChildren : [option, ...selectableChildren]; + addOptions(optionsToAdd); + } else { + // Multi-selection without implicit children: add only the clicked option + addOptions([option]); + } } else if (areAllChildrenSelected) { removeOptions([option, ...selectableChildren]); } else { diff --git a/datahub-web-react/src/app/domainV2/CreateNewDomainModal/CreateNewDomainModal.tsx b/datahub-web-react/src/app/domainV2/CreateNewDomainModal/CreateNewDomainModal.tsx new file mode 100644 index 00000000000000..98bc58ad5d63d6 --- /dev/null +++ b/datahub-web-react/src/app/domainV2/CreateNewDomainModal/CreateNewDomainModal.tsx @@ -0,0 +1,187 @@ +import { Modal } from '@components'; +import { message } from 'antd'; +import React, { useEffect, useState } from 'react'; + +import { ModalButton } from '@components/components/Modal/Modal'; + +import analytics, { EventType } from '@app/analytics'; +import { useUserContext } from '@app/context/useUserContext'; +import { useDomainsContext as useDomainsContextV2 } from '@app/domainV2/DomainsContext'; +import { useEnterKeyListener } from '@app/shared/useEnterKeyListener'; +import OwnersSection, { PendingOwner } from '@app/sharedV2/owners/OwnersSection'; +import { useIsNestedDomainsEnabled } from '@app/useAppConfig'; + +import { useCreateDomainMutation } from '@graphql/domain.generated'; +import { CorpUser, Entity } from '@types'; + +import DomainDetailsSection from './DomainDetailsSection'; +import { CreateNewDomainModalProps } from './types'; + +/** + * Modal for creating a new domain with owners and optional parent domain + */ +const CreateNewDomainModal: React.FC = ({ onClose, open, onCreate }) => { + const isNestedDomainsEnabled = useIsNestedDomainsEnabled(); + const { entityData } = useDomainsContextV2(); + const { user } = useUserContext(); + + // Domain details state + const [domainName, setDomainName] = useState(''); + const [domainDescription, setDomainDescription] = useState(''); + const [domainId, setDomainId] = useState(''); + const [selectedParentUrn, setSelectedParentUrn] = useState( + (isNestedDomainsEnabled && entityData?.urn) || '', + ); + + // Owners state + const [pendingOwners, setPendingOwners] = useState([]); + const [selectedOwnerUrns, setSelectedOwnerUrns] = useState([]); + const [placeholderOwners, setPlaceholderOwners] = useState([]); + + // Loading state + const [isLoading, setIsLoading] = useState(false); + + // Mutations + const [createDomainMutation] = useCreateDomainMutation(); + + // Set current user as placeholder owner when component mounts + useEffect(() => { + if (user?.urn && placeholderOwners.length === 0) { + const currentUserEntity: CorpUser = user; + console.log(currentUserEntity); + + setPlaceholderOwners([currentUserEntity]); + // Automatically select the current user + setSelectedOwnerUrns([user.urn]); + } + }, [user, placeholderOwners.length]); + + const onChangeOwners = (newOwners: PendingOwner[]) => { + setPendingOwners(newOwners); + }; + + /** + * Handler for creating the domain + */ + const onOk = async () => { + if (!domainName) { + message.error('Domain name is required'); + return; + } + + setIsLoading(true); + + try { + // Create the domain (owners will be added automatically by the backend) + const createDomainResult = await createDomainMutation({ + variables: { + input: { + id: domainId || undefined, + name: domainName.trim(), + description: domainDescription, + parentDomain: selectedParentUrn || undefined, + }, + }, + }); + + const newDomainUrn = createDomainResult.data?.createDomain; + + if (!newDomainUrn) { + message.error('Failed to create domain. An unexpected error occurred'); + setIsLoading(false); + return; + } + + // Analytics event + analytics.event({ + type: EventType.CreateDomainEvent, + parentDomainUrn: selectedParentUrn || undefined, + }); + + message.success(`Domain "${domainName}" successfully created`); + + // Call onCreate callback if provided + if (onCreate) { + onCreate( + newDomainUrn, + domainId || undefined, + domainName.trim(), + domainDescription, + selectedParentUrn || undefined, + ); + } + + onClose(); + resetForm(); + } catch (e: any) { + message.destroy(); + message.error('Failed to create domain. An unexpected error occurred'); + } finally { + setIsLoading(false); + } + }; + + const resetForm = () => { + setDomainName(''); + setDomainDescription(''); + setDomainId(''); + setSelectedParentUrn((isNestedDomainsEnabled && entityData?.urn) || ''); + setPendingOwners([]); + setSelectedOwnerUrns([]); + setPlaceholderOwners([]); + }; + + // Handle the Enter press + useEnterKeyListener({ + querySelectorToExecuteClick: '#createNewDomainButton', + }); + + // Modal buttons configuration + const buttons: ModalButton[] = [ + { + text: 'Cancel', + color: 'violet', + variant: 'text', + onClick: onClose, + buttonDataTestId: 'create-domain-modal-cancel-button', + }, + { + text: 'Create', + id: 'createNewDomainButton', + color: 'violet', + variant: 'filled', + onClick: onOk, + disabled: !domainName || isLoading, + isLoading, + buttonDataTestId: 'create-domain-modal-create-button', + }, + ]; + + return ( + + {/* Domain Details Section */} + + + {/* Owners Section */} + + + ); +}; + +export default CreateNewDomainModal; diff --git a/datahub-web-react/src/app/domainV2/CreateNewDomainModal/DomainDetailsSection.tsx b/datahub-web-react/src/app/domainV2/CreateNewDomainModal/DomainDetailsSection.tsx new file mode 100644 index 00000000000000..1f2c2012d678a6 --- /dev/null +++ b/datahub-web-react/src/app/domainV2/CreateNewDomainModal/DomainDetailsSection.tsx @@ -0,0 +1,87 @@ +import { Typography } from 'antd'; +import React from 'react'; + +import { Input, TextArea } from '@src/alchemy-components'; +import { Domain, EntityType } from '@src/types.generated'; + +import DomainSelector from './DomainSelector'; +import { DomainDetailsSectionProps } from './types'; + +const { Text } = Typography; + +/** + * Component for domain details form fields including name, description, parent domain, and advanced options + */ +const DomainDetailsSection: React.FC = ({ + domainName, + setDomainName, + domainDescription, + setDomainDescription, + domainId, + setDomainId, + selectedParentUrn, + setSelectedParentUrn, + isNestedDomainsEnabled, +}) => { + // Convert selectedParentUrn to Domain array for the selector + const selectedParentDomains: Domain[] = selectedParentUrn + ? [ + { + urn: selectedParentUrn, + type: EntityType.Domain, + __typename: 'Domain', + } as Domain, + ] + : []; + + const handleParentDomainsChange = (domains: Domain[]) => { + // For single selection, take the first domain or empty string + const parentUrn = domains.length > 0 ? domains[0].urn : ''; + setSelectedParentUrn(parentUrn); + }; + + return ( +
+ {/* Domain Name */} +
+ +
+ + {/* Domain Description */} +
+