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 com.linkedin.common.UrnArray;
import com.linkedin.common.urn.Urn;
import com.linkedin.common.urn.UrnUtils;
import com.linkedin.datahub.graphql.QueryContext;
@ -16,7 +17,9 @@ import com.linkedin.metadata.service.PageModuleService;
import com.linkedin.module.DataHubPageModuleParams;
import graphql.schema.DataFetcher;
import graphql.schema.DataFetchingEnvironment;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -91,6 +94,20 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
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;
}
@ -106,6 +123,11 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
if (params.getLinkParams() == null) {
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 {
// TODO: add more blocks to this check as we support creating more types of modules to this
// resolver

View File

@ -1,12 +1,16 @@
package com.linkedin.datahub.graphql.types.module;
import com.linkedin.common.urn.Urn;
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.LinkModuleParams;
import com.linkedin.datahub.graphql.generated.Post;
import com.linkedin.datahub.graphql.generated.RichTextModuleParams;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.module.DataHubPageModuleParams;
import java.util.List;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@ -44,6 +48,21 @@ public class PageModuleParamsMapper
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;
}
}

View File

@ -78,6 +78,11 @@ input PageModuleParamsInput {
The params required if the module is type RICH_TEXT
"""
richTextParams: RichTextModuleParamsInput
"""
The params required if the module is type ASSET_COLLECTION
"""
assetCollectionParams: AssetCollectionModuleParamsInput
}
"""
@ -100,6 +105,16 @@ input RichTextModuleParamsInput {
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
"""
@ -202,6 +217,11 @@ type DataHubPageModuleParams {
The params required if the module is type RICH_TEXT
"""
richTextParams: RichTextModuleParams
"""
The params required if the module is type ASSET_COLLECTION
"""
assetCollectionParams: AssetCollectionModuleParams
}
"""
@ -224,6 +244,16 @@ type RichTextModuleParams {
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
"""

View File

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

View File

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

View File

@ -1,5 +1,6 @@
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 { useTemplateOperations } from '@app/homeV3/context/hooks/useTemplateOperations';
import { useTemplateState } from '@app/homeV3/context/hooks/useTemplateState';
@ -33,8 +34,11 @@ export const PageTemplateProvider = ({
// Template operations
const { updateTemplateWithModule, removeModuleFromTemplate, upsertTemplate } = useTemplateOperations();
// Modal state
const moduleModalState = useModuleModalState();
// Module operations
const { addModule, removeModule, createModule } = useModuleOperations(
const { addModule, removeModule, upsertModule } = useModuleOperations(
isEditingGlobalTemplate,
personalTemplate,
globalTemplate,
@ -43,6 +47,7 @@ export const PageTemplateProvider = ({
updateTemplateWithModule,
removeModuleFromTemplate,
upsertTemplate,
moduleModalState.isEditing,
);
const value = useMemo(
@ -57,7 +62,8 @@ export const PageTemplateProvider = ({
setTemplate,
addModule,
removeModule,
createModule,
upsertModule,
moduleModalState,
}),
[
personalTemplate,
@ -70,7 +76,8 @@ export const PageTemplateProvider = ({
setTemplate,
addModule,
removeModule,
createModule,
upsertModule,
moduleModalState,
],
);
@ -86,4 +93,4 @@ export function usePageTemplateContext() {
}
// 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 mockAddModule = vi.fn();
const mockRemoveModule = vi.fn();
const mockCreateModule = vi.fn();
const mockUpsertModule = vi.fn();
const mockUpdateTemplateWithModule = vi.fn();
const mockRemoveModuleFromTemplate = vi.fn();
const mockUpsertTemplate = vi.fn();
@ -117,7 +117,7 @@ describe('PageTemplateContext', () => {
mockUseModuleOperations.mockReturnValue({
addModule: mockAddModule,
removeModule: mockRemoveModule,
createModule: mockCreateModule,
upsertModule: mockUpsertModule,
});
});
@ -183,6 +183,7 @@ describe('PageTemplateContext', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
);
});
@ -281,6 +282,7 @@ describe('PageTemplateContext', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
);
});
@ -328,7 +330,13 @@ describe('PageTemplateContext', () => {
expect(result.current.setGlobalTemplate).toBe(mockSetGlobalTemplate);
expect(result.current.setTemplate).toBe(mockSetTemplate);
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', () => {
@ -372,7 +380,7 @@ describe('PageTemplateContext', () => {
expect(mockAddModule).toHaveBeenCalledWith(moduleInput);
});
it('should provide working createModule function', () => {
it('should provide working upsertModule function', () => {
const { result } = renderHook(() => usePageTemplateContext(), {
wrapper: ({ children }) => (
<PageTemplateProvider personalTemplate={mockPersonalTemplate} globalTemplate={mockGlobalTemplate}>
@ -381,7 +389,7 @@ describe('PageTemplateContext', () => {
),
});
const createModuleInput = {
const upsertModuleInput = {
name: 'New Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal,
@ -393,10 +401,10 @@ describe('PageTemplateContext', () => {
};
act(() => {
result.current.createModule(createModuleInput);
result.current.upsertModule(upsertModuleInput);
});
expect(mockCreateModule).toHaveBeenCalledWith(createModuleInput);
expect(mockUpsertModule).toHaveBeenCalledWith(upsertModuleInput);
});
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,
mockRemoveModuleFromTemplate,
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(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate);
});
@ -153,6 +159,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
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(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, false, mockPersonalTemplate);
});
@ -199,6 +206,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
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(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, null);
});
@ -246,6 +254,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
);
@ -284,7 +293,12 @@ describe('useModuleOperations', () => {
setTimeout(resolve, 0);
});
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(mockPersonalTemplate, mockModule, position);
expect(mockUpdateTemplateWithModule).toHaveBeenCalledWith(
mockPersonalTemplate,
mockModule,
position,
false,
);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
expect(mockUpsertTemplate).toHaveBeenCalledWith(updatedTemplate, true, mockPersonalTemplate);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(mockPersonalTemplate); // Revert call
@ -306,6 +320,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -355,6 +370,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -404,6 +420,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -456,6 +473,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -512,6 +530,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -556,6 +575,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -594,6 +614,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -638,6 +659,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false, // isEditingModule
),
);
@ -666,7 +688,7 @@ describe('useModuleOperations', () => {
});
});
describe('createModule', () => {
describe('upsertModule', () => {
it('should create module and add it to template', async () => {
const { result } = renderHook(() =>
useModuleOperations(
@ -678,6 +700,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
);
@ -686,7 +709,7 @@ describe('useModuleOperations', () => {
rowSide: 'left',
};
const createModuleInput = {
const upsertModuleInput = {
name: 'Test Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal,
@ -731,7 +754,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({});
await act(async () => {
result.current.createModule(createModuleInput);
result.current.upsertModule(upsertModuleInput);
});
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -740,10 +763,8 @@ describe('useModuleOperations', () => {
name: 'Test Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal,
visibility: {
scope: PageModuleScope.Personal,
},
params: { limit: 10 },
urn: undefined,
},
},
});
@ -761,6 +782,7 @@ describe('useModuleOperations', () => {
},
},
position,
false,
);
expect(mockSetPersonalTemplate).toHaveBeenCalledWith(updatedTemplate);
@ -778,6 +800,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
);
@ -786,7 +809,7 @@ describe('useModuleOperations', () => {
rowSide: 'right',
};
const createModuleInput = {
const upsertModuleInput = {
name: 'Test Module',
type: DataHubPageModuleType.Link,
position,
@ -805,7 +828,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({});
await act(async () => {
result.current.createModule(createModuleInput);
await result.current.upsertModule(upsertModuleInput);
});
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -814,10 +837,8 @@ describe('useModuleOperations', () => {
name: 'Test Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Personal, // Default scope
visibility: {
scope: PageModuleScope.Personal,
},
params: {}, // Default empty params
urn: undefined,
},
},
});
@ -834,6 +855,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
);
@ -842,7 +864,7 @@ describe('useModuleOperations', () => {
rowSide: 'left',
};
const createModuleInput = {
const upsertModuleInput = {
name: 'Test Module',
type: DataHubPageModuleType.Link,
position,
@ -863,7 +885,7 @@ describe('useModuleOperations', () => {
});
await act(async () => {
result.current.createModule(createModuleInput);
result.current.upsertModule(upsertModuleInput);
});
// Wait for the async operation to complete
@ -888,6 +910,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
);
@ -896,7 +919,7 @@ describe('useModuleOperations', () => {
rowSide: 'left',
};
const createModuleInput = {
const upsertModuleInput = {
name: 'Global Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Global,
@ -940,7 +963,7 @@ describe('useModuleOperations', () => {
mockUpsertTemplate.mockResolvedValue({});
await act(async () => {
result.current.createModule(createModuleInput);
result.current.upsertModule(upsertModuleInput);
});
expect(mockUpsertPageModuleMutation).toHaveBeenCalledWith({
@ -949,10 +972,8 @@ describe('useModuleOperations', () => {
name: 'Global Module',
type: DataHubPageModuleType.Link,
scope: PageModuleScope.Global,
visibility: {
scope: PageModuleScope.Global,
},
params: {},
urn: undefined,
},
},
});
@ -970,6 +991,7 @@ describe('useModuleOperations', () => {
},
},
position,
false,
);
expect(mockSetGlobalTemplate).toHaveBeenCalledWith(updatedTemplate);
@ -990,6 +1012,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
{
initialProps: {
@ -1011,7 +1034,7 @@ describe('useModuleOperations', () => {
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(
({ isEditingGlobalTemplate, personalTemplate, globalTemplate }) =>
useModuleOperations(
@ -1023,6 +1046,7 @@ describe('useModuleOperations', () => {
mockUpdateTemplateWithModule,
mockRemoveModuleFromTemplate,
mockUpsertTemplate,
false,
),
{
initialProps: {
@ -1033,7 +1057,7 @@ describe('useModuleOperations', () => {
},
);
const initialCreateModule = result.current.createModule;
const initialUpsertModule = result.current.upsertModule;
rerender({
isEditingGlobalTemplate: true,
@ -1041,7 +1065,7 @@ describe('useModuleOperations', () => {
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',
};
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position);
const updatedTemplate = result.current.updateTemplateWithModule(mockTemplate, mockModule, position, false);
expect(updatedTemplate).not.toBeNull();
expect(updatedTemplate?.properties?.rows).toHaveLength(2);
@ -84,7 +84,7 @@ describe('useTemplateOperations', () => {
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?.properties?.rows).toHaveLength(1);
@ -101,7 +101,7 @@ describe('useTemplateOperations', () => {
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?.properties?.rows).toHaveLength(1);
@ -118,7 +118,7 @@ describe('useTemplateOperations', () => {
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?.properties?.rows).toHaveLength(2);
@ -148,7 +148,12 @@ describe('useTemplateOperations', () => {
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?.properties?.rows).toHaveLength(1);
@ -164,7 +169,7 @@ describe('useTemplateOperations', () => {
rowSide: 'left',
};
const updatedTemplate = result.current.updateTemplateWithModule(null, mockModule, position);
const updatedTemplate = result.current.updateTemplateWithModule(null, mockModule, position, false);
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 { useCallback, useMemo } from 'react';
import { UpsertModuleInput } from '@app/homeV3/context/types';
import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment, PageTemplateFragment, useUpsertPageModuleMutation } from '@graphql/template.generated';
import { DataHubPageModuleType, 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;
}
import { EntityType, PageModuleScope } from '@types';
export interface AddModuleInput {
module: PageModuleFragment;
@ -111,7 +103,7 @@ const validateRemoveModuleInput = (input: RemoveModuleInput): string | null => {
return null;
};
const validateCreateModuleInput = (input: CreateModuleInput): string | null => {
const validateUpsertModuleInput = (input: UpsertModuleInput): string | null => {
if (!input.name?.trim()) {
return 'Module name is required';
}
@ -134,6 +126,7 @@ export function useModuleOperations(
templateToUpdate: PageTemplateFragment | null,
module: PageModuleFragment,
position: ModulePositionInput,
isEditing: boolean,
) => PageTemplateFragment | null,
removeModuleFromTemplate: (
templateToUpdate: PageTemplateFragment | null,
@ -145,6 +138,7 @@ export function useModuleOperations(
isPersonal: boolean,
personalTemplate: PageTemplateFragment | null,
) => Promise<any>,
isEditingModule: boolean,
) {
const [upsertPageModuleMutation] = useUpsertPageModuleMutation();
@ -189,7 +183,7 @@ export function useModuleOperations(
}
// Update template state
const updatedTemplate = updateTemplateWithModule(templateToUpdate, module, position);
const updatedTemplate = updateTemplateWithModule(templateToUpdate, module, position, isEditingModule);
// Update local state immediately for optimistic UI
updateTemplateStateOptimistically(context, updatedTemplate, isPersonal);
@ -197,7 +191,7 @@ export function useModuleOperations(
// Persist changes
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
@ -233,27 +227,25 @@ export function useModuleOperations(
);
// Takes input and makes a call to create a module then add that module to the template
const createModule = useCallback(
(input: CreateModuleInput) => {
const upsertModule = useCallback(
(input: UpsertModuleInput) => {
// Validate input
const validationError = validateCreateModuleInput(input);
const validationError = validateUpsertModuleInput(input);
if (validationError) {
console.error('Invalid createModule input:', validationError);
console.error('Invalid upsertModule input:', validationError);
message.error(validationError);
return;
}
const { name, type, scope = PageModuleScope.Personal, params = {}, position } = input;
const { name, type, scope = PageModuleScope.Personal, params = {}, position, urn } = input;
// Create the module first
const moduleInput = {
name: name.trim(),
type,
scope,
visibility: {
scope,
},
params,
urn,
};
upsertPageModuleMutation({
@ -262,8 +254,8 @@ export function useModuleOperations(
.then((moduleResult) => {
const moduleUrn = moduleResult.data?.upsertPageModule?.urn;
if (!moduleUrn) {
console.error('Failed to create module - no URN returned');
message.error('Failed to create module');
console.error(`Failed to ${isEditingModule ? 'update' : 'create'} module - no URN returned`);
message.error(`Failed to ${isEditingModule ? 'update' : 'create'} module`);
return;
}
@ -286,16 +278,16 @@ export function useModuleOperations(
});
})
.catch((error) => {
console.error('Failed to create module:', error);
message.error('Failed to create module');
console.error(`Failed to ${isEditingModule ? 'update' : 'create'} module:`, error);
message.error(`Failed to ${isEditingModule ? 'update' : 'create'} module`);
});
},
[upsertPageModuleMutation, addModule],
[upsertPageModuleMutation, addModule, isEditingModule],
);
return {
addModule,
removeModule,
createModule,
upsertModule,
};
}

View File

@ -52,11 +52,25 @@ export function useTemplateOperations() {
templateToUpdate: PageTemplateFragment | null,
module: PageModuleFragment,
position: ModulePositionInput,
isEditingModule: boolean,
): PageTemplateFragment | null => {
if (!templateToUpdate) return null;
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) {
// 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';
// Input types for the methods
export interface CreateModuleInput {
export interface UpsertModuleInput {
urn?: string;
name: string;
type: DataHubPageModuleType;
scope?: PageModuleScope;
@ -21,6 +22,16 @@ export interface RemoveModuleInput {
moduleUrn: string;
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
export type PageTemplateContextState = {
@ -33,6 +44,7 @@ export type PageTemplateContextState = {
setGlobalTemplate: (template: PageTemplateFragment | null) => void;
setTemplate: (template: PageTemplateFragment | null) => void;
addModule: (input: AddModuleInput) => void;
createModule: (input: CreateModuleInput) => void;
upsertModule: (input: UpsertModuleInput) => void;
moduleModalState: ModuleModalState;
removeModule: (input: RemoveModuleInput) => void;
};

View File

@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
import { ModuleProps } from '@app/homeV3/module/types';
import SampleLargeModule from '@app/homeV3/modules/SampleLargeModule';
import YourAssetsModule from '@app/homeV3/modules/YourAssetsModule';
import AssetCollectionModule from '@app/homeV3/modules/assetCollection/AssetCollectionModule';
import TopDomainsModule from '@app/homeV3/modules/domains/TopDomainsModule';
import { DataHubPageModuleType } from '@types';
@ -12,6 +13,7 @@ export default function Module(props: ModuleProps) {
const Component = useMemo(() => {
if (module.properties.type === DataHubPageModuleType.OwnedAssets) return YourAssetsModule;
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
console.error(`Issue finding module with type ${module.properties.type}`);

View File

@ -9,14 +9,30 @@ import { Entity } from '@types';
interface Props {
entity: Entity;
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();
return (
<Link to={entityRegistry.getEntityUrl(entity.type, entity.urn)}>
<AutoCompleteEntityItem entity={entity} key={entity.urn} customDetailsRenderer={customDetailsRenderer} />
</Link>
<>
{navigateOnlyOnNameClick ? (
<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 { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import { DEFAULT_GLOBAL_MODULE_TYPES } from '@app/homeV3/modules/constants';
import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment } from '@graphql/template.generated';
@ -20,7 +21,17 @@ interface 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(() => {
removeModule({
@ -34,28 +45,27 @@ export default function ModuleMenu({ module, position }: Props) {
trigger={['click']}
menu={{
items: [
{
title: 'Edit',
key: 'edit',
label: 'Edit',
onClick: () => {
// TODO: Implement edit functionality
},
},
{
title: 'Duplicate',
label: 'Duplicate',
key: 'duplicate',
onClick: () => {
// TODO: Implement duplicate functionality
},
},
...(canEdit
? [
{
title: 'Edit',
key: 'edit',
label: 'Edit',
style: {
color: colors.gray[600],
fontSize: '14px',
},
onClick: handleEditModule,
},
]
: []),
{
title: 'Delete',
label: 'Delete',
key: 'delete',
style: {
color: colors.red[500],
fontSize: '14px',
},
onClick: handleDelete,
},

View File

@ -10,9 +10,11 @@ import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// Mock the PageTemplateContext
const mockRemoveModule = vi.fn();
const mockOpenToEdit = vi.fn();
vi.mock('@app/homeV3/context/PageTemplateContext', () => ({
usePageTemplateContext: () => ({
removeModule: mockRemoveModule,
moduleModalState: { openToEdit: mockOpenToEdit },
}),
}));
@ -24,6 +26,9 @@ vi.mock('@components', () => ({
</div>
)),
colors: {
gray: {
600: '#4B5563',
},
red: {
500: '#ef4444',
},
@ -60,7 +65,7 @@ describe('ModuleMenu', () => {
// Check that menu items are rendered
expect(screen.getByText('Edit')).toBeInTheDocument();
expect(screen.getByText('Duplicate')).toBeInTheDocument();
// expect(screen.getByText('Duplicate')).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} />);
// Click to open the dropdown
@ -243,14 +248,14 @@ describe('ModuleMenu', () => {
// Check that edit and duplicate options are present
const editButton = screen.getByText('Edit');
const duplicateButton = screen.getByText('Duplicate');
// const duplicateButton = screen.getByText('Duplicate');
expect(editButton).toBeInTheDocument();
expect(duplicateButton).toBeInTheDocument();
// expect(duplicateButton).toBeInTheDocument();
// Click edit and duplicate (should not throw errors)
fireEvent.click(editButton);
fireEvent.click(duplicateButton);
// fireEvent.click(duplicateButton);
// These are placeholder implementations, so we just verify they don't crash
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',
};
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[] = [
DEFAULT_MODULE_YOUR_ASSETS,
DEFAULT_MODULE_TOP_DOMAINS,
CUSTOM_LARGE_MODULE_ASSET_COLLECTION,
// Links isn't supported yet
// DEFAULT_MODULE_LINK,
];
export const ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.Link,
DataHubPageModuleType.AssetCollection,
DataHubPageModuleType.RichText,
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[] = [
DataHubPageModuleType.Domains,
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;
}
`;
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 { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import ModuleModalMapper from '@app/homeV3/moduleModals/ModuleModalMapper';
import useModulesAvailableToAdd from '@app/homeV3/modules/hooks/useModulesAvailableToAdd';
import AddModuleButton from '@app/homeV3/template/components/AddModuleButton';
import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
import { wrapRows } from '@app/homeV3/templateRow/utils';
import { DataHubPageTemplateRow } from '@types';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
@ -25,7 +28,10 @@ interface Props {
export default function Template({ className }: Props) {
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 wrappedRows = useMemo(() => wrapRows(rows), [rows]);
@ -42,6 +48,7 @@ export default function Template({ className }: Props) {
$hasRows={hasRows}
modulesAvailableToAdd={modulesAvailableToAdd}
/>
<ModuleModalMapper />
</Wrapper>
);
}

View File

@ -61,7 +61,9 @@ export default function AddModuleButton({ orientation, modulesAvailableToAdd, cl
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>) => {
// 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
const mockAddModule = vi.fn();
const mockOpenModal = vi.fn();
vi.mock('@app/homeV3/context/PageTemplateContext', () => ({
usePageTemplateContext: () => ({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
}),
}));
@ -58,6 +65,11 @@ describe('useAddModuleMenu', () => {
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', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
@ -86,7 +98,7 @@ describe('useAddModuleMenu', () => {
// Check Custom Large group
expect(items?.[1]).toHaveProperty('key', 'customLargeModulesGroup');
// @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
expect(items?.[1]?.children?.[0]).toHaveProperty('key', 'your-assets');
// @ts-expect-error SubMenuItem should have children
@ -168,4 +180,40 @@ describe('useAddModuleMenu', () => {
expect(adminGroup).toHaveProperty('expandIcon');
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,
position: ModulePositionInput,
closeMenu: () => void,
): MenuProps {
const { addModule } = usePageTemplateContext();
) {
const {
addModule,
moduleModalState: { open: openModal },
} = usePageTemplateContext();
const handleAddExistingModule = useCallback(
(module: PageModuleFragment) => {
@ -54,20 +57,15 @@ export default function useAddModuleMenu(
[addModule, position, closeMenu],
);
// TODO: use this commented out code later once we implement creating new modules
// const handleCreateNewModule = useCallback(
// (type: DataHubPageModuleType, name: string) => {
// createModule({
// name,
// type,
// position,
// });
// closeMenu();
// },
// [createModule, position, closeMenu],
// );
const handleOpenCreateModuleModal = useCallback(
(type: DataHubPageModuleType) => {
openModal(type, position);
closeMenu();
},
[openModal, position, closeMenu],
);
return useMemo(() => {
const menu = useMemo(() => {
const items: MenuProps['items'] = [];
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({
key: 'customLargeModulesGroup',
label: <GroupItem title="Custom Large" />,
type: 'group',
children: [yourAssets, domains],
children: [yourAssets, domains, assetCollection],
});
// Add admin created modules if available
@ -147,5 +160,7 @@ export default function useAddModuleMenu(
}
return { items };
}, [modulesAvailableToAdd, handleAddExistingModule]);
}, [modulesAvailableToAdd.adminCreatedModules, handleAddExistingModule, handleOpenCreateModuleModal]);
return menu;
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styled, { useTheme } from 'styled-components';
import { getColor } from '@components/theme/utils';
@ -15,26 +16,43 @@ import { Text } from '@src/alchemy-components';
import { useEntityRegistryV2 } from '@src/app/useEntityRegistry';
import { Entity, MatchedField } from '@src/types.generated';
const Container = styled.div`
const Container = styled.div<{
$navigateOnlyOnNameClick?: boolean;
}>`
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 8px 13px 8px 8px;
:hover {
cursor: pointer;
}
${(props) =>
!props.$navigateOnlyOnNameClick &&
`
:hover {
cursor: pointer;
}
`}
`;
// FYI: this hovering dependent on Container can't be applied by condition inside of styled component
// 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 & {
text-decoration: underline;
${(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`
white-space: nowrap;
`;
@ -80,6 +98,7 @@ interface EntityAutocompleteItemProps {
matchedFields?: MatchedField[];
variant?: EntityItemVariant;
customDetailsRenderer?: (entity: Entity) => void;
navigateOnlyOnNameClick?: boolean;
}
export default function AutoCompleteEntityItem({
@ -89,6 +108,7 @@ export default function AutoCompleteEntityItem({
matchedFields,
variant,
customDetailsRenderer,
navigateOnlyOnNameClick,
}: EntityAutocompleteItemProps) {
const theme = useTheme();
const entityRegistry = useEntityRegistryV2();
@ -96,8 +116,34 @@ export default function AutoCompleteEntityItem({
const displayType = getEntityDisplayType(entity, entityRegistry);
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 (
<Container>
<Container $navigateOnlyOnNameClick={navigateOnlyOnNameClick}>
<ContentContainer>
<IconContainer $variant={variant}>
<EntityIcon entity={entity} siblings={siblings} />
@ -110,31 +156,7 @@ export default function AutoCompleteEntityItem({
showArrow={false}
canOpen={variantProps?.showEntityPopover}
>
<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>
<DisplayNameWrapper>{displayNameContent}</DisplayNameWrapper>
</HoverEntityTooltip>
<EntitySubtitle

View File

@ -19,7 +19,7 @@ import {
} from '@app/searchV2/utils/constants';
import { FacetMetadata } from '@src/types.generated';
const FILTER_FIELDS = [
export const FILTER_FIELDS = [
PLATFORM_FILTER_NAME,
ENTITY_SUB_TYPE_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 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 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 { Entity } from '@types';
export function useGetEntities(urns: string[]): Entity[] {
const [verifiedUrns, setVerifiedUrns] = useState<string[]>([]);
export function useGetEntities(urns: string[]): {
entities: Entity[];
loading: boolean;
} {
const verifiedUrns = urns.filter((urn) => typeof urn === 'string' && urn.startsWith('urn:li:'));
useEffect(() => {
urns.forEach((urn) => {
if (urn.startsWith('urn:li:') && !verifiedUrns.includes(urn)) {
setVerifiedUrns((prevUrns) => [...prevUrns, urn]);
}
});
}, [urns, verifiedUrns]);
const { data } = useGetEntitiesQuery({ variables: { urns: verifiedUrns }, skip: !verifiedUrns.length });
return (data?.entities || []) as Entity[];
const { data, loading } = useGetEntitiesQuery({ variables: { urns: verifiedUrns }, skip: !verifiedUrns.length });
const entities: Entity[] = Array.isArray(data?.entities) ? (data?.entities.filter(Boolean) as Entity[]) : [];
return { entities, loading };
}

View File

@ -33,6 +33,9 @@ fragment PageModule on DataHubPageModule {
richTextParams {
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 {
content: string
}
/**
* The params required if the module is type ASSET_COLLECTION
*/
assetCollectionParams: optional AssetCollectionModuleParams
}