From a76c62c03900a4b870455e4062d725afc899fe0d Mon Sep 17 00:00:00 2001 From: Victor Tarasevich Date: Tue, 11 Nov 2025 14:04:21 +0300 Subject: [PATCH 1/8] feat(uploadFiles): add backend support of ASSET_DOCUMENTATION_LINKS scenario --- .../files/GetPresignedUploadUrlResolver.java | 12 +- .../src/main/resources/files.graphql | 5 + .../GetPresignedUploadUrlResolverTest.java | 175 ++++++++++++++++++ .../com/linkedin/file/DataHubFileInfo.pdl | 5 + .../openapi/v1/files/FilesController.java | 10 +- .../openapi/v1/files/FilesControllerTest.java | 63 +++++++ 6 files changed, 265 insertions(+), 5 deletions(-) diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolver.java index 131fd3b5c7335e..92833a0a5d66df 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolver.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolver.java @@ -81,6 +81,10 @@ private void validateInput(final QueryContext context, final GetPresignedUploadU if (scenario == UploadDownloadScenario.ASSET_DOCUMENTATION) { validateInputForAssetDocumentationScenario(context, input); } + + if (scenario == UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS) { + validateInputForAssetDocumentationScenario(context, input); + } } private void validateInputForAssetDocumentationScenario( @@ -119,8 +123,12 @@ private String getS3Key( if (scenario == UploadDownloadScenario.ASSET_DOCUMENTATION) { return String.format("%s/%s", s3Configuration.getAssetPathPrefix(), fileId); - } else { - throw new IllegalArgumentException("Unsupported upload scenario: " + scenario); } + + if (scenario == UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS) { + return String.format("%s/%s", s3Configuration.getAssetPathPrefix(), fileId); + } + + throw new IllegalArgumentException("Unsupported upload scenario: " + scenario); } } diff --git a/datahub-graphql-core/src/main/resources/files.graphql b/datahub-graphql-core/src/main/resources/files.graphql index 343b68f29b864e..83b600ffce321e 100644 --- a/datahub-graphql-core/src/main/resources/files.graphql +++ b/datahub-graphql-core/src/main/resources/files.graphql @@ -52,6 +52,11 @@ enum UploadDownloadScenario { Upload for asset documentation. """ ASSET_DOCUMENTATION + + """ + Upload for asset documentation links. + """ + ASSET_DOCUMENTATION_LINKS } type GetPresignedUploadUrlResponse { diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolverTest.java index 86de2fe0227366..3d3082b2b46fed 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/files/GetPresignedUploadUrlResolverTest.java @@ -768,4 +768,179 @@ public void testGetPresignedUploadUrlWhenBothAssetUrnAndSchemaFieldUrnAreProvide assertEquals(result.getFileId(), extractedFileId); assertTrue(result.getFileId().contains(testFileName)); } + + @Test + public void testGetPresignedUploadUrlWithAssetDocumentationLinksScenario() throws Exception { + String testFileName = "my_test_file.pdf"; + GetPresignedUploadUrlInput input = + createInput( + UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS, + TEST_ASSET_URN, + null, + TEST_CONTENT_TYPE, + testFileName); + + when(mockEnv.getArgument("input")).thenReturn(input); + when(mockEnv.getContext()).thenReturn(mockQueryContext); + + ArgumentCaptor s3KeyCaptor = ArgumentCaptor.forClass(String.class); + when(mockS3Util.generatePresignedUploadUrl( + eq(TEST_BUCKET_NAME), + s3KeyCaptor.capture(), + eq(TEST_EXPIRATION_SECONDS), + eq(TEST_CONTENT_TYPE))) + .thenReturn(MOCKED_PRESIGNED_URL); + + when(mockS3Configuration.getBucketName()).thenReturn(TEST_BUCKET_NAME); + when(mockS3Configuration.getPresignedUploadUrlExpirationSeconds()) + .thenReturn(TEST_EXPIRATION_SECONDS); + when(mockS3Configuration.getAssetPathPrefix()).thenReturn(TEST_ASSET_PATH_PREFIX); + + GetPresignedUploadUrlResolver resolver = + new GetPresignedUploadUrlResolver(mockS3Util, mockS3Configuration); + CompletableFuture future = resolver.get(mockEnv); + GetPresignedUploadUrlResponse result = future.get(); + + assertNotNull(result); + assertEquals(result.getUrl(), MOCKED_PRESIGNED_URL); + assertNotNull(result.getFileId()); + + // Verify that asset documentation authorization is called for ASSET_DOCUMENTATION_LINKS + // scenario + descriptionUtilsMockedStatic.verify( + () -> + DescriptionUtils.isAuthorizedToUpdateDescription( + any(QueryContext.class), any(Urn.class))); + + String capturedS3Key = s3KeyCaptor.getValue(); + assertTrue(capturedS3Key.startsWith(TEST_ASSET_PATH_PREFIX + "/")); + + // Extract fileId from s3Key + String expectedFileIdPrefix = TEST_ASSET_PATH_PREFIX + "/"; + String extractedFileId = capturedS3Key.substring(expectedFileIdPrefix.length()); + + assertEquals(result.getFileId(), extractedFileId); + assertTrue(result.getFileId().contains(testFileName)); + } + + @Test + public void testGetPresignedUploadUrlWithAssetDocumentationLinksScenarioAndSchemaField() + throws Exception { + String testFileName = "my_test_file.pdf"; + String schemaFieldUrn = + "urn:li:schemaField:(urn:li:dataset:(urn:li:dataPlatform:hive,my-dataset,PROD),myField)"; + GetPresignedUploadUrlInput input = + createInput( + UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS, + TEST_ASSET_URN, + schemaFieldUrn, + TEST_CONTENT_TYPE, + testFileName); + + when(mockEnv.getArgument("input")).thenReturn(input); + when(mockEnv.getContext()).thenReturn(mockQueryContext); + + ArgumentCaptor s3KeyCaptor = ArgumentCaptor.forClass(String.class); + when(mockS3Util.generatePresignedUploadUrl( + eq(TEST_BUCKET_NAME), + s3KeyCaptor.capture(), + eq(TEST_EXPIRATION_SECONDS), + eq(TEST_CONTENT_TYPE))) + .thenReturn(MOCKED_PRESIGNED_URL); + + when(mockS3Configuration.getBucketName()).thenReturn(TEST_BUCKET_NAME); + when(mockS3Configuration.getPresignedUploadUrlExpirationSeconds()) + .thenReturn(TEST_EXPIRATION_SECONDS); + when(mockS3Configuration.getAssetPathPrefix()).thenReturn(TEST_ASSET_PATH_PREFIX); + + GetPresignedUploadUrlResolver resolver = + new GetPresignedUploadUrlResolver(mockS3Util, mockS3Configuration); + CompletableFuture future = resolver.get(mockEnv); + GetPresignedUploadUrlResponse result = future.get(); + + assertNotNull(result); + assertEquals(result.getUrl(), MOCKED_PRESIGNED_URL); + assertNotNull(result.getFileId()); + + // Verify that schema field documentation authorization is called for ASSET_DOCUMENTATION_LINKS + // scenario + // when schema field URN is provided + descriptionUtilsMockedStatic.verify( + () -> + DescriptionUtils.isAuthorizedToUpdateFieldDescription( + any(QueryContext.class), any(Urn.class))); + + String capturedS3Key = s3KeyCaptor.getValue(); + assertTrue(capturedS3Key.startsWith(TEST_ASSET_PATH_PREFIX + "/")); + + // Extract fileId from s3Key + String expectedFileIdPrefix = TEST_ASSET_PATH_PREFIX + "/"; + String extractedFileId = capturedS3Key.substring(expectedFileIdPrefix.length()); + + assertEquals(result.getFileId(), extractedFileId); + assertTrue(result.getFileId().contains(testFileName)); + } + + @Test + public void testGetPresignedUploadUrlWithNullAssetUrnForAssetDocumentationLinks() + throws Exception { + GetPresignedUploadUrlInput input = + createInput( + UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS, + null, + null, + TEST_CONTENT_TYPE, + "test.png"); + + when(mockEnv.getArgument("input")).thenReturn(input); + when(mockEnv.getContext()).thenReturn(mockQueryContext); + + when(mockS3Configuration.getBucketName()).thenReturn(TEST_BUCKET_NAME); + when(mockS3Configuration.getPresignedUploadUrlExpirationSeconds()) + .thenReturn(TEST_EXPIRATION_SECONDS); + when(mockS3Configuration.getAssetPathPrefix()).thenReturn(TEST_ASSET_PATH_PREFIX); + + GetPresignedUploadUrlResolver resolver = + new GetPresignedUploadUrlResolver(mockS3Util, mockS3Configuration); + assertThrows( + "assetUrn is required for ASSET_DOCUMENTATION scenario", + IllegalArgumentException.class, + () -> resolver.get(mockEnv).get()); + } + + @Test + public void testGetPresignedUploadUrlWithAssetDocumentationLinksScenarioAuthorizationFailure() + throws Exception { + String testFileName = "my_test_file.pdf"; + GetPresignedUploadUrlInput input = + createInput( + UploadDownloadScenario.ASSET_DOCUMENTATION_LINKS, + TEST_ASSET_URN, + null, + TEST_CONTENT_TYPE, + testFileName); + + when(mockEnv.getArgument("input")).thenReturn(input); + when(mockEnv.getContext()).thenReturn(mockQueryContext); + + // Mock asset description authorization to return false + descriptionUtilsMockedStatic + .when( + () -> + DescriptionUtils.isAuthorizedToUpdateDescription( + any(QueryContext.class), any(Urn.class))) + .thenReturn(false); + + when(mockS3Configuration.getBucketName()).thenReturn(TEST_BUCKET_NAME); + when(mockS3Configuration.getPresignedUploadUrlExpirationSeconds()) + .thenReturn(TEST_EXPIRATION_SECONDS); + when(mockS3Configuration.getAssetPathPrefix()).thenReturn(TEST_ASSET_PATH_PREFIX); + + GetPresignedUploadUrlResolver resolver = + new GetPresignedUploadUrlResolver(mockS3Util, mockS3Configuration); + assertThrows( + "Unauthorized to edit documentation for asset: " + TEST_ASSET_URN, + AuthorizationException.class, + () -> resolver.get(mockEnv).get()); + } } diff --git a/metadata-models/src/main/pegasus/com/linkedin/file/DataHubFileInfo.pdl b/metadata-models/src/main/pegasus/com/linkedin/file/DataHubFileInfo.pdl index 7ea77a46d97d21..bd4190a86baa65 100644 --- a/metadata-models/src/main/pegasus/com/linkedin/file/DataHubFileInfo.pdl +++ b/metadata-models/src/main/pegasus/com/linkedin/file/DataHubFileInfo.pdl @@ -47,6 +47,11 @@ record DataHubFileInfo { * File uploaded for entity documentation */ ASSET_DOCUMENTATION + + /** + * Upload for asset documentation links. + **/ + ASSET_DOCUMENTATION_LINKS } /** diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/files/FilesController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/files/FilesController.java index bff8ae82d438b8..bd85f393403d3a 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/files/FilesController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/files/FilesController.java @@ -190,7 +190,10 @@ private void validateFilePermissions(String fileId, OperationContext opContext) FileUploadScenario scenario = fileInfo.getScenario(); switch (scenario) { case ASSET_DOCUMENTATION: - validateAssetDocumentationPermissions(fileInfo, opContext, authentication); + validateAssetReadPermissions(fileInfo, opContext, authentication); + break; + case ASSET_DOCUMENTATION_LINKS: + validateAssetReadPermissions(fileInfo, opContext, authentication); break; // Add additional scenarios here as needed default: @@ -201,14 +204,15 @@ private void validateFilePermissions(String fileId, OperationContext opContext) } /** - * Validates permissions for files in the ASSET_DOCUMENTATION scenario. + * Validates permissions for files in the ASSET_DOCUMENTATION and ASSET_DOCUMENTATION_LINKS + * scenario. * * @param fileInfo The file info containing the referenced asset * @param opContext The operation context for the current user * @param authentication The current user's authentication * @throws UnauthorizedException if the user doesn't have READ permission on the referenced asset */ - private void validateAssetDocumentationPermissions( + private void validateAssetReadPermissions( DataHubFileInfo fileInfo, OperationContext opContext, Authentication authentication) { Urn relatedUrn = fileInfo.getReferencedByAsset(); if (!AuthUtil.isAPIAuthorizedEntityUrns(opContext, READ, Collections.singleton(relatedUrn))) { diff --git a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/files/FilesControllerTest.java b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/files/FilesControllerTest.java index b84f2d7d34f80e..f337bbe30a01f6 100644 --- a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/files/FilesControllerTest.java +++ b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/files/FilesControllerTest.java @@ -494,6 +494,69 @@ public void testBuildOperationContextWithCorrectParameters() throws Exception { .andExpect(header().string(HttpHeaders.LOCATION, TEST_PRESIGNED_URL)); } + @Test + public void testGetFileWithValidAssetDocumentationLinksPermissions() throws Exception { + // Setup file ID with separator to test UUID extraction + String fileIdWithSeparator = "abc123__filename.pdf"; + String expectedKey = String.format("%s/%s", TEST_FOLDER, fileIdWithSeparator); + + when(mockS3Util.generatePresignedDownloadUrl( + eq(TEST_BUCKET), eq(expectedKey), eq(DEFAULT_EXPIRATION))) + .thenReturn(TEST_PRESIGNED_URL); + + // Setup EntityService to return file entity with ASSET_DOCUMENTATION_LINKS scenario + setupDefaultFileEntity( + TEST_FILE_URN, TEST_ASSET_URN, FileUploadScenario.ASSET_DOCUMENTATION_LINKS); + + // AuthUtil should return true (already set up in setup()) + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/openapi/v1/files/{folder}/{fileId}", TEST_FOLDER, fileIdWithSeparator)) + .andExpect(status().isFound()) + .andExpect(header().string(HttpHeaders.LOCATION, TEST_PRESIGNED_URL)); + } + + @Test + public void testGetFileWithoutAssetDocumentationLinksPermissions() throws Exception { + // Setup file ID with separator to test UUID extraction + String fileIdWithSeparator = "abc123__filename.pdf"; + + // Override AuthUtil to deny access + authUtilMock + .when(() -> AuthUtil.isAPIAuthorizedEntityUrns(any(), any(), anySet())) + .thenReturn(false); + + // Setup EntityService to return file entity with ASSET_DOCUMENTATION_LINKS scenario + setupDefaultFileEntity( + TEST_FILE_URN, TEST_ASSET_URN, FileUploadScenario.ASSET_DOCUMENTATION_LINKS); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/openapi/v1/files/{folder}/{fileId}", TEST_FOLDER, fileIdWithSeparator)) + .andExpect(status().isForbidden()); + } + + @Test + public void testGetFileWithAssetDocumentationLinksScenarioWhenFileEntityDoesNotExist() + throws Exception { + // Override EntityService to return null + when(mockEntityService.getEntityV2( + eq(mockSystemOperationContext), + eq(Constants.DATAHUB_FILE_ENTITY_NAME), + any(Urn.class), + any(HashSet.class), + anyBoolean())) + .thenReturn(null); + + mockMvc + .perform( + MockMvcRequestBuilders.get( + "/openapi/v1/files/{folder}/{fileId}", TEST_FOLDER, TEST_FILE_ID)) + .andExpect(status().isForbidden()); + } + @SpringBootConfiguration @Import({ FilesControllerTestConfig.class, From 802dff71c1d6c41d7ee14d801e6499ced9ee9b86 Mon Sep 17 00:00:00 2001 From: Victor Tarasevich Date: Tue, 11 Nov 2025 14:25:51 +0300 Subject: [PATCH 2/8] feat(uploadFiles): add FileNode component --- .../components/FileNode/FileIcon.tsx | 21 +++ .../components/FileNode/FileNode.stories.tsx | 165 ++++++++++++++++++ .../components/FileNode/FileNode.tsx | 128 ++++++++++++++ .../FileNode/__tests__/utils.test.ts | 83 +++++++++ .../components/FileNode/index.ts | 2 + .../components/FileNode/types.ts | 14 ++ .../components/FileNode/utils.ts | 58 ++++++ .../src/alchemy-components/index.ts | 1 + 8 files changed, 472 insertions(+) create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/FileIcon.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/FileNode.stories.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/FileNode.tsx create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/__tests__/utils.test.ts create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/index.ts create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/types.ts create mode 100644 datahub-web-react/src/alchemy-components/components/FileNode/utils.ts diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/FileIcon.tsx b/datahub-web-react/src/alchemy-components/components/FileNode/FileIcon.tsx new file mode 100644 index 00000000000000..71b7eb19e71b48 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/FileIcon.tsx @@ -0,0 +1,21 @@ +import React, { useMemo } from 'react'; +import styled from 'styled-components'; + +import { Icon } from '@components/components/Icon'; + +import { getFileIconFromExtension } from './utils'; + +const StyledIcon = styled(Icon)` + flex-shrink: 0; +`; + +interface Props { + extension?: string; + className?: string; +} + +export function FileIcon({ extension, className }: Props) { + const icon = useMemo(() => getFileIconFromExtension(extension || ''), [extension]); + + return ; +} diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.stories.tsx b/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.stories.tsx new file mode 100644 index 00000000000000..ee65d09d8d9269 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.stories.tsx @@ -0,0 +1,165 @@ +import { BADGE } from '@geometricpanda/storybook-addon-badges'; +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { GridList } from '@components/.docs/mdx-components'; +import { FileNode } from './FileNode'; + +const meta = { + title: 'Components / FileNode', + component: FileNode, + + // Display Properties + parameters: { + layout: 'centered', + badges: [BADGE.STABLE], + docs: { + subtitle: 'FileNode displays a file with its icon, name, and optional actions like closing or custom right content.', + }, + }, + + // Component-level argTypes + argTypes: { + fileName: { + description: 'Name of the file to display.', + control: { + type: 'text', + }, + }, + loading: { + description: 'Whether the file is in a loading state.', + control: { + type: 'boolean', + }, + }, + border: { + description: 'Whether to show a border around the FileNode.', + control: { + type: 'boolean', + }, + }, + extraRightContent: { + description: 'Extra content to show on the right side of the FileNode.', + control: { + type: 'text', + }, + }, + className: { + description: 'Additional CSS class name for the FileNode.', + control: { + type: 'text', + }, + }, + size: { + description: 'Size of the FileNode text.', + options: ['xs', 'sm', 'md', 'lg', 'xl'], + control: { + type: 'select', + }, + }, + onClick: { + description: 'Function to call when the FileNode is clicked.', + action: 'clicked', + }, + onClose: { + description: 'Function to call when the close button is clicked.', + action: 'closed', + }, + }, + + // Define defaults + args: { + fileName: 'example-file.pdf', + loading: false, + border: false, + size: 'md', + }, +} satisfies Meta; + +export default meta; + +// Stories + +type Story = StoryObj; + +// Basic story is what is displayed 1st in storybook & is used as the code sandbox +// Pass props to this so that it can be customized via the UI props panel +export const sandbox: Story = { + tags: ['dev'], + render: (props) => , +}; + +export const withFileName = () => ( + + + + + + +); + +export const loadingState = () => ( + + + +); + +export const withBorder = () => ( + + + + +); + +export const withCloseButton = () => ( + + console.log('Document closed')} + /> + console.log('Presentation closed')} + /> + +); + +export const withOnClick = () => ( + + console.log('Document clicked')} + /> + console.log('Presentation clicked')} + /> + +); + +export const withExtraRightContent = () => ( + + ✓} + /> + !} + /> + +); + +export const withSizeVariations = () => ( + + + + + + + + + + + +); diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.tsx b/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.tsx new file mode 100644 index 00000000000000..37a33fd022f4dc --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/FileNode.tsx @@ -0,0 +1,128 @@ +import { Button, colors } from '@components'; +import { Typography } from 'antd'; +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +import { + getExtensionFromFileName, + getFileNameFromUrl, +} from '@components/components/Editor/extensions/fileDragDrop/fileUtils'; +import { FileIcon } from '@components/components/FileNode/FileIcon'; +import { FileNodeProps } from '@components/components/FileNode/types'; +import { getFontSize } from '@components/theme/utils'; + +import Loading from '@app/shared/Loading'; + +const Container = styled.div<{ $border?: boolean; $fontSize?: string }>` + display: flex; + width: 100%; + + ${(props) => + props.$border && + ` + border-radius: 8px; + border: 1px solid ${colors.gray[100]}; + `} + + ${(props) => props.$fontSize && `font-size: ${props.$fontSize};`} +`; + +const FileDetails = styled.span` + display: flex; + gap: 4px; + align-items: center; + font-weight: 600; + width: 100%; + padding: 4px; +`; + +const SpaceFiller = styled.div` + flex-grow: 1; +`; + +const CloseButton = styled(Button)` + padding: 0; +`; + +const FileName = styled(Typography.Text)` + color: ${({ theme }) => theme.styles['primary-color']}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export function FileNode({ + fileName, + loading, + border, + extraRightContent, + className, + size, + onClick, + onClose, +}: FileNodeProps) { + const extension = useMemo(() => getExtensionFromFileName(fileName || ''), [fileName]); + + const closeHandler = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClose?.(e); + }, + [onClose], + ); + + const clickHandler = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onClick?.(e); + }, + [onClick], + ); + + const hasRightContent = useMemo(() => { + return !!onClose || !!extraRightContent; + }, [onClose, extraRightContent]); + + const fontSize = useMemo(() => getFontSize(size), [size]); + + if (!fileName) return null; + + if (loading) { + return ( + + + + Uploading {name}... + + + ); + } + + return ( + + + + {fileName} + + {hasRightContent && } + + {onClose && ( + + )} + + {extraRightContent} + + ); +} diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/__tests__/utils.test.ts b/datahub-web-react/src/alchemy-components/components/FileNode/__tests__/utils.test.ts new file mode 100644 index 00000000000000..5ee71f5bbb087c --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/__tests__/utils.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { getFileIconFromExtension } from '@components/components/FileNode/utils'; + +describe('utils', () => { + describe('getFileIconFromExtension', () => { + it('should return FilePdf for pdf extension', () => { + expect(getFileIconFromExtension('pdf')).toBe('FilePdf'); + expect(getFileIconFromExtension('PDF')).toBe('FilePdf'); // case-insensitive + }); + + it('should return FileWord for doc and docx extensions', () => { + expect(getFileIconFromExtension('doc')).toBe('FileWord'); + expect(getFileIconFromExtension('DOCX')).toBe('FileWord'); + }); + + it('should return FileText for txt, md, rtf extensions', () => { + expect(getFileIconFromExtension('txt')).toBe('FileText'); + expect(getFileIconFromExtension('md')).toBe('FileText'); + expect(getFileIconFromExtension('RTF')).toBe('FileText'); + }); + + it('should return FileXls for xls and xlsx extensions', () => { + expect(getFileIconFromExtension('xls')).toBe('FileXls'); + expect(getFileIconFromExtension('XLSX')).toBe('FileXls'); + }); + + it('should return FilePpt for ppt and pptx extensions', () => { + expect(getFileIconFromExtension('ppt')).toBe('FilePpt'); + expect(getFileIconFromExtension('PPTX')).toBe('FilePpt'); + }); + + it('should return FileJpg for image extensions', () => { + ['jpg', 'jpeg'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileJpg'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileJpg'); + }); + }); + + it('should return FilePng for png extensions', () => { + ['png'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FilePng'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FilePng'); + }); + }); + + it('should return FileImage for other image extensions', () => { + ['gif', 'webp', 'bmp', 'tiff'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileImage'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileImage'); + }); + }); + + it('should return FileVideo for video extensions', () => { + ['mp4', 'wmv', 'mov'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileVideo'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileVideo'); + }); + }); + + it('should return FileAudio for mp3 extension', () => { + expect(getFileIconFromExtension('mp3')).toBe('FileAudio'); + expect(getFileIconFromExtension('MP3')).toBe('FileAudio'); + }); + + it('should return FileZip for archive extensions', () => { + ['zip', 'rar', 'gz'].forEach((ext) => { + expect(getFileIconFromExtension(ext)).toBe('FileZip'); + expect(getFileIconFromExtension(ext.toUpperCase())).toBe('FileZip'); + }); + }); + + it('should return FileCsv for csv extension', () => { + expect(getFileIconFromExtension('csv')).toBe('FileCsv'); + expect(getFileIconFromExtension('CSV')).toBe('FileCsv'); + }); + + it('should return FileArrowDown for unknown extensions', () => { + expect(getFileIconFromExtension('unknown')).toBe('FileArrowDown'); + expect(getFileIconFromExtension('')).toBe('FileArrowDown'); + }); + }); +}); diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/index.ts b/datahub-web-react/src/alchemy-components/components/FileNode/index.ts new file mode 100644 index 00000000000000..3e75ad3b6114e8 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/index.ts @@ -0,0 +1,2 @@ +export { FileNode } from './FileNode'; +export { FileIcon } from './FileIcon'; diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/types.ts b/datahub-web-react/src/alchemy-components/components/FileNode/types.ts new file mode 100644 index 00000000000000..d0c5b7ae9dc4c3 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/types.ts @@ -0,0 +1,14 @@ +import React from 'react'; + +import { FontSizeOptions } from '@components/theme/config'; + +export interface FileNodeProps { + fileName?: string; + loading?: boolean; + border?: boolean; + extraRightContent?: React.ReactNode; + className?: string; + size?: FontSizeOptions; + onClick?: (e: React.MouseEvent) => void; + onClose?: (e: React.MouseEvent) => void; +} diff --git a/datahub-web-react/src/alchemy-components/components/FileNode/utils.ts b/datahub-web-react/src/alchemy-components/components/FileNode/utils.ts new file mode 100644 index 00000000000000..baf42fc67e2084 --- /dev/null +++ b/datahub-web-react/src/alchemy-components/components/FileNode/utils.ts @@ -0,0 +1,58 @@ +/** + * Get icon to show based on file extension + * @param extension - the extension of the file + * @returns string depicting the phosphor icon name + */ +export const getFileIconFromExtension = (extension: string) => { + switch (extension.toLowerCase()) { + case 'pdf': + return 'FilePdf'; + case 'doc': + case 'docx': + return 'FileWord'; + case 'txt': + case 'md': + case 'rtf': + case 'log': + case 'json': + return 'FileText'; + case 'xls': + case 'xlsx': + return 'FileXls'; + case 'ppt': + case 'pptx': + return 'FilePpt'; + case 'svg': + return 'FileSvg'; + case 'jpg': + case 'jpeg': + return 'FileJpg'; + case 'png': + return 'FilePng'; + case 'gif': + case 'webp': + case 'bmp': + case 'tiff': + return 'FileImage'; + case 'mp4': + case 'wmv': + case 'mov': + return 'FileVideo'; + case 'mp3': + return 'FileAudio'; + case 'zip': + case 'rar': + case 'gz': + return 'FileZip'; + case 'csv': + return 'FileCsv'; + case 'html': + return 'FileHtml'; + case 'py': + return 'FilePy'; + case 'java': + return 'FileCode'; + default: + return 'FileArrowDown'; + } +}; diff --git a/datahub-web-react/src/alchemy-components/index.ts b/datahub-web-react/src/alchemy-components/index.ts index c19bcb6359f975..b4324424ebec31 100644 --- a/datahub-web-react/src/alchemy-components/index.ts +++ b/datahub-web-react/src/alchemy-components/index.ts @@ -16,6 +16,7 @@ export * from './components/ColorPicker'; export * from './components/DatePicker'; export * from './components/Drawer'; export * from './components/Dropdown'; +export * from './components/FileNode'; export * from './components/GraphCard'; export * from './components/Heading'; export * from './components/Icon'; From 875483bc520ca3476381110588232f1ff0661050 Mon Sep 17 00:00:00 2001 From: Victor Tarasevich Date: Tue, 11 Nov 2025 15:17:31 +0300 Subject: [PATCH 3/8] feat(uploadFiles): use FileNode component in FileNodeView --- .../extensions/fileDragDrop/FileNodeView.tsx | 69 +++++----------- .../fileDragDrop/__tests__/fileUtils.test.ts | 79 ------------------- .../extensions/fileDragDrop/fileUtils.ts | 75 +++++------------- 3 files changed, 39 insertions(+), 184 deletions(-) diff --git a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx index 42c21aef01357f..07130889c445f1 100644 --- a/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx +++ b/datahub-web-react/src/alchemy-components/components/Editor/extensions/fileDragDrop/FileNodeView.tsx @@ -1,6 +1,5 @@ import { NodeViewComponentProps } from '@remirror/react'; -import { Typography } from 'antd'; -import React, { useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import styled from 'styled-components'; @@ -11,19 +10,12 @@ import { FileNodeAttributes, TEXT_FILE_TYPES_TO_PREVIEW, getExtensionFromFileName, - getFileIconFromExtension, getFileTypeFromFilename, handleFileDownload, } from '@components/components/Editor/extensions/fileDragDrop/fileUtils'; -import { Icon } from '@components/components/Icon'; +import { FileNode } from '@components/components/FileNode/FileNode'; import { colors } from '@components/theme'; -import Loading from '@app/shared/Loading'; - -const StyledIcon = styled(Icon)` - flex-shrink: 0; -`; - const FileContainer = styled.div<{ $isInline?: boolean }>` display: inline-block; @@ -47,21 +39,8 @@ const FileContainer = styled.div<{ $isInline?: boolean }>` color: ${({ theme }) => theme.styles['primary-color']}; `; -const FileDetails = styled.span` +const StyledFileNode = styled(FileNode)` max-width: 350px; - display: flex; - gap: 4px; - align-items: center; - font-weight: 600; - width: max-content; - padding: 4px; -`; - -const FileName = styled(Typography.Text)` - color: ${({ theme }) => theme.styles['primary-color']}; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; `; const StyledSyntaxHighlighter = styled(SyntaxHighlighter)` @@ -127,7 +106,6 @@ export const FileNodeView: React.FC = ({ node, onFileDownload const { url, name, type, size, id } = node.attrs; const extension = getExtensionFromFileName(name); const fileType = type || getFileTypeFromFilename(name); - const icon = getFileIconFromExtension(extension || ''); const shouldWrap = extension === 'txt'; const isPdf = fileType === 'application/pdf'; const isVideo = fileType.startsWith('video/'); @@ -184,39 +162,32 @@ export const FileNodeView: React.FC = ({ node, onFileDownload } }, [url, hasError, shouldShowPreview, isTextFile]); + const clickHandler = useCallback(() => { + onFileDownloadView?.(fileType, size); + handleFileDownload(url, name); + }, [fileType, size, url, name, onFileDownloadView]); + // Show loading state if no URL yet (file is being uploaded) if (!url) { return ( - - - Uploading {name}... - + ); } - const fileNode = ( - { - e.stopPropagation(); - // Track file download/view event - onFileDownloadView?.(fileType, size); - handleFileDownload(url, name); - }} - > - - {name} - - ); - const fileNodeWithButton = ( - {fileNode} -