feat(homePage): add the ability to create asset collection module (#14050)

Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
purnimagarg1 2025-07-18 02:21:02 +05:30 committed by GitHub
parent b550b8a903
commit 1350fe21fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1515 additions and 168 deletions

View File

@ -2,6 +2,7 @@ package com.linkedin.datahub.graphql.resolvers.module;
import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument; import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.bindArgument;
import com.linkedin.common.UrnArray;
import com.linkedin.common.urn.Urn; import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils; import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.QueryContext;
@ -16,7 +17,9 @@ import com.linkedin.metadata.service.PageModuleService;
import com.linkedin.module.DataHubPageModuleParams; import com.linkedin.module.DataHubPageModuleParams;
import graphql.schema.DataFetcher; import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment; import graphql.schema.DataFetchingEnvironment;
import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -91,6 +94,20 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
gmsParams.setRichTextParams(richTextParams); gmsParams.setRichTextParams(richTextParams);
} }
if (paramsInput.getAssetCollectionParams() != null) {
com.linkedin.module.AssetCollectionModuleParams assetCollectionParams =
new com.linkedin.module.AssetCollectionModuleParams();
List<Urn> urns =
paramsInput.getAssetCollectionParams().getAssetUrns().stream()
.map(UrnUtils::getUrn)
.collect(Collectors.toList());
UrnArray urnArray = new UrnArray(urns);
assetCollectionParams.setAssetUrns(urnArray);
gmsParams.setAssetCollectionParams(assetCollectionParams);
}
return gmsParams; return gmsParams;
} }
@ -106,6 +123,11 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
if (params.getLinkParams() == null) { if (params.getLinkParams() == null) {
throw new IllegalArgumentException("Did not provide link params for link module"); throw new IllegalArgumentException("Did not provide link params for link module");
} }
} else if (type.equals(com.linkedin.module.DataHubPageModuleType.ASSET_COLLECTION)) {
if (params.getAssetCollectionParams() == null) {
throw new IllegalArgumentException(
"Did not provide asset collection params for asset collection module");
}
} else { } else {
// TODO: add more blocks to this check as we support creating more types of modules to this // TODO: add more blocks to this check as we support creating more types of modules to this
// resolver // resolver

View File

@ -1,12 +1,16 @@
package com.linkedin.datahub.graphql.types.module; package com.linkedin.datahub.graphql.types.module;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext; import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.AssetCollectionModuleParams;
import com.linkedin.datahub.graphql.generated.EntityType; import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.LinkModuleParams; import com.linkedin.datahub.graphql.generated.LinkModuleParams;
import com.linkedin.datahub.graphql.generated.Post; import com.linkedin.datahub.graphql.generated.Post;
import com.linkedin.datahub.graphql.generated.RichTextModuleParams; import com.linkedin.datahub.graphql.generated.RichTextModuleParams;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper; import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.module.DataHubPageModuleParams; import com.linkedin.module.DataHubPageModuleParams;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nonnull; import javax.annotation.Nonnull;
import javax.annotation.Nullable; import javax.annotation.Nullable;
@ -44,6 +48,21 @@ public class PageModuleParamsMapper
result.setRichTextParams(richTextParams); result.setRichTextParams(richTextParams);
} }
// Map asset collection params if present
if (params.getAssetCollectionParams() != null
&& params.getAssetCollectionParams().getAssetUrns() != null) {
AssetCollectionModuleParams assetCollectionParams = new AssetCollectionModuleParams();
List<String> assetUrnStrings =
params.getAssetCollectionParams().getAssetUrns().stream()
.map(Urn::toString)
.collect(Collectors.toList());
assetCollectionParams.setAssetUrns(assetUrnStrings);
result.setAssetCollectionParams(assetCollectionParams);
}
return result; return result;
} }
} }

View File

@ -78,6 +78,11 @@ input PageModuleParamsInput {
The params required if the module is type RICH_TEXT The params required if the module is type RICH_TEXT
""" """
richTextParams: RichTextModuleParamsInput richTextParams: RichTextModuleParamsInput
"""
The params required if the module is type ASSET_COLLECTION
"""
assetCollectionParams: AssetCollectionModuleParamsInput
} }
""" """
@ -100,6 +105,16 @@ input RichTextModuleParamsInput {
content: String! content: String!
} }
"""
Input for the params required if the module is type ASSET_COLLECTION
"""
input AssetCollectionModuleParamsInput {
"""
The list of asset urns for the asset collection module
"""
assetUrns: [String!]!
}
""" """
The main properties of a DataHub page module The main properties of a DataHub page module
""" """
@ -202,6 +217,11 @@ type DataHubPageModuleParams {
The params required if the module is type RICH_TEXT The params required if the module is type RICH_TEXT
""" """
richTextParams: RichTextModuleParams richTextParams: RichTextModuleParams
"""
The params required if the module is type ASSET_COLLECTION
"""
assetCollectionParams: AssetCollectionModuleParams
} }
""" """
@ -224,6 +244,16 @@ type RichTextModuleParams {
content: String! content: String!
} }
"""
The params required if the module is type ASSET_COLLECTION
"""
type AssetCollectionModuleParams {
"""
The list of asset urns for the asset collection module
"""
assetUrns: [String!]!
}
""" """
Input for deleting a DataHub page module Input for deleting a DataHub page module
""" """

View File

@ -49,7 +49,9 @@ export const Checkbox = ({
</Label> </Label>
) : null} ) : null}
<CheckboxBase <CheckboxBase
onClick={() => { onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (!isDisabled) { if (!isDisabled) {
setChecked(!checked); setChecked(!checked);
setIsChecked?.(!checked); setIsChecked?.(!checked);

View File

@ -11,7 +11,7 @@ export function useHydratedEntityMap(urns?: (string | undefined | null)[]) {
); );
// Fetch entities // Fetch entities
const hydratedEntities = useGetEntities(uniqueEntityUrns); const { entities: hydratedEntities } = useGetEntities(uniqueEntityUrns);
// Create entity map // Create entity map
const hydratedEntityMap = useMemo( const hydratedEntityMap = useMemo(

View File

@ -1,5 +1,6 @@
import React, { ReactNode, createContext, useContext, useMemo } from 'react'; import React, { ReactNode, createContext, useContext, useMemo } from 'react';
import { useModuleModalState } from '@app/homeV3/context/hooks/useModuleModalState';
import { useModuleOperations } from '@app/homeV3/context/hooks/useModuleOperations'; import { useModuleOperations } from '@app/homeV3/context/hooks/useModuleOperations';
import { useTemplateOperations } from '@app/homeV3/context/hooks/useTemplateOperations'; import { useTemplateOperations } from '@app/homeV3/context/hooks/useTemplateOperations';
import { useTemplateState } from '@app/homeV3/context/hooks/useTemplateState'; import { useTemplateState } from '@app/homeV3/context/hooks/useTemplateState';
@ -33,8 +34,11 @@ export const PageTemplateProvider = ({
// Template operations // Template operations
const { updateTemplateWithModule, removeModuleFromTemplate, upsertTemplate } = useTemplateOperations(); const { updateTemplateWithModule, removeModuleFromTemplate, upsertTemplate } = useTemplateOperations();
// Modal state
const moduleModalState = useModuleModalState();
// Module operations // Module operations
const { addModule, removeModule, createModule } = useModuleOperations( const { addModule, removeModule, upsertModule } = useModuleOperations(
isEditingGlobalTemplate, isEditingGlobalTemplate,
personalTemplate, personalTemplate,
globalTemplate, globalTemplate,
@ -43,6 +47,7 @@ export const PageTemplateProvider = ({
updateTemplateWithModule, updateTemplateWithModule,
removeModuleFromTemplate, removeModuleFromTemplate,
upsertTemplate, upsertTemplate,
moduleModalState.isEditing,
); );
const value = useMemo( const value = useMemo(
@ -57,7 +62,8 @@ export const PageTemplateProvider = ({
setTemplate, setTemplate,
addModule, addModule,
removeModule, removeModule,
createModule, upsertModule,
moduleModalState,
}), }),
[ [
personalTemplate, personalTemplate,
@ -70,7 +76,8 @@ export const PageTemplateProvider = ({
setTemplate, setTemplate,
addModule, addModule,
removeModule, removeModule,
createModule, upsertModule,
moduleModalState,
], ],
); );
@ -86,4 +93,4 @@ export function usePageTemplateContext() {
} }
// Re-export types for convenience // Re-export types for convenience
export type { CreateModuleInput, AddModuleInput, RemoveModuleInput } from './types'; export type { UpsertModuleInput, AddModuleInput, RemoveModuleInput } from './types';

View File

@ -87,7 +87,7 @@ const mockSetGlobalTemplate = vi.fn();
const mockSetTemplate = vi.fn(); const mockSetTemplate = vi.fn();
const mockAddModule = vi.fn(); const mockAddModule = vi.fn();
const mockRemoveModule = vi.fn(); const mockRemoveModule = vi.fn();
const mockCreateModule = vi.fn(); const mockUpsertModule = vi.fn();
const mockUpdateTemplateWithModule = vi.fn(); const mockUpdateTemplateWithModule = vi.fn();
const mockRemoveModuleFromTemplate = vi.fn(); const mockRemoveModuleFromTemplate = vi.fn();
const mockUpsertTemplate = vi.fn(); const mockUpsertTemplate = vi.fn();
@ -117,7 +117,7 @@ describe('PageTemplateContext', () => {
mockUseModuleOperations.mockReturnValue({ mockUseModuleOperations.mockReturnValue({
addModule: mockAddModule, addModule: mockAddModule,
removeModule: mockRemoveModule, removeModule: mockRemoveModule,
createModule: mockCreateModule, upsertModule: mockUpsertModule,
}); });
}); });
@ -183,6 +183,7 @@ describe('PageTemplateContext', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
); );
}); });
@ -281,6 +282,7 @@ describe('PageTemplateContext', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
); );
}); });
@ -328,7 +330,13 @@ describe('PageTemplateContext', () => {
expect(result.current.setGlobalTemplate).toBe(mockSetGlobalTemplate); expect(result.current.setGlobalTemplate).toBe(mockSetGlobalTemplate);
expect(result.current.setTemplate).toBe(mockSetTemplate); expect(result.current.setTemplate).toBe(mockSetTemplate);
expect(result.current.addModule).toBe(mockAddModule); expect(result.current.addModule).toBe(mockAddModule);
expect(result.current.createModule).toBe(mockCreateModule); expect(result.current.upsertModule).toBe(mockUpsertModule);
expect(result.current.moduleModalState).toMatchObject({
isOpen: false,
isEditing: false,
open: expect.any(Function),
close: expect.any(Function),
});
}); });
it('should throw error when used outside provider', () => { it('should throw error when used outside provider', () => {
@ -372,7 +380,7 @@ describe('PageTemplateContext', () => {
expect(mockAddModule).toHaveBeenCalledWith(moduleInput); expect(mockAddModule).toHaveBeenCalledWith(moduleInput);
}); });
it('should provide working createModule function', () => { it('should provide working upsertModule function', () => {
const { result } = renderHook(() => usePageTemplateContext(), { const { result } = renderHook(() => usePageTemplateContext(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<PageTemplateProvider personalTemplate={mockPersonalTemplate} globalTemplate={mockGlobalTemplate}> <PageTemplateProvider personalTemplate={mockPersonalTemplate} globalTemplate={mockGlobalTemplate}>
@ -381,7 +389,7 @@ describe('PageTemplateContext', () => {
), ),
}); });
const createModuleInput = { const upsertModuleInput = {
name: 'New Module', name: 'New Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal, scope: PageModuleScope.Personal,
@ -393,10 +401,10 @@ describe('PageTemplateContext', () => {
}; };
act(() => { act(() => {
result.current.createModule(createModuleInput); result.current.upsertModule(upsertModuleInput);
}); });
expect(mockCreateModule).toHaveBeenCalledWith(createModuleInput); expect(mockUpsertModule).toHaveBeenCalledWith(upsertModuleInput);
}); });
it('should provide working setIsEditingGlobalTemplate function', () => { it('should provide working setIsEditingGlobalTemplate function', () => {

View File

@ -0,0 +1,175 @@
import { act, renderHook } from '@testing-library/react-hooks';
import { describe, expect, it } from 'vitest';
import { useModuleModalState } from '@app/homeV3/context/hooks/useModuleModalState';
import type { ModulePositionInput } from '@app/homeV3/template/types';
import type { PageModuleFragment } from '@graphql/template.generated';
import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
const mockModule: PageModuleFragment = {
urn: 'urn:li:pageModule:new',
type: EntityType.DatahubPageModule,
properties: {
name: 'New Module',
type: DataHubPageModuleType.AssetCollection,
visibility: { scope: PageModuleScope.Personal },
params: {},
},
};
const anotherMockModule: PageModuleFragment = {
urn: 'urn:li:pageModule:another',
type: EntityType.DatahubPageModule,
properties: {
name: 'Second Module',
type: DataHubPageModuleType.AssetCollection,
visibility: { scope: PageModuleScope.Personal },
params: {},
},
};
const TYPE = DataHubPageModuleType.AssetCollection;
const TYPE2 = DataHubPageModuleType.Domains;
const POS1: ModulePositionInput = { rowIndex: 0, rowSide: 'left' };
const POS2: ModulePositionInput = { rowIndex: 1, rowSide: 'right' };
const POS3: ModulePositionInput = { rowIndex: 5, rowSide: 'left' };
describe('useModuleModalState', () => {
it('should initialize to default state', () => {
const { result } = renderHook(() => useModuleModalState());
expect(result.current.isOpen).toBe(false);
expect(result.current.moduleType).toBeNull();
expect(result.current.position).toBeNull();
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should set correct state on open()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
expect(result.current.isOpen).toBe(true);
expect(result.current.moduleType).toBe(TYPE);
expect(result.current.position).toEqual(POS1);
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should update state to latest args on consecutive open()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
act(() => result.current.open(TYPE2, POS2));
expect(result.current.moduleType).toBe(TYPE2);
expect(result.current.position).toEqual(POS2);
expect(result.current.isOpen).toBe(true);
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should handle different position values on open()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE2, POS3));
expect(result.current.position).toEqual(POS3);
});
it('should set edit state module on openToEdit()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.openToEdit(TYPE, mockModule));
expect(result.current.isOpen).toBe(true);
expect(result.current.moduleType).toBe(TYPE);
expect(result.current.isEditing).toBe(true);
expect(result.current.initialState).toBe(mockModule);
expect(result.current.position).toBeNull();
});
it('should update to latest args on consecutive openToEdit()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.openToEdit(TYPE, mockModule));
act(() => result.current.openToEdit(TYPE2, anotherMockModule));
expect(result.current.isOpen).toBe(true);
expect(result.current.moduleType).toBe(TYPE2);
expect(result.current.isEditing).toBe(true);
expect(result.current.initialState).toBe(anotherMockModule);
expect(result.current.position).toBeNull();
});
it('should reset state after open() then close()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
act(() => result.current.close());
expect(result.current.isOpen).toBe(false);
expect(result.current.moduleType).toBeNull();
expect(result.current.position).toBeNull();
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should reset state after openToEdit() then close()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.openToEdit(TYPE, mockModule));
act(() => result.current.close());
expect(result.current.isOpen).toBe(false);
expect(result.current.moduleType).toBeNull();
expect(result.current.position).toBeNull();
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should reset edit state on open() after openToEdit()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.openToEdit(TYPE, mockModule));
act(() => result.current.open(TYPE2, POS2));
expect(result.current.isOpen).toBe(true);
expect(result.current.moduleType).toBe(TYPE2);
expect(result.current.position).toEqual(POS2);
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should maintain correct state on multiple alternations between open and openToEdit', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
expect(result.current.position).toEqual(POS1);
act(() => result.current.openToEdit(TYPE, mockModule));
expect(result.current.position).toEqual(POS1);
act(() => result.current.open(TYPE, POS2));
expect(result.current.position).toEqual(POS2);
act(() => result.current.openToEdit(TYPE2, mockModule));
expect(result.current.position).toEqual(POS2);
});
it('should fully reset all state after a complex sequence and close()', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
act(() => result.current.openToEdit(TYPE2, mockModule));
act(() => result.current.open(TYPE, POS2));
act(() => result.current.close());
expect(result.current.isOpen).toBe(false);
expect(result.current.moduleType).toBeNull();
expect(result.current.position).toBeNull();
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should not throw and always reset state on multiple close() calls', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
act(() => result.current.close());
act(() => result.current.close());
expect(result.current.isOpen).toBe(false);
expect(result.current.moduleType).toBeNull();
expect(result.current.position).toBeNull();
expect(result.current.isEditing).toBe(false);
expect(result.current.initialState).toBeNull();
});
it('should retain latest initialState and position after successive edits', () => {
const { result } = renderHook(() => useModuleModalState());
act(() => result.current.open(TYPE, POS1));
act(() => result.current.openToEdit(TYPE, mockModule));
act(() => result.current.openToEdit(TYPE, anotherMockModule));
expect(result.current.initialState).toBe(anotherMockModule);
expect(result.current.position).toEqual(POS1);
});
});

View File

@ -107,6 +107,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -137,7 +138,12 @@ describe('useModuleOperations', () => {
}); });
}); });
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockPersonalTemplate, mockModule, position); expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(
mockPersonalTemplate,
mockModule,
position,
false,
);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate); expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate);
}); });
@ -153,6 +159,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -183,7 +190,7 @@ describe('useModuleOperations', () => {
}); });
}); });
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockGlobalTemplate, mockModule, position); expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockGlobalTemplate, mockModule, position, false);
expect(mockSetGlobalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetGlobalTemplate).toHaveBeenCalledWith(updatedTemplate);
expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, false, mockPersonalTemplate); expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, false, mockPersonalTemplate);
}); });
@ -199,6 +206,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -230,7 +238,7 @@ describe('useModuleOperations', () => {
}); });
}); });
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockGlobalTemplate, mockModule, position); expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockGlobalTemplate, mockModule, position, false);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, null); expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, null);
}); });
@ -246,6 +254,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -284,7 +293,12 @@ describe('useModuleOperations', () => {
setTimeout(resolve, 0); setTimeout(resolve, 0);
}); });
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockPersonalTemplate, mockModule, position); expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(
mockPersonalTemplate,
mockModule,
position,
false,
);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate); expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(mockPersonalTemplate); // Revert call expect(mockSetPersonalTemplate).toHaveBeenCalledWith(mockPersonalTemplate); // Revert call
@ -306,6 +320,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -355,6 +370,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -404,6 +420,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -456,6 +473,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -512,6 +530,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -556,6 +575,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -594,6 +614,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -638,6 +659,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false, // isEditingModule
), ),
); );
@ -666,7 +688,7 @@ describe('useModuleOperations', () => {
}); });
}); });
describe('createModule', () => { describe('upsertModule', () => {
it('should create module and add it to template', async () => { it('should create module and add it to template', async () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useModuleOperations( useModuleOperations(
@ -678,6 +700,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -686,7 +709,7 @@ describe('useModuleOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const createModuleInput = { const upsertModuleInput = {
name: 'Test Module', name: 'Test Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal, scope: PageModuleScope.Personal,
@ -731,7 +754,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({}); mockUpsertTemplate.mockResolvedValue({});
await act(async () => { await act(async () => {
result.current.createModule(createModuleInput); result.current.upsertModule(upsertModuleInput);
}); });
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({ expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -740,10 +763,8 @@ describe('useModuleOperations', () => {
name: 'Test Module', name: 'Test Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal, scope: PageModuleScope.Personal,
visibility: {
scope: PageModuleScope.Personal,
},
params: { limit: 10 }, params: { limit: 10 },
urn: undefined,
}, },
}, },
}); });
@ -761,6 +782,7 @@ describe('useModuleOperations', () => {
}, },
}, },
position, position,
false,
); );
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
@ -778,6 +800,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -786,7 +809,7 @@ describe('useModuleOperations', () => {
rowSide: 'right', rowSide: 'right',
}; };
const createModuleInput = { const upsertModuleInput = {
name: 'Test Module', name: 'Test Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
position, position,
@ -805,7 +828,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({}); mockUpsertTemplate.mockResolvedValue({});
await act(async () => { await act(async () => {
result.current.createModule(createModuleInput); await result.current.upsertModule(upsertModuleInput);
}); });
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({ expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -814,10 +837,8 @@ describe('useModuleOperations', () => {
name: 'Test Module', name: 'Test Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal, // Default scope scope: PageModuleScope.Personal, // Default scope
visibility: {
scope: PageModuleScope.Personal,
},
params: {}, // Default empty params params: {}, // Default empty params
urn: undefined,
}, },
}, },
}); });
@ -834,6 +855,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -842,7 +864,7 @@ describe('useModuleOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const createModuleInput = { const upsertModuleInput = {
name: 'Test Module', name: 'Test Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
position, position,
@ -863,7 +885,7 @@ describe('useModuleOperations', () => {
}); });
await act(async () => { await act(async () => {
result.current.createModule(createModuleInput); result.current.upsertModule(upsertModuleInput);
}); });
// Wait for the async operation to complete // Wait for the async operation to complete
@ -888,6 +910,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
); );
@ -896,7 +919,7 @@ describe('useModuleOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const createModuleInput = { const upsertModuleInput = {
name: 'Global Module', name: 'Global Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Global, scope: PageModuleScope.Global,
@ -940,7 +963,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({}); mockUpsertTemplate.mockResolvedValue({});
await act(async () => { await act(async () => {
result.current.createModule(createModuleInput); result.current.upsertModule(upsertModuleInput);
}); });
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({ expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -949,10 +972,8 @@ describe('useModuleOperations', () => {
name: 'Global Module', name: 'Global Module',
type: DataHubPageModuleType.Link, type: DataHubPageModuleType.Link,
scope: PageModuleScope.Global, scope: PageModuleScope.Global,
visibility: {
scope: PageModuleScope.Global,
},
params: {}, params: {},
urn: undefined,
}, },
}, },
}); });
@ -970,6 +991,7 @@ describe('useModuleOperations', () => {
}, },
}, },
position, position,
false,
); );
expect(mockSetGlobalTemplate).toHaveBeenCalledWith(updatedTemplate); expect(mockSetGlobalTemplate).toHaveBeenCalledWith(updatedTemplate);
@ -990,6 +1012,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
{ {
initialProps: { initialProps: {
@ -1011,7 +1034,7 @@ describe('useModuleOperations', () => {
expect(result.current.addModule).not.toBe(initialAddModule); expect(result.current.addModule).not.toBe(initialAddModule);
}); });
it('should update createModule when dependencies change', () => { it('should update upsertModule when dependencies change', () => {
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ isEditingGlobalTemplate, personalTemplate, globalTemplate }) => ({ isEditingGlobalTemplate, personalTemplate, globalTemplate }) =>
useModuleOperations( useModuleOperations(
@ -1023,6 +1046,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule, mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate, mockRemoveModuleFromTemplate,
mockUpsertTemplate, mockUpsertTemplate,
false,
), ),
{ {
initialProps: { initialProps: {
@ -1033,7 +1057,7 @@ describe('useModuleOperations', () => {
}, },
); );
const initialCreateModule = result.current.createModule; const initialUpsertModule = result.current.upsertModule;
rerender({ rerender({
isEditingGlobalTemplate: true, isEditingGlobalTemplate: true,
@ -1041,7 +1065,7 @@ describe('useModuleOperations', () => {
globalTemplate: mockGlobalTemplate, globalTemplate: mockGlobalTemplate,
}); });
expect(result.current.createModule).not.toBe(initialCreateModule); expect(result.current.upsertModule).not.toBe(initialUpsertModule);
}); });
}); });
}); });

View File

@ -68,7 +68,7 @@ describe('useTemplateOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position, false);
expect(updatedTemplate).not.toBeNull(); expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(2); expect(updatedTemplate?.properties?.rows).toHaveLength(2);
@ -84,7 +84,7 @@ describe('useTemplateOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position, false);
expect(updatedTemplate).not.toBeNull(); expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(1); expect(updatedTemplate?.properties?.rows).toHaveLength(1);
@ -101,7 +101,7 @@ describe('useTemplateOperations', () => {
rowSide: 'right', rowSide: 'right',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position, false);
expect(updatedTemplate).not.toBeNull(); expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(1); expect(updatedTemplate?.properties?.rows).toHaveLength(1);
@ -118,7 +118,7 @@ describe('useTemplateOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position, false);
expect(updatedTemplate).not.toBeNull(); expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(2); expect(updatedTemplate?.properties?.rows).toHaveLength(2);
@ -148,7 +148,12 @@ describe('useTemplateOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(templateWithoutRows, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(
templateWithoutRows,
mockModule,
position,
false,
);
expect(updatedTemplate).not.toBeNull(); expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(1); expect(updatedTemplate?.properties?.rows).toHaveLength(1);
@ -164,7 +169,7 @@ describe('useTemplateOperations', () => {
rowSide: 'left', rowSide: 'left',
}; };
const updatedTemplate = result.current.updateTemplateWithModule(null, mockModule, position); const updatedTemplate = result.current.updateTemplateWithModule(null, mockModule, position, false);
expect(updatedTemplate).toBeNull(); expect(updatedTemplate).toBeNull();
}); });

View File

@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import { ModuleModalState } from '@app/homeV3/context/types';
import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment } from '@graphql/template.generated';
import { DataHubPageModuleType } from '@types';
export function useModuleModalState(): ModuleModalState {
const [moduleType, setModuleType] = useState<DataHubPageModuleType | null>(null);
const [isOpen, setIsOpen] = useState<boolean>(false);
const [position, setPosition] = useState<ModulePositionInput | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [initialState, setInitialState] = useState<PageModuleFragment | null>(null);
const open = useCallback((moduleTypeToCreate: DataHubPageModuleType, positionToCreate: ModulePositionInput) => {
setModuleType(moduleTypeToCreate);
setIsOpen(true);
setPosition(positionToCreate);
setIsEditing(false);
setInitialState(null);
}, []);
const openToEdit = useCallback((moduleTypeToEdit: DataHubPageModuleType, currentData: PageModuleFragment) => {
setModuleType(moduleTypeToEdit);
setIsEditing(true);
setInitialState(currentData);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setModuleType(null);
setPosition(null);
setIsOpen(false);
setIsEditing(false);
setInitialState(null);
}, []);
return {
moduleType,
isOpen,
position,
open,
close,
openToEdit,
isEditing,
initialState,
};
}

View File

@ -1,19 +1,11 @@
import { message } from 'antd'; import { message } from 'antd';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { UpsertModuleInput } from '@app/homeV3/context/types';
import { ModulePositionInput } from '@app/homeV3/template/types'; import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment, PageTemplateFragment, useUpsertPageModuleMutation } from '@graphql/template.generated'; import { PageModuleFragment, PageTemplateFragment, useUpsertPageModuleMutation } from '@graphql/template.generated';
import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types'; import { EntityType, PageModuleScope } from '@types';
// Input types for the methods
export interface CreateModuleInput {
name: string;
type: DataHubPageModuleType;
scope?: PageModuleScope;
params?: any; // Module-specific parameters
position: ModulePositionInput;
}
export interface AddModuleInput { export interface AddModuleInput {
module: PageModuleFragment; module: PageModuleFragment;
@ -111,7 +103,7 @@ const validateRemoveModuleInput = (input: RemoveModuleInput): string | null => {
return null; return null;
}; };
const validateCreateModuleInput = (input: CreateModuleInput): string | null => { const validateUpsertModuleInput = (input: UpsertModuleInput): string | null => {
if (!input.name?.trim()) { if (!input.name?.trim()) {
return 'Module name is required'; return 'Module name is required';
} }
@ -134,6 +126,7 @@ export function useModuleOperations(
templateToUpdate: PageTemplateFragment | null, templateToUpdate: PageTemplateFragment | null,
module: PageModuleFragment, module: PageModuleFragment,
position: ModulePositionInput, position: ModulePositionInput,
isEditing: boolean,
) => PageTemplateFragment | null, ) => PageTemplateFragment | null,
removeModuleFromTemplate: ( removeModuleFromTemplate: (
templateToUpdate: PageTemplateFragment | null, templateToUpdate: PageTemplateFragment | null,
@ -145,6 +138,7 @@ export function useModuleOperations(
isPersonal: boolean, isPersonal: boolean,
personalTemplate: PageTemplateFragment | null, personalTemplate: PageTemplateFragment | null,
) => Promise<any>, ) => Promise<any>,
isEditingModule: boolean,
) { ) {
const [upsertPageModuleMutation] = useUpsertPageModuleMutation(); const [upsertPageModuleMutation] = useUpsertPageModuleMutation();
@ -189,7 +183,7 @@ export function useModuleOperations(
} }
// Update template state // Update template state
const updatedTemplate = updateTemplateWithModule(templateToUpdate, module, position); const updatedTemplate = updateTemplateWithModule(templateToUpdate, module, position, isEditingModule);
// Update local state immediately for optimistic UI // Update local state immediately for optimistic UI
updateTemplateStateOptimistically(context, updatedTemplate, isPersonal); updateTemplateStateOptimistically(context, updatedTemplate, isPersonal);
@ -197,7 +191,7 @@ export function useModuleOperations(
// Persist changes // Persist changes
persistTemplateChanges(context, updatedTemplate, isPersonal, 'add module'); persistTemplateChanges(context, updatedTemplate, isPersonal, 'add module');
}, },
[context, updateTemplateWithModule], [context, isEditingModule, updateTemplateWithModule],
); );
// Removes a module from the template state and updates the appropriate template on the backend // Removes a module from the template state and updates the appropriate template on the backend
@ -233,27 +227,25 @@ export function useModuleOperations(
); );
// Takes input and makes a call to create a module then add that module to the template // Takes input and makes a call to create a module then add that module to the template
const createModule = useCallback( const upsertModule = useCallback(
(input: CreateModuleInput) => { (input: UpsertModuleInput) => {
// Validate input // Validate input
const validationError = validateCreateModuleInput(input); const validationError = validateUpsertModuleInput(input);
if (validationError) { if (validationError) {
console.error('Invalid createModule input:', validationError); console.error('Invalid upsertModule input:', validationError);
message.error(validationError); message.error(validationError);
return; return;
} }
const { name, type, scope = PageModuleScope.Personal, params = {}, position } = input; const { name, type, scope = PageModuleScope.Personal, params = {}, position, urn } = input;
// Create the module first // Create the module first
const moduleInput = { const moduleInput = {
name: name.trim(), name: name.trim(),
type, type,
scope, scope,
visibility: {
scope,
},
params, params,
urn,
}; };
upsertPageModuleMutation({ upsertPageModuleMutation({
@ -262,8 +254,8 @@ export function useModuleOperations(
.then((moduleResult) => { .then((moduleResult) => {
const moduleUrn = moduleResult.data?.upsertPageModule?.urn; const moduleUrn = moduleResult.data?.upsertPageModule?.urn;
if (!moduleUrn) { if (!moduleUrn) {
console.error('Failed to create module - no URN returned'); console.error(`Failed to ${isEditingModule ? 'update' : 'create'} module - no URN returned`);
message.error('Failed to create module'); message.error(`Failed to ${isEditingModule ? 'update' : 'create'} module`);
return; return;
} }
@ -286,16 +278,16 @@ export function useModuleOperations(
}); });
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to create module:', error); console.error(`Failed to ${isEditingModule ? 'update' : 'create'} module:`, error);
message.error('Failed to create module'); message.error(`Failed to ${isEditingModule ? 'update' : 'create'} module`);
}); });
}, },
[upsertPageModuleMutation, addModule], [upsertPageModuleMutation, addModule, isEditingModule],
); );
return { return {
addModule, addModule,
removeModule, removeModule,
createModule, upsertModule,
}; };
} }

View File

@ -52,11 +52,25 @@ export function useTemplateOperations() {
templateToUpdate: PageTemplateFragment | null, templateToUpdate: PageTemplateFragment | null,
module: PageModuleFragment, module: PageModuleFragment,
position: ModulePositionInput, position: ModulePositionInput,
isEditingModule: boolean,
): PageTemplateFragment | null => { ): PageTemplateFragment | null => {
if (!templateToUpdate) return null; if (!templateToUpdate) return null;
const newTemplate = { ...templateToUpdate }; const newTemplate = { ...templateToUpdate };
const newRows = [...(newTemplate.properties?.rows || [])]; let newRows = [...(newTemplate.properties?.rows || [])];
// Update the existing module in-place for Optimistic UI changes
if (isEditingModule && module.urn) {
newRows = newRows.map((row) => ({
...row,
modules: (row.modules || []).map((mod) => (mod.urn === module.urn ? { ...mod, ...module } : mod)),
}));
newTemplate.properties = {
...newTemplate.properties,
rows: newRows,
};
return newTemplate;
}
if (position.rowIndex === undefined) { if (position.rowIndex === undefined) {
// Add to new row at the end // Add to new row at the end

View File

@ -4,7 +4,8 @@ import { PageModuleFragment, PageTemplateFragment } from '@graphql/template.gene
import { DataHubPageModuleType, PageModuleScope } from '@types'; import { DataHubPageModuleType, PageModuleScope } from '@types';
// Input types for the methods // Input types for the methods
export interface CreateModuleInput { export interface UpsertModuleInput {
urn?: string;
name: string; name: string;
type: DataHubPageModuleType; type: DataHubPageModuleType;
scope?: PageModuleScope; scope?: PageModuleScope;
@ -21,6 +22,16 @@ export interface RemoveModuleInput {
moduleUrn: string; moduleUrn: string;
position: ModulePositionInput; position: ModulePositionInput;
} }
export interface ModuleModalState {
isOpen: boolean;
moduleType: DataHubPageModuleType | null;
position: ModulePositionInput | null;
open: (moduleType: DataHubPageModuleType, position: ModulePositionInput) => void;
close: () => void;
isEditing: boolean;
initialState: PageModuleFragment | null;
openToEdit: (moduleType: DataHubPageModuleType, currentData: PageModuleFragment) => void;
}
// Context state shape // Context state shape
export type PageTemplateContextState = { export type PageTemplateContextState = {
@ -33,6 +44,7 @@ export type PageTemplateContextState = {
setGlobalTemplate: (template: PageTemplateFragment | null) => void; setGlobalTemplate: (template: PageTemplateFragment | null) => void;
setTemplate: (template: PageTemplateFragment | null) => void; setTemplate: (template: PageTemplateFragment | null) => void;
addModule: (input: AddModuleInput) => void; addModule: (input: AddModuleInput) => void;
createModule: (input: CreateModuleInput) => void; upsertModule: (input: UpsertModuleInput) => void;
moduleModalState: ModuleModalState;
removeModule: (input: RemoveModuleInput) => void; removeModule: (input: RemoveModuleInput) => void;
}; };

View File

@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
import { ModuleProps } from '@app/homeV3/module/types'; import { ModuleProps } from '@app/homeV3/module/types';
import SampleLargeModule from '@app/homeV3/modules/SampleLargeModule'; import SampleLargeModule from '@app/homeV3/modules/SampleLargeModule';
import YourAssetsModule from '@app/homeV3/modules/YourAssetsModule'; import YourAssetsModule from '@app/homeV3/modules/YourAssetsModule';
import AssetCollectionModule from '@app/homeV3/modules/assetCollection/AssetCollectionModule';
import TopDomainsModule from '@app/homeV3/modules/domains/TopDomainsModule'; import TopDomainsModule from '@app/homeV3/modules/domains/TopDomainsModule';
import { DataHubPageModuleType } from '@types'; import { DataHubPageModuleType } from '@types';
@ -12,6 +13,7 @@ export default function Module(props: ModuleProps) {
const Component = useMemo(() => { const Component = useMemo(() => {
if (module.properties.type === DataHubPageModuleType.OwnedAssets) return YourAssetsModule; if (module.properties.type === DataHubPageModuleType.OwnedAssets) return YourAssetsModule;
if (module.properties.type === DataHubPageModuleType.Domains) return TopDomainsModule; if (module.properties.type === DataHubPageModuleType.Domains) return TopDomainsModule;
if (module.properties.type === DataHubPageModuleType.AssetCollection) return AssetCollectionModule;
// TODO: remove the sample large module once we have other modules to fill this out // TODO: remove the sample large module once we have other modules to fill this out
console.error(`Issue finding module with type ${module.properties.type}`); console.error(`Issue finding module with type ${module.properties.type}`);

View File

@ -9,14 +9,30 @@ import { Entity } from '@types';
interface Props { interface Props {
entity: Entity; entity: Entity;
customDetailsRenderer?: (entity: Entity) => void; customDetailsRenderer?: (entity: Entity) => void;
navigateOnlyOnNameClick?: boolean;
} }
export default function EntityItem({ entity, customDetailsRenderer }: Props) { export default function EntityItem({ entity, customDetailsRenderer, navigateOnlyOnNameClick = false }: Props) {
const entityRegistry = useEntityRegistryV2(); const entityRegistry = useEntityRegistryV2();
return ( return (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)}> <>
<AutoCompleteEntityItem entity={entity} key={entity.urn} customDetailsRenderer={customDetailsRenderer} /> {navigateOnlyOnNameClick ? (
</Link> <AutoCompleteEntityItem
entity={entity}
key={entity.urn}
customDetailsRenderer={customDetailsRenderer}
navigateOnlyOnNameClick
/>
) : (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)}>
<AutoCompleteEntityItem
entity={entity}
key={entity.urn}
customDetailsRenderer={customDetailsRenderer}
/>
</Link>
)}
</>
); );
} }

View File

@ -4,6 +4,7 @@ import React, { useCallback } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext'; import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import { DEFAULT_GLOBAL_MODULE_TYPES } from '@app/homeV3/modules/constants';
import { ModulePositionInput } from '@app/homeV3/template/types'; import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment } from '@graphql/template.generated'; import { PageModuleFragment } from '@graphql/template.generated';
@ -20,7 +21,17 @@ interface Props {
} }
export default function ModuleMenu({ module, position }: Props) { export default function ModuleMenu({ module, position }: Props) {
const { removeModule } = usePageTemplateContext(); const { type } = module.properties;
const canEdit = !DEFAULT_GLOBAL_MODULE_TYPES.includes(type);
const {
removeModule,
moduleModalState: { openToEdit },
} = usePageTemplateContext();
const handleEditModule = useCallback(() => {
openToEdit(type, module);
}, [module, openToEdit, type]);
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
removeModule({ removeModule({
@ -34,28 +45,27 @@ export default function ModuleMenu({ module, position }: Props) {
trigger={['click']} trigger={['click']}
menu={{ menu={{
items: [ items: [
{ ...(canEdit
title: 'Edit', ? [
key: 'edit', {
label: 'Edit', title: 'Edit',
onClick: () => { key: 'edit',
// TODO: Implement edit functionality label: 'Edit',
}, style: {
}, color: colors.gray[600],
{ fontSize: '14px',
title: 'Duplicate', },
label: 'Duplicate', onClick: handleEditModule,
key: 'duplicate', },
onClick: () => { ]
// TODO: Implement duplicate functionality : []),
},
},
{ {
title: 'Delete', title: 'Delete',
label: 'Delete', label: 'Delete',
key: 'delete', key: 'delete',
style: { style: {
color: colors.red[500], color: colors.red[500],
fontSize: '14px',
}, },
onClick: handleDelete, onClick: handleDelete,
}, },

View File

@ -10,9 +10,11 @@ import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// Mock the PageTemplateContext // Mock the PageTemplateContext
const mockRemoveModule = vi.fn(); const mockRemoveModule = vi.fn();
const mockOpenToEdit = vi.fn();
vi.mock('@app/homeV3/context/PageTemplateContext', () => ({ vi.mock('@app/homeV3/context/PageTemplateContext', () => ({
usePageTemplateContext: () => ({ usePageTemplateContext: () => ({
removeModule: mockRemoveModule, removeModule: mockRemoveModule,
moduleModalState: { openToEdit: mockOpenToEdit },
}), }),
})); }));
@ -24,6 +26,9 @@ vi.mock('@components', () => ({
</div> </div>
)), )),
colors: { colors: {
gray: {
600: '#4B5563',
},
red: { red: {
500: '#ef4444', 500: '#ef4444',
}, },
@ -60,7 +65,7 @@ describe('ModuleMenu', () => {
// Check that menu items are rendered // Check that menu items are rendered
expect(screen.getByText('Edit')).toBeInTheDocument(); expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Duplicate')).toBeInTheDocument(); // expect(screen.getByText('Duplicate')).toBeInTheDocument();
expect(screen.getByText('Delete')).toBeInTheDocument(); expect(screen.getByText('Delete')).toBeInTheDocument();
}); });
@ -234,7 +239,7 @@ describe('ModuleMenu', () => {
}); });
}); });
it('should handle edit and duplicate options (placeholder functionality)', () => { it('should handle edit option (placeholder functionality)', () => {
render(<ModuleMenu module={mockModule} position={mockPosition} />); render(<ModuleMenu module={mockModule} position={mockPosition} />);
// Click to open the dropdown // Click to open the dropdown
@ -243,14 +248,14 @@ describe('ModuleMenu', () => {
// Check that edit and duplicate options are present // Check that edit and duplicate options are present
const editButton = screen.getByText('Edit'); const editButton = screen.getByText('Edit');
const duplicateButton = screen.getByText('Duplicate'); // const duplicateButton = screen.getByText('Duplicate');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
expect(duplicateButton).toBeInTheDocument(); // expect(duplicateButton).toBeInTheDocument();
// Click edit and duplicate (should not throw errors) // Click edit and duplicate (should not throw errors)
fireEvent.click(editButton); fireEvent.click(editButton);
fireEvent.click(duplicateButton); // fireEvent.click(duplicateButton);
// These are placeholder implementations, so we just verify they don't crash // These are placeholder implementations, so we just verify they don't crash
expect(mockRemoveModule).not.toHaveBeenCalled(); expect(mockRemoveModule).not.toHaveBeenCalled();

View File

@ -0,0 +1,27 @@
import React, { useMemo } from 'react';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import AssetCollectionModal from '@app/homeV3/modules/assetCollection/AssetCollectionModal';
import { DataHubPageModuleType } from '@types';
export default function ModuleModalMapper() {
const {
moduleModalState: { moduleType },
} = usePageTemplateContext();
const ModuleModalComponent = useMemo(() => {
switch (moduleType) {
// TODO: add support of other module types
case DataHubPageModuleType.AssetCollection:
return AssetCollectionModal;
default:
return null;
}
}, [moduleType]);
if (moduleType === undefined) return null;
if (!ModuleModalComponent) return null;
return <ModuleModalComponent />;
}

View File

@ -0,0 +1,54 @@
import { Modal } from '@components';
import React from 'react';
import { ModalButton } from '@components/components/Modal/Modal';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
const modalStyles = {
maxWidth: '1000px',
minWidth: '800px',
maxHeight: '90%',
};
interface Props {
title: string;
subtitle?: string;
onUpsert: () => void;
}
export default function BaseModuleModal({ title, subtitle, children, onUpsert }: React.PropsWithChildren<Props>) {
const {
moduleModalState: { close, isOpen, isEditing },
} = usePageTemplateContext();
// Modal buttons configuration
const buttons: ModalButton[] = [
{
text: 'Cancel',
color: 'primary',
variant: 'text',
onClick: close,
},
{
text: `${isEditing ? 'Update' : 'Create'}`,
color: 'primary',
variant: 'filled',
onClick: onUpsert,
},
];
return (
<Modal
open={isOpen}
title={title}
subtitle={subtitle}
buttons={buttons}
onCancel={close}
maskClosable={false} // to avoid accidental clicks that closes the modal
style={modalStyles}
width="90%"
>
{children}
</Modal>
);
}

View File

@ -0,0 +1,34 @@
import { Input } from '@components';
import { Form, FormInstance } from 'antd';
import React from 'react';
import { FormValues } from '@app/homeV3/modules/assetCollection/types';
interface Props {
form: FormInstance;
formValues?: FormValues;
}
const ModuleDetailsForm = ({ form, formValues }: Props) => {
return (
<Form form={form} initialValues={formValues}>
<Form.Item
name="name"
rules={[
{
required: true,
message: 'Please enter the name',
},
]}
>
<Input label="Name" placeholder="Choose a name for your widget" isRequired />
</Form.Item>
{/* Should be used later, once support for description is added */}
{/* <Form.Item name="description">
<TextArea label="Description" placeholder="Help others understand what this collection contains..." />
</Form.Item> */}
</Form>
);
};
export default ModuleDetailsForm;

View File

@ -0,0 +1,63 @@
import { Form } from 'antd';
import React, { useState } from 'react';
import styled from 'styled-components';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import BaseModuleModal from '@app/homeV3/moduleModals/common/BaseModuleModal';
import ModuleDetailsForm from '@app/homeV3/moduleModals/common/ModuleDetailsForm';
import AssetsSection from '@app/homeV3/modules/assetCollection/AssetsSection';
import { DataHubPageModuleType } from '@types';
const ModalContent = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
const AssetCollectionModal = () => {
const {
upsertModule,
moduleModalState: { position, close, isEditing, initialState },
} = usePageTemplateContext();
const [form] = Form.useForm();
const currentName = initialState?.properties.name || '';
const currentAssets = (initialState?.properties?.params?.assetCollectionParams?.assetUrns || []).filter(
(urn): urn is string => typeof urn === 'string',
);
const urn = initialState?.urn;
const [selectedAssetUrns, setSelectedAssetUrns] = useState<string[]>(currentAssets);
const handleUpsertAssetCollectionModule = () => {
form.validateFields().then((values) => {
const { name } = values;
upsertModule({
urn,
name,
position: position ?? {},
type: DataHubPageModuleType.AssetCollection,
params: {
assetCollectionParams: {
assetUrns: selectedAssetUrns,
},
},
});
close();
});
};
return (
<BaseModuleModal
title={`${isEditing ? 'Edit' : 'Add'} Asset Collection`}
subtitle="Create a widget by selecting assets and information that will be shown to your users"
onUpsert={handleUpsertAssetCollectionModule}
>
<ModalContent>
<ModuleDetailsForm form={form} formValues={{ name: currentName }} />
<AssetsSection selectedAssetUrns={selectedAssetUrns} setSelectedAssetUrns={setSelectedAssetUrns} />
</ModalContent>
</BaseModuleModal>
);
};
export default AssetCollectionModal;

View File

@ -0,0 +1,36 @@
import React from 'react';
import EmptyContent from '@app/homeV3/module/components/EmptyContent';
import EntityItem from '@app/homeV3/module/components/EntityItem';
import LargeModule from '@app/homeV3/module/components/LargeModule';
import { ModuleProps } from '@app/homeV3/module/types';
import { useGetEntities } from '@app/sharedV2/useGetEntities';
import { Entity } from '@types';
const AssetCollectionModule = (props: ModuleProps) => {
const assetUrns =
props.module.properties.params.assetCollectionParams?.assetUrns.filter(
(urn): urn is string => typeof urn === 'string',
) || [];
const { entities, loading } = useGetEntities(assetUrns);
return (
<LargeModule {...props} loading={loading}>
{entities?.length === 0 ? (
<EmptyContent
icon="Stack"
title="No Assets"
description="Edit the module and add assets to see them in this list"
/>
) : (
entities
.filter((entity): entity is Entity => entity !== null)
.map((entity) => <EntityItem entity={entity} key={entity?.urn} />)
)}
</LargeModule>
);
};
export default AssetCollectionModule;

View File

@ -0,0 +1,29 @@
import React from 'react';
import styled from 'styled-components';
import { AppliedFieldFilterUpdater, FieldToAppliedFieldFiltersMap } from '@app/searchV2/filtersV2/types';
import Filters from '@app/searchV2/searchBarV2/components/Filters';
const FiltersContainer = styled.div`
margin: -8px;
`;
type Props = {
searchQuery: string | undefined;
appliedFilters?: FieldToAppliedFieldFiltersMap;
updateFieldFilters?: AppliedFieldFilterUpdater;
};
const AssetFilters = ({ searchQuery, appliedFilters, updateFieldFilters }: Props) => {
return (
<FiltersContainer>
<Filters
query={searchQuery ?? '*'}
appliedFilters={appliedFilters}
updateFieldAppliedFilters={updateFieldFilters}
/>
</FiltersContainer>
);
};
export default AssetFilters;

View File

@ -0,0 +1,53 @@
import { colors } from '@components';
import { Divider } from 'antd';
import React from 'react';
import styled from 'styled-components';
import SelectAssetsSection from '@app/homeV3/modules/assetCollection/SelectAssetsSection';
import SelectedAssetsSection from '@app/homeV3/modules/assetCollection/SelectedAssetsSection';
const Container = styled.div`
display: flex;
width: 100%;
gap: 16px;
`;
const LeftSection = styled.div`
flex: 6;
`;
const RightSection = styled.div`
flex: 4;
`;
export const VerticalDivider = styled(Divider)`
color: ${colors.gray[100]};
height: auto;
`;
type Props = {
selectedAssetUrns: string[];
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
};
const AssetsSection = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
return (
<Container>
<LeftSection>
<SelectAssetsSection
selectedAssetUrns={selectedAssetUrns}
setSelectedAssetUrns={setSelectedAssetUrns}
/>
</LeftSection>
<VerticalDivider type="vertical" />
<RightSection>
<SelectedAssetsSection
selectedAssetUrns={selectedAssetUrns}
setSelectedAssetUrns={setSelectedAssetUrns}
/>
</RightSection>
</Container>
);
};
export default AssetsSection;

View File

@ -0,0 +1,14 @@
import { Text } from '@components';
import React from 'react';
import { EmptyContainer } from '@app/homeV3/styledComponents';
const EmptySection = () => {
return (
<EmptyContainer>
<Text color="gray">No assets found.</Text>
</EmptyContainer>
);
};
export default EmptySection;

View File

@ -0,0 +1,100 @@
import { Checkbox, Loader, SearchBar, Text } from '@components';
import React, { useState } from 'react';
import styled from 'styled-components';
import EntityItem from '@app/homeV3/module/components/EntityItem';
import AssetFilters from '@app/homeV3/modules/assetCollection/AssetFilters';
import EmptySection from '@app/homeV3/modules/assetCollection/EmptySection';
import useGetAssetResults from '@app/homeV3/modules/assetCollection/useGetAssetResults';
import { LoaderContainer } from '@app/homeV3/styledComponents';
import { getEntityDisplayType } from '@app/searchV2/autoCompleteV2/utils';
import useAppliedFilters from '@app/searchV2/filtersV2/context/useAppliedFilters';
import { useEntityRegistryV2 } from '@app/useEntityRegistry';
import { Entity } from '@types';
const AssetsSection = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
`;
const ItemDetailsContainer = styled.div`
display: flex;
align-items: center;
`;
type Props = {
selectedAssetUrns: string[];
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
};
const SelectAssetsSection = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
const entityRegistry = useEntityRegistryV2();
const [searchQuery, setSearchQuery] = useState<string | undefined>();
const { appliedFilters, updateFieldFilters } = useAppliedFilters();
const { entities, loading } = useGetAssetResults({ searchQuery, appliedFilters });
const handleSearchChange = (value: string) => {
setSearchQuery(value);
};
const handleCheckboxChange = (urn: string) => {
setSelectedAssetUrns((prev) => (prev.includes(urn) ? prev.filter((u) => u !== urn) : [...prev, urn]));
};
const customDetailsRenderer = (entity: Entity) => {
const displayType = getEntityDisplayType(entity, entityRegistry);
return (
<ItemDetailsContainer>
<Text color="gray" size="sm">
{displayType}
</Text>
<Checkbox
size="xs"
isChecked={selectedAssetUrns?.includes(entity.urn)}
onCheckboxChange={() => handleCheckboxChange(entity.urn)}
/>
</ItemDetailsContainer>
);
};
let content;
if (loading) {
content = (
<LoaderContainer>
<Loader />
</LoaderContainer>
);
} else if (entities && entities.length > 0) {
content = entities?.map((entity) => (
<EntityItem
entity={entity}
key={entity.urn}
customDetailsRenderer={customDetailsRenderer}
navigateOnlyOnNameClick
/>
));
} else {
content = <EmptySection />;
}
return (
<AssetsSection>
<Text color="gray" weight="bold">
Search and Select Assets
</Text>
<SearchBar value={searchQuery} onChange={handleSearchChange} />
<AssetFilters
searchQuery={searchQuery}
appliedFilters={appliedFilters}
updateFieldFilters={updateFieldFilters}
/>
{content}
</AssetsSection>
);
};
export default SelectAssetsSection;

View File

@ -0,0 +1,74 @@
import { Text } from '@components';
import React from 'react';
import styled from 'styled-components';
import EntityItem from '@app/homeV3/module/components/EntityItem';
import { EmptyContainer, StyledIcon } from '@app/homeV3/styledComponents';
import { useGetEntities } from '@app/sharedV2/useGetEntities';
import { Entity } from '@types';
const SelectedAssetsContainer = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
`;
type Props = {
selectedAssetUrns: string[];
setSelectedAssetUrns: React.Dispatch<React.SetStateAction<string[]>>;
};
const SelectedAssetsSection = ({ selectedAssetUrns, setSelectedAssetUrns }: Props) => {
const { entities, loading } = useGetEntities(selectedAssetUrns);
const handleRemoveAsset = (entity: Entity) => {
const newUrns = selectedAssetUrns.filter((urn) => !(entity.urn === urn));
setSelectedAssetUrns(newUrns);
};
const renderRemoveAsset = (entity: Entity) => {
return (
<StyledIcon
icon="X"
source="phosphor"
color="gray"
size="md"
onClick={(e) => {
e.preventDefault();
handleRemoveAsset(entity);
}}
/>
);
};
let content;
if (entities && entities.length > 0) {
content = entities.map((entity) => (
<EntityItem
entity={entity}
key={entity.urn}
customDetailsRenderer={renderRemoveAsset}
navigateOnlyOnNameClick
/>
));
} else if (!loading && entities.length === 0) {
content = (
<EmptyContainer>
<Text color="gray">No assets selected.</Text>
</EmptyContainer>
);
}
return (
<SelectedAssetsContainer>
<Text color="gray" weight="bold">
Selected Assets
</Text>
{content}
</SelectedAssetsContainer>
);
};
export default SelectedAssetsSection;

View File

@ -0,0 +1,175 @@
import { renderHook } from '@testing-library/react-hooks';
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import useGetAssetResults from '@app/homeV3/modules/assetCollection/useGetAssetResults';
import { convertFiltersMapToFilters } from '@app/searchV2/filtersV2/utils';
import { UnionType } from '@app/searchV2/utils/constants';
import { generateOrFilters } from '@app/searchV2/utils/generateOrFilters';
import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
// Mock dependencies
vi.mock('@app/searchV2/filtersV2/utils', () => ({
convertFiltersMapToFilters: vi.fn(),
}));
vi.mock('@app/searchV2/utils/generateOrFilters', () => ({
generateOrFilters: vi.fn(),
}));
vi.mock('@graphql/search.generated', () => ({
useGetSearchResultsForMultipleQuery: vi.fn(),
}));
describe('useGetAssetResults', () => {
const mockAppliedFilters = new Map();
const mockFilters = [{ field: 'testField', value: 'testValue' }];
const mockOrFilters = [{ or: 'filters' }];
const mockData = {
searchAcrossEntities: {
searchResults: [{ entity: { urn: 'urn:li:entity:1' } }, { entity: { urn: 'urn:li:entity:2' } }],
},
};
const convertFiltersMapToFiltersMock = convertFiltersMapToFilters as unknown as Mock;
const generateOrFiltersMock = generateOrFilters as unknown as Mock;
const useGetSearchResultsForMultipleQueryMock = useGetSearchResultsForMultipleQuery as unknown as Mock;
beforeEach(() => {
vi.clearAllMocks();
convertFiltersMapToFiltersMock.mockReturnValue(mockFilters);
generateOrFiltersMock.mockReturnValue(mockOrFilters);
useGetSearchResultsForMultipleQueryMock.mockReturnValue({ data: mockData, loading: false });
});
it('should call convertFiltersMapToFilters with appliedFilters', () => {
renderHook(() => useGetAssetResults({ searchQuery: 'query', appliedFilters: mockAppliedFilters }));
expect(convertFiltersMapToFiltersMock).toHaveBeenCalledWith(mockAppliedFilters);
});
it('should call generateOrFilters with UnionType.AND and filters', () => {
renderHook(() => useGetAssetResults({ searchQuery: 'query', appliedFilters: mockAppliedFilters }));
expect(generateOrFiltersMock).toHaveBeenCalledWith(UnionType.AND, mockFilters);
});
it('should call useGetSearchResultsForMultipleQuery with correct variables', () => {
const searchQuery = 'query';
renderHook(() => useGetAssetResults({ searchQuery, appliedFilters: mockAppliedFilters }));
expect(useGetSearchResultsForMultipleQueryMock).toHaveBeenCalledWith({
variables: {
input: {
query: searchQuery,
start: 0,
count: 10,
orFilters: mockOrFilters,
searchFlags: { skipCache: true },
},
},
});
});
it('should use * as query if searchQuery is undefined', () => {
renderHook(() => useGetAssetResults({ searchQuery: undefined, appliedFilters: mockAppliedFilters }));
expect(useGetSearchResultsForMultipleQueryMock).toHaveBeenCalledWith(
expect.objectContaining({
variables: expect.objectContaining({ input: expect.objectContaining({ query: '*' }) }),
}),
);
});
it('should return entities from data', () => {
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'query', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual(mockData.searchAcrossEntities.searchResults.map((res) => res.entity));
});
it('should return empty array if data is undefined', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({ data: undefined, loading: false });
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'query', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual([]);
});
it('should return empty array if searchAcrossEntities is missing', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({ data: {}, loading: false });
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'foo', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual([]);
});
it('should return empty array if searchResults is missing', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({ data: { searchAcrossEntities: {} }, loading: false });
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'foo', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual([]);
});
it('should use loading from useGetSearchResultsForMultipleQuery', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({ data: mockData, loading: true });
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'bar', appliedFilters: mockAppliedFilters }),
);
expect(result.current.loading).toBe(true);
});
it('should handle empty appliedFilters', () => {
convertFiltersMapToFiltersMock.mockReturnValue([]);
renderHook(() => useGetAssetResults({ searchQuery: 'foo', appliedFilters: new Map() }));
expect(generateOrFiltersMock).toHaveBeenCalledWith(UnionType.AND, []);
});
it('should handle appliedFilters with multiple fields', () => {
const multiFilters = [
{ field: 'testField', value: 'testValue' },
{ field: 'anotherField', value: 'anotherValue' },
];
convertFiltersMapToFiltersMock.mockReturnValue(multiFilters);
renderHook(() => useGetAssetResults({ searchQuery: 'foo', appliedFilters: new Map([['k', { filters: [] }]]) }));
expect(generateOrFiltersMock).toHaveBeenCalledWith(UnionType.AND, multiFilters);
});
it('should pass through searchFlags.skipCache', () => {
renderHook(() => useGetAssetResults({ searchQuery: 'baz', appliedFilters: mockAppliedFilters }));
const call = useGetSearchResultsForMultipleQueryMock.mock.calls[0][0];
expect(call.variables.input.searchFlags.skipCache).toBe(true);
});
it('should memoize filter conversion results (filters object)', () => {
// Set up for checking memoization via rerender
const { rerender } = renderHook(
({ searchQuery, appliedFilters }) => useGetAssetResults({ searchQuery, appliedFilters }),
{
initialProps: { searchQuery: 'foo', appliedFilters: mockAppliedFilters },
},
);
rerender({ searchQuery: 'foo', appliedFilters: mockAppliedFilters });
// convertFiltersMapToFilters should not be called additional times (React useMemo prevents it)
expect(convertFiltersMapToFiltersMock).toHaveBeenCalledTimes(1);
});
it('should handle when searchResults is not an array (null)', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({
data: { searchAcrossEntities: { searchResults: null } },
loading: false,
});
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'result', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual([]);
});
it('should use empty array for entities if searchResults is empty', () => {
useGetSearchResultsForMultipleQueryMock.mockReturnValue({
data: { searchAcrossEntities: { searchResults: [] } },
loading: false,
});
const { result } = renderHook(() =>
useGetAssetResults({ searchQuery: 'result', appliedFilters: mockAppliedFilters }),
);
expect(result.current.entities).toEqual([]);
});
});

View File

@ -0,0 +1,4 @@
export type FormValues = {
name: string;
description?: string;
};

View File

@ -0,0 +1,39 @@
import { useMemo } from 'react';
import { FieldToAppliedFieldFiltersMap } from '@app/searchV2/filtersV2/types';
import { convertFiltersMapToFilters } from '@app/searchV2/filtersV2/utils';
import { UnionType } from '@app/searchV2/utils/constants';
import { generateOrFilters } from '@app/searchV2/utils/generateOrFilters';
import { useGetSearchResultsForMultipleQuery } from '@graphql/search.generated';
interface Props {
searchQuery: string | undefined;
appliedFilters?: FieldToAppliedFieldFiltersMap;
}
export default function useGetAssetResults({ searchQuery, appliedFilters }: Props) {
const filters = useMemo(() => convertFiltersMapToFilters(appliedFilters), [appliedFilters]);
const orFilters = generateOrFilters(UnionType.AND, filters);
const { data, loading } = useGetSearchResultsForMultipleQuery({
variables: {
input: {
query: searchQuery || '*',
start: 0,
count: 10,
orFilters,
searchFlags: {
skipCache: true,
},
},
},
});
const entities = data?.searchAcrossEntities?.searchResults?.map((res) => res.entity) || [];
return {
entities,
loading,
};
}

View File

@ -49,16 +49,24 @@ export const DEFAULT_MODULE_LINK: ModuleInfo = {
key: 'default_module_quick_link', key: 'default_module_quick_link',
}; };
export const CUSTOM_LARGE_MODULE_ASSET_COLLECTION: ModuleInfo = {
type: DataHubPageModuleType.AssetCollection,
name: 'Asset Collection',
description: MODULE_TYPE_TO_DESCRIPTION.get(DataHubPageModuleType.AssetCollection),
icon: MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.AssetCollection) ?? DEFAULT_MODULE_ICON,
key: 'custom_large_module_asset_collection',
};
export const DEFAULT_MODULES: ModuleInfo[] = [ export const DEFAULT_MODULES: ModuleInfo[] = [
DEFAULT_MODULE_YOUR_ASSETS, DEFAULT_MODULE_YOUR_ASSETS,
DEFAULT_MODULE_TOP_DOMAINS, DEFAULT_MODULE_TOP_DOMAINS,
CUSTOM_LARGE_MODULE_ASSET_COLLECTION,
// Links isn't supported yet // Links isn't supported yet
// DEFAULT_MODULE_LINK, // DEFAULT_MODULE_LINK,
]; ];
export const ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES: DataHubPageModuleType[] = [ export const ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.Link, DataHubPageModuleType.Link,
DataHubPageModuleType.AssetCollection,
DataHubPageModuleType.RichText, DataHubPageModuleType.RichText,
DataHubPageModuleType.Hierarchy, DataHubPageModuleType.Hierarchy,
]; ];
@ -66,4 +74,10 @@ export const ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES: DataHubPageModuleType[
export const ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES: DataHubPageModuleType[] = [ export const ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.Domains, DataHubPageModuleType.Domains,
DataHubPageModuleType.OwnedAssets, DataHubPageModuleType.OwnedAssets,
DataHubPageModuleType.AssetCollection,
];
export const DEFAULT_GLOBAL_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.OwnedAssets,
DataHubPageModuleType.Domains,
]; ];

View File

@ -59,3 +59,17 @@ export const StyledIcon = styled(Icon)`
cursor: pointer; cursor: pointer;
} }
`; `;
export const LoaderContainer = styled.div`
display: flex;
height: 100%;
min-height: 200px;
`;
export const EmptyContainer = styled.div`
display: flex;
height: 50%;
width: 100%;
justify-content: center;
align-items: center;
`;

View File

@ -3,11 +3,14 @@ import React, { useMemo } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext'; import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import ModuleModalMapper from '@app/homeV3/moduleModals/ModuleModalMapper';
import useModulesAvailableToAdd from '@app/homeV3/modules/hooks/useModulesAvailableToAdd'; import useModulesAvailableToAdd from '@app/homeV3/modules/hooks/useModulesAvailableToAdd';
import AddModuleButton from '@app/homeV3/template/components/AddModuleButton'; import AddModuleButton from '@app/homeV3/template/components/AddModuleButton';
import TemplateRow from '@app/homeV3/templateRow/TemplateRow'; import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
import { wrapRows } from '@app/homeV3/templateRow/utils'; import { wrapRows } from '@app/homeV3/templateRow/utils';
import { DataHubPageTemplateRow } from '@types';
const Wrapper = styled.div` const Wrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -25,7 +28,10 @@ interface Props {
export default function Template({ className }: Props) { export default function Template({ className }: Props) {
const { template } = usePageTemplateContext(); const { template } = usePageTemplateContext();
const rows = useMemo(() => template?.properties?.rows ?? [], [template?.properties?.rows]); const rows = useMemo(
() => (template?.properties?.rows ?? []) as DataHubPageTemplateRow[],
[template?.properties?.rows],
);
const hasRows = useMemo(() => !!rows.length, [rows.length]); const hasRows = useMemo(() => !!rows.length, [rows.length]);
const wrappedRows = useMemo(() => wrapRows(rows), [rows]); const wrappedRows = useMemo(() => wrapRows(rows), [rows]);
@ -42,6 +48,7 @@ export default function Template({ className }: Props) {
$hasRows={hasRows} $hasRows={hasRows}
modulesAvailableToAdd={modulesAvailableToAdd} modulesAvailableToAdd={modulesAvailableToAdd}
/> />
<ModuleModalMapper />
</Wrapper> </Wrapper>
); );
} }

View File

@ -61,7 +61,9 @@ export default function AddModuleButton({ orientation, modulesAvailableToAdd, cl
rowSide, rowSide,
}; };
const menu = useAddModuleMenu(modulesAvailableToAdd, position, () => setIsOpened(false)); const closeMenu = () => setIsOpened(false);
const menu = useAddModuleMenu(modulesAvailableToAdd, position, closeMenu);
const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// FYI: Antd can open dropdown in the cursor's position only for contextMenu trigger // FYI: Antd can open dropdown in the cursor's position only for contextMenu trigger

View File

@ -11,9 +11,16 @@ import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// Mock the PageTemplateContext // Mock the PageTemplateContext
const mockAddModule = vi.fn(); const mockAddModule = vi.fn();
const mockOpenModal = vi.fn();
vi.mock('@app/homeV3/context/PageTemplateContext', () => ({ vi.mock('@app/homeV3/context/PageTemplateContext', () => ({
usePageTemplateContext: () => ({ usePageTemplateContext: () => ({
addModule: mockAddModule, addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
}), }),
})); }));
@ -58,6 +65,11 @@ describe('useAddModuleMenu', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
function getChildren(item: any): any[] {
if (item && 'children' in item && Array.isArray(item.children)) return item.children;
return [];
}
it('should return menu items with hardcoded custom modules', () => { it('should return menu items with hardcoded custom modules', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useAddModuleMenu( useAddModuleMenu(
@ -86,7 +98,7 @@ describe('useAddModuleMenu', () => {
// Check Custom Large group // Check Custom Large group
expect(items?.[1]).toHaveProperty('key', 'customLargeModulesGroup'); expect(items?.[1]).toHaveProperty('key', 'customLargeModulesGroup');
// @ts-expect-error SubMenuItem should have children // @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children).toHaveLength(2); expect(items?.[1]?.children).toHaveLength(3);
// @ts-expect-error SubMenuItem should have children // @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[0]).toHaveProperty('key', 'your-assets'); expect(items?.[1]?.children?.[0]).toHaveProperty('key', 'your-assets');
// @ts-expect-error SubMenuItem should have children // @ts-expect-error SubMenuItem should have children
@ -168,4 +180,40 @@ describe('useAddModuleMenu', () => {
expect(adminGroup).toHaveProperty('expandIcon'); expect(adminGroup).toHaveProperty('expandIcon');
expect(adminGroup).toHaveProperty('popupClassName'); expect(adminGroup).toHaveProperty('popupClassName');
}); });
it('should open module modal when Asset Collection is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
// Third child is Asset Collection
const assetCollectionItem = customLargeChildren[2];
assetCollectionItem.onClick?.({} as any);
expect(mockOpenModal).toHaveBeenCalledWith(DataHubPageModuleType.AssetCollection, mockPosition);
expect(mockCloseMenu).toHaveBeenCalled();
});
it('should not call addModule when Asset Collection is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
const assetCollectionItem = customLargeChildren[2];
assetCollectionItem.onClick?.({} as any);
expect(mockAddModule).not.toHaveBeenCalledWith(
expect.objectContaining({
module: expect.objectContaining({
urn: 'urn:li:dataHubPageModule:asset-collection',
}),
}),
mockPosition,
);
});
it('should not open modal when Your Assets is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
const yourAssetsItem = customLargeChildren[0];
yourAssetsItem.onClick?.({} as any);
expect(mockOpenModal).not.toHaveBeenCalled();
});
}); });

View File

@ -40,8 +40,11 @@ export default function useAddModuleMenu(
modulesAvailableToAdd: ModulesAvailableToAdd, modulesAvailableToAdd: ModulesAvailableToAdd,
position: ModulePositionInput, position: ModulePositionInput,
closeMenu: () => void, closeMenu: () => void,
): MenuProps { ) {
const { addModule } = usePageTemplateContext(); const {
addModule,
moduleModalState: { open: openModal },
} = usePageTemplateContext();
const handleAddExistingModule = useCallback( const handleAddExistingModule = useCallback(
(module: PageModuleFragment) => { (module: PageModuleFragment) => {
@ -54,20 +57,15 @@ export default function useAddModuleMenu(
[addModule, position, closeMenu], [addModule, position, closeMenu],
); );
// TODO: use this commented out code later once we implement creating new modules const handleOpenCreateModuleModal = useCallback(
// const handleCreateNewModule = useCallback( (type: DataHubPageModuleType) => {
// (type: DataHubPageModuleType, name: string) => { openModal(type, position);
// createModule({ closeMenu();
// name, },
// type, [openModal, position, closeMenu],
// position, );
// });
// closeMenu();
// },
// [createModule, position, closeMenu],
// );
return useMemo(() => { const menu = useMemo(() => {
const items: MenuProps['items'] = []; const items: MenuProps['items'] = [];
const quickLink = { const quickLink = {
@ -113,11 +111,26 @@ export default function useAddModuleMenu(
}, },
}; };
const assetCollection = {
title: 'Asset Collection',
key: 'asset-collection',
label: (
<MenuItem
description="A curated list of assets of your choosing"
title="Asset Collection"
icon="Stack"
/>
),
onClick: () => {
handleOpenCreateModuleModal(DataHubPageModuleType.AssetCollection);
},
};
items.push({ items.push({
key: 'customLargeModulesGroup', key: 'customLargeModulesGroup',
label: <GroupItem title="Custom Large" />, label: <GroupItem title="Custom Large" />,
type: 'group', type: 'group',
children: [yourAssets, domains], children: [yourAssets, domains, assetCollection],
}); });
// Add admin created modules if available // Add admin created modules if available
@ -147,5 +160,7 @@ export default function useAddModuleMenu(
} }
return { items }; return { items };
}, [modulesAvailableToAdd, handleAddExistingModule]); }, [modulesAvailableToAdd.adminCreatedModules, handleAddExistingModule, handleOpenCreateModuleModal]);
return menu;
} }

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom';
import styled, { useTheme } from 'styled-components'; import styled, { useTheme } from 'styled-components';
import { getColor } from '@components/theme/utils'; import { getColor } from '@components/theme/utils';
@ -15,26 +16,43 @@ import { Text } from '@src/alchemy-components';
import { useEntityRegistryV2 } from '@src/app/useEntityRegistry'; import { useEntityRegistryV2 } from '@src/app/useEntityRegistry';
import { Entity, MatchedField } from '@src/types.generated'; import { Entity, MatchedField } from '@src/types.generated';
const Container = styled.div` const Container = styled.div<{
$navigateOnlyOnNameClick?: boolean;
}>`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding: 8px 13px 8px 8px; padding: 8px 13px 8px 8px;
:hover { ${(props) =>
cursor: pointer; !props.$navigateOnlyOnNameClick &&
} `
:hover {
cursor: pointer;
}
`}
`; `;
// FYI: this hovering dependent on Container can't be applied by condition inside of styled component // FYI: this hovering dependent on Container can't be applied by condition inside of styled component
// so we have this separated version with hover // so we have this separated version with hover
const DisplayNameWithHover = styled(DisplayName)<{ $decorationColor?: string }>`
// On container hover
const DisplayNameHoverFromContainer = styled(DisplayName)<{ $decorationColor?: string }>`
${Container}:hover & { ${Container}:hover & {
text-decoration: underline; text-decoration: underline;
${(props) => props.$decorationColor && `text-decoration-color: ${props.$decorationColor};`} ${(props) => props.$decorationColor && `text-decoration-color: ${props.$decorationColor};`}
} }
`; `;
// On self (name) hover only
const DisplayNameHoverFromSelf = styled(DisplayName)<{ $decorationColor?: string }>`
&:hover {
text-decoration: underline;
cursor: pointer;
${(props) => props.$decorationColor && `text-decoration-color: ${props.$decorationColor};`}
}
`;
const DisplayNameWrapper = styled.div` const DisplayNameWrapper = styled.div`
white-space: nowrap; white-space: nowrap;
`; `;
@ -80,6 +98,7 @@ interface EntityAutocompleteItemProps {
matchedFields?: MatchedField[]; matchedFields?: MatchedField[];
variant?: EntityItemVariant; variant?: EntityItemVariant;
customDetailsRenderer?: (entity: Entity) => void; customDetailsRenderer?: (entity: Entity) => void;
navigateOnlyOnNameClick?: boolean;
} }
export default function AutoCompleteEntityItem({ export default function AutoCompleteEntityItem({
@ -89,6 +108,7 @@ export default function AutoCompleteEntityItem({
matchedFields, matchedFields,
variant, variant,
customDetailsRenderer, customDetailsRenderer,
navigateOnlyOnNameClick,
}: EntityAutocompleteItemProps) { }: EntityAutocompleteItemProps) {
const theme = useTheme(); const theme = useTheme();
const entityRegistry = useEntityRegistryV2(); const entityRegistry = useEntityRegistryV2();
@ -96,8 +116,34 @@ export default function AutoCompleteEntityItem({
const displayType = getEntityDisplayType(entity, entityRegistry); const displayType = getEntityDisplayType(entity, entityRegistry);
const variantProps = VARIANT_STYLES.get(variant ?? 'default'); const variantProps = VARIANT_STYLES.get(variant ?? 'default');
const DisplayNameHoverComponent = navigateOnlyOnNameClick
? DisplayNameHoverFromSelf
: DisplayNameHoverFromContainer;
const displayNameContent = variantProps?.nameCanBeHovered ? (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)}>
<DisplayNameHoverComponent
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
$decorationColor={getColor(variantProps?.nameColor, variantProps?.nameColorLevel, theme)}
/>
</Link>
) : (
<DisplayName
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
showNameTooltipIfTruncated
/>
);
return ( return (
<Container> <Container $navigateOnlyOnNameClick={navigateOnlyOnNameClick}>
<ContentContainer> <ContentContainer>
<IconContainer $variant={variant}> <IconContainer $variant={variant}>
<EntityIcon entity={entity} siblings={siblings} /> <EntityIcon entity={entity} siblings={siblings} />
@ -110,31 +156,7 @@ export default function AutoCompleteEntityItem({
showArrow={false} showArrow={false}
canOpen={variantProps?.showEntityPopover} canOpen={variantProps?.showEntityPopover}
> >
<DisplayNameWrapper> <DisplayNameWrapper>{displayNameContent}</DisplayNameWrapper>
{variantProps?.nameCanBeHovered ? (
<DisplayNameWithHover
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
$decorationColor={getColor(
variantProps?.nameColor,
variantProps?.nameColorLevel,
theme,
)}
/>
) : (
<DisplayName
displayName={displayName}
highlight={query}
color={variantProps?.nameColor}
colorLevel={variantProps?.nameColorLevel}
weight={variantProps?.nameWeight}
showNameTooltipIfTruncated
/>
)}
</DisplayNameWrapper>
</HoverEntityTooltip> </HoverEntityTooltip>
<EntitySubtitle <EntitySubtitle

View File

@ -19,7 +19,7 @@ import {
} from '@app/searchV2/utils/constants'; } from '@app/searchV2/utils/constants';
import { FacetMetadata } from '@src/types.generated'; import { FacetMetadata } from '@src/types.generated';
const FILTER_FIELDS = [ export const FILTER_FIELDS = [
PLATFORM_FILTER_NAME, PLATFORM_FILTER_NAME,
ENTITY_SUB_TYPE_FILTER_NAME, ENTITY_SUB_TYPE_FILTER_NAME,
OWNERS_FILTER_NAME, OWNERS_FILTER_NAME,

View File

@ -0,0 +1,120 @@
import { renderHook } from '@testing-library/react-hooks';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useGetEntities } from '@app/sharedV2/useGetEntities';
import { useGetEntitiesQuery } from '@graphql/entity.generated';
import { type Entity, EntityType } from '@types';
// Mocking the query hook
vi.mock('@graphql/entity.generated', () => ({
useGetEntitiesQuery: vi.fn(),
}));
const useGetEntitiesQueryMock = useGetEntitiesQuery as unknown as ReturnType<typeof vi.fn>;
const VALID_URNS = ['urn:li:dataset:123', 'urn:li:chart:456'];
const MIXED_URNS = ['notAUrn', 'urn:li:dataset:1', 'foo', 'urn:li:chart:2'];
const INVALID_URNS = ['foo', 'bar', 'notUrn'];
const MOCK_ENTITIES: Entity[] = [
{ urn: 'urn:li:dataset:123', type: EntityType.Dataset },
{ urn: 'urn:li:chart:456', type: EntityType.Chart },
];
const PARTIAL_ENTITIES: Entity[] = [{ urn: 'urn:li:chart:2', type: EntityType.Chart }];
describe('useGetEntities', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should filter for valid urns and call useGetEntitiesQuery', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: MOCK_ENTITIES }, loading: false });
renderHook(() => useGetEntities([...VALID_URNS]));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: VALID_URNS },
skip: false,
});
});
it('should filter out invalid urns', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: PARTIAL_ENTITIES }, loading: false });
renderHook(() => useGetEntities([...MIXED_URNS]));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: ['urn:li:dataset:1', 'urn:li:chart:2'] },
skip: false,
});
});
it('should skip query if no valid urns are provided', () => {
renderHook(() => useGetEntities([...INVALID_URNS]));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: [] },
skip: true,
});
});
it('should skip query if urns array is empty', () => {
renderHook(() => useGetEntities([]));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: [] },
skip: true,
});
});
it('should return loading=true when query is loading', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: undefined, loading: true });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(result.current.loading).toBe(true);
expect(result.current.entities).toEqual([]);
});
it('should return entities from data', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: MOCK_ENTITIES }, loading: false });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(result.current.entities).toEqual(MOCK_ENTITIES);
expect(result.current.loading).toBe(false);
});
it('should return an empty array of entities if data is undefined', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: undefined, loading: false });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(result.current.entities).toEqual([]);
});
it('should return an empty array if data.entities is missing', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: {}, loading: false });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(result.current.entities).toEqual([]);
});
it('should return an empty array if data.entities is null', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: null }, loading: false });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(result.current.entities).toEqual([]);
});
it('should default to empty array if data.entities is not an array', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: 123 }, loading: false });
const { result } = renderHook(() => useGetEntities([...VALID_URNS]));
expect(Array.isArray(result.current.entities)).toBe(true);
expect(result.current.entities.length).toBe(0);
});
it('should call with skip: true if all urns are filtered out (not matching urn:li:)', () => {
renderHook(() => useGetEntities(['foo', 'bar']));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: [] },
skip: true,
});
});
it('should handle mixed scenarios: valid, invalid, undefined', () => {
useGetEntitiesQueryMock.mockReturnValue({ data: { entities: PARTIAL_ENTITIES }, loading: false });
renderHook(() => useGetEntities(['urn:li:chart:2', undefined as any, 'bad', null as any]));
expect(useGetEntitiesQueryMock).toHaveBeenCalledWith({
variables: { urns: ['urn:li:chart:2'] },
skip: false,
});
});
});

View File

@ -13,7 +13,7 @@ export function usePropagationDetails(sourceDetail?: StringMapEntry[] | null) {
const originEntityUrn = sourceDetail?.find((mapEntry) => mapEntry.key === 'origin')?.value || ''; const originEntityUrn = sourceDetail?.find((mapEntry) => mapEntry.key === 'origin')?.value || '';
const viaEntityUrn = sourceDetail?.find((mapEntry) => mapEntry.key === 'via')?.value || ''; const viaEntityUrn = sourceDetail?.find((mapEntry) => mapEntry.key === 'via')?.value || '';
const entities = useGetEntities([originEntityUrn, viaEntityUrn]); const { entities } = useGetEntities([originEntityUrn, viaEntityUrn]);
const originEntity = entities.find((e) => e.urn === originEntityUrn); const originEntity = entities.find((e) => e.urn === originEntityUrn);
const viaEntity = entities.find((e) => e.urn === viaEntityUrn); const viaEntity = entities.find((e) => e.urn === viaEntityUrn);

View File

@ -1,19 +1,13 @@
import { useEffect, useState } from 'react';
import { useGetEntitiesQuery } from '@graphql/entity.generated'; import { useGetEntitiesQuery } from '@graphql/entity.generated';
import { Entity } from '@types'; import { Entity } from '@types';
export function useGetEntities(urns: string[]): Entity[] { export function useGetEntities(urns: string[]): {
const [verifiedUrns, setVerifiedUrns] = useState<string[]>([]); entities: Entity[];
loading: boolean;
} {
const verifiedUrns = urns.filter((urn) => typeof urn === 'string' && urn.startsWith('urn:li:'));
useEffect(() => { const { data, loading } = useGetEntitiesQuery({ variables: { urns: verifiedUrns }, skip: !verifiedUrns.length });
urns.forEach((urn) => { const entities: Entity[] = Array.isArray(data?.entities) ? (data?.entities.filter(Boolean) as Entity[]) : [];
if (urn.startsWith('urn:li:') && !verifiedUrns.includes(urn)) { return { entities, loading };
setVerifiedUrns((prevUrns) => [...prevUrns, urn]);
}
});
}, [urns, verifiedUrns]);
const { data } = useGetEntitiesQuery({ variables: { urns: verifiedUrns }, skip: !verifiedUrns.length });
return (data?.entities || []) as Entity[];
} }

View File

@ -33,6 +33,9 @@ fragment PageModule on DataHubPageModule {
richTextParams { richTextParams {
content content
} }
assetCollectionParams {
assetUrns
}
} }
} }
} }

View File

@ -0,0 +1,10 @@
namespace com.linkedin.module
import com.linkedin.common.Urn
/**
* The params required if the module is type ASSET_COLLECTION
*/
record AssetCollectionModuleParams {
assetUrns: array[Urn]
}

View File

@ -19,4 +19,8 @@ record DataHubPageModuleParams {
richTextParams: optional record RichTextModuleParams { richTextParams: optional record RichTextModuleParams {
content: string content: string
} }
/**
* The params required if the module is type ASSET_COLLECTION
*/
assetCollectionParams: optional AssetCollectionModuleParams
} }