Browse Source

[ui-storagebrowser] adds file preview and save for files (#3869)

* [ui-storagebrowser] adds file preview and save for files

* disable edit as soon as save button is clicked
Ram Prasad Agarwal 1 year ago
parent
commit
0e9437647e

+ 41 - 13
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss

@@ -20,6 +20,7 @@
   .hue-storage-file-page {
   .hue-storage-file-page {
     display: flex;
     display: flex;
     flex: 1;
     flex: 1;
+    height: 100%;
     flex-direction: column;
     flex-direction: column;
     gap: vars.$fluidx-spacing-s;
     gap: vars.$fluidx-spacing-s;
     padding: vars.$fluidx-spacing-s 0;
     padding: vars.$fluidx-spacing-s 0;
@@ -84,20 +85,47 @@
       gap: vars.$fluidx-spacing-s;
       gap: vars.$fluidx-spacing-s;
     }
     }
 
 
-    .preview__textarea {
-      resize: none;
-      width: 100%;
-      height: 100%;
-      border: none;
-      border-radius: 0;
-      padding: vars.$fluidx-spacing-s;
-      box-shadow: none;
-    }
+    .preview__content {
+      display: flex;
+      flex: 1;
+      justify-content: center;
+      align-items: center;
+      background-color: vars.$fluidx-gray-040;
+
+      .preview__textarea {
+        resize: none;
+        width: 100%;
+        height: 100%;
+        border: none;
+        border-radius: 0;
+        padding: vars.$fluidx-spacing-s;
+        box-shadow: none;
+      }
 
 
-    .preview__textarea[readonly] {
-      cursor: text;
-      color: vars.$fluidx-black;
-      background-color: vars.$fluidx-white;
+      .preview__textarea[readonly] {
+        cursor: text;
+        color: vars.$fluidx-black;
+        background-color: vars.$fluidx-white;
+      }
+
+      .preview__document {
+        display: flex;
+        align-items: center;
+        flex-direction: column;
+        gap: 16px;
+      }
+
+      audio {
+        width: 90%;
+      }
+
+      video {
+        height: 90%;
+      }
+
+      .preview__unsupported {
+        font-size: vars.$font-size-lg;
+      }
     }
     }
   }
   }
 }
 }

+ 96 - 1
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx

@@ -34,8 +34,14 @@ jest.mock('../../../utils/huePubSub', () => ({
   publish: jest.fn()
   publish: jest.fn()
 }));
 }));
 
 
+const mockSave = jest.fn();
+jest.mock('../../../api/utils', () => ({
+  post: () => mockSave()
+}));
+
 // Mock data for fileData
 // Mock data for fileData
 const mockFileData: PathAndFileData = {
 const mockFileData: PathAndFileData = {
+  editable: true,
   path: '/path/to/file.txt',
   path: '/path/to/file.txt',
   stats: {
   stats: {
     size: 123456,
     size: 123456,
@@ -50,7 +56,8 @@ const mockFileData: PathAndFileData = {
   rwx: 'rwxr-xr-x',
   rwx: 'rwxr-xr-x',
   breadcrumbs: [],
   breadcrumbs: [],
   view: {
   view: {
-    contents: 'Initial file content'
+    contents: 'Initial file content',
+    compression: 'none'
   },
   },
   files: [],
   files: [],
   page: {
   page: {
@@ -92,6 +99,22 @@ describe('StorageFilePage', () => {
     expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
     expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
   });
   });
 
 
+  it('hide edit button when editable is false', () => {
+    render(<StorageFilePage fileData={{ ...mockFileData, editable: false }} />);
+
+    expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
+  });
+
+  it('hide edit button when editable is false', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, view: { ...mockFileData.view, compression: 'zip' } }}
+      />
+    );
+
+    expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
+  });
+
   it('shows save and cancel buttons when editing', async () => {
   it('shows save and cancel buttons when editing', async () => {
     const user = userEvent.setup();
     const user = userEvent.setup();
     render(<StorageFilePage fileData={mockFileData} />);
     render(<StorageFilePage fileData={mockFileData} />);
@@ -191,4 +214,76 @@ describe('StorageFilePage', () => {
     expect(screen.queryByRole('button', { name: 'Download' })).toBeNull();
     expect(screen.queryByRole('button', { name: 'Download' })).toBeNull();
     expect(screen.queryByRole('link', { name: 'Download' })).toBeNull();
     expect(screen.queryByRole('link', { name: 'Download' })).toBeNull();
   });
   });
+
+  it('renders a textarea for text files', () => {
+    render(
+      <StorageFilePage
+        fileData={{
+          ...mockFileData,
+          view: { contents: 'Text file content' },
+          path: '/path/to/textfile.txt'
+        }}
+      />
+    );
+
+    const textarea = screen.getByRole('textbox');
+    expect(textarea).toBeInTheDocument();
+    expect(textarea).toHaveValue('Text file content');
+  });
+
+  it('renders an image for image files', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, path: '/path/to/imagefile.png', view: { contents: '' } }}
+      />
+    );
+
+    const img = screen.getByRole('img');
+    expect(img).toBeInTheDocument();
+    expect(img).toHaveAttribute('src', expect.stringContaining('imagefile.png'));
+  });
+
+  it('renders a preview button for document files', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, path: '/path/to/documentfile.pdf', view: { contents: '' } }}
+      />
+    );
+
+    expect(screen.getByRole('button', { name: /preview document/i })).toBeInTheDocument();
+  });
+
+  it('renders an audio player for audio files', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, path: '/path/to/audiofile.mp3', view: { contents: '' } }}
+      />
+    );
+
+    const audio = screen.getByTestId('preview__content__audio'); // audio tag can't be access using getByRole
+    expect(audio).toBeInTheDocument();
+    expect(audio.children[0]).toHaveAttribute('src', expect.stringContaining('audiofile.mp3'));
+  });
+
+  it('renders a video player for video files', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, path: '/path/to/videofile.mp4', view: { contents: '' } }}
+      />
+    );
+
+    const video = screen.getByTestId('preview__content__video'); // video tag can't be access using getByRole
+    expect(video).toBeInTheDocument();
+    expect(video.children[0]).toHaveAttribute('src', expect.stringContaining('videofile.mp4'));
+  });
+
+  it('displays a message for unsupported file types', () => {
+    render(
+      <StorageFilePage
+        fileData={{ ...mockFileData, path: '/path/to/unsupportedfile.xyz', view: { contents: '' } }}
+      />
+    );
+
+    expect(screen.getByText(/preview not available for this file/i)).toBeInTheDocument();
+  });
 });
 });

+ 157 - 66
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx

@@ -20,8 +20,15 @@ import './StorageFilePage.scss';
 import { i18nReact } from '../../../utils/i18nReact';
 import { i18nReact } from '../../../utils/i18nReact';
 import Button, { PrimaryButton } from 'cuix/dist/components/Button';
 import Button, { PrimaryButton } from 'cuix/dist/components/Button';
 import { getFileMetaData } from './StorageFilePage.util';
 import { getFileMetaData } from './StorageFilePage.util';
-import { DOWNLOAD_API_URL } from '../../../reactComponents/FileChooser/api';
+import { DOWNLOAD_API_URL, SAVE_FILE_API_URL } from '../../../reactComponents/FileChooser/api';
 import huePubSub from '../../../utils/huePubSub';
 import huePubSub from '../../../utils/huePubSub';
+import useSaveData from '../../../utils/hooks/useSaveData';
+import {
+  EDITABLE_FILE_FORMATS,
+  SUPPORTED_FILE_EXTENSIONS,
+  SupportedFileTypes
+} from '../../../utils/constants/storageBrowser';
+import { Spin } from 'antd';
 
 
 const StorageFilePage = ({ fileData }: { fileData: PathAndFileData }): JSX.Element => {
 const StorageFilePage = ({ fileData }: { fileData: PathAndFileData }): JSX.Element => {
   const { t } = i18nReact.useTranslation();
   const { t } = i18nReact.useTranslation();
@@ -29,89 +36,173 @@ const StorageFilePage = ({ fileData }: { fileData: PathAndFileData }): JSX.Eleme
   const [fileContent, setFileContent] = React.useState(fileData.view?.contents);
   const [fileContent, setFileContent] = React.useState(fileData.view?.contents);
   const fileMetaData = useMemo(() => getFileMetaData(t, fileData), [t, fileData]);
   const fileMetaData = useMemo(() => getFileMetaData(t, fileData), [t, fileData]);
 
 
+  const { loading: isSaving, save } = useSaveData(SAVE_FILE_API_URL);
+
   const handleEdit = () => {
   const handleEdit = () => {
     setIsEditing(true);
     setIsEditing(true);
   };
   };
 
 
-  const handleDownload = () => {
-    huePubSub.publish('hue.global.info', { message: t('Downloading your file, Please wait...') });
+  const handleCancel = () => {
+    setIsEditing(false);
+    setFileContent(fileData.view?.contents);
   };
   };
 
 
   const handleSave = () => {
   const handleSave = () => {
-    // TODO: Save file content to API
     setIsEditing(false);
     setIsEditing(false);
+    save(
+      {
+        path: fileData.path,
+        encoding: 'utf-8',
+        contents: fileContent
+      },
+      {
+        onError: () => {
+          setIsEditing(true);
+        },
+        onSuccess: () => {
+          huePubSub.publish('hue.global.info', { message: t('Changes saved!') });
+        }
+      }
+    );
   };
   };
 
 
-  const handleCancel = () => {
-    setIsEditing(false);
-    setFileContent(fileData.view?.contents);
+  const handleDownload = () => {
+    huePubSub.publish('hue.global.info', { message: t('Downloading your file, Please wait...') });
   };
   };
 
 
+  const filePreviewUrl = `${DOWNLOAD_API_URL}${fileData.path}?disposition=inline`;
+
+  const fileName = fileData?.path?.split('/')?.pop();
+  const fileType = useMemo(() => {
+    const fileExtension = fileName?.split('.')?.pop()?.toLocaleLowerCase();
+    if (!fileExtension) {
+      return SupportedFileTypes.OTHER;
+    }
+    return SUPPORTED_FILE_EXTENSIONS[fileExtension] ?? SupportedFileTypes.OTHER;
+  }, [fileName]);
+
+  const isEditingEnabled =
+    !isEditing &&
+    fileData.editable &&
+    EDITABLE_FILE_FORMATS[fileType] &&
+    fileData?.view?.compression?.toLocaleLowerCase() === 'none';
+
   return (
   return (
-    <div className="hue-storage-file-page">
-      <div className="meta-data">
-        {fileMetaData.map((row, index) => (
-          <div key={'meta-data-group' + index} className="meta-data__group">
-            {row.map(item => (
-              <div key={item.name} className="meta-data__column">
-                <div className="meta-data__column-label">{item.label}</div>
-                <div className="meta-data__column-value">{item.value}</div>
-              </div>
-            ))}
+    <Spin spinning={isSaving}>
+      <div className="hue-storage-file-page">
+        <div className="meta-data">
+          {fileMetaData.map((row, index) => (
+            <div key={'meta-data-group' + index} className="meta-data__group">
+              {row.map(item => (
+                <div key={item.name} className="meta-data__column">
+                  <div className="meta-data__column-label">{item.label}</div>
+                  <div className="meta-data__column-value">{item.value}</div>
+                </div>
+              ))}
+            </div>
+          ))}
+        </div>
+
+        <div className="preview">
+          <div className="preview__title-bar">
+            {t('Content')}
+            <div className="preview__action-group">
+              {isEditingEnabled && (
+                <PrimaryButton
+                  data-testid="preview--edit--button"
+                  data-event=""
+                  onClick={handleEdit}
+                >
+                  {t('Edit')}
+                </PrimaryButton>
+              )}
+              {isEditing && (
+                <>
+                  <PrimaryButton
+                    data-testid="preview--save--button"
+                    data-event=""
+                    onClick={handleSave}
+                    disabled={fileContent === fileData.view?.contents}
+                  >
+                    {t('Save')}
+                  </PrimaryButton>
+                  <Button
+                    role="button"
+                    data-testid="preview--cancel--button"
+                    data-event=""
+                    onClick={handleCancel}
+                  >
+                    {t('Cancel')}
+                  </Button>
+                </>
+              )}
+              {fileData.show_download_button && (
+                <a href={`${DOWNLOAD_API_URL}${fileData.path}`}>
+                  <PrimaryButton
+                    data-testid="preview--download--button"
+                    data-event=""
+                    onClick={handleDownload}
+                  >
+                    {t('Download')}
+                  </PrimaryButton>
+                </a>
+              )}
+            </div>
           </div>
           </div>
-        ))}
-      </div>
 
 
-      <div className="preview">
-        <div className="preview__title-bar">
-          {t('Content')}
-          <div className="preview__action-group">
-            <PrimaryButton
-              data-testid="preview--edit--button"
-              data-event=""
-              onClick={handleEdit}
-              hidden={isEditing}
-            >
-              {t('Edit')}
-            </PrimaryButton>
-            <PrimaryButton
-              data-testid="preview--save--button"
-              data-event=""
-              onClick={handleSave}
-              disabled={fileContent === fileData.view?.contents}
-              hidden={!isEditing}
-            >
-              {t('Save')}
-            </PrimaryButton>
-            <Button
-              role="button"
-              data-testid="preview--cancel--button"
-              data-event=""
-              onClick={handleCancel}
-              hidden={!isEditing}
-            >
-              {t('Cancel')}
-            </Button>
-            <a href={`${DOWNLOAD_API_URL}${fileData.path}`} hidden={!fileData.show_download_button}>
-              <PrimaryButton
-                data-testid="preview--download--button"
-                data-event=""
-                onClick={handleDownload}
-              >
-                {t('Download')}
-              </PrimaryButton>
-            </a>
+          <div className="preview__content">
+            {fileType === SupportedFileTypes.TEXT && (
+              <textarea
+                value={fileContent}
+                onChange={e => setFileContent(e.target.value)}
+                readOnly={!isEditing}
+                className="preview__textarea"
+              />
+            )}
+
+            {fileType === SupportedFileTypes.IMAGE && <img src={filePreviewUrl} alt={fileName} />}
+
+            {fileType === SupportedFileTypes.DOCUMENT && (
+              <div className="preview__document">
+                <div>
+                  <PrimaryButton
+                    data-testid=""
+                    data-event=""
+                    onClick={() => window.open(filePreviewUrl)}
+                  >
+                    {t('Preview document')}
+                  </PrimaryButton>
+                </div>
+                <div>{t('The Document will open in a new tab.')}</div>
+              </div>
+            )}
+
+            {fileType === SupportedFileTypes.AUDIO && (
+              <audio controls preload="auto" data-testid="preview__content__audio">
+                <source src={filePreviewUrl} />
+                {t('Your browser does not support the audio element.')}
+              </audio>
+            )}
+
+            {fileType === SupportedFileTypes.VIDEO && (
+              <video controls preload="auto" data-testid="preview__content__video">
+                <source src={filePreviewUrl} />
+                {t('Your browser does not support the video element.')}
+              </video>
+            )}
+
+            {fileType === SupportedFileTypes.OTHER && (
+              <div className="preview__unsupported">
+                {t('Preview not available for this file. Please download the file to view.')}
+                <br />
+                {t(`Supported file extensions: 
+                ${Object.keys(SUPPORTED_FILE_EXTENSIONS).join(', ')}`)}
+              </div>
+            )}
           </div>
           </div>
         </div>
         </div>
-
-        <textarea
-          value={fileContent}
-          onChange={e => setFileContent(e.target.value)}
-          readOnly={!isEditing}
-          className="preview__textarea"
-        />
       </div>
       </div>
-    </div>
+    </Spin>
   );
   );
 };
 };
 
 

+ 1 - 0
desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts

@@ -20,6 +20,7 @@ import { ContentSummary } from './types';
 export const FILESYSTEMS_API_URL = '/api/v1/storage/filesystems';
 export const FILESYSTEMS_API_URL = '/api/v1/storage/filesystems';
 export const VIEWFILES_API_URl = '/api/v1/storage/view=';
 export const VIEWFILES_API_URl = '/api/v1/storage/view=';
 export const DOWNLOAD_API_URL = '/filebrowser/download=';
 export const DOWNLOAD_API_URL = '/filebrowser/download=';
+export const SAVE_FILE_API_URL = '/filebrowser/save';
 const MAKE_DIRECTORY_API_URL = '/api/v1/storage/mkdir';
 const MAKE_DIRECTORY_API_URL = '/api/v1/storage/mkdir';
 const TOUCH_API_URL = '/api/v1/storage/touch';
 const TOUCH_API_URL = '/api/v1/storage/touch';
 const CONTENT_SUMMARY_API_URL = '/api/v1/storage/content_summary=';
 const CONTENT_SUMMARY_API_URL = '/api/v1/storage/content_summary=';

+ 2 - 0
desktop/core/src/desktop/js/reactComponents/FileChooser/types.ts

@@ -74,9 +74,11 @@ export interface BreadcrumbData {
 
 
 interface FileView {
 interface FileView {
   contents: string;
   contents: string;
+  compression?: string;
 }
 }
 
 
 export interface PathAndFileData {
 export interface PathAndFileData {
+  editable?: boolean;
   path: string;
   path: string;
   breadcrumbs: BreadcrumbData[];
   breadcrumbs: BreadcrumbData[];
   files: File[];
   files: File[];

+ 37 - 0
desktop/core/src/desktop/js/utils/constants/storageBrowser.ts

@@ -15,3 +15,40 @@
 // limitations under the License.
 // limitations under the License.
 
 
 export const DEFAULT_PAGE_SIZE = 50;
 export const DEFAULT_PAGE_SIZE = 50;
+
+export enum SupportedFileTypes {
+  IMAGE = 'image',
+  TEXT = 'text',
+  DOCUMENT = 'document',
+  AUDIO = 'audio',
+  VIDEO = 'video',
+  OTHER = 'other'
+}
+
+export const SUPPORTED_FILE_EXTENSIONS = {
+  png: SupportedFileTypes.IMAGE,
+  jpg: SupportedFileTypes.IMAGE,
+  jpeg: SupportedFileTypes.IMAGE,
+
+  txt: SupportedFileTypes.TEXT,
+  log: SupportedFileTypes.TEXT,
+  json: SupportedFileTypes.TEXT,
+  csv: SupportedFileTypes.TEXT,
+  sql: SupportedFileTypes.TEXT,
+  tsv: SupportedFileTypes.TEXT,
+
+  // TODO: add feature to edit these files
+  // parquet: SupportedFileTypes.TEXT,
+  // orc: SupportedFileTypes.TEXT,
+  // avro: SupportedFileTypes.TEXT,
+
+  pdf: SupportedFileTypes.DOCUMENT,
+
+  mp3: SupportedFileTypes.AUDIO,
+
+  mp4: SupportedFileTypes.VIDEO
+};
+
+export const EDITABLE_FILE_FORMATS = {
+  [SupportedFileTypes.TEXT]: 1
+};

+ 14 - 5
desktop/core/src/desktop/js/utils/hooks/useSaveData.ts

@@ -17,18 +17,21 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { ApiFetchOptions, post } from '../../api/utils';
 import { ApiFetchOptions, post } from '../../api/utils';
 
 
-export interface Options<T> {
-  postOptions?: ApiFetchOptions<T>;
-  skip?: boolean;
+interface saveOptions<T> {
   onSuccess?: (data: T) => void;
   onSuccess?: (data: T) => void;
   onError?: (error: Error) => void;
   onError?: (error: Error) => void;
 }
 }
 
 
+export interface Options<T> extends saveOptions<T> {
+  postOptions?: ApiFetchOptions<T>;
+  skip?: boolean;
+}
+
 interface UseSaveData<T, U> {
 interface UseSaveData<T, U> {
   data?: T;
   data?: T;
   loading: boolean;
   loading: boolean;
   error?: Error;
   error?: Error;
-  save: (body: U) => void;
+  save: (body: U, saveOption: saveOptions<T>) => void;
 }
 }
 
 
 const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSaveData<T, U> => {
 const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSaveData<T, U> => {
@@ -48,7 +51,7 @@ const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSav
   );
   );
 
 
   const saveData = useCallback(
   const saveData = useCallback(
-    async (body: U) => {
+    async (body: U, saveOptions: saveOptions<T>) => {
       // Avoid Posting data if the skip option is true
       // Avoid Posting data if the skip option is true
       // or if the URL is not provided
       // or if the URL is not provided
       if (options?.skip || !url) {
       if (options?.skip || !url) {
@@ -60,11 +63,17 @@ const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSav
       try {
       try {
         const response = await post<T, U>(url, body, postOptions);
         const response = await post<T, U>(url, body, postOptions);
         setData(response);
         setData(response);
+        if (saveOptions?.onSuccess) {
+          saveOptions.onSuccess(response);
+        }
         if (localOptions?.onSuccess) {
         if (localOptions?.onSuccess) {
           localOptions.onSuccess(response);
           localOptions.onSuccess(response);
         }
         }
       } catch (error) {
       } catch (error) {
         setError(error);
         setError(error);
+        if (saveOptions?.onError) {
+          saveOptions.onError(error);
+        }
         if (localOptions?.onError) {
         if (localOptions?.onError) {
           localOptions.onError(error);
           localOptions.onError(error);
         }
         }