Преглед на файлове

[ui-importer-modal] Bulk Edit column modal second iteration (#4165)

* Modal with data

before starting new work

header name changed but entire column vanishes

Optimized code but with removing column bug

Fixed mapping issue

removed grey color

Row data present as column values in sample values

Fixed padding for the edit columns button

WIP

wip

* nits left in first commit

* Integrating the new api

Before review comments

First iteration of styling

* Fixed styling issues

* useLoadData hook used

* LoadingErrorWrapper used

* unit tests for the bulk edit columns

* Fixing as part of review comments

* Fixing 2 based on review comments

* Merged properly

* Linting fixed

* Fixed the tests for editColumns

* Removed loadingErrorWrapper

* Fixing the linting issues

* Updated the scss file

* Revert "Removed loadingErrorWrapper"

This reverts commit d49bf7df8b2d3dd0abce492dcc98e1a8530cd68d.

* Removing all the errors after making changes in the previous commits

* nits

* Fixed faling unit tests

* prettier errors fixed

* Dropdown default string value in uppercase like other values

* Based on review comments

* resolve conflict

* Fixed the errors after merge

* Changes based on review comments

* AI comments and suggestions updates

* Review changes

* Changes based on review comments

* final review changes

* Adding TODO

* Fixed the unit tests

* Removing unneccessary comments

* Added the eslint statement that was removed mistakenly

* Correcting tests

* Unneccesary file

* Removing all the unneccessary mocks and tests

* Merging conflicts

* fixing linting issues

* Fixing post merging issues

* Removing unneccessary scss file

* fixing the test

* changing the folder name to EditColumnsModal

* Changes based on review comments

* Fixing the style issues

* Styling issues fixed

* Adding aria-labels

* Renaming to editableRows

* Changes to the test in the filePreview

* updating the test

* Renaming

* sql dialect changed

* If error dont should table

* test that EditColumnsModal is hidden before the the click and then that it is visible later

* test that Edit Columns is visible and that it opens the dialog when clicked

* Fixing the unit tests

* clear error message

* Tests optimized

* Add basic validation for duplicate or empty column names

* Changes acc to the review comments

* testing for no sample data related cases

* Fixing the border of the select dropdown

* Fixing the padding of edit columns button

* nits

* Changing the error message

* Final review changes

* Fix unresolved GitHub conversation - clarify CSS selectors
Ananya_Agarwal преди 2 месеца
родител
ревизия
3e646de44f

+ 1 - 0
desktop/core/src/desktop/js/apps/admin/Components/utils.ts

@@ -18,5 +18,6 @@ export const SERVER_LOGS_API_URL = '/api/v1/logs';
 export const CHECK_CONFIG_EXAMPLES_API_URL = '/api/v1/check_config';
 export const INSTALL_APP_EXAMPLES_API_URL = '/api/v1/install_app_examples';
 export const INSTALL_AVAILABLE_EXAMPLES_API_URL = '/api/v1/available_app_examples';
+export const SQL_TYPE_MAPPING_API_URL = '/api/v1/importer/sql_type_mapping';
 export const HUE_DOCS_CONFIG_URL = 'https://docs.gethue.com/administrator/configuration/';
 export const USAGE_ANALYTICS_API_URL = '/api/v1/usage_analytics';

+ 0 - 41
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumnsModal.tsx

@@ -1,41 +0,0 @@
-// 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 from 'react';
-import { i18nReact } from '../../../../utils/i18nReact';
-import Modal from 'cuix/dist/components/Modal';
-import './EditColumns.scss';
-
-interface EditColumnsModalProps {
-  isOpen: boolean;
-  closeModal: () => void;
-}
-
-const EditColumnsModal = ({ isOpen, closeModal }: EditColumnsModalProps): JSX.Element => {
-  const { t } = i18nReact.useTranslation();
-
-  return (
-    <Modal
-      cancelText={t('Cancel')}
-      okText={t('Done')}
-      title={t('Edit Columns')}
-      open={isOpen}
-      onCancel={closeModal}
-    />
-  );
-};
-
-export default EditColumnsModal;

+ 21 - 3
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumns.scss → desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.scss

@@ -16,8 +16,26 @@
 
 @use 'variables' as vars;
 
-.antd.cuix {
-  .hue-importer-edit-columns {
-    padding: vars.$fluidx-spacing-s;
+.hue-importer-edit-columns-modal {
+  .ant-modal-content {
+    width: 800px;
+  }
+
+  &__input-title {
+    width: 100px;
+  }
+
+  &__type-select {
+    width: 100px;
+
+    .ant-select-selector {
+      border: 1px solid vars.$fluidx-gray-600;
+      border-radius: vars.$border-radius-base;
+    }
+  }
+
+  &__no-sample {
+    color: vars.$fluidx-gray-500;
+    font-style: italic;
   }
 }

+ 419 - 0
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.test.tsx

@@ -0,0 +1,419 @@
+// 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 from 'react';
+import { render, screen, waitFor, RenderResult } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+import EditColumnsModal, { Column } from './EditColumnsModal';
+
+interface MockLoadDataReturn {
+  data: string[] | null;
+  loading: boolean;
+  error: Error | null;
+}
+
+const mockUseLoadData = jest.fn<MockLoadDataReturn, []>();
+
+jest.mock('../../../../utils/hooks/useLoadData/useLoadData', () => {
+  return jest.fn().mockImplementation(() => mockUseLoadData());
+});
+
+jest.mock('../../../../utils/i18nReact', () => ({
+  i18nReact: {
+    useTranslation: () => ({
+      t: (key: string, params?: Record<string, unknown>) => {
+        if (params) {
+          return Object.entries(params).reduce((str, [param, value]) => {
+            return str.replace(new RegExp(`{{${param}}}`, 'g'), String(value));
+          }, key);
+        }
+        return key;
+      },
+      ready: true
+    })
+  }
+}));
+
+describe('EditColumnsModal', () => {
+  const DEFAULT_COLUMNS: Column[] = [
+    { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+    { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+  ];
+
+  const DEFAULT_SAMPLE = { importerDataKey: 'row1', col1: 'val1', col2: 42 };
+
+  const DEFAULT_SQL_TYPES = ['STRING', 'INT', 'FLOAT'];
+
+  const MOCK_STATES = {
+    success: {
+      data: DEFAULT_SQL_TYPES,
+      loading: false,
+      error: null
+    },
+    loading: {
+      data: null,
+      loading: true,
+      error: null
+    },
+    error: {
+      data: null,
+      loading: false,
+      error: new Error('Failed to fetch SQL types')
+    },
+    empty: {
+      data: [] as string[],
+      loading: false,
+      error: null
+    },
+    invalidData: {
+      data: 'invalid-string-data' as unknown as string[],
+      loading: false,
+      error: null
+    }
+  };
+
+  interface RenderModalOptions {
+    columns?: Column[];
+    sample?: typeof DEFAULT_SAMPLE;
+    sqlDialect?: string;
+    setColumns?: jest.Mock;
+    closeModal?: jest.Mock;
+  }
+
+  const renderModal = ({
+    columns = DEFAULT_COLUMNS,
+    sample = DEFAULT_SAMPLE,
+    sqlDialect = 'hive',
+    setColumns = jest.fn(),
+    closeModal = jest.fn()
+  }: RenderModalOptions = {}): RenderResult & { setColumns: jest.Mock; closeModal: jest.Mock } => {
+    const result = render(
+      <EditColumnsModal
+        isOpen={true}
+        closeModal={closeModal}
+        columns={columns}
+        setColumns={setColumns}
+        sample={sample}
+        sqlDialect={sqlDialect}
+      />
+    );
+
+    return { ...result, setColumns, closeModal };
+  };
+
+  const getColumnTypeSelects = () =>
+    screen.getAllByLabelText('Column type').filter(el => el.tagName === 'DIV');
+
+  beforeEach(() => {
+    mockUseLoadData.mockReturnValue(MOCK_STATES.success);
+  });
+
+  describe('Basic functionality', () => {
+    it('should list existing modal columns as expected', () => {
+      renderModal();
+
+      expect(screen.getByDisplayValue('col1')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('col2')).toBeInTheDocument();
+
+      expect(screen.getByText('STRING')).toBeInTheDocument();
+      expect(screen.getByText('INT')).toBeInTheDocument();
+
+      expect(screen.getByText('val1')).toBeInTheDocument();
+      expect(screen.getByText('42')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('comment1')).toBeInTheDocument();
+      expect(screen.getByDisplayValue('comment2')).toBeInTheDocument();
+    });
+
+    it('should call setColumns with modified data from the table when Done is clicked', async () => {
+      const { setColumns, closeModal } = renderModal();
+      const user = userEvent.setup();
+
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('col1')).toBeInTheDocument();
+      });
+
+      const nameInput = screen.getByDisplayValue('col1');
+      await user.clear(nameInput);
+      await user.type(nameInput, 'newCol1');
+
+      const commentTextarea = screen.getByDisplayValue('comment1');
+      await user.clear(commentTextarea);
+      await user.type(commentTextarea, 'new comment');
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      await user.click(doneButton);
+
+      await waitFor(() => {
+        expect(setColumns).toHaveBeenCalledWith([
+          { ...DEFAULT_COLUMNS[0], title: 'newCol1', type: 'STRING', comment: 'new comment' },
+          { ...DEFAULT_COLUMNS[1], type: 'INT' }
+        ]);
+        expect(closeModal).toHaveBeenCalled();
+      });
+    });
+
+    it('should display SQL type options in select dropdown', async () => {
+      renderModal();
+
+      await waitFor(() => {
+        expect(screen.getByText('STRING')).toBeInTheDocument();
+        expect(screen.getByText('INT')).toBeInTheDocument();
+      });
+
+      const typeSelects = getColumnTypeSelects();
+      expect(typeSelects).toHaveLength(2);
+    });
+  });
+
+  describe('Edge cases with column data', () => {
+    it('should handle empty columns array', () => {
+      renderModal({ columns: [] });
+
+      expect(screen.getByText('Edit Columns')).toBeInTheDocument();
+      expect(screen.queryByDisplayValue('col1')).not.toBeInTheDocument();
+      expect(screen.queryByDisplayValue('col2')).not.toBeInTheDocument();
+    });
+
+    it('should prevent saving when duplicate column names exist', async () => {
+      const duplicateColumns: Column[] = [
+        { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      const { setColumns } = renderModal({ columns: duplicateColumns });
+
+      await waitFor(() => {
+        expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).toBeDisabled();
+
+      const user = userEvent.setup();
+      await user.click(doneButton);
+
+      expect(setColumns).not.toHaveBeenCalled();
+    });
+
+    it('should prevent saving when empty column names exist', async () => {
+      const columnsWithEmpty: Column[] = [
+        { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      const { setColumns } = renderModal({ columns: columnsWithEmpty });
+
+      await waitFor(() => {
+        expect(screen.getByText('1 column(s) have empty names')).toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).toBeDisabled();
+
+      const user = userEvent.setup();
+      await user.click(doneButton);
+
+      expect(setColumns).not.toHaveBeenCalled();
+    });
+
+    it('should allow saving when duplicate names are fixed', async () => {
+      const duplicateColumns: Column[] = [
+        { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      const { setColumns } = renderModal({ columns: duplicateColumns });
+      const user = userEvent.setup();
+
+      // Initially should show error and disable button
+      await waitFor(() => {
+        expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument();
+      });
+      expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled();
+
+      // Fix the duplicate by changing one name
+      const nameInputs = screen.getAllByDisplayValue('col1');
+      await user.clear(nameInputs[1]);
+      await user.type(nameInputs[1], 'col2_fixed');
+
+      // Error should disappear and button should be enabled
+      await waitFor(() => {
+        expect(screen.queryByText('Column name "col1" must be unique')).not.toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).not.toBeDisabled();
+
+      // Should now allow saving
+      await user.click(doneButton);
+
+      await waitFor(() => {
+        expect(setColumns).toHaveBeenCalledWith([
+          { ...duplicateColumns[0], title: 'col1', type: 'STRING' },
+          { ...duplicateColumns[1], title: 'col2_fixed', type: 'INT' }
+        ]);
+      });
+    });
+
+    it('should show error status on inputs with validation errors', async () => {
+      const duplicateColumns: Column[] = [
+        { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      renderModal({ columns: duplicateColumns });
+
+      await waitFor(() => {
+        const nameInputs = screen.getAllByDisplayValue('col1');
+        // Both inputs should have error status since they're duplicates
+        expect(nameInputs[0].closest('.ant-input')).toHaveClass('ant-input-status-error');
+        expect(nameInputs[1].closest('.ant-input')).toHaveClass('ant-input-status-error');
+      });
+    });
+
+    it('should allow saving when empty names are fixed', async () => {
+      const columnsWithEmpty: Column[] = [
+        { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      const { setColumns } = renderModal({ columns: columnsWithEmpty });
+      const user = userEvent.setup();
+
+      await waitFor(() => {
+        expect(screen.getByText('1 column(s) have empty names')).toBeInTheDocument();
+      });
+
+      const emptyNameInput = screen.getAllByLabelText('Column title')[0];
+      await user.type(emptyNameInput, 'fixed_name');
+
+      await waitFor(() => {
+        expect(screen.queryByText('1 column(s) have empty names')).not.toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).not.toBeDisabled();
+
+      await user.click(doneButton);
+
+      await waitFor(() => {
+        expect(setColumns).toHaveBeenCalledWith([
+          { ...columnsWithEmpty[0], title: 'fixed_name', type: 'STRING' },
+          { ...columnsWithEmpty[1], type: 'INT' }
+        ]);
+      });
+    });
+
+    it('should handle multiple validation errors simultaneously', async () => {
+      const problematicColumns: Column[] = [
+        { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'duplicate', dataIndex: 'col2', type: 'int', comment: 'comment2' },
+        { title: 'duplicate', dataIndex: 'col3', type: 'string', comment: 'comment3' }
+      ];
+
+      const { setColumns } = renderModal({ columns: problematicColumns });
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('Column name "duplicate" must be unique. 1 column(s) have empty names')
+        ).toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).toBeDisabled();
+
+      const user = userEvent.setup();
+      await user.click(doneButton);
+
+      expect(setColumns).not.toHaveBeenCalled();
+    });
+
+    it('should trim whitespace from column names during validation', async () => {
+      const columnsWithWhitespace: Column[] = [
+        { title: '  col1  ', dataIndex: 'col1', type: 'string', comment: 'comment1' },
+        { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' }
+      ];
+
+      renderModal({ columns: columnsWithWhitespace });
+
+      await waitFor(() => {
+        expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument();
+      });
+
+      const doneButton = screen.getByRole('button', { name: 'Done' });
+      expect(doneButton).toBeDisabled();
+    });
+  });
+
+  describe('SQL types error handling', () => {
+    it('should handle SQL type loading error and display error message', async () => {
+      mockUseLoadData.mockReturnValue(MOCK_STATES.error);
+
+      renderModal();
+
+      await waitFor(() => {
+        expect(
+          screen.getByText(
+            'Failed to fetch SQL types for engine hive, make sure the engine is properly configured in Hue.'
+          )
+        ).toBeInTheDocument();
+      });
+
+      expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument();
+    });
+
+    it('should handle empty SQL types response and display error message', async () => {
+      mockUseLoadData.mockReturnValue(MOCK_STATES.empty);
+
+      renderModal();
+
+      await waitFor(() => {
+        expect(screen.getByText('No SQL types returned from server.')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument();
+    });
+
+    it('should handle SQL types loading state', () => {
+      mockUseLoadData.mockReturnValue(MOCK_STATES.loading);
+
+      renderModal();
+
+      const typeSelects = getColumnTypeSelects();
+      expect(typeSelects).toHaveLength(2);
+
+      typeSelects.forEach(select => {
+        expect(select).toHaveClass('ant-select-disabled');
+        expect(select).toHaveClass('ant-select-loading');
+      });
+
+      expect(screen.getByText('Edit Columns')).toBeInTheDocument();
+    });
+
+    it('should handle invalid SQL type data format', async () => {
+      mockUseLoadData.mockReturnValue(MOCK_STATES.invalidData);
+
+      renderModal();
+
+      await waitFor(() => {
+        expect(screen.getByText('No SQL types returned from server.')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument();
+    });
+  });
+});

+ 321 - 0
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.tsx

@@ -0,0 +1,321 @@
+// 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, { useState, useEffect, ChangeEvent, useMemo } from 'react';
+import { i18nReact } from '../../../../utils/i18nReact';
+import Modal from 'cuix/dist/components/Modal';
+import Table from 'cuix/dist/components/Table';
+import Input from 'cuix/dist/components/Input';
+import Select from 'cuix/dist/components/Select';
+import { Alert } from 'antd';
+import { SQL_TYPE_MAPPING_API_URL } from '../../../admin/Components/utils';
+import useLoadData from '../../../../utils/hooks/useLoadData/useLoadData';
+import LoadingErrorWrapper from '../../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper';
+
+import './EditColumnsModal.scss';
+import { ImporterTableData, BaseColumnProperties } from '../../types';
+
+export interface Column extends BaseColumnProperties {
+  title: string;
+  dataIndex: string;
+}
+
+interface EditableRow extends Required<BaseColumnProperties> {
+  key: number;
+  title: string;
+  type: string;
+  sample: string;
+  comment: string;
+}
+
+interface EditColumnsModalProps {
+  isOpen: boolean;
+  closeModal: () => void;
+  columns: Column[];
+  setColumns: (cols: Column[]) => void;
+  sample?: ImporterTableData;
+  sqlDialect?: string;
+  fileFormat?: {
+    type?: string;
+    fieldSeparator?: string;
+    hasHeader?: boolean;
+  };
+}
+
+const EditColumnsModal = ({
+  isOpen,
+  closeModal,
+  columns,
+  setColumns,
+  sample,
+  sqlDialect = 'hive',
+  fileFormat
+}: EditColumnsModalProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const [editableRows, setEditableRows] = useState<EditableRow[]>([]);
+
+  const {
+    data: sqlTypesData,
+    loading: sqlTypesLoading,
+    error: sqlTypesError
+  } = useLoadData<string[]>(`${SQL_TYPE_MAPPING_API_URL}?sql_dialect=${sqlDialect}`);
+
+  const sqlTypes = useMemo(() => {
+    if (sqlTypesData && Array.isArray(sqlTypesData) && sqlTypesData.length > 0) {
+      return sqlTypesData;
+    }
+    return [];
+  }, [sqlTypesData]);
+
+  const validateColumnNames = useMemo(() => {
+    const errors = new Set<number>();
+    const titleCounts = new Map<string, number[]>();
+
+    editableRows.forEach((row, index) => {
+      const title = row.title.trim();
+
+      if (!title) {
+        errors.add(index);
+      }
+
+      if (!titleCounts.has(title)) {
+        titleCounts.set(title, []);
+      }
+      titleCounts.get(title)!.push(index);
+    });
+
+    titleCounts.forEach(indices => {
+      if (indices.length > 1) {
+        indices.forEach(index => errors.add(index));
+      }
+    });
+
+    return { errors, duplicates: titleCounts };
+  }, [editableRows]);
+
+  const sampleDataAnalysis = useMemo(() => {
+    const missingSampleCount = editableRows.filter(row => !row.sample.trim()).length;
+
+    if (missingSampleCount === 0) {
+      return { recommendations: [] };
+    }
+
+    const isCompletelyMissing = missingSampleCount === editableRows.length;
+    const fileType = fileFormat?.type;
+
+    const getFileSpecificGuidance = () => {
+      if (fileType === 'csv') {
+        return t('Check CSV settings: field separator, quote character, or "Has Header" option.');
+      }
+      if (fileType === 'excel') {
+        return t('Try different Excel sheet or verify sheet contains data.');
+      }
+      if (fileType === 'json') {
+        return t('Verify JSON structure and data format.');
+      }
+      return t('Check file format settings and data structure.');
+    };
+
+    const message = isCompletelyMissing
+      ? `${t('No sample data detected.')} ${getFileSpecificGuidance()} ${t('Import may fail.')}`
+      : `${t('{{count}} columns missing data.', { count: missingSampleCount })} ${t('Verify column types before importing.')}`;
+
+    return {
+      recommendations: [message],
+      hasNoSampleData: isCompletelyMissing,
+      hasPartialSampleData: !isCompletelyMissing
+    };
+  }, [editableRows, fileFormat, t]);
+
+  const hasValidationErrors = validateColumnNames.errors.size > 0;
+
+  const getValidationErrorMessages = (): string[] => {
+    const messages: string[] = [];
+    const duplicateNames = new Set<string>();
+
+    validateColumnNames.duplicates.forEach((indices, title) => {
+      if (indices.length > 1 && title.trim()) {
+        duplicateNames.add(title.trim());
+      }
+    });
+
+    if (duplicateNames.size > 0) {
+      duplicateNames.forEach(name => {
+        messages.push(t('Column name "{{name}}" must be unique', { name }));
+      });
+    }
+
+    const emptyCount = Array.from(validateColumnNames.errors).filter(
+      index => !editableRows[index]?.title.trim()
+    ).length;
+
+    if (emptyCount > 0) {
+      messages.push(t('{{count}} column(s) have empty names', { count: emptyCount }));
+    }
+
+    return messages;
+  };
+
+  const errors = [
+    {
+      enabled: !!sqlTypesError,
+      message: t(
+        'Failed to fetch SQL types for engine {{engine}}, make sure the engine is properly configured in Hue.',
+        { engine: sqlDialect }
+      )
+    },
+    {
+      enabled: !sqlTypesLoading && sqlTypes.length === 0,
+      message: t('No SQL types returned from server.')
+    }
+  ];
+
+  useEffect(() => {
+    setEditableRows(
+      columns.map((col, idx) => ({
+        key: idx,
+        title: col.title,
+        type: (col.type || 'string').toUpperCase(),
+        sample: sample && sample[col.dataIndex] !== undefined ? String(sample[col.dataIndex]) : '',
+        comment: col.comment || ''
+      }))
+    );
+  }, [columns, sample]);
+
+  const handleChange = (rowIndex: number, field: keyof EditableRow, value: string) => {
+    setEditableRows(rows =>
+      rows.map((row, i) => (i === rowIndex ? { ...row, [field]: value } : row))
+    );
+  };
+
+  const handleDone = async () => {
+    if (hasValidationErrors) {
+      return;
+    }
+
+    const updatedColumns = editableRows.map(row => ({
+      ...columns[row.key],
+      title: row.title.trim(),
+      type: row.type,
+      comment: row.comment
+    }));
+    setColumns(updatedColumns);
+    closeModal();
+  };
+
+  const modalColumns = [
+    {
+      title: t('Title'),
+      dataIndex: 'title',
+      render: (text: string, _: EditableRow, rowIndex: number) => (
+        <Input
+          value={text}
+          className="hue-importer-edit-columns-modal__input-title"
+          onChange={(e: ChangeEvent<HTMLInputElement>) =>
+            handleChange(rowIndex, 'title', e.target.value)
+          }
+          aria-label={t('Column title')}
+          status={validateColumnNames.errors.has(rowIndex) ? 'error' : undefined}
+        />
+      )
+    },
+    {
+      title: t('Type'),
+      dataIndex: 'type',
+      render: (value: string, _: EditableRow, rowIndex: number) => (
+        <Select
+          value={value}
+          onChange={(val: string) => handleChange(rowIndex, 'type', val)}
+          className="hue-importer-edit-columns-modal__type-select"
+          getPopupContainer={triggerNode => triggerNode.parentNode}
+          disabled={sqlTypesLoading || sqlTypes.length === 0}
+          loading={sqlTypesLoading}
+          options={sqlTypes.map(type => ({ label: type, value: type }))}
+          aria-label={t('Column type')}
+        />
+      )
+    },
+    {
+      title: t('Sample'),
+      dataIndex: 'sample',
+      render: (text: string) => {
+        if (text) {
+          return <span>{text}</span>;
+        }
+
+        const allEmpty = editableRows.every(row => !row.sample.trim());
+        const warningText = allEmpty ? t('Check file format') : t('Empty column');
+
+        return (
+          <span className="hue-importer-edit-columns-modal__no-sample" title={warningText}>
+            {t('No data')}
+          </span>
+        );
+      }
+    },
+    {
+      title: t('Comment'),
+      dataIndex: 'comment',
+      render: (text: string, _: EditableRow, rowIndex: number) => (
+        <textarea
+          value={text}
+          onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
+            handleChange(rowIndex, 'comment', e.target.value)
+          }
+          rows={2}
+          aria-label={t('Column comment')}
+        />
+      )
+    }
+  ];
+
+  const validationErrorMessages = getValidationErrorMessages();
+
+  return (
+    <Modal
+      open={isOpen}
+      onCancel={closeModal}
+      title={t('Edit Columns')}
+      cancelText={t('Cancel')}
+      okText={t('Done')}
+      onOk={handleDone}
+      okButtonProps={{ disabled: hasValidationErrors }}
+      className="hue-importer-edit-columns-modal"
+    >
+      <LoadingErrorWrapper loading={sqlTypesLoading} errors={errors} hideOnError={true}>
+        {validationErrorMessages.length > 0 && (
+          <Alert
+            message={validationErrorMessages.join('. ')}
+            type="error"
+            showIcon
+            style={{ marginBottom: 16 }}
+          />
+        )}
+        {sampleDataAnalysis.recommendations.length > 0 && (
+          <Alert
+            message={sampleDataAnalysis.recommendations.join(' ')}
+            type="warning"
+            showIcon
+            style={{ marginBottom: 16 }}
+          />
+        )}
+        <Table columns={modalColumns} dataSource={editableRows} pagination={false} />
+      </LoadingErrorWrapper>
+    </Modal>
+  );
+};
+
+export default EditColumnsModal;

+ 4 - 0
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/FilePreviewTab.scss

@@ -41,5 +41,9 @@
       display: flex;
       justify-content: space-between;
     }
+
+    &__edit-columns-button {
+      padding: vars.$fluidx-spacing-s;
+    }
   }
 }

+ 61 - 49
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/FilePreviewTab.test.tsx

@@ -23,13 +23,16 @@ import { FileMetaData, ImporterFileSource } from '../types';
 import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
 import { mocked } from 'jest-mock';
 
-const mockPreviewData = jest.fn().mockReturnValue({
-  columns: [{ name: 'Name' }, { name: 'Age' }],
+const mockPreviewData = {
+  columns: [
+    { name: 'Name', type: 'string' },
+    { name: 'Age', type: 'int' }
+  ],
   previewData: {
     name: ['Alice', 'Bob'],
     age: ['30', '25']
   }
-});
+};
 
 jest.mock('../../../utils/hooks/useLoadData/useLoadData');
 jest.mock('../../../utils/hooks/useSaveData/useSaveData', () => ({
@@ -40,6 +43,18 @@ jest.mock('../../../utils/hooks/useSaveData/useSaveData', () => ({
   }))
 }));
 
+jest.mock('../../../utils/hooks/useDataCatalog/useDataCatalog', () => ({
+  useDataCatalog: jest.fn(() => ({
+    loading: false,
+    databases: [{ name: 'default' }],
+    connectors: [{ id: 'hive', displayName: 'Hive' }],
+    computes: [{ id: 'compute1', name: 'Compute 1' }],
+    setCompute: jest.fn(),
+    setConnector: jest.fn(),
+    setDatabase: jest.fn()
+  }))
+}));
+
 describe('FilePreviewTab', () => {
   const mockFileMetaData: FileMetaData = {
     source: ImporterFileSource.LOCAL,
@@ -56,16 +71,7 @@ describe('FilePreviewTab', () => {
 
   const mockOnDestinationConfigChange = jest.fn();
 
-  beforeEach(() => {
-    jest.clearAllMocks();
-    mocked(useLoadData).mockImplementation(() => ({
-      loading: false,
-      data: mockPreviewData(),
-      reloadData: jest.fn()
-    }));
-  });
-
-  it('should render correctly', async () => {
+  const renderComponent = () =>
     render(
       <FilePreviewTab
         fileMetaData={mockFileMetaData}
@@ -74,59 +80,65 @@ describe('FilePreviewTab', () => {
       />
     );
 
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (mocked(useLoadData) as jest.MockedFunction<typeof useLoadData>).mockReturnValue({
+      loading: false,
+      data: mockPreviewData,
+      reloadData: jest.fn()
+    });
+  });
+
+  it('should render correctly', async () => {
+    const user = userEvent.setup();
+    renderComponent();
+
     await waitFor(() => {
-      expect(screen.getByText('Engine')).toBeInTheDocument();
-      expect(screen.getByText('Database')).toBeInTheDocument();
-      expect(screen.getByText('Table Name')).toBeInTheDocument();
+      const editColumnsButton = screen.getByRole('button', { name: 'Edit Columns' });
+      expect(editColumnsButton).toBeInTheDocument();
+      expect(editColumnsButton).toBeVisible();
+    });
+
+    const editColumnsButton = screen.getByRole('button', { name: 'Edit Columns' });
+
+    expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+    await user.click(editColumnsButton);
+
+    await waitFor(() => {
+      const modal = screen.getByRole('dialog');
+      expect(modal).toBeVisible();
+      expect(modal).toHaveTextContent('Edit Columns');
     });
   });
 
   it('should display data in the table after previewData is available', async () => {
-    render(
-      <FilePreviewTab
-        fileMetaData={mockFileMetaData}
-        destinationConfig={mockDestinationConfig}
-        onDestinationConfigChange={mockOnDestinationConfigChange}
-      />
-    );
+    renderComponent();
 
     await waitFor(() => {
-      expect(screen.getByText('Alice')).toBeInTheDocument();
-      expect(screen.getByText('30')).toBeInTheDocument();
-      expect(screen.getByText('Bob')).toBeInTheDocument();
-      expect(screen.getByText('25')).toBeInTheDocument();
+      expect(screen.getByText('Alice')).toBeVisible();
+      expect(screen.getByText('30')).toBeVisible();
+      expect(screen.getByText('Bob')).toBeVisible();
+      expect(screen.getByText('25')).toBeVisible();
     });
   });
 
-  it('should open edit columns modal when button is clicked', async () => {
-    render(
-      <FilePreviewTab
-        fileMetaData={mockFileMetaData}
-        destinationConfig={mockDestinationConfig}
-        onDestinationConfigChange={mockOnDestinationConfigChange}
-      />
-    );
-
-    const editColumnsButton = screen.getByText('Edit Columns');
+  it('should close edit columns modal when Cancel button is clicked', async () => {
+    const user = userEvent.setup();
+    renderComponent();
 
-    await userEvent.click(editColumnsButton);
+    const editColumnsButton = screen.getByRole('button', { name: 'Edit Columns' });
+    await user.click(editColumnsButton);
 
     await waitFor(() => {
-      expect(screen.getByRole('dialog')).toBeInTheDocument();
+      expect(screen.getByRole('dialog')).toBeVisible();
     });
-  });
 
-  it('should display source configuration', async () => {
-    render(
-      <FilePreviewTab
-        fileMetaData={mockFileMetaData}
-        destinationConfig={mockDestinationConfig}
-        onDestinationConfigChange={mockOnDestinationConfigChange}
-      />
-    );
+    const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+    await user.click(cancelButton);
 
     await waitFor(() => {
-      expect(screen.getByText('Configure source')).toBeInTheDocument();
+      expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
     });
   });
 });

+ 42 - 6
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/FilePreviewTab.tsx

@@ -31,7 +31,8 @@ import { BorderlessButton } from 'cuix/dist/components/Button';
 import PaginatedTable from '../../../reactComponents/PaginatedTable/PaginatedTable';
 import { FILE_GUESS_METADATA, FILE_GUESS_HEADER, FILE_PREVIEW_URL } from '../api';
 import SourceConfiguration from './SourceConfiguration/SourceConfiguration';
-import EditColumnsModal from './EditColumns/EditColumnsModal';
+import EditColumnsModal from './EditColumnsModal/EditColumnsModal';
+import type { Column } from './EditColumnsModal/EditColumnsModal';
 import DestinationSettings from './DestinationSettings/DestinationSettings';
 
 import './FilePreviewTab.scss';
@@ -51,6 +52,7 @@ const FilePreviewTab = ({
   const [fileFormat, setFileFormat] = useState<CombinedFileFormat | undefined>();
 
   const [isEditColumnsOpen, setIsEditColumnsOpen] = useState(false);
+  const [editableColumns, setEditableColumns] = useState<Column[]>([]);
 
   const { loading: guessingFormat } = useLoadData<FileFormatResponse>(FILE_GUESS_METADATA, {
     params: {
@@ -106,13 +108,35 @@ const FilePreviewTab = ({
       skip:
         !fileFormat?.type ||
         fileFormat?.hasHeader === undefined ||
-        destinationConfig.connectorId === undefined
+        destinationConfig.connectorId === undefined,
+      onSuccess: data => {
+        if (data?.columns && Array.isArray(data.columns)) {
+          setEditableColumns(
+            convertToAntdColumns(data.columns).map(col => ({
+              title: col?.title != null ? String(col.title) : '',
+              dataIndex: String(col.dataIndex || '')
+            }))
+          );
+        } else {
+          setEditableColumns([]);
+        }
+      }
     }
   );
 
-  const columns = convertToAntdColumns(previewData?.columns ?? []);
+  const originalColumns = convertToAntdColumns(previewData?.columns ?? []);
   const tableData = convertToDataSource(previewData?.previewData ?? {});
 
+  const displayColumns =
+    editableColumns.length > 0
+      ? editableColumns.map(col => ({
+          title: col.title,
+          dataIndex: col.dataIndex,
+          key: col.dataIndex,
+          width: '100px'
+        }))
+      : originalColumns;
+
   return (
     <div className="hue-importer-preview-page">
       <div className="hue-importer-preview-page__metadata">
@@ -124,20 +148,32 @@ const FilePreviewTab = ({
       <div className="hue-importer-preview-page__main-section">
         <div className="hue-importer-preview-page__header-section">
           <SourceConfiguration fileFormat={fileFormat} setFileFormat={setFileFormat} />
-          <BorderlessButton onClick={() => setIsEditColumnsOpen(true)}>
+          <BorderlessButton
+            onClick={() => setIsEditColumnsOpen(true)}
+            className="hue-importer-preview-page__edit-columns-button"
+          >
             {t('Edit Columns')}
           </BorderlessButton>
         </div>
         <PaginatedTable<ImporterTableData>
           loading={guessingFormat || loadingPreview || guessingHeader}
           data={tableData}
-          columns={columns}
+          columns={displayColumns}
           rowKey="importerDataKey"
           isDynamicHeight
           locale={{ emptyText: t('No data found in the file!') }}
         />
       </div>
-      <EditColumnsModal isOpen={isEditColumnsOpen} closeModal={() => setIsEditColumnsOpen(false)} />
+
+      <EditColumnsModal
+        isOpen={isEditColumnsOpen}
+        closeModal={() => setIsEditColumnsOpen(false)}
+        columns={editableColumns}
+        setColumns={setEditableColumns}
+        sample={tableData[0]}
+        sqlDialect={destinationConfig.connectorId}
+        fileFormat={fileFormat}
+      />
     </div>
   );
 };

+ 1 - 0
desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/SourceConfiguration/SourceConfiguration.tsx

@@ -62,6 +62,7 @@ const SourceConfiguration = ({
                 onChange={value => onChange(value, config.name)}
                 value={fileFormat?.[config.name]}
                 getPopupContainer={triggerNode => triggerNode.parentElement}
+                aria-label={t(config.label)}
               />
             </div>
           ))}

+ 7 - 3
desktop/core/src/desktop/js/apps/newimporter/types.ts

@@ -66,11 +66,15 @@ export interface FileMetaData {
   source: ImporterFileSource;
 }
 
-export type FilePreviewTableColumn = {
+export interface BaseColumnProperties {
+  type?: string;
+  comment?: string;
+}
+
+export interface FilePreviewTableColumn extends BaseColumnProperties {
   importerDataKey?: string; // key for identifying unique data row
   name: string;
-  type?: string;
-};
+}
 
 export interface FilePreviewTableData {
   [key: string]: (string | number)[];

+ 1 - 1
desktop/core/src/desktop/js/apps/newimporter/utils/utils.ts

@@ -49,7 +49,7 @@ export const convertToDataSource = (
       importerDataKey: `importer-row__${index}`
     };
     Object.keys(inputData).forEach(key => {
-      row[key] = inputData[key][index] ?? null;
+      row[toCamelCase(key)] = inputData[key][index] ?? null;
     });
     return row;
   });

+ 1 - 7
tools/cloudera/build_hue_common.sh

@@ -44,18 +44,12 @@ function install_build_dependencies() {
             libbz2-dev libncurses5-dev libgdbm-dev libreadline-dev libkrb5-dev \
             liblzma-dev uuid-dev libldap2-dev libffi-dev zlib1g-dev libssl-dev wget curl'
           ;;
-      redhat9|redhat8|centos7)
+      redhat9|redhat8|redhat8-arm64|centos7)
           sudo -- sh -c 'yum groupinstall -y "Development Tools" && \
             yum install -y \
             bzip2-devel ncurses-devel gdbm-devel readline-devel krb5-devel \
             xz-devel libuuid-devel openldap-devel libffi-devel zlib-devel openssl-devel wget curl'
           ;;
-      redhat8-arm64)
-          sudo -- sh -c 'yum groupinstall -y "Development Tools" --nobest && \
-            yum install -y \
-            bzip2-devel ncurses-devel gdbm-devel readline-devel krb5-devel \
-            xz-devel libuuid-devel openldap-devel libffi-devel zlib-devel openssl-devel wget curl'
-          ;;
       sles12|sles15)
           sudo -- sh -c 'zypper refresh'
           sudo -- sh -c 'zypper install -y \