feat(ui) Add ability to add custom global widgets to templates (#14163)

This commit is contained in:
Chris Collins 2025-07-22 20:36:10 -04:00 committed by GitHub
parent e5056d9b55
commit c9fe4ff1b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 397 additions and 222 deletions

View File

@ -1,7 +1,5 @@
import { IconNames } from '@components';
import { ModuleInfo } from '@app/homeV3/modules/types';
import { DataHubPageModuleType } from '@types';
// TODO: remove these description once descriptions in modules are implemented
@ -25,59 +23,11 @@ export const MODULE_TYPE_TO_ICON: Map<DataHubPageModuleType, IconNames> = new Ma
export const DEFAULT_MODULE_ICON = 'Database';
export const DEFAULT_MODULE_YOUR_ASSETS: ModuleInfo = {
type: DataHubPageModuleType.OwnedAssets,
name: 'Your Assets',
description: MODULE_TYPE_TO_DESCRIPTION.get(DataHubPageModuleType.OwnedAssets),
icon: MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.OwnedAssets) ?? DEFAULT_MODULE_ICON,
key: 'default_module_your_assets',
};
export const DEFAULT_MODULE_TOP_DOMAINS: ModuleInfo = {
type: DataHubPageModuleType.Domains,
name: 'Domains',
description: MODULE_TYPE_TO_DESCRIPTION.get(DataHubPageModuleType.Domains),
icon: MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.Domains) ?? DEFAULT_MODULE_ICON,
key: 'default_module_top_domains',
};
export const DEFAULT_MODULE_LINK: ModuleInfo = {
type: DataHubPageModuleType.Link,
name: 'Quick Link',
description: MODULE_TYPE_TO_DESCRIPTION.get(DataHubPageModuleType.Link),
icon: MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.Link) ?? DEFAULT_MODULE_ICON,
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_MODULE_HIERARCHY_VIEW: ModuleInfo = {
type: DataHubPageModuleType.Hierarchy,
name: 'Hierarchy View',
description: MODULE_TYPE_TO_DESCRIPTION.get(DataHubPageModuleType.Hierarchy),
icon: MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.Hierarchy) ?? DEFAULT_MODULE_ICON,
key: 'default_module_hierarchy_view',
};
export const DEFAULT_MODULES: ModuleInfo[] = [
DEFAULT_MODULE_HIERARCHY_VIEW,
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[] = [
export const CUSTOM_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.Link,
DataHubPageModuleType.RichText,
DataHubPageModuleType.Hierarchy,
DataHubPageModuleType.AssetCollection,
];
export const ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES: DataHubPageModuleType[] = [

View File

@ -1,58 +0,0 @@
import { useMemo } from 'react';
import {
ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES,
ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES,
DEFAULT_MODULES,
} from '@app/homeV3/modules/constants';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import { PageModuleFragment } from '@graphql/template.generated';
import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// TODO: Mocked default modules (should be replaced with the real calling of endpoint once it implemented)
export const MOCKED_ADMIN_CREATED_MODULES: PageModuleFragment[] = [
{
urn: 'urn:li:dataHubPageModule:link_admin_1',
type: EntityType.DatahubPageModule,
properties: {
name: 'Link 1 (example)',
type: DataHubPageModuleType.Link,
visibility: {
scope: PageModuleScope.Global,
},
params: {},
},
},
{
urn: 'urn:li:dataHubPageModule:link_admin_2',
type: EntityType.DatahubPageModule,
properties: {
name: 'Link 2 (example)',
type: DataHubPageModuleType.Link,
visibility: {
scope: PageModuleScope.Global,
},
params: {},
},
},
];
export default function useModulesAvailableToAdd(): ModulesAvailableToAdd {
// TODO:: here we have to add logic with getting available modules
return useMemo(() => {
const customModules = DEFAULT_MODULES.filter((module) =>
ADD_MODULE_MENU_SECTION_CUSTOM_MODULE_TYPES.includes(module.type),
);
const customLargeModules = DEFAULT_MODULES.filter((module) =>
ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES.includes(module.type),
);
const adminCreatedModules = MOCKED_ADMIN_CREATED_MODULES;
return {
customModules,
customLargeModules,
adminCreatedModules,
};
}, []);
}

View File

@ -1,6 +1,5 @@
import { IconNames } from '@components';
import { PageModuleFragment } from '@graphql/template.generated';
import { DataHubPageModuleType } from '@types';
export type ModuleInfo = {
@ -11,9 +10,3 @@ export type ModuleInfo = {
description?: string;
icon: IconNames;
};
export type ModulesAvailableToAdd = {
customModules: ModuleInfo[];
customLargeModules: ModuleInfo[];
adminCreatedModules: PageModuleFragment[]; // Full module fragments for admin-created modules
};

View File

@ -4,7 +4,6 @@ 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 DragAndDropProvider from '@app/homeV3/template/components/DragAndDropProvider';
import TemplateGrid from '@app/homeV3/template/components/TemplateGrid';
@ -35,19 +34,14 @@ function Template({ className }: Props) {
);
const hasRows = useMemo(() => !!rows.length, [rows.length]);
const wrappedRows = useMemo(() => wrapRows(rows), [rows]);
const modulesAvailableToAdd = useModulesAvailableToAdd();
return (
<Wrapper className={className}>
<DragAndDropProvider>
<TemplateGrid wrappedRows={wrappedRows} modulesAvailableToAdd={modulesAvailableToAdd} />
<TemplateGrid wrappedRows={wrappedRows} />
</DragAndDropProvider>
<StyledAddModulesButton
orientation="horizontal"
$hasRows={hasRows}
modulesAvailableToAdd={modulesAvailableToAdd}
/>
<StyledAddModulesButton orientation="horizontal" $hasRows={hasRows} />
<ModuleModalMapper />
</Wrapper>
);

View File

@ -2,7 +2,6 @@ import { Button, Dropdown, Icon, colors } from '@components';
import React, { useMemo, useState } from 'react';
import styled from 'styled-components';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import useAddModuleMenu from '@app/homeV3/template/components/addModuleMenu/useAddModuleMenu';
import { ModulePositionInput, RowSide } from '@app/homeV3/template/types';
@ -44,13 +43,12 @@ const StyledVisibleOnHoverButton = styled(StyledButton)`
interface Props {
orientation: AddModuleButtonOrientation;
modulesAvailableToAdd: ModulesAvailableToAdd;
className?: string;
rowIndex?: number;
rowSide?: RowSide;
}
export default function AddModuleButton({ orientation, modulesAvailableToAdd, className, rowIndex, rowSide }: Props) {
export default function AddModuleButton({ orientation, className, rowIndex, rowSide }: Props) {
const [isOpened, setIsOpened] = useState<boolean>(false);
const ButtonComponent = useMemo(() => (isOpened ? StyledButton : StyledVisibleOnHoverButton), [isOpened]);
@ -63,7 +61,7 @@ export default function AddModuleButton({ orientation, modulesAvailableToAdd, cl
const closeMenu = () => setIsOpened(false);
const menu = useAddModuleMenu(modulesAvailableToAdd, position, closeMenu);
const menu = useAddModuleMenu(position, closeMenu);
const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// FYI: Antd can open dropdown in the cursor's position only for contextMenu trigger

View File

@ -1,16 +1,14 @@
import React, { memo, useMemo } from 'react';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import NewRowDropZone from '@app/homeV3/template/components/NewRowDropZone';
import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
import { WrappedRow } from '@app/homeV3/templateRow/types';
interface Props {
wrappedRows: WrappedRow[];
modulesAvailableToAdd: ModulesAvailableToAdd;
}
function TemplateGrid({ wrappedRows, modulesAvailableToAdd }: Props) {
function TemplateGrid({ wrappedRows }: Props) {
// Memoize the template rows with drop zones between them
const templateRowsWithDropZones = useMemo(() => {
const result: React.ReactElement[] = [];
@ -24,9 +22,7 @@ function TemplateGrid({ wrappedRows, modulesAvailableToAdd }: Props) {
// Add the actual row
const rowKey = `templateRow-${i}`;
result.push(
<TemplateRow key={rowKey} row={row} rowIndex={i} modulesAvailableToAdd={modulesAvailableToAdd} />,
);
result.push(<TemplateRow key={rowKey} row={row} rowIndex={i} />);
// Add drop zone after each row (for inserting between/after rows)
const finalDropKey = `drop-zone-after-${i}`;
@ -34,7 +30,7 @@ function TemplateGrid({ wrappedRows, modulesAvailableToAdd }: Props) {
});
return result;
}, [wrappedRows, modulesAvailableToAdd]);
}, [wrappedRows]);
return <>{templateRowsWithDropZones}</>;
}

View File

@ -2,7 +2,6 @@ import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { vi } from 'vitest';
import type { ModuleInfo, ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import useAddModuleMenu from '@app/homeV3/template/components/addModuleMenu/useAddModuleMenu';
import { ModulePositionInput } from '@app/homeV3/template/types';
@ -12,16 +11,70 @@ import { DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// Mock the PageTemplateContext
const mockAddModule = vi.fn();
const mockOpenModal = vi.fn();
// Mock template and globalTemplate data - using any to avoid complex type issues
const mockTemplate = {
properties: {
rows: [
{
modules: [
{
properties: {
type: DataHubPageModuleType.Link,
},
},
],
},
],
},
} as any;
const mockGlobalTemplate = {
properties: {
rows: [
{
modules: [
{
urn: 'urn:li:dataHubPageModule:admin1',
type: EntityType.DatahubPageModule,
properties: {
name: 'Admin Widget 1',
type: DataHubPageModuleType.Link,
visibility: { scope: PageModuleScope.Global },
params: {},
},
} as PageModuleFragment,
{
urn: 'urn:li:dataHubPageModule:admin2',
type: EntityType.DatahubPageModule,
properties: {
name: 'Admin Widget 2',
type: DataHubPageModuleType.RichText,
visibility: { scope: PageModuleScope.Global },
params: {},
},
} as PageModuleFragment,
],
},
],
},
} as any;
const mockEmptyGlobalTemplate = {
properties: {
rows: [],
},
} as any;
// Mock function for usePageTemplateContext
const { mockUsePageTemplateContext } = vi.hoisted(() => {
return {
mockUsePageTemplateContext: vi.fn(),
};
});
vi.mock('@app/homeV3/context/PageTemplateContext', () => ({
usePageTemplateContext: () => ({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
}),
usePageTemplateContext: mockUsePageTemplateContext,
}));
// Mock components that are rendered inside the menu items
@ -32,7 +85,7 @@ vi.mock('@app/homeV3/template/components/addModuleMenu/components/GroupItem', ()
vi.mock('@app/homeV3/template/components/addModuleMenu/components/ModuleMenuItem', () => ({
__esModule: true,
default: ({ module }: { module: ModuleInfo }) => <div data-testid={`module-${module.key}`}>{module.name}</div>,
default: ({ module }: { module: any }) => <div data-testid={`module-${module.key}`}>{module.name}</div>,
}));
vi.mock('@app/homeV3/template/components/addModuleMenu/components/MenuItem', () => ({
@ -44,25 +97,20 @@ describe('useAddModuleMenu', () => {
const mockCloseMenu = vi.fn();
const mockPosition: ModulePositionInput = { rowIndex: 0, rowSide: 'left' };
const modulesAvailableToAdd: ModulesAvailableToAdd = {
customModules: [], // Not used in new implementation
customLargeModules: [], // Not used in new implementation
adminCreatedModules: [
{
urn: 'urn:li:dataHubPageModule:admin1',
type: EntityType.DatahubPageModule,
properties: {
name: 'Admin Widget 1',
type: DataHubPageModuleType.Link,
visibility: { scope: PageModuleScope.Global },
params: {},
},
} as PageModuleFragment,
],
};
beforeEach(() => {
vi.clearAllMocks();
// Set up default mock implementation
mockUsePageTemplateContext.mockReturnValue({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
template: mockTemplate,
globalTemplate: mockEmptyGlobalTemplate,
});
});
function getChildren(item: any): any[] {
@ -70,18 +118,8 @@ describe('useAddModuleMenu', () => {
return [];
}
it('should return menu items with hardcoded custom modules', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
{
customModules: [],
customLargeModules: [],
adminCreatedModules: [],
},
mockPosition,
mockCloseMenu,
),
);
it('should return menu items with hardcoded custom modules when no global template custom modules exist', () => {
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const { items } = result.current;
expect(items).toHaveLength(2);
@ -103,10 +141,25 @@ describe('useAddModuleMenu', () => {
expect(items?.[1]?.children?.[0]).toHaveProperty('key', 'your-assets');
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[1]).toHaveProperty('key', 'domains');
// @ts-expect-error SubMenuItem should have children
expect(items?.[1]?.children?.[2]).toHaveProperty('key', 'asset-collection');
});
it('should include admin created modules when available', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
it('should include admin created modules when available in global template', () => {
// Mock the context to return globalTemplate with custom modules
mockUsePageTemplateContext.mockReturnValue({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
template: mockTemplate,
globalTemplate: mockGlobalTemplate,
});
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const { items } = result.current;
expect(items).toHaveLength(3);
@ -114,13 +167,15 @@ describe('useAddModuleMenu', () => {
// Check Admin Created Widgets group
expect(items?.[2]).toHaveProperty('key', 'adminCreatedModulesGroup');
// @ts-expect-error SubMenuItem should have children
expect(items?.[2]?.children).toHaveLength(1);
expect(items?.[2]?.children).toHaveLength(2);
// @ts-expect-error SubMenuItem should have children
expect(items?.[2]?.children?.[0]).toHaveProperty('key', 'urn:li:dataHubPageModule:admin1');
// @ts-expect-error SubMenuItem should have children
expect(items?.[2]?.children?.[1]).toHaveProperty('key', 'urn:li:dataHubPageModule:admin2');
});
it('should call addModule and closeMenu when Your Assets is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
// @ts-expect-error SubMenuItem should have children
const yourAssetsItem = result.current?.items?.[1]?.children?.[0];
@ -140,7 +195,7 @@ describe('useAddModuleMenu', () => {
});
it('should call addModule and closeMenu when Domains is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
// @ts-expect-error SubMenuItem should have children
const domainsItem = result.current?.items?.[1]?.children?.[1];
@ -160,21 +215,47 @@ describe('useAddModuleMenu', () => {
});
it('should call addModule and closeMenu when admin created module is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
// Mock the context to return globalTemplate with custom modules
mockUsePageTemplateContext.mockReturnValue({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
template: mockTemplate,
globalTemplate: mockGlobalTemplate,
});
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
// @ts-expect-error SubMenuItem should have children
const adminModuleItem = result.current?.items?.[2]?.children?.[0];
adminModuleItem.onClick?.({} as any); // simulate click
expect(mockAddModule).toHaveBeenCalledWith({
module: modulesAvailableToAdd.adminCreatedModules[0],
module: mockGlobalTemplate.properties.rows[0].modules[0],
position: mockPosition,
});
expect(mockCloseMenu).toHaveBeenCalled();
});
it('should set expandIcon and popupClassName for admin created modules group', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
// Mock the context to return globalTemplate with custom modules
mockUsePageTemplateContext.mockReturnValue({
addModule: mockAddModule,
moduleModalState: {
open: mockOpenModal,
close: vi.fn(),
isOpen: false,
isEditing: false,
},
template: mockTemplate,
globalTemplate: mockGlobalTemplate,
});
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const adminGroup = result.current?.items?.[2];
expect(adminGroup).toHaveProperty('expandIcon');
@ -182,7 +263,7 @@ describe('useAddModuleMenu', () => {
});
it('should open module modal when Asset Collection is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
// Third child is Asset Collection
const assetCollectionItem = customLargeChildren[2];
@ -193,7 +274,7 @@ describe('useAddModuleMenu', () => {
});
it('should not call addModule when Asset Collection is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
const assetCollectionItem = customLargeChildren[2];
assetCollectionItem.onClick?.({} as any);
@ -209,11 +290,31 @@ describe('useAddModuleMenu', () => {
});
it('should not open modal when Your Assets is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockPosition, mockCloseMenu));
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const customLargeChildren = getChildren(result.current.items?.[1]);
const yourAssetsItem = customLargeChildren[0];
yourAssetsItem.onClick?.({} as any);
expect(mockOpenModal).not.toHaveBeenCalled();
});
it('should open module modal when Quick Link is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const customChildren = getChildren(result.current.items?.[0]);
const quickLinkItem = customChildren[0];
quickLinkItem.onClick?.({} as any);
expect(mockOpenModal).toHaveBeenCalledWith(DataHubPageModuleType.Link, mockPosition);
expect(mockCloseMenu).toHaveBeenCalled();
});
it('should open module modal when Documentation is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(mockPosition, mockCloseMenu));
const customChildren = getChildren(result.current.items?.[0]);
const documentationItem = customChildren[1];
documentationItem.onClick?.({} as any);
expect(mockOpenModal).toHaveBeenCalledWith(DataHubPageModuleType.RichText, mockPosition);
expect(mockCloseMenu).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,208 @@
import { getCustomGlobalModules } from '@app/homeV3/template/components/addModuleMenu/utils';
import { PageModuleFragment, PageTemplateFragment } from '@graphql/template.generated';
import { DataHubPageModuleType, EntityType, PageModuleScope, PageTemplateScope, PageTemplateSurfaceType } from '@types';
// Helper function to create mock modules
const createMockModule = (name: string, urn: string, type: DataHubPageModuleType): PageModuleFragment => ({
urn,
type: EntityType.DatahubPageModule,
properties: {
name,
type,
visibility: { scope: PageModuleScope.Personal },
params: {},
},
});
// Helper function to create mock template
const createMockTemplate = (modules: PageModuleFragment[][]): PageTemplateFragment => ({
urn: 'urn:li:pageTemplate:test',
type: EntityType.DatahubPageTemplate,
properties: {
rows: modules.map((rowModules) => ({
modules: rowModules,
})),
surface: { surfaceType: PageTemplateSurfaceType.HomePage },
visibility: { scope: PageTemplateScope.Global },
},
});
describe('getCustomGlobalModules', () => {
describe('null and empty template handling', () => {
it('should return empty array when template is null', () => {
const result = getCustomGlobalModules(null);
expect(result).toEqual([]);
});
it('should return empty array when template has no rows', () => {
const template = createMockTemplate([]);
const result = getCustomGlobalModules(template);
expect(result).toEqual([]);
});
it('should return empty array when template rows have no modules', () => {
const template = createMockTemplate([[], []]);
const result = getCustomGlobalModules(template);
expect(result).toEqual([]);
});
});
describe('custom module filtering', () => {
it('should return only custom modules (Link, RichText, Hierarchy, AssetCollection)', () => {
const customModules = [
createMockModule('Link Module', 'urn:li:pageModule:link', DataHubPageModuleType.Link),
createMockModule('RichText Module', 'urn:li:pageModule:richtext', DataHubPageModuleType.RichText),
createMockModule('Hierarchy Module', 'urn:li:pageModule:hierarchy', DataHubPageModuleType.Hierarchy),
createMockModule(
'AssetCollection Module',
'urn:li:pageModule:collection',
DataHubPageModuleType.AssetCollection,
),
];
const template = createMockTemplate([customModules]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(4);
expect(result).toEqual(customModules);
});
it('should exclude non-custom modules (OwnedAssets, Domains)', () => {
const mixedModules = [
createMockModule('Link Module', 'urn:li:pageModule:link', DataHubPageModuleType.Link),
createMockModule('OwnedAssets Module', 'urn:li:pageModule:owned', DataHubPageModuleType.OwnedAssets),
createMockModule('RichText Module', 'urn:li:pageModule:richtext', DataHubPageModuleType.RichText),
createMockModule('Domains Module', 'urn:li:pageModule:domains', DataHubPageModuleType.Domains),
];
const template = createMockTemplate([mixedModules]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(2);
expect(result[0].properties.type).toBe(DataHubPageModuleType.Link);
expect(result[1].properties.type).toBe(DataHubPageModuleType.RichText);
});
it('should return empty array when template contains only non-custom modules', () => {
const nonCustomModules = [
createMockModule('OwnedAssets Module', 'urn:li:pageModule:owned', DataHubPageModuleType.OwnedAssets),
createMockModule('Domains Module', 'urn:li:pageModule:domains', DataHubPageModuleType.Domains),
];
const template = createMockTemplate([nonCustomModules]);
const result = getCustomGlobalModules(template);
expect(result).toEqual([]);
});
});
describe('multiple rows handling', () => {
it('should collect custom modules from multiple rows', () => {
const row1Modules = [
createMockModule('Link Module 1', 'urn:li:pageModule:link1', DataHubPageModuleType.Link),
createMockModule('OwnedAssets Module', 'urn:li:pageModule:owned', DataHubPageModuleType.OwnedAssets),
];
const row2Modules = [
createMockModule('RichText Module', 'urn:li:pageModule:richtext', DataHubPageModuleType.RichText),
createMockModule('Domains Module', 'urn:li:pageModule:domains', DataHubPageModuleType.Domains),
];
const row3Modules = [
createMockModule(
'AssetCollection Module',
'urn:li:pageModule:collection',
DataHubPageModuleType.AssetCollection,
),
];
const template = createMockTemplate([row1Modules, row2Modules, row3Modules]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(3);
expect(result[0].properties.name).toBe('Link Module 1');
expect(result[1].properties.name).toBe('RichText Module');
expect(result[2].properties.name).toBe('AssetCollection Module');
});
it('should handle rows with mixed content correctly', () => {
const mixedRows = [
[], // Empty row
[createMockModule('OwnedAssets Module', 'urn:li:pageModule:owned', DataHubPageModuleType.OwnedAssets)], // Non-custom only
[createMockModule('Link Module', 'urn:li:pageModule:link', DataHubPageModuleType.Link)], // Custom only
[
createMockModule(
'Hierarchy Module',
'urn:li:pageModule:hierarchy',
DataHubPageModuleType.Hierarchy,
),
createMockModule('Domains Module', 'urn:li:pageModule:domains', DataHubPageModuleType.Domains),
], // Mixed
];
const template = createMockTemplate(mixedRows);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(2);
expect(result[0].properties.type).toBe(DataHubPageModuleType.Link);
expect(result[1].properties.type).toBe(DataHubPageModuleType.Hierarchy);
});
});
describe('edge cases', () => {
it('should preserve module order when collecting from multiple rows', () => {
const row1 = [
createMockModule('Link Module A', 'urn:li:pageModule:linkA', DataHubPageModuleType.Link),
createMockModule('RichText Module B', 'urn:li:pageModule:richtextB', DataHubPageModuleType.RichText),
];
const row2 = [
createMockModule('Hierarchy Module C', 'urn:li:pageModule:hierarchyC', DataHubPageModuleType.Hierarchy),
createMockModule(
'AssetCollection Module D',
'urn:li:pageModule:collectionD',
DataHubPageModuleType.AssetCollection,
),
];
const template = createMockTemplate([row1, row2]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(4);
expect(result[0].properties.name).toBe('Link Module A');
expect(result[1].properties.name).toBe('RichText Module B');
expect(result[2].properties.name).toBe('Hierarchy Module C');
expect(result[3].properties.name).toBe('AssetCollection Module D');
});
it('should handle single row with single custom module', () => {
const singleModule = [
createMockModule('Single Link Module', 'urn:li:pageModule:single', DataHubPageModuleType.Link),
];
const template = createMockTemplate([singleModule]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(1);
expect(result[0].properties.name).toBe('Single Link Module');
});
it('should return array with distinct modules (no duplicates expected from structure)', () => {
const modules = [
createMockModule('Link Module 1', 'urn:li:pageModule:link1', DataHubPageModuleType.Link),
createMockModule('Link Module 2', 'urn:li:pageModule:link2', DataHubPageModuleType.Link),
createMockModule('RichText Module 1', 'urn:li:pageModule:richtext1', DataHubPageModuleType.RichText),
];
const template = createMockTemplate([modules]);
const result = getCustomGlobalModules(template);
expect(result).toHaveLength(3);
// Verify each module is distinct
const urns = result.map((module) => module.urn);
expect(new Set(urns).size).toBe(urns.length);
});
});
});

View File

@ -5,11 +5,11 @@ import { RESET_DROPDOWN_MENU_STYLES_CLASSNAME } from '@components/components/Dro
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import { LARGE_MODULE_TYPES, SMALL_MODULE_TYPES } from '@app/homeV3/modules/constants';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import { convertModuleToModuleInfo } from '@app/homeV3/modules/utils';
import GroupItem from '@app/homeV3/template/components/addModuleMenu/components/GroupItem';
import MenuItem from '@app/homeV3/template/components/addModuleMenu/components/MenuItem';
import ModuleMenuItem from '@app/homeV3/template/components/addModuleMenu/components/ModuleMenuItem';
import { getCustomGlobalModules } from '@app/homeV3/template/components/addModuleMenu/utils';
import { ModulePositionInput } from '@app/homeV3/template/types';
import { PageModuleFragment } from '@graphql/template.generated';
@ -37,15 +37,12 @@ const DOMAINS_MODULE: PageModuleFragment = {
},
};
export default function useAddModuleMenu(
modulesAvailableToAdd: ModulesAvailableToAdd,
position: ModulePositionInput,
closeMenu: () => void,
) {
export default function useAddModuleMenu(position: ModulePositionInput, closeMenu: () => void) {
const {
addModule,
moduleModalState: { open: openModal },
template,
globalTemplate,
} = usePageTemplateContext();
const isLargeModuleRow =
@ -162,9 +159,10 @@ export default function useAddModuleMenu(
children: [yourAssets, domains, assetCollection, hierarchyView],
});
// Add admin created modules if available
if (modulesAvailableToAdd.adminCreatedModules.length) {
const adminModuleItems = modulesAvailableToAdd.adminCreatedModules.map((module) => ({
// Add global custom modules if available
const customGlobalModules: PageModuleFragment[] = getCustomGlobalModules(globalTemplate);
if (customGlobalModules.length > 0) {
const adminModuleItems = customGlobalModules.map((module) => ({
title: module.properties.name,
key: module.urn,
label: <ModuleMenuItem module={convertModuleToModuleInfo(module)} />,
@ -189,13 +187,7 @@ export default function useAddModuleMenu(
}
return { items };
}, [
isLargeModuleRow,
isSmallModuleRow,
modulesAvailableToAdd.adminCreatedModules,
handleOpenCreateModuleModal,
handleAddExistingModule,
]);
}, [isLargeModuleRow, isSmallModuleRow, globalTemplate, handleOpenCreateModuleModal, handleAddExistingModule]);
return menu;
}

View File

@ -0,0 +1,16 @@
import { CUSTOM_MODULE_TYPES } from '@app/homeV3/modules/constants';
import { PageModuleFragment, PageTemplateFragment } from '@graphql/template.generated';
export function getCustomGlobalModules(globalTemplate: PageTemplateFragment | null) {
const customGlobalModules: PageModuleFragment[] = [];
globalTemplate?.properties.rows.forEach((row) => {
row.modules.forEach((module) => {
if (CUSTOM_MODULE_TYPES.includes(module.properties.type)) {
customGlobalModules.push(module);
}
});
});
return customGlobalModules;
}

View File

@ -1,18 +1,16 @@
import { useDndContext } from '@dnd-kit/core';
import React, { memo } from 'react';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import RowLayout from '@app/homeV3/templateRow/components/RowLayout';
import { useTemplateRowLogic } from '@app/homeV3/templateRow/hooks/useTemplateRowLogic';
import { WrappedRow } from '@app/homeV3/templateRow/types';
interface Props {
row: WrappedRow;
modulesAvailableToAdd: ModulesAvailableToAdd;
rowIndex: number;
}
function TemplateRow({ row, modulesAvailableToAdd, rowIndex }: Props) {
function TemplateRow({ row, rowIndex }: Props) {
const { modulePositions, isSmallRow } = useTemplateRowLogic(row, rowIndex);
const { active } = useDndContext();
const isActiveModuleSmall = active?.data?.current?.isSmall;
@ -29,7 +27,6 @@ function TemplateRow({ row, modulesAvailableToAdd, rowIndex }: Props) {
rowIndex={rowIndex}
modulePositions={modulePositions}
shouldDisableDropZones={isDropZoneDisabled}
modulesAvailableToAdd={modulesAvailableToAdd}
isSmallRow={isSmallRow}
/>
);

View File

@ -3,7 +3,6 @@ import React, { memo } from 'react';
import styled from 'styled-components';
import Module from '@app/homeV3/module/Module';
import { ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import AddModuleButton from '@app/homeV3/template/components/AddModuleButton';
import { ModulePositionInput } from '@app/homeV3/template/types';
import ModuleDropZone from '@app/homeV3/templateRow/components/ModuleDropZone';
@ -25,7 +24,6 @@ interface Props {
rowIndex: number;
modulePositions: ModulePosition[];
shouldDisableDropZones: boolean;
modulesAvailableToAdd: ModulesAvailableToAdd;
isSmallRow: boolean | null;
}
@ -39,15 +37,10 @@ const ModuleWrapper = memo(({ module, position }: ModuleWrapperProps) => (
<Module module={module} position={position} />
));
function RowLayout({ rowIndex, modulePositions, shouldDisableDropZones, modulesAvailableToAdd, isSmallRow }: Props) {
function RowLayout({ rowIndex, modulePositions, shouldDisableDropZones, isSmallRow }: Props) {
return (
<RowWrapper>
<AddModuleButton
orientation="vertical"
modulesAvailableToAdd={modulesAvailableToAdd}
rowIndex={rowIndex}
rowSide="left"
/>
<AddModuleButton orientation="vertical" rowIndex={rowIndex} rowSide="left" />
{/* Drop zone at the beginning of the row */}
<ModuleDropZone
@ -70,12 +63,7 @@ function RowLayout({ rowIndex, modulePositions, shouldDisableDropZones, modulesA
</React.Fragment>
))}
<AddModuleButton
orientation="vertical"
modulesAvailableToAdd={modulesAvailableToAdd}
rowIndex={rowIndex}
rowSide="right"
/>
<AddModuleButton orientation="vertical" rowIndex={rowIndex} rowSide="right" />
</RowWrapper>
);
}