feat(ui/summary): build add, view and delete link functionalities in the new summary tab (#14540)

This commit is contained in:
purnimagarg1 2025-08-27 23:19:23 +05:30 committed by GitHub
parent 3fe39bf149
commit 4f98d15d9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 546 additions and 1 deletions

View File

@ -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 (
<HoverEntityTooltip entity={user} showArrow={false}>
<Link
to={`${entityRegistry.getEntityUrl(user.type, user.urn)}`}
onClick={(e) => {
e.stopPropagation();
}}
>
<Avatar
name={entityRegistry.getDisplayName(user.type, user)}
imageUrl={avatarUrl}
size={size}
type={user.type === EntityType.CorpUser ? AvatarType.user : AvatarType.group}
showInPill
/>
</Link>
</HoverEntityTooltip>
);
}

View File

@ -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 (
<SummaryWrapper>
<PropertiesHeader />
<StyledDivider />
<AboutSection />
{!hideLinksButton && <Links />}
<StyledDivider />
</SummaryWrapper>
);

View File

@ -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 (
<>
<StyledButton variant="text" icon={{ icon: 'Plus', source: 'phosphor' }} onClick={handleButtonClick}>
Add Link
</StyledButton>
{showAddLinkModal && <AddLinkModal setShowAddLinkModal={setShowAddLinkModal} />}
</>
);
}

View File

@ -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<React.SetStateAction<boolean>>;
};
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 (
<Modal title="Add Link" onCancel={handleClose} buttons={buttons}>
<Form form={form} autoComplete="off">
<Form.Item
name="url"
rules={[
{
required: true,
message: 'A URL is required.',
},
{
type: 'url',
message: 'This field must be a valid url.',
},
]}
>
<Input label="URL" placeholder="https://" isRequired />
</Form.Item>
<Form.Item
name="label"
rules={[
{
required: true,
message: 'A label is required.',
},
]}
>
<Input label="Label" placeholder="A short label for this link" isRequired />
</Form.Item>
</Form>
</Modal>
);
}

View File

@ -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 (
<a href={link.url} target="_blank" rel="noreferrer">
<LinkContainer>
<LeftSection>
<Icon icon="LinkSimple" source="phosphor" color="primary" size="lg" />
<Text color="primary" lineHeight="normal">
{link.description || link.label}
</Text>
</LeftSection>
<RightSection>
<Text color="gray" size="sm">
Added {formatDateString(link.created.time)} by{' '}
</Text>
<AvatarPillWithLinkAndHover user={createdBy} size="sm" entityRegistry={entityRegistry} />
<StyledIcon icon="PencilSimpleLine" source="phosphor" color="gray" size="md" />
<StyledIcon
icon="Trash"
source="phosphor"
color="red"
size="md"
onClick={(e) => {
e.preventDefault();
setSelectedLink(link);
setShowConfirmDelete(true);
}}
/>
</RightSection>
</LinkContainer>
</a>
);
}

View File

@ -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 (
<LinksSection>
<LinksList />
<AddLinkButton />
</LinksSection>
);
}

View File

@ -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<boolean>(false);
const [selectedLink, setSelectedLink] = useState<InstitutionalMemoryMetadata | null>(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 (
<>
<ListContainer>
{links.map((link) => {
return (
<LinkItem
link={link}
setSelectedLink={setSelectedLink}
setShowConfirmDelete={setShowConfirmDelete}
/>
);
})}
</ListContainer>
<ConfirmationModal
isOpen={showConfirmDelete}
handleClose={handleCloseModal}
handleConfirm={handleDelete}
modalTitle="Confirm Delete"
modalText="Are you sure you want to delete this link?"
/>
</>
);
}

View File

@ -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();
});
});
});

View File

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