From 4f98d15d9aefa2562c2038502c6f6cf636140131 Mon Sep 17 00:00:00 2001
From: purnimagarg1 <139125209+purnimagarg1@users.noreply.github.com>
Date: Wed, 27 Aug 2025 23:19:23 +0530
Subject: [PATCH] feat(ui/summary): build add, view and delete link
functionalities in the new summary tab (#14540)
---
.../Avatar/AvatarPillWithLinkAndHover.tsx | 42 ++++
.../src/app/entityV2/summary/SummaryTab.tsx | 10 +-
.../entityV2/summary/links/AddLinkButton.tsx | 25 +++
.../entityV2/summary/links/AddLinkModal.tsx | 64 +++++++
.../app/entityV2/summary/links/LinkItem.tsx | 79 ++++++++
.../src/app/entityV2/summary/links/Links.tsx | 20 ++
.../app/entityV2/summary/links/LinksList.tsx | 64 +++++++
.../links/__tests__/useLinkUtils.test.ts | 181 ++++++++++++++++++
.../entityV2/summary/links/useLinkUtils.ts | 62 ++++++
9 files changed, 546 insertions(+), 1 deletion(-)
create mode 100644 datahub-web-react/src/alchemy-components/components/Avatar/AvatarPillWithLinkAndHover.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/AddLinkButton.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/AddLinkModal.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/LinkItem.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/Links.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/LinksList.tsx
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/__tests__/useLinkUtils.test.ts
create mode 100644 datahub-web-react/src/app/entityV2/summary/links/useLinkUtils.ts
diff --git a/datahub-web-react/src/alchemy-components/components/Avatar/AvatarPillWithLinkAndHover.tsx b/datahub-web-react/src/alchemy-components/components/Avatar/AvatarPillWithLinkAndHover.tsx
new file mode 100644
index 0000000000..8c868fa847
--- /dev/null
+++ b/datahub-web-react/src/alchemy-components/components/Avatar/AvatarPillWithLinkAndHover.tsx
@@ -0,0 +1,42 @@
+import { Avatar } from '@components';
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+import { AvatarType } from '@components/components/AvatarStack/types';
+import { AvatarSizeOptions } from '@components/theme/config';
+
+import EntityRegistry from '@app/entityV2/EntityRegistry';
+import { HoverEntityTooltip } from '@app/recommendations/renderer/component/HoverEntityTooltip';
+
+import { CorpGroup, CorpUser, EntityType } from '@types';
+
+interface Props {
+ user?: CorpUser | CorpGroup;
+ size?: AvatarSizeOptions;
+ entityRegistry: EntityRegistry;
+}
+
+export default function AvatarPillWithLinkAndHover({ user, size, entityRegistry }: Props) {
+ if (!user) return null;
+
+ const avatarUrl = user.editableProperties?.pictureLink;
+
+ return (
+
+ {
+ e.stopPropagation();
+ }}
+ >
+
+
+
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/SummaryTab.tsx b/datahub-web-react/src/app/entityV2/summary/SummaryTab.tsx
index 4f107a1f5a..7b20fa0d40 100644
--- a/datahub-web-react/src/app/entityV2/summary/SummaryTab.tsx
+++ b/datahub-web-react/src/app/entityV2/summary/SummaryTab.tsx
@@ -4,6 +4,7 @@ import React from 'react';
import styled from 'styled-components';
import AboutSection from '@app/entityV2/summary/documentation/AboutSection';
+import Links from '@app/entityV2/summary/links/Links';
import PropertiesHeader from '@app/entityV2/summary/properties/PropertiesHeader';
const SummaryWrapper = styled.div`
@@ -18,12 +19,19 @@ const StyledDivider = styled(Divider)`
color: ${colors.gray[100]};
`;
-export default function SummaryTab() {
+interface Props {
+ hideLinksButton?: boolean;
+}
+
+export default function SummaryTab({ properties }: { properties?: Props }) {
+ const hideLinksButton = properties?.hideLinksButton;
+
return (
+ {!hideLinksButton && }
);
diff --git a/datahub-web-react/src/app/entityV2/summary/links/AddLinkButton.tsx b/datahub-web-react/src/app/entityV2/summary/links/AddLinkButton.tsx
new file mode 100644
index 0000000000..7a9d9ef0f4
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/AddLinkButton.tsx
@@ -0,0 +1,25 @@
+import { Button } from '@components';
+import React, { useState } from 'react';
+import styled from 'styled-components';
+
+import AddLinkModal from '@app/entityV2/summary/links/AddLinkModal';
+
+const StyledButton = styled(Button)`
+ width: fit-content;
+`;
+
+export default function AddLinkButton() {
+ const [showAddLinkModal, setShowAddLinkModal] = useState(false);
+
+ const handleButtonClick = () => {
+ setShowAddLinkModal(true);
+ };
+ return (
+ <>
+
+ Add Link
+
+ {showAddLinkModal && }
+ >
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/links/AddLinkModal.tsx b/datahub-web-react/src/app/entityV2/summary/links/AddLinkModal.tsx
new file mode 100644
index 0000000000..305a90ffb2
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/AddLinkModal.tsx
@@ -0,0 +1,64 @@
+import { Input, Modal } from '@components';
+import { Form } from 'antd';
+import { useForm } from 'antd/lib/form/Form';
+import React from 'react';
+
+import { ModalButton } from '@components/components/Modal/Modal';
+
+import { useLinkUtils } from '@app/entityV2/summary/links/useLinkUtils';
+
+type Props = {
+ setShowAddLinkModal: React.Dispatch>;
+};
+
+export default function AddLinkModal({ setShowAddLinkModal }: Props) {
+ const [form] = useForm();
+ const { handleAddLink } = useLinkUtils();
+
+ const handleClose = () => {
+ setShowAddLinkModal(false);
+ };
+
+ const handleAdd = () => {
+ form.validateFields()
+ .then((values) => handleAddLink(values))
+ .then(() => handleClose());
+ };
+
+ const buttons: ModalButton[] = [
+ { text: 'Cancel', variant: 'outline', color: 'gray', onClick: handleClose },
+ { text: 'Add', variant: 'filled', onClick: handleAdd },
+ ];
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/links/LinkItem.tsx b/datahub-web-react/src/app/entityV2/summary/links/LinkItem.tsx
new file mode 100644
index 0000000000..bc62c0f809
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/LinkItem.tsx
@@ -0,0 +1,79 @@
+import { Icon, Text, colors } from '@components';
+import React from 'react';
+import styled from 'styled-components';
+
+import AvatarPillWithLinkAndHover from '@components/components/Avatar/AvatarPillWithLinkAndHover';
+
+import { formatDateString } from '@app/entityV2/shared/containers/profile/utils';
+import { useEntityRegistryV2 } from '@app/useEntityRegistry';
+
+import { InstitutionalMemoryMetadata } from '@types';
+
+const LinkContainer = styled.div`
+ display: flex;
+ width: 100%;
+ border-radius: 8px;
+ background-color: ${colors.gray[1500]};
+ justify-content: space-between;
+ padding: 8px 4px;
+`;
+
+const LeftSection = styled.div`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+`;
+
+const RightSection = styled.div`
+ display: flex;
+ gap: 8px;
+ align-items: center;
+`;
+
+const StyledIcon = styled(Icon)`
+ :hover {
+ cursor: pointer;
+ }
+`;
+
+type Props = {
+ link: InstitutionalMemoryMetadata;
+ setSelectedLink: (link: InstitutionalMemoryMetadata | null) => void;
+ setShowConfirmDelete: (show: boolean) => void;
+};
+
+export default function LinkItem({ link, setSelectedLink, setShowConfirmDelete }: Props) {
+ const entityRegistry = useEntityRegistryV2();
+ const createdBy = link.actor;
+
+ return (
+
+
+
+
+
+ {link.description || link.label}
+
+
+
+
+ Added {formatDateString(link.created.time)} by{' '}
+
+
+
+ {
+ e.preventDefault();
+ setSelectedLink(link);
+ setShowConfirmDelete(true);
+ }}
+ />
+
+
+
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/links/Links.tsx b/datahub-web-react/src/app/entityV2/summary/links/Links.tsx
new file mode 100644
index 0000000000..e63cf9dc3d
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/Links.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import styled from 'styled-components';
+
+import AddLinkButton from '@app/entityV2/summary/links/AddLinkButton';
+import LinksList from '@app/entityV2/summary/links/LinksList';
+
+const LinksSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export default function Links() {
+ return (
+
+
+
+
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/links/LinksList.tsx b/datahub-web-react/src/app/entityV2/summary/links/LinksList.tsx
new file mode 100644
index 0000000000..960d622962
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/LinksList.tsx
@@ -0,0 +1,64 @@
+import React, { useState } from 'react';
+import styled from 'styled-components';
+
+import { useEntityData } from '@app/entity/shared/EntityContext';
+import LinkItem from '@app/entityV2/summary/links/LinkItem';
+import { useLinkUtils } from '@app/entityV2/summary/links/useLinkUtils';
+import { ConfirmationModal } from '@app/sharedV2/modals/ConfirmationModal';
+
+import { InstitutionalMemoryMetadata } from '@types';
+
+const ListContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+`;
+
+export default function LinksList() {
+ const { entityData } = useEntityData();
+ const links = entityData?.institutionalMemory?.elements || [];
+ const [showConfirmDelete, setShowConfirmDelete] = useState(false);
+ const [selectedLink, setSelectedLink] = useState(null);
+ const { handleDeleteLink } = useLinkUtils();
+
+ if (links.length === 0) {
+ return null;
+ }
+
+ const handleDelete = () => {
+ if (selectedLink) {
+ handleDeleteLink(selectedLink).then(() => {
+ setSelectedLink(null);
+ setShowConfirmDelete(false);
+ });
+ }
+ };
+
+ const handleCloseModal = () => {
+ setShowConfirmDelete(false);
+ setSelectedLink(null);
+ };
+
+ return (
+ <>
+
+ {links.map((link) => {
+ return (
+
+ );
+ })}
+
+
+ >
+ );
+}
diff --git a/datahub-web-react/src/app/entityV2/summary/links/__tests__/useLinkUtils.test.ts b/datahub-web-react/src/app/entityV2/summary/links/__tests__/useLinkUtils.test.ts
new file mode 100644
index 0000000000..aa1f851e3a
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/__tests__/useLinkUtils.test.ts
@@ -0,0 +1,181 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { message } from 'antd';
+import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import analytics, { EntityActionType, EventType } from '@app/analytics';
+// Helpers
+import { useUserContext } from '@app/context/useUserContext';
+import { useEntityData, useMutationUrn, useRefetch } from '@app/entity/shared/EntityContext';
+import { useLinkUtils } from '@app/entityV2/summary/links/useLinkUtils';
+
+import { EntityType } from '@types';
+
+// Mock external modules
+vi.mock('@app/context/useUserContext', () => ({
+ useUserContext: vi.fn(),
+}));
+
+vi.mock('@app/entity/shared/EntityContext', () => ({
+ useEntityData: vi.fn(),
+ useMutationUrn: vi.fn(),
+ useRefetch: vi.fn(),
+}));
+
+const removeLinkMutationMock = vi.fn();
+const addLinkMutationMock = vi.fn();
+vi.mock('@graphql/mutations.generated', () => ({
+ useRemoveLinkMutation: () => [removeLinkMutationMock],
+ useAddLinkMutation: () => [addLinkMutationMock],
+}));
+
+vi.mock('antd', () => ({
+ message: {
+ success: vi.fn(),
+ error: vi.fn(),
+ destroy: vi.fn(),
+ },
+}));
+
+vi.mock('@app/analytics', () => ({
+ __esModule: true,
+ default: { event: vi.fn() },
+ EventType: { EntityActionEvent: 'EntityActionEvent' },
+ EntityActionType: { UpdateLinks: 'UpdateLinks' },
+}));
+
+const mockUserContext = (user: any) => {
+ (useUserContext as Mock).mockReturnValue(user);
+};
+
+const mockEntityContext = ({
+ urn = 'test-urn',
+ entityType = 'dataset',
+ refetch = vi.fn(),
+ mutationUrn = 'mutation-urn',
+} = {}) => {
+ (useEntityData as Mock).mockReturnValue({ urn, entityType });
+ (useRefetch as Mock).mockReturnValue(refetch);
+ (useMutationUrn as Mock).mockReturnValue(mutationUrn);
+ return { urn, entityType, refetch, mutationUrn };
+};
+
+// Mock link
+const mockDeleteLinkInput = {
+ url: 'http://test.com',
+ associatedUrn: 'urn:foo',
+ actor: { urn: 'urn:actor', type: EntityType.CorpUser, username: 'actor' },
+ author: { urn: 'urn:author', type: EntityType.CorpUser, username: 'author' },
+ created: { time: Date.now() },
+ description: 'desc',
+ label: 'label',
+};
+
+beforeEach(() => {
+ vi.clearAllMocks();
+});
+
+describe('useLinkUtils', () => {
+ describe('handleDeleteLink', () => {
+ it('should remove a link and show success message when mutation succeeds', async () => {
+ const { refetch } = mockEntityContext();
+ mockUserContext({ urn: 'user-1' });
+
+ removeLinkMutationMock.mockResolvedValueOnce({});
+
+ const { result } = renderHook(() => useLinkUtils());
+ await act(async () => {
+ await result.current.handleDeleteLink(mockDeleteLinkInput);
+ });
+
+ expect(removeLinkMutationMock).toHaveBeenCalledWith({
+ variables: { input: { linkUrl: 'http://test.com', resourceUrn: 'urn:foo' } },
+ });
+ expect(message.success).toHaveBeenCalledWith({ content: 'Link Removed', duration: 2 });
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ it('should show error message when link removal fails', async () => {
+ const { refetch } = mockEntityContext();
+ mockUserContext({ urn: 'user-1' });
+
+ removeLinkMutationMock.mockRejectedValueOnce(new Error('Network issue'));
+
+ const { result } = renderHook(() => useLinkUtils());
+ await act(async () => {
+ await result.current.handleDeleteLink(mockDeleteLinkInput);
+ });
+
+ expect(message.destroy).toHaveBeenCalled();
+ expect(message.error).toHaveBeenCalledWith({
+ content: expect.stringContaining('Error removing link:'),
+ duration: 2,
+ });
+ expect(refetch).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleAddLink', () => {
+ it('should add a link, show success message, trigger analytics event, and refetch when mutation succeeds', async () => {
+ const { refetch, mutationUrn, entityType } = mockEntityContext();
+ mockUserContext({ urn: 'user-1' });
+
+ addLinkMutationMock.mockResolvedValueOnce({});
+
+ const { result } = renderHook(() => useLinkUtils());
+ await act(async () => {
+ await result.current.handleAddLink({ url: 'http://test.com.com', label: 'Test Link' });
+ });
+
+ expect(addLinkMutationMock).toHaveBeenCalledWith({
+ variables: {
+ input: { linkUrl: 'http://test.com.com', label: 'Test Link', resourceUrn: mutationUrn },
+ },
+ });
+
+ expect(message.success).toHaveBeenCalledWith({ content: 'Link Added', duration: 2 });
+
+ expect(analytics.event).toHaveBeenCalledWith({
+ type: EventType.EntityActionEvent,
+ entityType,
+ entityUrn: mutationUrn,
+ actionType: EntityActionType.UpdateLinks,
+ });
+
+ expect(refetch).toHaveBeenCalled();
+ });
+
+ it('should show error message when link add mutation fails', async () => {
+ mockEntityContext();
+ mockUserContext({ urn: 'user-1' });
+
+ addLinkMutationMock.mockRejectedValueOnce(new Error('Add failed'));
+
+ const { result } = renderHook(() => useLinkUtils());
+ await act(async () => {
+ await result.current.handleAddLink({ url: 'bad-url', label: 'Bad' });
+ });
+
+ expect(message.destroy).toHaveBeenCalled();
+ expect(message.error).toHaveBeenCalledWith({
+ content: expect.stringContaining('Failed to add link:'),
+ duration: 3,
+ });
+ });
+
+ it('should show error message when adding link without a user context', async () => {
+ mockEntityContext();
+ mockUserContext(undefined);
+
+ const { result } = renderHook(() => useLinkUtils());
+ await act(async () => {
+ await result.current.handleAddLink({ url: 'test', label: 'no-user' });
+ });
+
+ expect(message.error).toHaveBeenCalledWith({
+ content: 'Error adding link: no user',
+ duration: 2,
+ });
+ expect(addLinkMutationMock).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/datahub-web-react/src/app/entityV2/summary/links/useLinkUtils.ts b/datahub-web-react/src/app/entityV2/summary/links/useLinkUtils.ts
new file mode 100644
index 0000000000..e7c62479c3
--- /dev/null
+++ b/datahub-web-react/src/app/entityV2/summary/links/useLinkUtils.ts
@@ -0,0 +1,62 @@
+import { message } from 'antd';
+
+import analytics, { EntityActionType, EventType } from '@app/analytics';
+import { useUserContext } from '@app/context/useUserContext';
+import { useEntityData, useMutationUrn, useRefetch } from '@app/entity/shared/EntityContext';
+
+import { useAddLinkMutation, useRemoveLinkMutation } from '@graphql/mutations.generated';
+import { InstitutionalMemoryMetadata } from '@types';
+
+export function useLinkUtils() {
+ const { urn: entityUrn, entityType } = useEntityData();
+ const refetch = useRefetch();
+ const mutationUrn = useMutationUrn();
+ const user = useUserContext();
+
+ const [removeLinkMutation] = useRemoveLinkMutation();
+ const [addLinkMutation] = useAddLinkMutation();
+
+ const handleDeleteLink = async (link: InstitutionalMemoryMetadata) => {
+ try {
+ await removeLinkMutation({
+ variables: { input: { linkUrl: link.url, resourceUrn: link.associatedUrn || entityUrn } },
+ });
+ message.success({ content: 'Link Removed', duration: 2 });
+ } catch (e: unknown) {
+ message.destroy();
+ if (e instanceof Error) {
+ message.error({ content: `Error removing link: \n ${e.message || ''}`, duration: 2 });
+ }
+ }
+ refetch?.();
+ };
+
+ const handleAddLink = async (formValues) => {
+ if (user?.urn) {
+ try {
+ await addLinkMutation({
+ variables: {
+ input: { linkUrl: formValues.url, label: formValues.label, resourceUrn: mutationUrn },
+ },
+ });
+ message.success({ content: 'Link Added', duration: 2 });
+ analytics.event({
+ type: EventType.EntityActionEvent,
+ entityType,
+ entityUrn: mutationUrn,
+ actionType: EntityActionType.UpdateLinks,
+ });
+ refetch?.();
+ } catch (e: unknown) {
+ message.destroy();
+ if (e instanceof Error) {
+ message.error({ content: `Failed to add link: \n ${e.message || ''}`, duration: 3 });
+ }
+ }
+ } else {
+ message.error({ content: `Error adding link: no user`, duration: 2 });
+ }
+ };
+
+ return { handleDeleteLink, handleAddLink };
+}