mirror of
https://github.com/datahub-project/datahub.git
synced 2025-08-29 03:25:58 +00:00
feat(ui/summary): build add, view and delete link functionalities in the new summary tab (#14540)
This commit is contained in:
parent
3fe39bf149
commit
4f98d15d9a
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
20
datahub-web-react/src/app/entityV2/summary/links/Links.tsx
Normal file
20
datahub-web-react/src/app/entityV2/summary/links/Links.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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?"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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 };
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user