feat(customHomePage): add skeleton for adding new modules to a template (#13994)

This commit is contained in:
v-tarasevich-blitz-brain 2025-07-10 18:02:15 +03:00 committed by GitHub
parent 3c717bac36
commit 296b2d56cd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 843 additions and 14 deletions

View File

@ -44,6 +44,13 @@ const meta = {
},
},
},
menu: {
description: 'The menu props from Antd',
},
resetDefaultMenuStyles: {
description:
'Adds styles to reset the default styles in the menu. It works only with the main dropdown. To reset styles in the child popup set popupClassName to RESET_DROPDOWN_MENU_STYLES_CLASSNAME',
},
},
// Define defaults

View File

@ -1,18 +1,34 @@
// Reset antd styles for the menu dropdown including submenu
// Styles can't be modified by styled as submenu is rendering in another portal
import '@components/components/Dropdown/reset-dropdown-menu-styles.less';
import { Dropdown as AntdDropdown } from 'antd';
import React, { useMemo } from 'react';
import { RESET_DROPDOWN_MENU_STYLES_CLASSNAME } from '@components/components/Dropdown/constants';
import { DropdownProps } from '@components/components/Dropdown/types';
import { useOverlayClassStackContext } from '@components/components/Utils/OverlayClassContext/OverlayClassContext';
export default function Dropdown({ children, overlayClassName, ...props }: React.PropsWithChildren<DropdownProps>) {
export default function Dropdown({
children,
overlayClassName,
resetDefaultMenuStyles,
...props
}: React.PropsWithChildren<DropdownProps>) {
// Get all overlay classes from parents
const overlayClassNames = useOverlayClassStackContext();
const finalOverlayClassName = useMemo(() => {
const overlayClassNamesWithDefault = [
...overlayClassNames,
...(resetDefaultMenuStyles ? [RESET_DROPDOWN_MENU_STYLES_CLASSNAME] : []),
];
if (overlayClassName) {
return [...overlayClassNames, overlayClassName].join(' ');
return [...overlayClassNamesWithDefault, overlayClassName].join(' ');
}
return overlayClassNames.join(' ');
}, [overlayClassName, overlayClassNames]);
return overlayClassNamesWithDefault.join(' ');
}, [overlayClassName, overlayClassNames, resetDefaultMenuStyles]);
return (
<AntdDropdown trigger={['click']} {...props} overlayClassName={finalOverlayClassName}>

View File

@ -0,0 +1 @@
export const RESET_DROPDOWN_MENU_STYLES_CLASSNAME = 'datahub-reset-dropdown-menu-styles';

View File

@ -0,0 +1,15 @@
.datahub-reset-dropdown-menu-styles {
& .ant-dropdown-menu {
padding: 8px;
margin: 16px; // compensation of padding as it computes incorrect position of a submenu
}
& .ant-dropdown-menu-submenu,
& .ant-dropdown-menu-item,
& .ant-dropdown-menu-item-group-list,
& .ant-dropdown-menu-item-group-title,
& .ant-dropdown-menu-submenu-title {
padding: 0px;
margin: 0px;
}
}

View File

@ -2,5 +2,15 @@ import { DropdownProps as AntdDropdwonProps } from 'antd';
export type DropdownProps = Pick<
AntdDropdwonProps,
'open' | 'overlayClassName' | 'disabled' | 'dropdownRender' | 'onOpenChange' | 'placement'
>;
| 'open'
| 'overlayClassName'
| 'disabled'
| 'dropdownRender'
| 'onOpenChange'
| 'placement'
| 'menu'
| 'trigger'
| 'destroyPopupOnHide'
> & {
resetDefaultMenuStyles?: boolean;
};

View File

@ -4,7 +4,7 @@ import { useGlobalSettings } from '@app/context/GlobalSettingsContext';
import { useUserContext } from '@app/context/useUserContext';
import { Announcements } from '@app/homeV3/announcements/Announcements';
import { CenteredContainer, ContentContainer, ContentDiv } from '@app/homeV3/styledComponents';
import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
import Template from '@app/homeV3/template/Template';
const HomePageContent = () => {
const { settings } = useGlobalSettings();
@ -17,10 +17,7 @@ const HomePageContent = () => {
<CenteredContainer>
<ContentDiv>
<Announcements />
{template?.properties.rows.map((row, i) => {
const key = `templateRow-${i}`;
return <TemplateRow key={key} row={row} />;
})}
<Template template={template} />
</ContentDiv>
</CenteredContainer>
</ContentContainer>

View File

@ -63,6 +63,8 @@ vi.mock('@components', () => ({
},
}));
const MOCKED_TIMESTAMP = 1752056099724;
describe('LargeModule', () => {
const mockModule: ModuleProps['module'] = {
urn: 'urn:li:dataHubPageModule:test',
@ -73,6 +75,13 @@ describe('LargeModule', () => {
visibility: {
scope: PageModuleScope.Global,
},
created: {
time: MOCKED_TIMESTAMP,
},
lastModified: {
time: MOCKED_TIMESTAMP,
},
params: {},
},
};

View File

@ -0,0 +1,89 @@
import { DEFAULT_MODULE_ICON, MODULE_TYPE_TO_DESCRIPTION, MODULE_TYPE_TO_ICON } from '@app/homeV3/modules/constants';
import {
convertModuleToModuleInfo,
getModuleDescription,
getModuleIcon,
getModuleName,
getModuleType,
} from '@app/homeV3/modules/utils';
import { DataHubPageModule, DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
const MOCKED_TIMESTAMP = 1752056099724;
const MOCKED_MODULE: DataHubPageModule = {
urn: 'urn:li:dataHubPageModule:example',
type: EntityType.DatahubPageModule,
properties: {
created: {
time: MOCKED_TIMESTAMP,
},
lastModified: {
time: MOCKED_TIMESTAMP,
},
name: 'Link 1 (example)',
type: DataHubPageModuleType.Link,
visibility: {
scope: PageModuleScope.Global,
},
params: {},
},
};
describe('getModuleType', () => {
it('should return the correct type from module.properties.type', () => {
const module = MOCKED_MODULE;
expect(getModuleType(module)).toBe(module.properties.type);
});
});
describe('getModuleIcon', () => {
it('should return the corresponding icon from MODULE_TYPE_TO_ICON', () => {
const module = {
...MOCKED_MODULE,
...{ properties: { ...MOCKED_MODULE.properties, type: DataHubPageModuleType.Domains } },
};
const expectedIcon = MODULE_TYPE_TO_ICON.get(DataHubPageModuleType.Domains);
expect(getModuleIcon(module)).toBe(expectedIcon);
});
it('should return the default icon when the type is not found', () => {
const module = {
...MOCKED_MODULE,
...{ properties: { ...MOCKED_MODULE.properties, type: 'UnknownType' as DataHubPageModuleType } },
};
expect(getModuleIcon(module)).toBe(DEFAULT_MODULE_ICON);
});
});
describe('getModuleName', () => {
it('should return the module name from properties', () => {
const module = MOCKED_MODULE;
expect(getModuleName(module)).toBe(module.properties.name);
});
});
describe('getModuleDescription', () => {
it('should return description from MODULE_TYPE_TO_DESCRIPTION map', () => {
const module = MOCKED_MODULE;
const moduleType = getModuleType(module);
const expectedDescription = MODULE_TYPE_TO_DESCRIPTION.get(moduleType);
expect(getModuleDescription(module)).toBe(expectedDescription);
});
});
describe('convertModuleToModuleInfo', () => {
it('should correctly convert DataHubPageModule to ModuleInfo', () => {
const module = MOCKED_MODULE;
const moduleInfo = convertModuleToModuleInfo(module);
expect(moduleInfo).toEqual({
urn: module.urn,
key: module.urn,
type: getModuleType(module),
name: getModuleName(module),
description: getModuleDescription(module),
icon: getModuleIcon(module),
});
});
});

View File

@ -0,0 +1,69 @@
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
export const MODULE_TYPE_TO_DESCRIPTION: Map<DataHubPageModuleType, string> = new Map([
[DataHubPageModuleType.AssetCollection, 'A curated list of assets of your choosing'],
[DataHubPageModuleType.Domains, 'Most used domains in your organization'],
[DataHubPageModuleType.Hierarchy, 'Top down view of assets'],
[DataHubPageModuleType.Link, 'Choose links that are important'],
[DataHubPageModuleType.OwnedAssets, 'Assets the current user owns'],
[DataHubPageModuleType.RichText, 'Pin docs for your DataHub users'],
]);
export const MODULE_TYPE_TO_ICON: Map<DataHubPageModuleType, IconNames> = new Map([
[DataHubPageModuleType.AssetCollection, 'Stack'],
[DataHubPageModuleType.Domains, 'Globe'],
[DataHubPageModuleType.Hierarchy, 'SortAscending'],
[DataHubPageModuleType.Link, 'LinkSimple'],
[DataHubPageModuleType.OwnedAssets, 'Database'],
[DataHubPageModuleType.RichText, 'TextT'],
]);
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 DEFAULT_MODULES: ModuleInfo[] = [
DEFAULT_MODULE_YOUR_ASSETS,
DEFAULT_MODULE_TOP_DOMAINS,
// 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,
];
export const ADD_MODULE_MENU_SECTION_CUSTOM_LARGE_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.Domains,
DataHubPageModuleType.OwnedAssets,
];

View File

@ -0,0 +1,70 @@
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 { convertModuleToModuleInfo } from '@app/homeV3/modules/utils';
import { DataHubPageModule, DataHubPageModuleType, EntityType, PageModuleScope } from '@types';
// TODO: Mocked default modules (should be replaced with the real calling of endpoint once it implemented)
const MOCKED_ADMIN_CREATED_MODULES: DataHubPageModule[] = [
{
urn: 'urn:li:dataHubPageModule:link_admin_1',
type: EntityType.DatahubPageModule,
properties: {
created: {
time: 1752056099724,
},
lastModified: {
time: 1752056099724,
},
name: 'Link 1 (example)',
type: DataHubPageModuleType.Link,
visibility: {
scope: PageModuleScope.Global,
},
params: {},
},
},
{
urn: 'urn:li:dataHubPageModule:link_admin_2',
type: EntityType.DatahubPageModule,
properties: {
created: {
time: 1752056099724,
},
lastModified: {
time: 1752056099724,
},
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.map(convertModuleToModuleInfo);
return {
customModules,
customLargeModules,
adminCreatedModules,
};
}, []);
}

View File

@ -0,0 +1,18 @@
import { IconNames } from '@components';
import { DataHubPageModuleType } from '@types';
export type ModuleInfo = {
key: string;
urn?: string; // Filled in a case of working with an existing module (e.g. admin created modules)
type: DataHubPageModuleType;
name: string;
description?: string;
icon: IconNames;
};
export type ModulesAvailableToAdd = {
customModules: ModuleInfo[];
customLargeModules: ModuleInfo[];
adminCreatedModules: ModuleInfo[];
};

View File

@ -0,0 +1,34 @@
import { IconNames } from '@components';
import { DEFAULT_MODULE_ICON, MODULE_TYPE_TO_DESCRIPTION, MODULE_TYPE_TO_ICON } from '@app/homeV3/modules/constants';
import { ModuleInfo } from '@app/homeV3/modules/types';
import { DataHubPageModule, DataHubPageModuleType } from '@types';
export function getModuleType(module: DataHubPageModule): DataHubPageModuleType {
return module.properties.type;
}
export function getModuleIcon(module: DataHubPageModule): IconNames {
return MODULE_TYPE_TO_ICON.get(getModuleType(module)) ?? DEFAULT_MODULE_ICON;
}
export function getModuleName(module: DataHubPageModule): string {
return module.properties.name;
}
export function getModuleDescription(module: DataHubPageModule): string | undefined {
// TODO: implement getting of the correct description
return MODULE_TYPE_TO_DESCRIPTION.get(getModuleType(module));
}
export function convertModuleToModuleInfo(module: DataHubPageModule): ModuleInfo {
return {
urn: module.urn,
key: module.urn,
type: getModuleType(module),
name: getModuleName(module),
description: getModuleDescription(module),
icon: getModuleIcon(module),
};
}

View File

@ -51,6 +51,7 @@ export const ContentDiv = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
`;
export const StyledIcon = styled(Icon)`

View File

@ -0,0 +1,59 @@
import { spacing } from '@components';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import useModulesAvailableToAdd from '@app/homeV3/modules/hooks/useModulesAvailableToAdd';
import AddModuleButton from '@app/homeV3/template/components/AddModuleButton';
import { AddModuleHandlerInput } from '@app/homeV3/template/types';
import TemplateRow from '@app/homeV3/templateRow/TemplateRow';
import { DataHubPageTemplate } from '@types';
const Wrapper = styled.div`
display: flex;
flex-direction: column;
gap: ${spacing.md};
`;
// Additional margin to have width of content excluding side buttons
const StyledAddModulesButton = styled(AddModuleButton)<{ $hasRows?: boolean }>`
${(props) => props.$hasRows && 'margin: 0 48px;'}
`;
interface Props {
template: DataHubPageTemplate | null | undefined;
className?: string;
}
export default function Template({ template, className }: Props) {
const hasRows = !!template?.properties?.rows?.length;
const onAddModule = useCallback((input: AddModuleHandlerInput) => {
// TODO: implement the real handler
console.log('onAddModule handled with input', input);
}, []);
const modulesAvailableToAdd = useModulesAvailableToAdd();
return (
<Wrapper className={className}>
{template?.properties?.rows.map((row, i) => {
const key = `templateRow-${i}`;
return (
<TemplateRow
key={key}
row={row}
rowIndex={i}
modulesAvailableToAdd={modulesAvailableToAdd}
onAddModule={onAddModule}
/>
);
})}
<StyledAddModulesButton
orientation="horizontal"
$hasRows={hasRows}
modulesAvailableToAdd={modulesAvailableToAdd}
onAddModule={onAddModule}
/>
</Wrapper>
);
}

View File

@ -0,0 +1,110 @@
import { Button, Dropdown, Icon, colors } from '@components';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { ModuleInfo, ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import useAddModuleMenu from '@app/homeV3/template/components/addModuleMenu/useAddModuleMenu';
import { AddModuleHandlerInput, RowSide } from '@app/homeV3/template/types';
type AddModuleButtonOrientation = 'vertical' | 'horizontal';
const Wrapper = styled.div``;
const StyledDropdownContainer = styled.div`
max-width: 330px;
`;
const StyledButton = styled(Button)<{ $orientation: AddModuleButtonOrientation; $opened?: boolean }>`
${(props) =>
props.$orientation === 'vertical'
? `
height: 100%;
width: 32px;
`
: `
width: 32px;
width: 100%;
`}
justify-content: center;
background: ${colors.gray[1600]};
:hover {
background: ${colors.gray[1600]};
}
`;
const StyledVisibleOnHoverButton = styled(StyledButton)`
visibility: hidden;
${Wrapper}:hover & {
visibility: visible;
}
`;
interface Props {
orientation: AddModuleButtonOrientation;
modulesAvailableToAdd: ModulesAvailableToAdd;
onAddModule?: (input: AddModuleHandlerInput) => void;
className?: string;
rowIndex?: number;
rowSide?: RowSide;
}
export default function AddModuleButton({
orientation,
modulesAvailableToAdd,
onAddModule,
className,
rowIndex,
rowSide,
}: Props) {
const [isOpened, setIsOpened] = useState<boolean>(false);
const ButtonComponent = useMemo(() => (isOpened ? StyledButton : StyledVisibleOnHoverButton), [isOpened]);
const onAddModuleHandler = useCallback(
(module: ModuleInfo) => {
setIsOpened(false);
onAddModule?.({
module,
rowIndex,
rowSide,
});
},
[onAddModule, rowIndex, rowSide],
);
const menu = useAddModuleMenu(modulesAvailableToAdd, onAddModuleHandler);
const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
// FYI: Antd can open dropdown in the cursor's position only for contextMenu trigger
// we handle left click and emit contextmenu event instead to open the dropdown in the cursor's position
const event = new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
});
event.target?.dispatchEvent(event);
setIsOpened(true);
};
return (
<Wrapper className={className}>
<Dropdown
open={isOpened}
trigger={['click', 'contextMenu']}
onOpenChange={(open) => setIsOpened(open)}
dropdownRender={(originNode) => <StyledDropdownContainer>{originNode}</StyledDropdownContainer>}
menu={menu}
resetDefaultMenuStyles
>
<ButtonComponent $orientation={orientation} color="gray" variant="text" size="xs" onClick={onClick}>
<Icon icon="Plus" source="phosphor" color="primary" />
</ButtonComponent>
</Dropdown>
</Wrapper>
);
}

View File

@ -0,0 +1,124 @@
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import type { ModuleInfo, ModulesAvailableToAdd } from '@app/homeV3/modules/types';
import useAddModuleMenu from '@app/homeV3/template/components/addModuleMenu/useAddModuleMenu';
import { DataHubPageModuleType } from '@types';
// Mock components that are rendered inside the menu items
vi.mock('@app/homeV3/template/components/addModuleMenu/components/GroupItem', () => ({
__esModule: true,
default: ({ title }: { title: string }) => <div data-testid="group-item">{title}</div>,
}));
vi.mock('@app/homeV3/template/components/addModuleMenu/components/ModuleMenuItem', () => ({
__esModule: true,
default: ({ module }: { module: ModuleInfo }) => <div data-testid={`module-${module.key}`}>{module.name}</div>,
}));
vi.mock('@app/homeV3/template/components/addModuleMenu/components/MenuItem', () => ({
__esModule: true,
default: ({ title }: { title: string }) => <div data-testid="menu-item">{title}</div>,
}));
describe('useAddModuleMenu', () => {
const mockOnClick = vi.fn();
const modulesAvailableToAdd: ModulesAvailableToAdd = {
customModules: [
{ key: 'custom1', name: 'Custom 1', icon: 'Edit', type: DataHubPageModuleType.Link },
{ key: 'custom2', name: 'Custom 2', icon: 'Setting', type: DataHubPageModuleType.Link },
],
customLargeModules: [{ key: 'large1', name: 'Large 1', icon: 'BarChart', type: DataHubPageModuleType.Domains }],
adminCreatedModules: [
{ key: 'admin1', name: 'Admin Widget 1', icon: 'Database', type: DataHubPageModuleType.Link },
],
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should return empty items when no modules are available', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
{
customModules: [],
customLargeModules: [],
adminCreatedModules: [],
},
mockOnClick,
),
);
expect(result.current.items).toHaveLength(0);
});
it('should generate correct items for customModules group', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
{
...modulesAvailableToAdd,
customLargeModules: [],
adminCreatedModules: [],
},
mockOnClick,
),
);
const { items } = result.current;
expect(items).toHaveLength(1);
expect(items?.[0]).toHaveProperty('key', 'customModulesGroup');
// @ts-expect-error SubMenuItem should have children
expect(items?.[0]?.children).toHaveLength(2);
});
it('should generate correct items for customLargeModules group', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
{
...modulesAvailableToAdd,
customModules: [],
adminCreatedModules: [],
},
mockOnClick,
),
);
const { items } = result.current;
expect(items).toHaveLength(1);
expect(items?.[0]).toHaveProperty('key', 'customLargeModulesGroup');
// @ts-expect-error SubMenuItem should have children
expect(items?.[0]?.children).toHaveLength(1);
});
it('should generate correct items for adminCreatedModules group with expandIcon and popupClassName', () => {
const { result } = renderHook(() =>
useAddModuleMenu(
{
...modulesAvailableToAdd,
customModules: [],
customLargeModules: [],
},
mockOnClick,
),
);
const { items } = result.current;
expect(items).toHaveLength(1);
expect(items?.[0]).toHaveProperty('key', 'adminCreatedModulesGroup');
// @ts-expect-error SubMenuItem should have children
expect(items?.[0]?.children).toHaveLength(1);
});
it('should call onClick handler when a module item is clicked', () => {
const { result } = renderHook(() => useAddModuleMenu(modulesAvailableToAdd, mockOnClick));
// @ts-expect-error SubMenuItem should have children
const moduleItem = result.current?.items?.[0]?.children?.[0];
moduleItem.onClick?.({} as any); // simulate click
expect(mockOnClick).toHaveBeenCalledWith(modulesAvailableToAdd.customModules[0]);
});
});

View File

@ -0,0 +1,14 @@
import { Text } from '@components';
import React from 'react';
interface Props {
title: string;
}
export default function GroupItem({ title }: Props) {
return (
<Text color="gray" weight="bold">
{title}
</Text>
);
}

View File

@ -0,0 +1,58 @@
import { Icon, IconNames, Text } from '@components';
import React from 'react';
import styled from 'styled-components';
import spacing from '@components/theme/foundations/spacing';
const Wrapper = styled.div`
display: flex;
gap: ${spacing.xsm};
padding: ${spacing.xsm};
align-items: center;
`;
const Container = styled.div`
display: flex;
flex-direction: column;
text-overflow: ellipsis;
word-wrap: nowrap;
`;
const IconWrapper = styled.div`
display: flex;
flex-shrink: 0;
`;
const SpaceFiller = styled.div`
flex-grow: 1;
`;
interface Props {
icon: IconNames;
title: string;
description?: string;
hasChildren?: boolean;
}
export default function MenuItem({ icon, title, description, hasChildren }: Props) {
return (
<Wrapper>
<IconWrapper>
<Icon icon={icon} source="phosphor" color="gray" size="2xl" />
</IconWrapper>
<Container>
<Text weight="semiBold">{title}</Text>
{description && (
<Text color="gray" colorLevel={1700} size="sm">
{description}
</Text>
)}
</Container>
<SpaceFiller />
{hasChildren && <Icon icon="CaretRight" source="phosphor" color="gray" size="lg" />}
</Wrapper>
);
}

View File

@ -0,0 +1,12 @@
import React from 'react';
import { ModuleInfo } from '@app/homeV3/modules/types';
import MenuItem from '@app/homeV3/template/components/addModuleMenu/components/MenuItem';
interface Props {
module: ModuleInfo;
}
export default function ModuleMenuItem({ module }: Props) {
return <MenuItem description={module.description} title={module.name} icon={module.icon} />;
}

View File

@ -0,0 +1,67 @@
import { MenuProps } from 'antd';
import { ItemType } from 'antd/lib/menu/hooks/useItems';
import React, { useCallback, useMemo } from 'react';
import { RESET_DROPDOWN_MENU_STYLES_CLASSNAME } from '@components/components/Dropdown/constants';
import { ModuleInfo, ModulesAvailableToAdd } from '@app/homeV3/modules/types';
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';
export default function useAddModuleMenu(
modulesAvailableToAdd: ModulesAvailableToAdd,
onClick?: (module: ModuleInfo) => void,
): MenuProps {
const convertModule = useCallback(
(module: ModuleInfo): ItemType => ({
title: module.name,
key: module.key,
label: <ModuleMenuItem module={module} />,
onClick: () => onClick?.(module),
}),
[onClick],
);
return useMemo(() => {
const items: MenuProps['items'] = [];
if (modulesAvailableToAdd.customModules.length) {
items.push({
key: 'customModulesGroup',
label: <GroupItem title="Custom" />,
type: 'group',
children: modulesAvailableToAdd.customModules.map(convertModule),
});
}
if (modulesAvailableToAdd.customLargeModules.length) {
items.push({
key: 'customLargeModulesGroup',
label: <GroupItem title="Custom Large" />,
type: 'group',
children: modulesAvailableToAdd.customLargeModules.map(convertModule),
});
}
if (modulesAvailableToAdd.adminCreatedModules.length) {
items.push({
key: 'adminCreatedModulesGroup',
title: 'Admin Created Widgets',
label: (
<MenuItem
icon="Database"
title="Admin Created Widgets"
description="Your organizations data products"
hasChildren
/>
),
expandIcon: <></>, // hide the default expand icon
popupClassName: RESET_DROPDOWN_MENU_STYLES_CLASSNAME, // reset styles of submenu
children: modulesAvailableToAdd.adminCreatedModules.map(convertModule),
});
}
return { items };
}, [modulesAvailableToAdd, convertModule]);
}

View File

@ -0,0 +1,10 @@
import { ModuleInfo } from '@app/homeV3/modules/types';
export type RowSide = 'left' | 'right';
export interface AddModuleHandlerInput {
module: ModuleInfo;
// When these fields are empty it means adding a module to the new row
rowIndex?: number;
rowSide?: RowSide;
}

View File

@ -2,8 +2,11 @@ import React 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 { AddModuleHandlerInput } from '@app/homeV3/template/types';
import { PageTemplateRowFragment } from '@graphql/template.generated';
import { DataHubPageTemplateRow } from '@types';
const RowWrapper = styled.div`
display: flex;
@ -13,15 +16,34 @@ const RowWrapper = styled.div`
`;
interface Props {
row: PageTemplateRowFragment;
row: DataHubPageTemplateRow;
onAddModule?: (input: AddModuleHandlerInput) => void;
modulesAvailableToAdd: ModulesAvailableToAdd;
rowIndex: number;
}
export default function TemplateRow({ row }: Props) {
export default function TemplateRow({ row, onAddModule, modulesAvailableToAdd, rowIndex }: Props) {
return (
<RowWrapper>
<AddModuleButton
orientation="vertical"
modulesAvailableToAdd={modulesAvailableToAdd}
onAddModule={onAddModule}
rowIndex={rowIndex}
rowSide="left"
/>
{row.modules.map((module) => (
<Module key={module.urn} module={module} />
))}
<AddModuleButton
orientation="vertical"
modulesAvailableToAdd={modulesAvailableToAdd}
onAddModule={onAddModule}
rowIndex={rowIndex}
rowSide="right"
/>
</RowWrapper>
);
}

View File

@ -11,6 +11,12 @@ fragment pageTemplateFields on DataHubPageTemplate {
visibility {
scope
}
created {
time
}
lastModified {
time
}
}
}
@ -26,8 +32,19 @@ fragment PageModule on DataHubPageModule {
properties {
name
type
created {
time
}
lastModified {
time
}
visibility {
scope
}
params {
richTextParams {
content
}
}
}
}