mirror of
https://github.com/datahub-project/datahub.git
synced 2025-11-01 19:25:56 +00:00
feat(customHomePage): add skeleton for adding new modules to a template (#13994)
This commit is contained in:
parent
3c717bac36
commit
296b2d56cd
@ -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
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export const RESET_DROPDOWN_MENU_STYLES_CLASSNAME = 'datahub-reset-dropdown-menu-styles';
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
69
datahub-web-react/src/app/homeV3/modules/constants.ts
Normal file
69
datahub-web-react/src/app/homeV3/modules/constants.ts
Normal 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,
|
||||
];
|
||||
@ -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,
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
18
datahub-web-react/src/app/homeV3/modules/types.ts
Normal file
18
datahub-web-react/src/app/homeV3/modules/types.ts
Normal 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[];
|
||||
};
|
||||
34
datahub-web-react/src/app/homeV3/modules/utils.ts
Normal file
34
datahub-web-react/src/app/homeV3/modules/utils.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -51,6 +51,7 @@ export const ContentDiv = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
export const StyledIcon = styled(Icon)`
|
||||
|
||||
59
datahub-web-react/src/app/homeV3/template/Template.tsx
Normal file
59
datahub-web-react/src/app/homeV3/template/Template.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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} />;
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
10
datahub-web-react/src/app/homeV3/template/types.ts
Normal file
10
datahub-web-react/src/app/homeV3/template/types.ts
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user