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 }; +}