mirror of
https://github.com/datahub-project/datahub.git
synced 2025-11-03 20:27:50 +00:00
feat(homePage): add the ability to create asset collection module (#14050)
Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
parent
b550b8a903
commit
1350fe21fa
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
"""
|
||||
|
||||
@ -49,7 +49,9 @@ export const Checkbox = ({
|
||||
</Label>
|
||||
) : null}
|
||||
<CheckboxBase
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (!isDisabled) {
|
||||
setChecked(!checked);
|
||||
setIsChecked?.(!checked);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,4 @@
|
||||
export type FormValues = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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;
|
||||
`;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -33,6 +33,9 @@ fragment PageModule on DataHubPageModule {
|
||||
richTextParams {
|
||||
content
|
||||
}
|
||||
assetCollectionParams {
|
||||
assetUrns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user