feat(homepage): Add support for Quick Link modules in the new home page (#14141)

Co-authored-by: Chris Collins <chriscollins3456@gmail.com>
This commit is contained in:
purnimagarg1 2025-07-19 01:02:32 +05:30 committed by GitHub
parent 5e98093e31
commit 0c74310e3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 385 additions and 67 deletions

View File

@ -11,6 +11,7 @@ import com.linkedin.datahub.graphql.concurrency.GraphQLConcurrencyUtils;
import com.linkedin.datahub.graphql.exception.AuthorizationException;
import com.linkedin.datahub.graphql.generated.DataHubPageModule;
import com.linkedin.datahub.graphql.generated.DataHubPageModuleType;
import com.linkedin.datahub.graphql.generated.LinkModuleParamsInput;
import com.linkedin.datahub.graphql.generated.PageModuleScope;
import com.linkedin.datahub.graphql.generated.UpsertPageModuleInput;
import com.linkedin.datahub.graphql.types.module.PageModuleMapper;
@ -87,8 +88,18 @@ public class UpsertPageModuleResolver implements DataFetcher<CompletableFuture<D
DataHubPageModuleParams gmsParams = new DataHubPageModuleParams();
if (paramsInput.getLinkParams() != null) {
LinkModuleParamsInput inputValues = paramsInput.getLinkParams();
com.linkedin.module.LinkModuleParams linkParams = new com.linkedin.module.LinkModuleParams();
linkParams.setLinkUrn(UrnUtils.getUrn(paramsInput.getLinkParams().getLinkUrn()));
linkParams.setLinkUrl(inputValues.getLinkUrl());
if (inputValues.getImageUrl() != null) {
linkParams.setImageUrl(inputValues.getImageUrl());
}
if (inputValues.getDescription() != null) {
linkParams.setDescription(inputValues.getDescription());
}
gmsParams.setLinkParams(linkParams);
}

View File

@ -3,9 +3,7 @@ package com.linkedin.datahub.graphql.types.module;
import com.linkedin.common.urn.Urn;
import com.linkedin.datahub.graphql.QueryContext;
import com.linkedin.datahub.graphql.generated.AssetCollectionModuleParams;
import com.linkedin.datahub.graphql.generated.EntityType;
import com.linkedin.datahub.graphql.generated.LinkModuleParams;
import com.linkedin.datahub.graphql.generated.Post;
import com.linkedin.datahub.graphql.generated.RichTextModuleParams;
import com.linkedin.datahub.graphql.types.mappers.ModelMapper;
import com.linkedin.module.DataHubPageModuleParams;
@ -32,13 +30,22 @@ public class PageModuleParamsMapper
new com.linkedin.datahub.graphql.generated.DataHubPageModuleParams();
// Map link params if present
if (params.getLinkParams() != null && params.getLinkParams().getLinkUrn() != null) {
LinkModuleParams linkModuleParams = new LinkModuleParams();
Post link = new Post();
link.setUrn(params.getLinkParams().getLinkUrn().toString());
link.setType(EntityType.POST);
linkModuleParams.setLink(link);
result.setLinkParams(linkModuleParams);
if (params.getLinkParams() != null) {
com.linkedin.module.LinkModuleParams linkParams = params.getLinkParams();
if (linkParams.getLinkUrl() != null) {
LinkModuleParams linkModuleParams = new LinkModuleParams();
linkModuleParams.setLinkUrl(linkParams.getLinkUrl());
if (linkParams.getImageUrl() != null) {
linkModuleParams.setImageUrl(linkParams.getImageUrl());
}
if (linkParams.getDescription() != null) {
linkModuleParams.setDescription(linkParams.getDescription());
}
result.setLinkParams(linkModuleParams);
}
}
// Map rich text params if present

View File

@ -90,9 +90,19 @@ Input for the params required if the module is type LINK
"""
input LinkModuleParamsInput {
"""
The URN of the Post entity containing the link
The URL of the link
"""
linkUrn: String!
linkUrl: String!
"""
The image URL of the link
"""
imageUrl: String
"""
The description of the link
"""
description: String
}
"""
@ -229,9 +239,19 @@ The params required if the module is type LINK
"""
type LinkModuleParams {
"""
The Post entity containing the link
The URL of the link
"""
link: Post!
linkUrl: String!
"""
The image URL of the link
"""
imageUrl: String
"""
The description of the link
"""
description: String
}
"""

View File

@ -138,7 +138,7 @@ public class UpsertPageModuleResolverTest {
PageModuleParamsInput paramsInput = new PageModuleParamsInput();
paramsInput.setLinkParams(new LinkModuleParamsInput());
paramsInput.getLinkParams().setLinkUrn("urn:li:post:test-post");
paramsInput.getLinkParams().setLinkUrl("https://example.com/test-link");
input.setParams(paramsInput);
Urn moduleUrn = UrnUtils.getUrn(TEST_MODULE_URN);
@ -286,7 +286,7 @@ public class UpsertPageModuleResolverTest {
// Set link params instead of rich text params
PageModuleParamsInput paramsInput = new PageModuleParamsInput();
paramsInput.setLinkParams(new LinkModuleParamsInput());
paramsInput.getLinkParams().setLinkUrn("urn:li:post:test-post");
paramsInput.getLinkParams().setLinkUrl("https://example.com/test-link");
input.setParams(paramsInput);
when(mockEnvironment.getArgument("input")).thenReturn(input);

View File

@ -202,7 +202,9 @@ public class PageModuleTypeTest {
com.linkedin.module.DataHubPageModuleParams params =
new com.linkedin.module.DataHubPageModuleParams();
com.linkedin.module.LinkModuleParams linkParams = new com.linkedin.module.LinkModuleParams();
linkParams.setLinkUrn(UrnUtils.getUrn("urn:li:post:test-post"));
linkParams.setLinkUrl("https://example.com/test-link");
params.setLinkParams(linkParams);
gmsProperties.setParams(params);

View File

@ -6,6 +6,7 @@ import YourAssetsModule from '@app/homeV3/modules/YourAssetsModule';
import AssetCollectionModule from '@app/homeV3/modules/assetCollection/AssetCollectionModule';
import DocumentationModule from '@app/homeV3/modules/documentation/DocumentationModule';
import TopDomainsModule from '@app/homeV3/modules/domains/TopDomainsModule';
import LinkModule from '@app/homeV3/modules/link/LinkModule';
import { DataHubPageModuleType } from '@types';
@ -17,6 +18,7 @@ function Module(props: ModuleProps) {
if (module.properties.type === DataHubPageModuleType.OwnedAssets) return YourAssetsModule;
if (module.properties.type === DataHubPageModuleType.Domains) return TopDomainsModule;
if (module.properties.type === DataHubPageModuleType.AssetCollection) return AssetCollectionModule;
if (module.properties.type === DataHubPageModuleType.Link) return LinkModule;
if (module.properties.type === DataHubPageModuleType.RichText) return DocumentationModule;
// TODO: remove the sample large module once we have other modules to fill this out

View File

@ -7,6 +7,7 @@ import ModuleContainer from '@app/homeV3/module/components/ModuleContainer';
import ModuleMenu from '@app/homeV3/module/components/ModuleMenu';
import ModuleName from '@app/homeV3/module/components/ModuleName';
import { ModuleProps } from '@app/homeV3/module/types';
import { FloatingRightHeaderSection } from '@app/homeV3/styledComponents';
const ModuleHeader = styled.div`
position: relative;
@ -33,18 +34,6 @@ const DragHandle = styled.div<{ $isDragging?: boolean }>`
flex: 1;
`;
const FloatingRightHeaderSection = styled.div`
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding-right: 16px;
right: 0px;
top: 0px;
height: 100%;
`;
const Content = styled.div<{ $hasViewAll: boolean }>`
margin: 16px;
overflow-y: auto;

View File

@ -1,14 +1,12 @@
import { borders, colors, radius } from '@components';
import styled from 'styled-components';
const ModuleContainer = styled.div<{ $height: string; $isDragging?: boolean }>`
const ModuleContainer = styled.div<{ $height?: string; $isDragging?: boolean }>`
background: ${colors.white};
border: ${borders['1px']} ${colors.gray[100]};
border-radius: ${radius.lg};
flex: 1;
overflow-x: hidden;
height: ${(props) => props.$height};
box-shadow:
0px 2px 18px 0px rgba(17, 7, 69, 0.01),
0px 4px 12px 0px rgba(17, 7, 69, 0.03);
@ -24,6 +22,12 @@ const ModuleContainer = styled.div<{ $height: string; $isDragging?: boolean }>`
transform: translateZ(0) scale(1.02);
opacity: 0.5;
`}
${(props) =>
props.$height &&
`
height: ${props.$height};
`}
`;
export default ModuleContainer;

View File

@ -15,6 +15,8 @@ const StyledIcon = styled(Icon)`
}
` as typeof Icon;
const DropdownWrapper = styled.div``;
interface Props {
module: PageModuleFragment;
position: ModulePositionInput;
@ -40,39 +42,45 @@ export default function ModuleMenu({ module, position }: Props) {
});
}, [removeModule, module.urn, position]);
const handleMenuClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
}, []);
return (
<Dropdown
trigger={['click']}
menu={{
items: [
...(canEdit
? [
{
title: 'Edit',
key: 'edit',
label: 'Edit',
style: {
color: colors.gray[600],
fontSize: '14px',
<DropdownWrapper onClick={handleMenuClick}>
<Dropdown
trigger={['click']}
menu={{
items: [
...(canEdit
? [
{
title: 'Edit',
key: 'edit',
label: 'Edit',
style: {
color: colors.gray[600],
fontSize: '14px',
},
onClick: handleEditModule,
},
onClick: handleEditModule,
},
]
: []),
{
title: 'Delete',
label: 'Delete',
key: 'delete',
style: {
color: colors.red[500],
fontSize: '14px',
]
: []),
{
title: 'Delete',
label: 'Delete',
key: 'delete',
style: {
color: colors.red[500],
fontSize: '14px',
},
onClick: handleDelete,
},
onClick: handleDelete,
},
],
}}
>
<StyledIcon icon="DotsThreeVertical" source="phosphor" size="lg" />
</Dropdown>
],
}}
>
<StyledIcon icon="DotsThreeVertical" source="phosphor" size="lg" />
</Dropdown>
</DropdownWrapper>
);
}

View File

@ -0,0 +1,39 @@
import React from 'react';
import styled from 'styled-components';
import ModuleContainer from '@app/homeV3/module/components/ModuleContainer';
import ModuleMenu from '@app/homeV3/module/components/ModuleMenu';
import { ModuleProps } from '@app/homeV3/module/types';
import { FloatingRightHeaderSection } from '@app/homeV3/styledComponents';
const Container = styled.div`
display: flex;
flex-direction: column;
position: relative;
height: 100%;
justify-content: center;
`;
const Content = styled.div`
margin: 16px 32px 16px 16px;
position: relative;
`;
const StyledModuleContainer = styled(ModuleContainer)<{ clickable?: boolean }>`
max-height: 72px;
${({ clickable }) => clickable && `cursor: pointer;`}
`;
export default function SmallModule({ children, module, position, onClick }: React.PropsWithChildren<ModuleProps>) {
return (
<StyledModuleContainer clickable={!!onClick} onClick={onClick}>
<Container>
<Content>{children}</Content>
<FloatingRightHeaderSection>
<ModuleMenu module={module} position={position} />
</FloatingRightHeaderSection>
</Container>
</StyledModuleContainer>
);
}

View File

@ -14,6 +14,7 @@ vi.mock('styled-components', () => {
};
styledFactory.div = styledFactory('div');
styledFactory.button = styledFactory('button');
styledFactory.Icon = styledFactory(() => null);
return {
__esModule: true,
default: styledFactory,
@ -45,6 +46,7 @@ vi.mock('@components', () => ({
),
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children }: any) => <span>{children}</span>,
Icon: () => <svg />,
borders: {
'1px': '1px solid',
},

View File

@ -7,4 +7,5 @@ import { PageModuleFragment } from '@graphql/template.generated';
export interface ModuleProps {
module: PageModuleFragment;
position: ModulePositionInput;
onClick?: () => void;
}

View File

@ -3,6 +3,7 @@ import React, { useMemo } from 'react';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import AssetCollectionModal from '@app/homeV3/modules/assetCollection/AssetCollectionModal';
import DocumentationModal from '@app/homeV3/modules/documentation/DocumentationModal';
import LinkModal from '@app/homeV3/modules/link/LinkModal';
import { DataHubPageModuleType } from '@types';
@ -16,6 +17,8 @@ export default function ModuleModalMapper() {
// TODO: add support of other module types
case DataHubPageModuleType.AssetCollection:
return AssetCollectionModal;
case DataHubPageModuleType.Link:
return LinkModal;
case DataHubPageModuleType.RichText:
return DocumentationModal;
default:

View File

@ -14,6 +14,7 @@ interface Props {
title: string;
subtitle?: string;
onUpsert: () => void;
width?: string;
submitButtonProps?: Partial<ModalButton>;
}
@ -22,6 +23,7 @@ export default function BaseModuleModal({
subtitle,
children,
onUpsert,
width,
submitButtonProps,
}: React.PropsWithChildren<Props>) {
const {
@ -54,7 +56,7 @@ export default function BaseModuleModal({
onCancel={close}
maskClosable={false} // to avoid accidental clicks that closes the modal
bodyStyle={modalBodyStyles}
width="70%"
width={width || '90%'}
style={{ maxWidth: 1100 }}
>
{children}

View File

@ -81,3 +81,11 @@ export const DEFAULT_GLOBAL_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.OwnedAssets,
DataHubPageModuleType.Domains,
];
export const LARGE_MODULE_TYPES: DataHubPageModuleType[] = [
DataHubPageModuleType.OwnedAssets,
DataHubPageModuleType.Domains,
DataHubPageModuleType.AssetCollection,
DataHubPageModuleType.Hierarchy,
DataHubPageModuleType.RichText,
];

View File

@ -0,0 +1,38 @@
import { Input, TextArea } from '@components';
import { Form, FormInstance } from 'antd';
import React from 'react';
import { LinkModuleParams } from '@types';
interface Props {
form: FormInstance;
formValues?: LinkModuleParams;
}
export default function LinkForm({ form, formValues }: Props) {
return (
<Form form={form} initialValues={formValues}>
<Form.Item
name="linkUrl"
rules={[
{
required: true,
message: 'Please enter the link URL',
},
{
type: 'url',
message: 'Please enter a valid URL',
},
]}
>
<Input label="Link" placeholder="https://www.datahub.com" isRequired />
</Form.Item>
<Form.Item name="imageUrl">
<Input label="Image URL (Optional)" placeholder="Your image URL" />
</Form.Item>
<Form.Item name="description">
<TextArea label="Description (Optional)" placeholder="Add description..." />
</Form.Item>
</Form>
);
}

View File

@ -0,0 +1,69 @@
import { Form } from 'antd';
import React from 'react';
import styled from 'styled-components';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import BaseModuleModal from '@app/homeV3/moduleModals/common/BaseModuleModal';
import ModuleDetailsForm from '@app/homeV3/moduleModals/common/ModuleDetailsForm';
import LinkForm from '@app/homeV3/modules/link/LinkForm';
import { DataHubPageModuleType } from '@types';
const ModalContent = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export default function LinkModal() {
const {
upsertModule,
moduleModalState: { position, close, isEditing, initialState },
} = usePageTemplateContext();
const [form] = Form.useForm();
const currentName = initialState?.properties.name || '';
const linkParams = initialState?.properties?.params?.linkParams;
const urn = initialState?.urn;
/* TODO: Uncomment to validate and disable button once the Rich Text module PR is merged
const titleValue = Form.useWatch('title', form);
const linkUrlValue = Form.useWatch('link', form);
const isDisabled = !titleValue?.trim() || !linkUrlValue?.trim(); */
const handleUpsertDocumentationModule = () => {
form.validateFields().then((values) => {
const { name, linkUrl, imageUrl, description } = values;
upsertModule({
urn,
name,
position: position ?? {},
type: DataHubPageModuleType.Link,
params: {
linkParams: {
linkUrl,
imageUrl,
description,
},
},
});
close();
});
};
return (
<BaseModuleModal
title={`${isEditing ? 'Edit' : 'Add'} Quick Link`}
subtitle="Add links to your home page"
onUpsert={handleUpsertDocumentationModule}
width="40%"
/* TODO: Uncomment to validate and disable button once the Rich Text module PR is merged
submitButtonProps={{ disabled: isDisabled }} */
>
<ModalContent>
<ModuleDetailsForm form={form} formValues={{ name: currentName }} />
<LinkForm form={form} formValues={linkParams ?? undefined} />
</ModalContent>
</BaseModuleModal>
);
}

View File

@ -0,0 +1,75 @@
import { Icon, Text } from '@components';
import React from 'react';
import styled from 'styled-components';
import SmallModule from '@app/homeV3/module/components/SmallModule';
import { ModuleProps } from '@app/homeV3/module/types';
const Container = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-right: 8px;
`;
const RightSection = styled.div`
display: flex;
`;
const LeftSection = styled.div`
display: flex;
gap: 8px;
align-items: center;
`;
const TextSection = styled.div`
display: flex;
flex-direction: column;
`;
const Image = styled.img`
height: 24px;
width: 24px;
object-fit: contain;
background-color: transparent;
`;
export default function LinkModule(props: ModuleProps) {
const { name } = props.module.properties;
const { linkParams } = props.module.properties.params;
function goToLink() {
if (linkParams?.linkUrl) {
window.open(linkParams.linkUrl, '_blank');
}
}
return (
<SmallModule {...props} onClick={goToLink}>
<Container>
<LeftSection>
{linkParams?.imageUrl ? (
<Image src={linkParams?.imageUrl} />
) : (
<Icon icon="LinkSimple" source="phosphor" size="3xl" color="gray" />
)}
<TextSection>
<Text color="gray" colorLevel={600} weight="bold" size="lg" lineHeight="normal">
{name}
</Text>
{linkParams?.description && (
<Text color="gray" size="sm">
{linkParams?.description}
</Text>
)}
</TextSection>
</LeftSection>
<RightSection>
<a href={linkParams?.linkUrl} target="_blank" rel="noopener noreferrer">
<Icon icon="ArrowUpRight" source="phosphor" size="lg" color="gray" />
</a>
</RightSection>
</Container>
</SmallModule>
);
}

View File

@ -0,0 +1,5 @@
export type LinkFormValues = {
linkUrl: string;
imageUrl?: string;
description?: string;
};

View File

@ -89,3 +89,15 @@ export const EmptyContainer = styled.div`
justify-content: center;
align-items: center;
`;
export const FloatingRightHeaderSection = styled.div`
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
padding-right: 16px;
right: 0px;
top: 0px;
height: 100%;
`;

View File

@ -4,6 +4,7 @@ import React, { useCallback, useMemo } from 'react';
import { RESET_DROPDOWN_MENU_STYLES_CLASSNAME } from '@components/components/Dropdown/constants';
import { usePageTemplateContext } from '@app/homeV3/context/PageTemplateContext';
import { LARGE_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';
@ -44,8 +45,15 @@ export default function useAddModuleMenu(
const {
addModule,
moduleModalState: { open: openModal },
template,
} = usePageTemplateContext();
const isRowWithLargeModule =
position.rowIndex !== undefined &&
template?.properties.rows[position.rowIndex]?.modules?.some((module) =>
LARGE_MODULE_TYPES.includes(module.properties.type),
);
const handleAddExistingModule = useCallback(
(module: PageModuleFragment) => {
addModule({
@ -73,8 +81,9 @@ export default function useAddModuleMenu(
key: 'quick-link',
label: <MenuItem description="Choose links that are important" title="Quick Link" icon="LinkSimple" />,
onClick: () => {
// TODO: open up modal to add a quick link
handleOpenCreateModuleModal(DataHubPageModuleType.Link);
},
disabled: isRowWithLargeModule,
};
const documentation = {
@ -160,7 +169,12 @@ export default function useAddModuleMenu(
}
return { items };
}, [modulesAvailableToAdd.adminCreatedModules, handleAddExistingModule, handleOpenCreateModuleModal]);
}, [
isRowWithLargeModule,
modulesAvailableToAdd.adminCreatedModules,
handleOpenCreateModuleModal,
handleAddExistingModule,
]);
return menu;
}

View File

@ -36,6 +36,11 @@ fragment PageModule on DataHubPageModule {
assetCollectionParams {
assetUrns
}
linkParams {
linkUrl
imageUrl
description
}
}
}
}

View File

@ -10,7 +10,9 @@ record DataHubPageModuleParams {
* The params required if the module is type LINK
*/
linkParams: optional record LinkModuleParams {
linkUrn: Urn
linkUrl: string
imageUrl: optional string
description: optional string
}
/**