Browse Source

[ui-storagebrowser] view file content as text (#3823)

Co-authored-by: Harsh Gupta <42064744+Harshg999@users.noreply.github.com>
Ram Prasad Agarwal 1 year ago
parent
commit
53e8d58fdc

+ 16 - 2
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage/StorageBrowserPage.scss

@@ -32,8 +32,22 @@
   }
 
   .hue-storage-browser__tab {
+    display: flex;
+    height: 100%;
     background-color: vars.$fluidx-gray-100;
     padding: 0 16px;
-    height: 100%;
+
+    .ant-tabs-content-holder {
+      display: flex;
+      flex: 1;
+      overflow: auto;
+
+      .ant-tabs-content,
+      .ant-tabs-tabpane-active {
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+      }
+    }
   }
-}
+}

+ 8 - 7
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage/StorageBrowserTabContents/StorageBrowserTabContent.scss

@@ -17,17 +17,19 @@
 @use 'mixins';
 
 .antd.cuix {
-  .hue-storage-browser-tabContent {
-    margin: vars.$fluidx-spacing-s 0;
+  .hue-storage-browser-tab-content {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    height: 100%;
   }
 
   .hue-storage-browser__title-bar {
     display: flex;
-    margin: 0 vars.$fluidx-spacing-s;
+    gap: vars.$fluidx-spacing-xs;
   }
 
   .hue-storage-browser__icon {
-    margin-right: vars.$fluidx-spacing-xs;
     flex: 0 0 auto;
     height: vars.$fluidx-heading-h3-line-height;
   }
@@ -41,13 +43,12 @@
 
   .hue-storage-browser__path-browser-panel {
     display: flex;
-    margin: vars.$fluidx-spacing-xs 0;
     align-items: center;
+    gap: vars.$fluidx-spacing-xs;
   }
 
   .hue-storage-browser__filePath {
     flex: 0 0 auto;
     font-weight: 600;
-    margin: 0 vars.$fluidx-spacing-xs 0 vars.$fluidx-spacing-s;
   }
-}
+}

+ 27 - 18
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage/StorageBrowserTabContents/StorageBrowserTabContent.tsx

@@ -23,11 +23,16 @@ import BucketIcon from '@cloudera/cuix-core/icons/react/BucketIcon';
 import PathBrowser from '../../../../reactComponents/FileChooser/PathBrowser/PathBrowser';
 import StorageBrowserTable from '../StorageBrowserTable/StorageBrowserTable';
 import { VIEWFILES_API_URl } from '../../../../reactComponents/FileChooser/api';
-import { PathAndFileData, SortOrder } from '../../../../reactComponents/FileChooser/types';
+import {
+  BrowserViewType,
+  PathAndFileData,
+  SortOrder
+} from '../../../../reactComponents/FileChooser/types';
 import { DEFAULT_PAGE_SIZE } from '../../../../utils/constants/storageBrowser';
 import useLoadData from '../../../../utils/hooks/useLoadData';
 
 import './StorageBrowserTabContent.scss';
+import StorageFilePage from '../../StorageFilePage/StorageFilePage';
 
 interface StorageBrowserTabContentProps {
   user_home_dir: string;
@@ -35,7 +40,7 @@ interface StorageBrowserTabContentProps {
 }
 
 const defaultProps = {
-  testId: 'hue-storage-browser-tabContent'
+  testId: 'hue-storage-browser-tab-content'
 };
 
 const StorageBrowserTabContent = ({
@@ -69,11 +74,11 @@ const StorageBrowserTabContent = ({
 
   return (
     <Spin spinning={loading}>
-      <div className="hue-storage-browser-tabContent" data-testid={testId}>
+      <div className="hue-storage-browser-tab-content" data-testid={testId}>
         <div className="hue-storage-browser__title-bar" data-testid={`${testId}-title-bar`}>
           <BucketIcon className="hue-storage-browser__icon" data-testid={`${testId}-icon`} />
           <h3 className="hue-storage-browser__folder-name" data-testid={`${testId}-folder-namer`}>
-            {filesData?.breadcrumbs[filesData?.breadcrumbs?.length - 1].label}
+            {filesData?.path?.split('/').pop()}
           </h3>
         </div>
         <div
@@ -88,20 +93,24 @@ const StorageBrowserTabContent = ({
             showIcon={false}
           />
         </div>
-        <StorageBrowserTable
-          filesData={filesData}
-          pageSize={pageSize}
-          onFilepathChange={setFilePath}
-          onPageSizeChange={setPageSize}
-          onPageNumberChange={setPageNumber}
-          onSortByColumnChange={setSortByColumn}
-          onSortOrderChange={setSortOrder}
-          onSearch={setSearchTerm}
-          sortByColumn={sortByColumn}
-          sortOrder={sortOrder}
-          refetchData={reloadData}
-          filePath={filePath}
-        />
+        {filesData?.type === BrowserViewType.file ? (
+          <StorageFilePage fileData={filesData} />
+        ) : (
+          <StorageBrowserTable
+            filesData={filesData}
+            pageSize={pageSize}
+            onFilepathChange={setFilePath}
+            onPageSizeChange={setPageSize}
+            onPageNumberChange={setPageNumber}
+            onSortByColumnChange={setSortByColumn}
+            onSortOrderChange={setSortOrder}
+            onSearch={setSearchTerm}
+            sortByColumn={sortByColumn}
+            sortOrder={sortOrder}
+            refetchData={reloadData}
+            filePath={filePath}
+          />
+        )}
       </div>
     </Spin>
   );

+ 6 - 4
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage/StorageBrowserTable/StorageBrowserTable.scss

@@ -24,7 +24,7 @@ $icon-margin: 5px;
 .antd.cuix {
   .hue-storage-browser__actions-bar {
     display: flex;
-    margin: vars.$fluidx-spacing-s;
+    margin: vars.$fluidx-spacing-s 0;
     justify-content: space-between;
   }
 
@@ -46,8 +46,6 @@ $icon-margin: 5px;
   }
 
   .hue-storage-browser__table {
-    margin: 0 vars.$fluidx-spacing-s;
-
     .ant-table-placeholder {
       height: $table-placeholder-height;
       text-align: center;
@@ -65,6 +63,10 @@ $icon-margin: 5px;
   }
 
   .hue-storage-browser__table-row {
+    :hover {
+      cursor: pointer;
+    }
+
     td.ant-table-cell {
       height: $cell-height;
       @include mixins.nowrap-ellipsis;
@@ -87,4 +89,4 @@ $icon-margin: 5px;
 .hue-storage-browser__actions-dropdown {
   width: $action-dropdown-width;
   @include mixins.hue-svg-icon__d3-conflict;
-}
+}

+ 57 - 78
desktop/core/src/desktop/js/apps/storageBrowser/StorageBrowserPage/StorageBrowserTable/StorageBrowserTable.tsx

@@ -14,11 +14,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import React, { useEffect, useMemo, useState, useCallback } from 'react';
+import React, { useMemo, useState, useCallback, useEffect } from 'react';
 import { ColumnProps } from 'antd/lib/table';
-import { Dropdown, Input, Spin } from 'antd';
+import { Dropdown, Input, Spin, Tooltip } from 'antd';
 import { MenuItemGroupType } from 'antd/lib/menu/hooks/useItems';
-import Tooltip from 'antd/es/tooltip';
 
 import FolderIcon from '@cloudera/cuix-core/icons/react/ProjectIcon';
 import SortAscending from '@cloudera/cuix-core/icons/react/SortAscendingIcon';
@@ -39,14 +38,15 @@ import { mkdir, touch } from '../../../../reactComponents/FileChooser/api';
 import {
   StorageBrowserTableData,
   SortOrder,
-  PathAndFileData,
-  BrowserViewType
+  PathAndFileData
 } from '../../../../reactComponents/FileChooser/types';
 import Pagination from '../../../../reactComponents/Pagination/Pagination';
 import StorageBrowserActions from '../StorageBrowserActions/StorageBrowserActions';
 import InputModal from '../../InputModal/InputModal';
+import formatBytes from '../../../../utils/formatBytes';
 
 import './StorageBrowserTable.scss';
+import { formatTimestamp } from '../../../../utils/dateTimeUtils';
 
 interface StorageBrowserTableProps {
   className?: string;
@@ -91,29 +91,28 @@ const StorageBrowserTable = ({
   ...restProps
 }: StorageBrowserTableProps): JSX.Element => {
   const [loadingFiles, setLoadingFiles] = useState<boolean>(false);
-  const [tableHeight, setTableHeight] = useState<number>();
+  const [tableHeight, setTableHeight] = useState<number>(100);
   const [selectedFiles, setSelectedFiles] = useState<StorageBrowserTableData[]>([]);
   const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
   const [showNewFileModal, setShowNewFileModal] = useState<boolean>(false);
-  const [viewType, setViewType] = useState<BrowserViewType>(BrowserViewType.dir);
 
   const { t } = i18nReact.useTranslation();
 
   const tableData: StorageBrowserTableData[] = useMemo(() => {
-    return (
-      filesData?.files
-        ?.filter(file => !['.', '..'].includes(file.name)) // removes ..(previous folder) and .(current folder)
-        .map(file => ({
-          name: file.name,
-          size: file.humansize,
-          user: file.stats.user,
-          group: file.stats.group,
-          permission: file.rwx,
-          mtime: file.mtime,
-          type: file.type,
-          path: file.path
-        })) ?? []
-    );
+    if (!filesData?.files) {
+      return [];
+    }
+
+    return filesData?.files?.map(file => ({
+      name: file.name,
+      size: formatBytes(file.stats?.size),
+      user: file.stats.user,
+      group: file.stats.group,
+      permission: file.rwx,
+      mtime: file.stats?.mtime ? formatTimestamp(new Date(Number(file.stats.mtime) * 1000)) : '-',
+      type: file.type,
+      path: file.path
+    }));
   }, [filesData]);
 
   const newActionsMenuItems: MenuItemGroupType[] = [
@@ -171,7 +170,7 @@ const StorageBrowserTable = ({
 
   const getColumns = (file: StorageBrowserTableData) => {
     const columns: ColumnProps<StorageBrowserTableData>[] = [];
-    for (const [key] of Object.entries(file)) {
+    for (const key of Object.keys(file)) {
       const column: ColumnProps<StorageBrowserTableData> = {
         dataIndex: key,
         title: (
@@ -195,9 +194,8 @@ const StorageBrowserTable = ({
       };
       if (key === 'name') {
         column.width = '40%';
-        //TODO: Apply tooltip only for truncated values
         column.render = (_, record: StorageBrowserTableData) => (
-          <Tooltip title={record.name}>
+          <Tooltip title={record.name} mouseEnterDelay={1.5}>
             <span className="hue-storage-browser__table-cell-icon">
               {record.type === 'dir' ? <FolderIcon /> : <FileOutlined />}
             </span>
@@ -302,14 +300,6 @@ const StorageBrowserTable = ({
     };
   }, []);
 
-  useEffect(() => {
-    if (filesData?.type === 'file') {
-      setViewType(BrowserViewType.file);
-    } else {
-      setViewType(BrowserViewType.dir);
-    }
-  }, [filesData]);
-
   const locale = {
     emptyText: t('Folder is empty')
   };
@@ -326,56 +316,45 @@ const StorageBrowserTable = ({
           }}
         />
         <div className="hue-storage-browser__actions-bar-right">
-          {viewType === BrowserViewType.dir && (
-            <>
-              <StorageBrowserActions
-                selectedFiles={selectedFiles}
-                setLoadingFiles={setLoadingFiles}
-                onSuccessfulAction={refetchData}
-              />
-              <Dropdown
-                overlayClassName="hue-storage-browser__actions-dropdown"
-                menu={{
-                  items: newActionsMenuItems,
-                  className: 'hue-storage-browser__action-menu'
-                }}
-                trigger={['hover', 'click']}
-              >
-                <PrimaryButton data-event={''}>
-                  {t('New')}
-                  <DropDownIcon />
-                </PrimaryButton>
-              </Dropdown>
-            </>
-          )}
+          <StorageBrowserActions
+            selectedFiles={selectedFiles}
+            setLoadingFiles={setLoadingFiles}
+            onSuccessfulAction={refetchData}
+          />
+          <Dropdown
+            overlayClassName="hue-storage-browser__actions-dropdown"
+            menu={{
+              items: newActionsMenuItems,
+              className: 'hue-storage-browser__action-menu'
+            }}
+            trigger={['hover', 'click']}
+          >
+            <PrimaryButton data-event="">
+              {t('New')}
+              <DropDownIcon />
+            </PrimaryButton>
+          </Dropdown>
         </div>
       </div>
 
       <Spin spinning={loadingFiles}>
-        {viewType === BrowserViewType.dir && (
-          <Table
-            className={className}
-            columns={getColumns(tableData[0] ?? [])}
-            dataSource={tableData}
-            onRow={onRowClicked}
-            pagination={false}
-            rowClassName={rowClassName}
-            rowKey={(record, index) => record.path + '' + index}
-            rowSelection={{
-              type: 'checkbox',
-              ...rowSelection
-            }}
-            scroll={{ y: tableHeight }}
-            data-testid={`${testId}`}
-            locale={locale}
-            {...restProps}
-          />
-        )}
-
-        {viewType === BrowserViewType.file && (
-          // TODO: code for file view
-          <div> File view</div>
-        )}
+        <Table
+          className={className}
+          columns={getColumns(tableData[0] ?? {})}
+          dataSource={tableData?.slice(2)}
+          onRow={onRowClicked}
+          pagination={false}
+          rowClassName={rowClassName}
+          rowKey={(record, index) => record.path + '' + index}
+          rowSelection={{
+            type: 'checkbox',
+            ...rowSelection
+          }}
+          scroll={{ y: tableHeight }}
+          data-testid={`${testId}`}
+          locale={locale}
+          {...restProps}
+        />
 
         {filesData?.page && (
           <Pagination

+ 103 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.scss

@@ -0,0 +1,103 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use 'variables' as vars;
+
+.antd.cuix {
+  .hue-storage-file-page {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    gap: vars.$fluidx-spacing-s;
+    padding: vars.$fluidx-spacing-s 0;
+
+    .meta-data {
+      display: flex;
+      gap: vars.$fluidx-spacing-s;
+      padding: vars.$fluidx-spacing-s;
+      background-color: vars.$fluidx-gray-040;
+      border-radius: vars.$border-radius-base;
+    }
+
+    .meta-data__group {
+      display: flex;
+      gap: vars.$fluidx-spacing-s;
+      border-right: 1px solid vars.$fluidx-gray-300;
+      padding-right: vars.$fluidx-spacing-s;
+
+      &:last-child {
+        border-right: none;
+      }
+    }
+
+    .meta-data__column {
+      display: flex;
+      flex-direction: column;
+
+    }
+
+    .meta-data__column-label {
+      font-size: vars.$font-size-sm;
+      color: vars.$fluidx-gray-700;
+      text-transform: uppercase;
+    }
+
+    .meta-data__column-value {
+      font-size: vars.$font-size-base;
+      color: vars.$fluidx-gray-900;
+    }
+
+    .preview {
+      display: flex;
+      flex-direction: column;
+      flex: 1;
+    }
+
+    .preview__title-bar {
+      display: flex;
+      justify-content: space-between;
+      gap: vars.$fluidx-spacing-s;
+      padding: vars.$fluidx-spacing-s;
+      font-size: vars.$font-size-lg;
+      background-color: vars.$fluidx-gray-040;
+      color: vars.$fluidx-gray-700;
+      font-weight: vars.$fluidx-heading-h4-weight;
+      align-items: center;
+      border-bottom: 1px solid vars.$fluidx-gray-300;
+    }
+
+    .preview__action-group {
+      display: flex;
+      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__textarea[readonly] {
+      cursor: text;
+      color: vars.$fluidx-black;
+      background-color: vars.$fluidx-white;
+    }
+  }
+}

+ 131 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx

@@ -0,0 +1,131 @@
+import React from 'react';
+import { render, screen, waitFor } from '@testing-library/react';
+import StorageFilePage from './StorageFilePage';
+import { PathAndFileData } from '../../../reactComponents/FileChooser/types';
+import '@testing-library/jest-dom';
+import userEvent from '@testing-library/user-event';
+
+jest.mock('../../../utils/dateTimeUtils', () => ({
+  ...jest.requireActual('../../../utils/dateTimeUtils'),
+  formatTimestamp: () => {
+    return 'April 8, 2021 at 00:00 AM';
+  }
+}));
+
+// Mock data for fileData
+const mockFileData: PathAndFileData = {
+  path: '/path/to/file.txt',
+  stats: {
+    size: 123456,
+    user: 'testuser',
+    group: 'testgroup',
+    mtime: '1617877200',
+    atime: '1617877200',
+    mode: 33188,
+    path: '/path/to/file.txt',
+    aclBit: false
+  },
+  rwx: 'rwxr-xr-x',
+  breadcrumbs: [],
+  view: {
+    contents: 'Initial file content'
+  },
+  files: [],
+  page: {
+    number: 1,
+    num_pages: 1,
+    previous_page_number: 1,
+    next_page_number: 1,
+    start_index: 1,
+    end_index: 1,
+    total_count: 1
+  },
+  pagesize: 100
+};
+
+describe('StorageFilePage', () => {
+  it('renders file metadata and content', () => {
+    render(<StorageFilePage fileData={mockFileData} />);
+
+    expect(screen.getByText('Size')).toBeInTheDocument();
+    expect(screen.getByText('120.56 KB')).toBeInTheDocument();
+    expect(screen.getByText('Created By')).toBeInTheDocument();
+    expect(screen.getByText('testuser')).toBeInTheDocument();
+    expect(screen.getByText('Group')).toBeInTheDocument();
+    expect(screen.getByText('testgroup')).toBeInTheDocument();
+    expect(screen.getByText('Permissions')).toBeInTheDocument();
+    expect(screen.getByText('rwxr-xr-x')).toBeInTheDocument();
+    expect(screen.getByText('Last Modified')).toBeInTheDocument();
+    expect(screen.getByText('April 8, 2021 at 00:00 AM')).toBeInTheDocument();
+    expect(screen.getByText('Content')).toBeInTheDocument();
+    expect(screen.getByText('Initial file content')).toBeInTheDocument();
+  });
+
+  it('shows edit button and hides save/cancel buttons initially', () => {
+    render(<StorageFilePage fileData={mockFileData} />);
+
+    expect(screen.getByRole('button', { name: 'Edit' })).toBeVisible();
+    expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
+  });
+
+  it('shows save and cancel buttons when editing', async () => {
+    const user = userEvent.setup();
+    render(<StorageFilePage fileData={mockFileData} />);
+
+    expect(screen.getByRole('button', { name: 'Edit' })).toBeVisible();
+    expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
+
+    await user.click(screen.getByRole('button', { name: 'Edit' }));
+
+    await waitFor(() => {
+      expect(screen.getByRole('button', { name: 'Save' })).toBeVisible();
+      expect(screen.getByRole('button', { name: 'Cancel' })).toBeVisible();
+    });
+
+    expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull();
+  });
+
+  it('updates textarea value and calls handleSave', async () => {
+    const user = userEvent.setup();
+    render(<StorageFilePage fileData={mockFileData} />);
+
+    await user.click(screen.getByRole('button', { name: 'Edit' }));
+
+    const textarea = screen.getByRole('textbox');
+    expect(textarea).toBeVisible();
+
+    await user.clear(textarea);
+    await user.type(textarea, 'Updated file content');
+
+    expect(textarea).toHaveValue('Updated file content');
+
+    await user.click(screen.getByRole('button', { name: 'Save' }));
+
+    expect(screen.getByText('Updated file content')).toBeInTheDocument();
+    expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
+    expect(screen.getByRole('button', { name: 'Edit' })).toBeVisible();
+  });
+
+  it('cancels editing and reverts textarea value', async () => {
+    const user = userEvent.setup();
+    render(<StorageFilePage fileData={mockFileData} />);
+
+    await user.click(screen.getByRole('button', { name: 'Edit' }));
+
+    const textarea = screen.getByRole('textbox');
+    expect(textarea).toBeVisible();
+
+    await user.clear(textarea);
+    await user.type(textarea, 'Updated file content');
+    expect(textarea).toHaveValue('Updated file content');
+
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
+    expect(textarea).toHaveValue('Initial file content');
+    expect(screen.queryByRole('button', { name: 'Save' })).toBeNull();
+    expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
+    expect(screen.getByRole('button', { name: 'Edit' })).toBeVisible();
+  });
+});

+ 104 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.tsx

@@ -0,0 +1,104 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useMemo } from 'react';
+import { PathAndFileData } from '../../../reactComponents/FileChooser/types';
+import './StorageFilePage.scss';
+import { i18nReact } from '../../../utils/i18nReact';
+import Button, { PrimaryButton } from 'cuix/dist/components/Button';
+import { getFileMetaData } from './StorageFilePage.util';
+
+const StorageFilePage = ({ fileData }: { fileData: PathAndFileData }): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const [isEditing, setIsEditing] = React.useState(false);
+  const [fileContent, setFileContent] = React.useState(fileData.view?.contents);
+  const fileMetaData = useMemo(() => getFileMetaData(t, fileData), [t, fileData]);
+
+  const handleEdit = () => {
+    setIsEditing(true);
+  };
+
+  const handleSave = () => {
+    // TODO: Save file content to API
+    setIsEditing(false);
+  };
+
+  const handleCancel = () => {
+    setIsEditing(false);
+    setFileContent(fileData.view?.contents);
+  };
+
+  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>
+            ))}
+          </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>
+          </div>
+        </div>
+
+        <textarea
+          data-testid="file-content"
+          value={fileContent}
+          onChange={e => setFileContent(e.target.value)}
+          readOnly={!isEditing}
+          className="preview__textarea"
+        />
+      </div>
+    </div>
+  );
+};
+
+export default StorageFilePage;

+ 62 - 0
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.util.ts

@@ -0,0 +1,62 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { TFunction } from 'i18next';
+import { PathAndFileData } from '../../../reactComponents/FileChooser/types';
+import { formatTimestamp } from '../../../utils/dateTimeUtils';
+import formatBytes from '../../../utils/formatBytes';
+
+export type MetaData = {
+  name: string;
+  label: string;
+  value: string;
+};
+
+export const getFileMetaData = (t: TFunction, fileData: PathAndFileData): MetaData[][] => {
+  return [
+    [
+      {
+        name: 'size',
+        label: t('Size'),
+        value: formatBytes(fileData.stats?.size)
+      },
+      {
+        name: 'user',
+        label: t('Created By'),
+        value: fileData.stats?.user
+      }
+    ],
+    [
+      {
+        name: 'group',
+        label: t('Group'),
+        value: fileData.stats?.group
+      },
+      {
+        name: 'permissions',
+        label: t('Permissions'),
+        value: fileData.rwx
+      },
+      {
+        name: 'mtime',
+        label: t('Last Modified'),
+        value: fileData.stats?.mtime
+          ? formatTimestamp(new Date(Number(fileData.stats.mtime) * 1000))
+          : '-'
+      }
+    ]
+  ];
+};

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

@@ -72,6 +72,10 @@ export interface BreadcrumbData {
   url: string;
 }
 
+interface FileView {
+  contents: string;
+}
+
 export interface PathAndFileData {
   path: string;
   breadcrumbs: BreadcrumbData[];
@@ -79,6 +83,9 @@ export interface PathAndFileData {
   page: PageStats;
   pagesize: number;
   type?: string;
+  stats: Stats;
+  rwx: string;
+  view: FileView;
 }
 
 export interface ContentSummary {

+ 3 - 1
desktop/core/src/desktop/js/utils/dateTimeUtils.ts

@@ -14,6 +14,8 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import { DateTimeFormatOptions } from 'luxon';
+
 /**
  * Calculates the duration between two dates.
  * @param start The start date.
@@ -57,7 +59,7 @@ export const calculateDuration = (start: Date | string, end: Date | string): str
  * @returns The formatted timestamp as a string.
  */
 export const formatTimestamp = (timestamp: Date | string): string => {
-  const options = {
+  const options: DateTimeFormatOptions = {
     year: 'numeric',
     month: 'long',
     day: 'numeric',