diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss
index b67f76ff49f..eb39508564c 100644
--- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss
+++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss
@@ -20,6 +20,7 @@
.hue-storage-file-page {
display: flex;
flex: 1;
+ height: 100%;
flex-direction: column;
gap: vars.$fluidx-spacing-s;
padding: vars.$fluidx-spacing-s 0;
@@ -84,20 +85,47 @@
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;
+ }
}
}
}
\ No newline at end of file
diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx
index 31811a6b42c..f1ed41ffa62 100644
--- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx
+++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx
@@ -34,8 +34,14 @@ jest.mock('../../../utils/huePubSub', () => ({
publish: jest.fn()
}));
+const mockSave = jest.fn();
+jest.mock('../../../api/utils', () => ({
+ post: () => mockSave()
+}));
+
// Mock data for fileData
const mockFileData: PathAndFileData = {
+ editable: true,
path: '/path/to/file.txt',
stats: {
size: 123456,
@@ -50,7 +56,8 @@ const mockFileData: PathAndFileData = {
rwx: 'rwxr-xr-x',
breadcrumbs: [],
view: {
- contents: 'Initial file content'
+ contents: 'Initial file content',
+ compression: 'none'
},
files: [],
page: {
@@ -92,6 +99,22 @@ describe('StorageFilePage', () => {
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
});
+ it('hide edit button when editable is false', () => {
+ render();
+
+ expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
+ });
+
+ it('hide edit button when editable is false', () => {
+ render(
+
+ );
+
+ expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
+ });
+
it('shows save and cancel buttons when editing', async () => {
const user = userEvent.setup();
render();
@@ -191,4 +214,76 @@ describe('StorageFilePage', () => {
expect(screen.queryByRole('button', { name: 'Download' })).toBeNull();
expect(screen.queryByRole('link', { name: 'Download' })).toBeNull();
});
+
+ it('renders a textarea for text files', () => {
+ render(
+
+ );
+
+ const textarea = screen.getByRole('textbox');
+ expect(textarea).toBeInTheDocument();
+ expect(textarea).toHaveValue('Text file content');
+ });
+
+ it('renders an image for image files', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.getByRole('button', { name: /preview document/i })).toBeInTheDocument();
+ });
+
+ it('renders an audio player for audio files', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.getByText(/preview not available for this file/i)).toBeInTheDocument();
+ });
});
diff --git a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx
index fb41d37df45..9bbb6599742 100644
--- a/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx
+++ b/desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx
@@ -20,8 +20,15 @@ import './StorageFilePage.scss';
import { i18nReact } from '../../../utils/i18nReact';
import Button, { PrimaryButton } from 'cuix/dist/components/Button';
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 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 { t } = i18nReact.useTranslation();
@@ -29,89 +36,173 @@ const StorageFilePage = ({ fileData }: { fileData: PathAndFileData }): JSX.Eleme
const [fileContent, setFileContent] = React.useState(fileData.view?.contents);
const fileMetaData = useMemo(() => getFileMetaData(t, fileData), [t, fileData]);
+ const { loading: isSaving, save } = useSaveData(SAVE_FILE_API_URL);
+
const handleEdit = () => {
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 = () => {
- // TODO: Save file content to API
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 (
-
-
- {fileMetaData.map((row, index) => (
-
- {row.map(item => (
-
-
{item.label}
-
{item.value}
-
- ))}
+
+
+
+ {fileMetaData.map((row, index) => (
+
+ {row.map(item => (
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+ ))}
+
+
+
+
+ {t('Content')}
+
+ {isEditingEnabled && (
+
+ {t('Edit')}
+
+ )}
+ {isEditing && (
+ <>
+
+ {t('Save')}
+
+
+ >
+ )}
+ {fileData.show_download_button && (
+
+
+ {t('Download')}
+
+
+ )}
+
- ))}
-
-
-
- {t('Content')}
-
-
- {t('Edit')}
-
-
- {t('Save')}
-
-
-
-
- {t('Download')}
-
-
+
+ {fileType === SupportedFileTypes.TEXT && (
+
-
-
-
+
);
};
diff --git a/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts b/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts
index 9d107fa9143..65a7ea78a43 100644
--- a/desktop/core/src/desktop/js/reactComponents/FileChooser/api.ts
+++ b/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 VIEWFILES_API_URl = '/api/v1/storage/view=';
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 TOUCH_API_URL = '/api/v1/storage/touch';
const CONTENT_SUMMARY_API_URL = '/api/v1/storage/content_summary=';
diff --git a/desktop/core/src/desktop/js/reactComponents/FileChooser/types.ts b/desktop/core/src/desktop/js/reactComponents/FileChooser/types.ts
index 8baad9f8d53..b16b37271df 100644
--- a/desktop/core/src/desktop/js/reactComponents/FileChooser/types.ts
+++ b/desktop/core/src/desktop/js/reactComponents/FileChooser/types.ts
@@ -74,9 +74,11 @@ export interface BreadcrumbData {
interface FileView {
contents: string;
+ compression?: string;
}
export interface PathAndFileData {
+ editable?: boolean;
path: string;
breadcrumbs: BreadcrumbData[];
files: File[];
diff --git a/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts b/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts
index da294457bee..a4250f0119b 100644
--- a/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts
+++ b/desktop/core/src/desktop/js/utils/constants/storageBrowser.ts
@@ -15,3 +15,40 @@
// limitations under the License.
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
+};
diff --git a/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts b/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts
index 6405a0b1b17..b02e793f723 100644
--- a/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts
+++ b/desktop/core/src/desktop/js/utils/hooks/useSaveData.ts
@@ -17,18 +17,21 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ApiFetchOptions, post } from '../../api/utils';
-export interface Options
{
- postOptions?: ApiFetchOptions;
- skip?: boolean;
+interface saveOptions {
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
}
+export interface Options extends saveOptions {
+ postOptions?: ApiFetchOptions;
+ skip?: boolean;
+}
+
interface UseSaveData {
data?: T;
loading: boolean;
error?: Error;
- save: (body: U) => void;
+ save: (body: U, saveOption: saveOptions) => void;
}
const useSaveData = (url?: string, options?: Options): UseSaveData => {
@@ -48,7 +51,7 @@ const useSaveData = (url?: string, options?: Options): UseSav
);
const saveData = useCallback(
- async (body: U) => {
+ async (body: U, saveOptions: saveOptions) => {
// Avoid Posting data if the skip option is true
// or if the URL is not provided
if (options?.skip || !url) {
@@ -60,11 +63,17 @@ const useSaveData = (url?: string, options?: Options): UseSav
try {
const response = await post(url, body, postOptions);
setData(response);
+ if (saveOptions?.onSuccess) {
+ saveOptions.onSuccess(response);
+ }
if (localOptions?.onSuccess) {
localOptions.onSuccess(response);
}
} catch (error) {
setError(error);
+ if (saveOptions?.onError) {
+ saveOptions.onError(error);
+ }
if (localOptions?.onError) {
localOptions.onError(error);
}