Skip to content

[ui-importer] Public API integration #4137

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 55 commits into from
Jul 15, 2025
Merged
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
cb8883d
[importer] Add new component and API endpoint with new directory stru…
Harshg999 Apr 1, 2025
e763d4c
[importer] Implement file upload API for CSV and Excel formats with v…
Harshg999 Apr 8, 2025
da13af9
Refactor importer API: remove unused import and delete obsolete templ…
Harshg999 Apr 8, 2025
25527a7
Refactors file format detection and metadata extraction
Harshg999 Apr 25, 2025
0b53a51
Add file metadata detection and update dependencies
Harshg999 Apr 25, 2025
355b7a4
Refactors file upload API for better separation of concerns
Harshg999 Apr 29, 2025
3ec9c7b
Refactor file metadata detection API and improve efficiency
Harshg999 Apr 29, 2025
5298263
Improves file metadata extraction and error handling
Harshg999 Apr 30, 2025
4749bc8
Improves file type detection with graceful magic lib fallback
Harshg999 Apr 30, 2025
92bb7d1
Adds file preview API for data import functionality
Harshg999 May 5, 2025
eb1e590
Merge branch 'master' of github.com:cloudera/hue into new-importer-wo…
ramprasadagarwal May 6, 2025
58a40d7
[ui-importer] Public API integration
ramprasadagarwal May 6, 2025
fb34811
[importer] Add new component and API endpoint with new directory stru…
Harshg999 Apr 1, 2025
bad25d1
[importer] Implement file upload API for CSV and Excel formats with v…
Harshg999 Apr 8, 2025
45cb6b7
Refactor importer API: remove unused import and delete obsolete templ…
Harshg999 Apr 8, 2025
5000db1
Refactors file format detection and metadata extraction
Harshg999 Apr 25, 2025
fd931d2
Add file metadata detection and update dependencies
Harshg999 Apr 25, 2025
8ddcea0
Refactors file upload API for better separation of concerns
Harshg999 Apr 29, 2025
a72b3b8
Refactor file metadata detection API and improve efficiency
Harshg999 Apr 29, 2025
30235e4
Improves file metadata extraction and error handling
Harshg999 Apr 30, 2025
d2552ad
Improves file type detection with graceful magic lib fallback
Harshg999 Apr 30, 2025
5ed73c2
Adds file preview API for data import functionality
Harshg999 May 5, 2025
649b789
Merge branch 'new-importer-working-dir' of github.com:cloudera/hue in…
ramprasadagarwal May 6, 2025
7b35fc8
[importer] Add new component and API endpoint with new directory stru…
Harshg999 Apr 1, 2025
f562dc9
[importer] Implement file upload API for CSV and Excel formats with v…
Harshg999 Apr 8, 2025
d7a3037
Refactor importer API: remove unused import and delete obsolete templ…
Harshg999 Apr 8, 2025
faf36c5
Refactors file format detection and metadata extraction
Harshg999 Apr 25, 2025
d2d3c81
Add file metadata detection and update dependencies
Harshg999 Apr 25, 2025
ad8ba89
Refactors file upload API for better separation of concerns
Harshg999 Apr 29, 2025
31a75f1
Refactor file metadata detection API and improve efficiency
Harshg999 Apr 29, 2025
938cee7
Improves file metadata extraction and error handling
Harshg999 Apr 30, 2025
6fceede
Improves file type detection with graceful magic lib fallback
Harshg999 Apr 30, 2025
04bcf00
Adds file preview API for data import functionality
Harshg999 May 5, 2025
f6438be
fix the api integration
ramprasadagarwal May 12, 2025
e316224
Merge branch 'new-importer-working-dir' of github.com:cloudera/hue in…
ramprasadagarwal May 12, 2025
22a4865
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 6, 2025
8f843fb
revert extra changes
ramprasadagarwal Jun 6, 2025
cb8e35c
[importer] Refactor file format handling and add support for guessing…
ramprasadagarwal Jun 7, 2025
a37558e
[importer] Update API constants for file guessing and preview URLs
ramprasadagarwal Jun 10, 2025
68a2df2
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 10, 2025
e1e2aa8
[importer] Enhance file format handling and update tests for EXCEL su…
ramprasadagarwal Jun 12, 2025
1ab4200
[test] Update test description for non-EXCEL file type in SourceConfi…
ramprasadagarwal Jun 12, 2025
55ba6fa
[test] Enhance tests for ImporterFilePreview and SourceConfiguration …
ramprasadagarwal Jun 24, 2025
324cd43
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 24, 2025
457ec6d
[importer] fix the getDefaultTableName function
ramprasadagarwal Jun 25, 2025
a6b28be
[test] Refactor ImporterFilePreview tests to use act for rendering an…
ramprasadagarwal Jun 25, 2025
2725311
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 25, 2025
38e1083
lint fix
ramprasadagarwal Jun 25, 2025
bfacaee
remove hardcoded defaultDialect
ramprasadagarwal Jun 26, 2025
5cf00fb
fix the tests mocked url
ramprasadagarwal Jun 26, 2025
e897946
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 26, 2025
6884980
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 27, 2025
44ae88d
fix test
ramprasadagarwal Jun 27, 2025
14f9248
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jun 27, 2025
bd948a2
Merge branch 'master' of github.com:cloudera/hue into feat/importer-6
ramprasadagarwal Jul 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -113,7 +113,7 @@ describe('DestinationSettings Component', () => {
id: 'connector2',
displayName: 'Connector 2'
});
expect(defaultProps.onChange).toHaveBeenCalledWith('engine', 'connector2');
expect(defaultProps.onChange).toHaveBeenCalledWith('connectorId', 'connector2');
});

it('should call onChange when database dropdown changes', async () => {
Original file line number Diff line number Diff line change
@@ -50,7 +50,7 @@ const DestinationSettings = ({
const inputConfig = [
{
label: t('Engine'),
name: 'engine',
name: 'connectorId',
type: 'select',
options: connectors.map(connector => ({
label: connector.displayName,
@@ -61,20 +61,22 @@ const DestinationSettings = ({
label: t('Compute'),
name: 'compute',
type: 'select',
options: computes?.map(compute => ({
label: compute.name,
value: compute.id
})),
options:
computes?.map(compute => ({
label: compute.name,
value: compute.id
})) ?? [],
hidden: computes?.length === 1
},
{
label: t('Database'),
name: 'database',
type: 'select',
options: databases?.map(database => ({
label: database,
value: database
}))
options:
databases?.map(database => ({
label: database,
value: database
})) ?? []
},
{
label: t('Table Name'),
@@ -84,7 +86,7 @@ const DestinationSettings = ({
].filter(({ hidden }) => !hidden);

const handleDropdownChange = (name: string, value: string) => {
if (name === 'engine') {
if (name === 'connectorId') {
const selectedConnector = connectors?.find(connector => connector.id === value);
if (selectedConnector) {
setConnector(selectedConnector);
@@ -109,28 +111,61 @@ const DestinationSettings = ({
};

useEffect(() => {
if (defaultValues?.connectorId && connectors?.length) {
if (!connectors?.length) {
return;
}

if (defaultValues?.connectorId) {
const selectedConnector = connectors.find(conn => conn.id === defaultValues.connectorId);
if (selectedConnector) {
setConnector(selectedConnector);
}
} else {
setConnector(connectors[0]);
onChange('connectorId', connectors[0].id);
}
if (defaultValues?.database && databases?.length) {
}, [connectors, defaultValues?.connectorId]);

useEffect(() => {
if (!databases?.length) {
return;
}

if (defaultValues?.database) {
const selectedDatabase = databases.find(db => db === defaultValues.database);
if (selectedDatabase) {
setDatabase(selectedDatabase);
}
} else if (!database) {
setDatabase(databases[0]);
onChange('database', databases[0]);
}
if (defaultValues?.computeId && computes?.length) {
}, [databases, defaultValues?.database]);

useEffect(() => {
if (!computes?.length) {
return;
}

if (defaultValues?.computeId) {
const selectedCompute = computes.find(comp => comp.id === defaultValues.computeId);
if (selectedCompute) {
setCompute(selectedCompute);
}
} else if (!compute) {
setCompute(computes[0]);
onChange('computeId', computes[0].id);
}
}, [computes, defaultValues?.computeId]);

useEffect(() => {
if (defaultValues?.tableName && defaultValues.tableName !== tableName) {
setTableName(defaultValues.tableName);
}
}, [defaultValues, connectors, databases, computes, setConnector, setDatabase, setCompute]);
}, [defaultValues?.tableName]);

const selectedSettings = {
engine: connector?.id,
connectorId: connector?.id,
compute: compute?.id,
database: database,
tableName: tableName
Original file line number Diff line number Diff line change
@@ -16,35 +16,50 @@

import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ImporterFilePreview from './ImporterFilePreview';
import { FileMetaData } from '../types';
import { FileMetaData, ImporterFileSource } from '../types';
import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
import { mocked } from 'jest-mock';

const mockSave = jest.fn();
const mockPreviewData = jest.fn().mockReturnValue({
columns: [{ name: 'Name' }, { name: 'Age' }],
previewData: {
name: ['Alice', 'Bob'],
age: ['30', '25']
}
});

jest.mock('../../../utils/hooks/useLoadData/useLoadData');
jest.mock('../../../utils/hooks/useSaveData/useSaveData', () => ({
__esModule: true,
default: jest.fn(() => ({
data: {
columns: [{ name: 'Name' }, { name: 'Age' }],
sample: [
['Alice', '30'],
['Bob', '25']
]
},
save: mockSave,
save: jest.fn(),
loading: false
}))
}));

describe('ImporterFilePreview', () => {
const mockFileMetaData: FileMetaData = {
source: 'localfile',
type: 'csv',
path: '/path/to/file.csv'
source: ImporterFileSource.LOCAL,
path: '/path/to/file.csv',
fileName: 'file.csv'
};

beforeEach(() => {
jest.clearAllMocks();
mocked(useLoadData).mockImplementation(() => ({
loading: false,
data: mockPreviewData(),
reloadData: jest.fn()
}));
});

it('should render correctly', async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

await waitFor(() => {
expect(screen.getByText('Preview')).toBeInTheDocument();
@@ -53,22 +68,67 @@ describe('ImporterFilePreview', () => {
});
});

it('should call guessFormat and guessFields when the component mounts', async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
it('should display data in the table after previewData is available', async () => {
await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

await waitFor(() => {
expect(mockSave).toHaveBeenCalledTimes(2);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
});
});

it('should display data in the table after previewData is available', async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
it('should open edit columns modal when button is clicked', async () => {
await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

const editColumnsButton = screen.getByText('Edit Columns');

await act(async () => {
await userEvent.click(editColumnsButton);
});

await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});

it('should display source configuration', async () => {
await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

expect(screen.getByText('Configure source')).toBeInTheDocument();
});

it('should display cancel button', async () => {
await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

const cancelButton = screen.getByRole('button', { name: 'Cancel' });
expect(cancelButton).toBeInTheDocument();
});

it('should handle complete file format workflow', async () => {
mocked(useLoadData).mockImplementation(() => ({
loading: false,
data: mockPreviewData(),
reloadData: jest.fn()
}));

await act(async () => {
render(<ImporterFilePreview fileMetaData={mockFileMetaData} />);
});

await waitFor(() => {
expect(screen.getByText('Preview')).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('30')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('25')).toBeInTheDocument();
});
});
});
Original file line number Diff line number Diff line change
@@ -14,20 +14,28 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
import {
CombinedFileFormat,
DestinationConfig,
FileFormatResponse,
FileMetaData,
GuessFieldTypesResponse,
FilePreviewResponse,
GuessHeaderResponse,
ImporterTableData
} from '../types';
import { convertToAntdColumns, convertToDataSource, getDefaultTableName } from '../utils/utils';
import { i18nReact } from '../../../utils/i18nReact';
import { BorderlessButton, PrimaryButton } from 'cuix/dist/components/Button';
import PaginatedTable from '../../../reactComponents/PaginatedTable/PaginatedTable';
import { GUESS_FORMAT_URL, GUESS_FIELD_TYPES_URL, FINISH_IMPORT_URL } from '../api';
import {
FILE_GUESS_METADATA,
FILE_GUESS_HEADER,
FILE_PREVIEW_URL,
FINISH_IMPORT_URL
} from '../api';
import SourceConfiguration from './SourceConfiguration/SourceConfiguration';
import EditColumnsModal from './EditColumns/EditColumnsModal';
import DestinationSettings from './DestinationSettings/DestinationSettings';
@@ -40,10 +48,11 @@ interface ImporterFilePreviewProps {

const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.Element => {
const { t } = i18nReact.useTranslation();
const [fileFormat, setFileFormat] = useState<FileFormatResponse | undefined>();
const [fileFormat, setFileFormat] = useState<CombinedFileFormat | undefined>();

const [isEditColumnsOpen, setIsEditColumnsOpen] = useState(false);
const [destinationConfig, setDestinationConfig] = useState<DestinationConfig>({
tableName: getDefaultTableName(fileMetaData.path, fileMetaData.source)
tableName: getDefaultTableName(fileMetaData)
});

const handleDestinationSettingsChange = (name: string, value: string) => {
@@ -53,49 +62,65 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
}));
};

const { save: guessFormat, loading: guessingFormat } = useSaveData<FileFormatResponse>(
GUESS_FORMAT_URL,
{
onSuccess: data => {
setFileFormat(data);
}
const { loading: guessingFormat } = useLoadData<FileFormatResponse>(FILE_GUESS_METADATA, {
params: {
file_path: fileMetaData.path,
import_type: fileMetaData.source
},
skip: !fileMetaData.path,
onSuccess: data => {
setFileFormat({
...data,
recordSeparator: data?.recordSeparator?.includes('\n') ? '\\n' : data?.recordSeparator,
selectedSheetName: data?.sheetNames?.[0]
});
}
);

const {
save: guessFields,
data: previewData,
loading: guessingFields
} = useSaveData<GuessFieldTypesResponse>(GUESS_FIELD_TYPES_URL);
});

const { save, loading: finalizingImport } =
useSaveData<GuessFieldTypesResponse>(FINISH_IMPORT_URL);
const { loading: guessingHeader } = useLoadData<GuessHeaderResponse>(FILE_GUESS_HEADER, {
params: {
file_path: fileMetaData.path,
file_type: fileFormat?.type,
import_type: fileMetaData.source,
sheet_name: fileFormat?.selectedSheetName
},
skip: !fileFormat?.type,
onSuccess: data => {
setFileFormat(prev => ({
...(prev ?? {}),
hasHeader: data.hasHeader
}));
},
onError: () => {
setFileFormat(prev => ({
...(prev ?? {}),
hasHeader: false
}));
}
});

useEffect(() => {
const guessFormatPayload = {
inputFormat: fileMetaData.source,
file_type: fileMetaData.type,
path: fileMetaData.path
};
const guessFormatormData = new FormData();
guessFormatormData.append('fileFormat', JSON.stringify(guessFormatPayload));
guessFormat(guessFormatormData);
}, [fileMetaData]);

useEffect(() => {
if (!fileFormat) {
return;
const { data: previewData, loading: loadingPreview } = useLoadData<FilePreviewResponse>(
FILE_PREVIEW_URL,
{
params: {
file_path: fileMetaData.path,
file_type: fileFormat?.type,
import_type: fileMetaData.source,
sql_dialect: destinationConfig.connectorId,
has_header: fileFormat?.hasHeader,
sheet_name: fileFormat?.selectedSheetName,
field_separator: fileFormat?.fieldSeparator,
quote_char: fileFormat?.quoteChar,
record_separator: fileFormat?.recordSeparator
},
skip:
!fileFormat?.type ||
fileFormat?.hasHeader === undefined ||
destinationConfig.connectorId === undefined
}
);

const payload = {
path: fileMetaData.path,
format: fileFormat,
inputFormat: fileMetaData.source
};
const formData = new FormData();
formData.append('fileFormat', JSON.stringify(payload));
guessFields(formData);
}, [fileMetaData.path, fileFormat]);
const { save, loading: finalizingImport } = useSaveData(FINISH_IMPORT_URL);

const handleFinishImport = () => {
const source = {
@@ -120,7 +145,7 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
};

const columns = convertToAntdColumns(previewData?.columns ?? []);
const tableData = convertToDataSource(columns, previewData?.sample);
const tableData = convertToDataSource(previewData?.previewData ?? {});

return (
<div className="hue-importer-preview-page">
@@ -149,7 +174,7 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
</BorderlessButton>
</div>
<PaginatedTable<ImporterTableData>
loading={guessingFormat || guessingFields}
loading={guessingFormat || loadingPreview || guessingHeader}
data={tableData}
columns={columns}
rowKey="importerDataKey"
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
// 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, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import SourceConfiguration from './SourceConfiguration';
import { FileFormatResponse, ImporterFileTypes } from '../../types';
import { CombinedFileFormat, ImporterFileTypes } from '../../types';

describe('SourceConfiguration Component', () => {
const mockSetFileFormat = jest.fn();
const mockFileFormat: FileFormatResponse = {
const mockFileFormat: CombinedFileFormat = {
quoteChar: '"',
recordSeparator: '\\n',
type: ImporterFileTypes.EXCEL,
hasHeader: true,
fieldSeparator: ',',
status: 0
fieldSeparator: ','
};

beforeEach(() => {
@@ -27,6 +42,17 @@ describe('SourceConfiguration Component', () => {
expect(getByText('Configure source')).toBeInTheDocument();
});

it('should render as collapsible details element', () => {
const { container, getByText } = render(
<SourceConfiguration fileFormat={mockFileFormat} setFileFormat={mockSetFileFormat} />
);

const detailsElement = container.querySelector('details');
expect(detailsElement).toBeInTheDocument();
expect(detailsElement).toHaveClass('hue-importer-configuration');
expect(getByText('File Type')).not.toBeVisible();
});

it('should call setFileFormat on option change', async () => {
const { getByText, getAllByRole } = render(
<SourceConfiguration fileFormat={mockFileFormat} setFileFormat={mockSetFileFormat} />
@@ -44,7 +70,7 @@ describe('SourceConfiguration Component', () => {
);
});

it('should show fieldSepator and other downdown when fileType is CSV', () => {
it('should show fieldSeparator and other dropdown when fileType is CSV', () => {
const { getAllByRole, getByText } = render(
<SourceConfiguration
fileFormat={{ ...mockFileFormat, type: ImporterFileTypes.CSV }}
@@ -62,18 +88,138 @@ describe('SourceConfiguration Component', () => {
expect(getByText('Quote Character')).toBeInTheDocument();
});

it('should not show fieldSepator and other downdown when fileType is not CSV', () => {
it('should not show fieldSeparator and other dropdown when fileType is not CSV', () => {
const { getAllByRole, getByText, queryByText } = render(
<SourceConfiguration fileFormat={mockFileFormat} setFileFormat={mockSetFileFormat} />
);

const selectElement = getAllByRole('combobox');

expect(selectElement).toHaveLength(3);
expect(getByText('File Type')).toBeInTheDocument();
expect(getByText('Has Header')).toBeInTheDocument();
expect(queryByText('Field Separator')).not.toBeInTheDocument();
expect(queryByText('Record Separator')).not.toBeInTheDocument();
expect(queryByText('Quote Character')).not.toBeInTheDocument();
});

it('should show select sheet dropdown when fileType is EXCEL', () => {
const { getAllByRole, getByText } = render(
<SourceConfiguration
fileFormat={{ ...mockFileFormat, type: ImporterFileTypes.EXCEL }}
setFileFormat={mockSetFileFormat}
/>
);

const selectElement = getAllByRole('combobox');

expect(selectElement).toHaveLength(3);
expect(getByText('Sheet Name')).toBeInTheDocument();
});

it('should not show select sheet dropdown when fileType is not EXCEL', () => {
const { getAllByRole, queryByText } = render(
<SourceConfiguration
fileFormat={{ ...mockFileFormat, type: ImporterFileTypes.CSV }}
setFileFormat={mockSetFileFormat}
/>
);

const selectElement = getAllByRole('combobox');

expect(selectElement).toHaveLength(5);
expect(queryByText('Sheet Name')).not.toBeInTheDocument();
});

it('should not call setFileFormat when fileFormat is undefined', async () => {
const { getAllByRole } = render(
<SourceConfiguration fileFormat={undefined} setFileFormat={mockSetFileFormat} />
);

const selectElement = getAllByRole('combobox')[0];
await userEvent.click(selectElement);

const csvOption = getAllByRole('option').find(option => option.textContent === 'CSV');
if (csvOption) {
await userEvent.click(csvOption);
}

expect(mockSetFileFormat).not.toHaveBeenCalled();
});

it('should call setFileFormat with correct parameters for hasHeader change', async () => {
const { getByText, getAllByRole } = render(
<SourceConfiguration fileFormat={mockFileFormat} setFileFormat={mockSetFileFormat} />
);

const hasHeaderSelect = getAllByRole('combobox')[1];
await userEvent.click(hasHeaderSelect);

const noOption = getByText('No');
await userEvent.click(noOption);

await waitFor(() =>
expect(mockSetFileFormat).toHaveBeenCalledWith({
...mockFileFormat,
hasHeader: false
})
);
});

it('should call setFileFormat with correct parameters for fieldSeparator change', async () => {
const csvFormat = { ...mockFileFormat, type: ImporterFileTypes.CSV };
const { getByText, getAllByRole } = render(
<SourceConfiguration fileFormat={csvFormat} setFileFormat={mockSetFileFormat} />
);

const fieldSeparatorSelect = getAllByRole('combobox')[2];
await userEvent.click(fieldSeparatorSelect);

const tabOption = getByText('^Tab (\\t)');
await userEvent.click(tabOption);

await waitFor(() =>
expect(mockSetFileFormat).toHaveBeenCalledWith({
...csvFormat,
fieldSeparator: '\\t'
})
);
});

it('should call setFileFormat with correct parameters for quoteChar change', async () => {
const csvFormat = { ...mockFileFormat, type: ImporterFileTypes.CSV };
const { getByText, getAllByRole } = render(
<SourceConfiguration fileFormat={csvFormat} setFileFormat={mockSetFileFormat} />
);

const quoteCharSelect = getAllByRole('combobox')[4];
await userEvent.click(quoteCharSelect);

const singleQuoteOption = getByText("Single Quote (')");
await userEvent.click(singleQuoteOption);

await waitFor(() =>
expect(mockSetFileFormat).toHaveBeenCalledWith({
...csvFormat,
quoteChar: "'"
})
);
});

it('should show JSON type dropdown when fileType is JSON', () => {
const jsonFormat = { ...mockFileFormat, type: ImporterFileTypes.JSON };
const { getAllByRole, getByText, queryByText } = render(
<SourceConfiguration fileFormat={jsonFormat} setFileFormat={mockSetFileFormat} />
);

const selectElement = getAllByRole('combobox');

expect(selectElement).toHaveLength(2);
expect(getByText('File Type')).toBeInTheDocument();
expect(getByText('Has Header')).toBeInTheDocument();
expect(queryByText('Field Separator')).not.toBeInTheDocument();
expect(queryByText('Record Separator')).not.toBeInTheDocument();
expect(queryByText('Quote Character')).not.toBeInTheDocument();
expect(queryByText('Sheet Name')).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -14,12 +14,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import React, { useCallback } from 'react';
import React from 'react';
import Select from 'cuix/dist/components/Select/Select';
import ConfigureIcon from '@cloudera/cuix-core/icons/react/ConfigureIcon';
import { i18nReact } from '../../../../utils/i18nReact';
import { sourceConfigs } from '../../constants';
import { FileFormatResponse } from '../../types';
import { CombinedFileFormat, FileFormatResponse } from '../../types';

import './SourceConfiguration.scss';

@@ -33,19 +33,14 @@ const SourceConfiguration = ({
}: SourceConfigurationProps): JSX.Element => {
const { t } = i18nReact.useTranslation();

const onChange = useCallback(
(value: string | number | boolean, name: keyof FileFormatResponse) => {
if (fileFormat) {
setFileFormat({
...fileFormat,
[name]: value
});
}
},
[fileFormat, setFileFormat]
);

const filteredSourceConfigs = sourceConfigs.filter(config => !config.hidden?.(fileFormat?.type));
const onChange = (value: string | number | boolean, name: keyof CombinedFileFormat) => {
if (fileFormat) {
setFileFormat({
...fileFormat,
[name]: value
});
}
};

return (
<details className="hue-importer-configuration">
@@ -54,20 +49,22 @@ const SourceConfiguration = ({
{t('Configure source')}
</summary>
<div className="hue-importer-configuration-options">
{filteredSourceConfigs.map(config => (
<div key={config.name}>
<label htmlFor={config.name}>{t(config.label)}</label>
<Select
bordered={true}
className="hue-importer-configuration__dropdown"
id={config.name}
options={config.options}
onChange={value => onChange(value, config.name)}
value={fileFormat?.[config.name]}
getPopupContainer={triggerNode => triggerNode.parentElement}
/>
</div>
))}
{sourceConfigs
.filter(config => !config.hidden?.(fileFormat?.type))
.map(config => (
<div key={config.name}>
<label htmlFor={config.name}>{t(config.label)}</label>
<Select
bordered={true}
className="hue-importer-configuration__dropdown"
id={config.name}
options={config.options}
onChange={value => onChange(value, config.name)}
value={fileFormat?.[config.name]}
getPopupContainer={triggerNode => triggerNode.parentElement}
/>
</div>
))}
</div>
</details>
);
Original file line number Diff line number Diff line change
@@ -70,8 +70,8 @@ const ImporterSourceSelector = ({ setFileMetaData }: ImporterSourceSelectorProps
upload(payload, {
onSuccess: data => {
setFileMetaData({
path: data.local_file_url,
type: data.file_type,
path: data.file_path,
fileName: file.name,
source: ImporterFileSource.LOCAL
});
},
7 changes: 4 additions & 3 deletions desktop/core/src/desktop/js/apps/newimporter/api.ts
Original file line number Diff line number Diff line change
@@ -14,7 +14,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

export const UPLOAD_LOCAL_FILE_API_URL = '/indexer/api/indexer/upload_local_file';
export const GUESS_FORMAT_URL = '/indexer/api/indexer/guess_format';
export const GUESS_FIELD_TYPES_URL = '/indexer/api/indexer/guess_field_types';
export const UPLOAD_LOCAL_FILE_API_URL = '/api/v1/importer/upload/file';
export const FILE_GUESS_METADATA = '/api/v1/importer/file/guess_metadata';
export const FILE_GUESS_HEADER = '/api/v1/importer/file/guess_header';
export const FILE_PREVIEW_URL = '/api/v1/importer/file/preview';
export const FINISH_IMPORT_URL = '/indexer/api/importer/submit';
10 changes: 8 additions & 2 deletions desktop/core/src/desktop/js/apps/newimporter/constants.ts
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { FileFormatResponse, ImporterFileTypes } from './types';
import { CombinedFileFormat, ImporterFileTypes } from './types';

export const separator = [
{ value: ',', label: 'Comma (,)' },
@@ -30,7 +30,7 @@ export const separator = [
];

export const sourceConfigs: {
name: keyof FileFormatResponse;
name: keyof CombinedFileFormat;
label: string;
hidden?: (type?: ImporterFileTypes) => boolean;
options: {
@@ -72,5 +72,11 @@ export const sourceConfigs: {
label: 'Quote Character',
hidden: (type?: ImporterFileTypes) => type !== ImporterFileTypes.CSV,
options: separator
},
{
name: 'selectedSheetName',
label: 'Sheet Name',
hidden: (type?: ImporterFileTypes) => type !== ImporterFileTypes.EXCEL,
options: []
}
];
53 changes: 25 additions & 28 deletions desktop/core/src/desktop/js/apps/newimporter/types.ts
Original file line number Diff line number Diff line change
@@ -21,52 +21,49 @@ export enum ImporterFileTypes {
}

export enum ImporterFileSource {
LOCAL = 'localfile',
REMOTE = 'file'
LOCAL = 'local',
REMOTE = 'remote'
}

export interface LocalFileUploadResponse {
local_file_url: string;
file_type: ImporterFileTypes;
file_path: string;
}

export interface FileFormatResponse {
fieldSeparator: string;
hasHeader: boolean;
quoteChar: string;
recordSeparator: string;
status: number;
type: ImporterFileTypes;
type?: ImporterFileTypes;
fieldSeparator?: string;
quoteChar?: string;
recordSeparator?: string;
sheetNames?: string[];
}

export interface GuessHeaderResponse {
hasHeader?: boolean;
}

export interface CombinedFileFormat extends FileFormatResponse, GuessHeaderResponse {
selectedSheetName?: string;
}

export interface FileMetaData {
path: string;
type: ImporterFileTypes;
fileName?: string;
source: ImporterFileSource;
}

export type GuessFieldTypesColumn = {
export type FilePreviewTableColumn = {
importerDataKey?: string; // key for identifying unique data row
name: string;
type?: string;
unique?: boolean;
keep?: boolean;
required?: boolean;
multiValued?: boolean;
showProperties?: boolean;
level?: number;
length?: number;
keyType?: string;
isPartition?: boolean;
partitionValue?: string;
comment?: string;
scale?: number;
precision?: number;
};

export interface GuessFieldTypesResponse {
columns: GuessFieldTypesColumn[];
sample: string[][];
export interface FilePreviewTableData {
[key: string]: (string | number)[];
}

export interface FilePreviewResponse {
columns: FilePreviewTableColumn[];
previewData: FilePreviewTableData;
}

export interface ImporterTableData {
265 changes: 226 additions & 39 deletions desktop/core/src/desktop/js/apps/newimporter/utils/utils.test.ts
Original file line number Diff line number Diff line change
@@ -14,17 +14,64 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import { ColumnProps } from 'cuix/dist/components/Table';
import { convertToAntdColumns, convertToDataSource, getDefaultTableName } from './utils';
import { ImporterFileSource, GuessFieldTypesColumn, ImporterTableData } from '../types';
import { ImporterFileSource, FilePreviewTableColumn, FilePreviewTableData } from '../types';

describe('convertToAntdColumns', () => {
it('should return an empty array when no input is provided', () => {
expect(convertToAntdColumns()).toEqual([]);
it('should correctly convert FilePreviewTableColumn[] to ColumnProps[]', () => {
const input: FilePreviewTableColumn[] = [{ name: 'name' }, { name: 'age' }];
const expectedOutput = [
{ title: 'name', dataIndex: 'name', key: 'name', width: '100px' },
{ title: 'age', dataIndex: 'age', key: 'age', width: '100px' }
];

expect(convertToAntdColumns(input)).toEqual(expectedOutput);
});

it('should correctly convert GuessFieldTypesColumn[] to ColumnProps[]', () => {
const input: GuessFieldTypesColumn[] = [{ name: 'name' }, { name: 'age' }];
it('should handle single column input', () => {
const input: FilePreviewTableColumn[] = [{ name: 'singleColumn' }];
const expectedOutput = [
{ title: 'singleColumn', dataIndex: 'singleColumn', key: 'singleColumn', width: '100px' }
];

expect(convertToAntdColumns(input)).toEqual(expectedOutput);
});

it('should handle columns with special characters', () => {
const input: FilePreviewTableColumn[] = [
{ name: 'first-name' },
{ name: 'email_address' },
{ name: 'phone number' }
];
const expectedOutput = [
{ title: 'first-name', dataIndex: 'firstName', key: 'first-name', width: '100px' },
{ title: 'email_address', dataIndex: 'emailAddress', key: 'email_address', width: '100px' },
{ title: 'phone number', dataIndex: 'phoneNumber', key: 'phone number', width: '100px' }
];

expect(convertToAntdColumns(input)).toEqual(expectedOutput);
});

it('should handle columns with numbers and special characters', () => {
const input: FilePreviewTableColumn[] = [
{ name: 'column1' },
{ name: '2nd_column' },
{ name: 'column-3' }
];
const expectedOutput = [
{ title: 'column1', dataIndex: 'column1', key: 'column1', width: '100px' },
{ title: '2nd_column', dataIndex: '2NdColumn', key: '2nd_column', width: '100px' },
{ title: 'column-3', dataIndex: 'column3', key: 'column-3', width: '100px' }
];

expect(convertToAntdColumns(input)).toEqual(expectedOutput);
});

it('should handle columns with type property', () => {
const input: FilePreviewTableColumn[] = [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'number' }
];
const expectedOutput = [
{ title: 'name', dataIndex: 'name', key: 'name', width: '100px' },
{ title: 'age', dataIndex: 'age', key: 'age', width: '100px' }
@@ -35,70 +82,210 @@ describe('convertToAntdColumns', () => {
});

describe('convertToDataSource', () => {
const columns: ColumnProps<ImporterTableData>[] = [
{ title: 'Name', dataIndex: 'name', key: 'name', width: '100px' },
{ title: 'Age', dataIndex: 'age', key: 'age', width: '100px' }
];

it('should return an empty array when no apiResponse is provided', () => {
expect(convertToDataSource(columns)).toEqual([]);
expect(convertToDataSource({})).toEqual([]);
});

it('should correctly convert apiResponse to GuessFieldTypesColumn[]', () => {
const apiResponse: string[][] = [
['Alice', '30'],
['Bob', '25']
it('should correctly convert apiResponse to FilePreviewTableColumn[]', () => {
const apiResponse: FilePreviewTableData = {
name: ['Alice', 'Bob'],
age: ['30', '25']
};

const expectedOutput = [
{ importerDataKey: 'importer-row__0', name: 'Alice', age: '30' },
{ importerDataKey: 'importer-row__1', name: 'Bob', age: '25' }
];

expect(convertToDataSource(apiResponse)).toEqual(expectedOutput);
});

it('should handle uneven array lengths with null values', () => {
const apiResponse: FilePreviewTableData = {
name: ['Alice', 'Bob', 'Charlie'],
age: ['30', '25'],
city: ['New York']
};

const expectedOutput = [
{ importerDataKey: 'Alice__0', name: 'Alice', age: '30' },
{ importerDataKey: 'Bob__1', name: 'Bob', age: '25' }
{ importerDataKey: 'importer-row__0', name: 'Alice', age: '30', city: 'New York' },
{ importerDataKey: 'importer-row__1', name: 'Bob', age: '25', city: null },
{ importerDataKey: 'importer-row__2', name: 'Charlie', age: null, city: null }
];

expect(convertToDataSource(columns, apiResponse)).toEqual(expectedOutput);
expect(convertToDataSource(apiResponse)).toEqual(expectedOutput);
});

it('should handle empty arrays in apiResponse', () => {
const apiResponse: FilePreviewTableData = {
name: [],
age: []
};

expect(convertToDataSource(apiResponse)).toEqual([]);
});
});

describe('getDefaultTableName', () => {
it('should extract file name from LOCALFILE path using pattern', () => {
const filePath = '/user/data/:myDocument;v1.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.LOCAL);
it('should extract file name from LOCAL file metadata with fileName provided', () => {
const fileMetaData = {
path: '/user/data/myDocument.csv',
fileName: 'myDocument.csv',
source: ImporterFileSource.LOCAL
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('myDocument');
});

it('should return empty string if LOCALFILE pattern does not match', () => {
const filePath = '/user/data/myDocument.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.LOCAL);
it('should return empty string if LOCAL file has no fileName property', () => {
const fileMetaData = {
path: '/user/data/myDocument.csv',
source: ImporterFileSource.LOCAL
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('');
});

it('should handle LOCAL file with complex filename', () => {
const fileMetaData = {
path: '/user/data/complex-file_name.with.dots.xlsx',
fileName: 'complex-file_name.with.dots.xlsx',
source: ImporterFileSource.LOCAL
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('complex_file_name_with_dots');
});

it('should handle LOCAL file with special characters in filename', () => {
const fileMetaData = {
path: '/user/data/file@2023#test.csv',
fileName: 'file@2023#test.csv',
source: ImporterFileSource.LOCAL
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('file_2023_test');
});

it('should handle LOCAL file with spaces in filename', () => {
const fileMetaData = {
path: '/user/data/my file name.csv',
fileName: 'my file name.csv',
source: ImporterFileSource.LOCAL
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('my_file_name');
});

it('should extract file name from REMOTE path as last part of path', () => {
const filePath = 'https://demo.gethue.com/hue/test-file.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.REMOTE);
expect(result).toBe('test-file');
const fileMetaData = {
path: '/user/data/test-file.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('test_file');
});

it('should handle file names with multiple dots correctly', () => {
const filePath = 'https://demo.gethue.com/hue/test.file.name.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.REMOTE);
const fileMetaData = {
path: '/user/data/test.file.name.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('test_file_name');
});

it('should handle file names with no extension correctly', () => {
const filePath = 'https://demo.gethue.com/hue/test-file';
const result = getDefaultTableName(filePath, ImporterFileSource.REMOTE);
expect(result).toBe('test-file');
const fileMetaData = {
path: '/user/data/test-file',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('test_file');
});

it('should handle file names with special characters correctly', () => {
const filePath = 'https://demo.gethue.com/hue/test-file@2023.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.REMOTE);
expect(result).toBe('test-file@2023');
const fileMetaData = {
path: '/user/data/test-file@2023.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('test_file_2023');
});

it('should handle file names with spaces correctly', () => {
const filePath = 'https://demo.gethue.com/hue/test file.csv';
const result = getDefaultTableName(filePath, ImporterFileSource.REMOTE);
expect(result).toBe('test file');
const fileMetaData = {
path: '/user/data/test file.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('test_file');
});

it('should handle REMOTE files with only extension', () => {
const fileMetaData = {
path: '/user/data/.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('');
});

it('should handle REMOTE files with multiple extensions', () => {
const fileMetaData = {
path: '/user/data/file.backup.tar.gz',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('file_backup_tar');
});

it('should handle REMOTE files with numbers and underscores', () => {
const fileMetaData = {
path: '/user/data/data_file_2023_v1.xlsx',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('data_file_2023_v1');
});

it('should handle S3 URLs', () => {
const fileMetaData = {
path: 's3://bucket-name/folder/data-file.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('data_file');
});

it('should handle Azure blob URLs', () => {
const fileMetaData = {
path: 'abfs://container/data.file.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('data_file');
});

it('should return empty string for empty file paths', () => {
const localFileMetaData = {
path: '',
fileName: '',
source: ImporterFileSource.LOCAL
};
const remoteFileMetaData = {
path: '',
source: ImporterFileSource.REMOTE
};
expect(getDefaultTableName(localFileMetaData)).toBe('');
expect(getDefaultTableName(remoteFileMetaData)).toBe('');
});

it('should handle single character file names', () => {
const fileMetaData = {
path: '/user/data/a.csv',
source: ImporterFileSource.REMOTE
};
const result = getDefaultTableName(fileMetaData);
expect(result).toBe('a');
});
});
69 changes: 39 additions & 30 deletions desktop/core/src/desktop/js/apps/newimporter/utils/utils.ts
Original file line number Diff line number Diff line change
@@ -15,57 +15,66 @@
// limitations under the License.

import { type ColumnProps } from 'cuix/dist/components/Table';
import { ImporterFileSource, GuessFieldTypesColumn, ImporterTableData } from '../types';
import {
ImporterFileSource,
FilePreviewTableColumn,
ImporterTableData,
FilePreviewResponse,
FileMetaData
} from '../types';
import { getLastDirOrFileNameFromPath } from '../../../reactComponents/PathBrowser/PathBrowser.util';
import { toCamelCase } from '../../../utils/string/changeCasing';

export const convertToAntdColumns = (
input?: GuessFieldTypesColumn[]
input?: FilePreviewTableColumn[]
): ColumnProps<ImporterTableData>[] => {
if (!input) {
return [];
}
return input?.map(item => ({
title: item.name,
dataIndex: item.name,
dataIndex: toCamelCase(item.name),
key: item.name,
width: '100px'
}));
};

export const convertToDataSource = (
columns: ColumnProps<ImporterTableData>[],
apiResponse?: string[][]
inputData: FilePreviewResponse['previewData']
): ImporterTableData[] => {
if (!apiResponse) {
return [];
}
return apiResponse?.map((rowData, index) => {
const row = {
importerDataKey: `${rowData[0]}__${index}` // this ensure the key is unique
const maxLength = Math.max(...Object.values(inputData).map(arr => arr.length));

const data = Array.from({ length: maxLength }, (_, index) => {
const row: ImporterTableData = {
importerDataKey: `importer-row__${index}`
};
columns.forEach((column, index) => {
if (column.key) {
row[column.key] = rowData[index];
}
Object.keys(inputData).forEach(key => {
row[key] = inputData[key][index] ?? null;
});
return row;
});

return data;
};

export const getDefaultTableName = (filePath: string, fileSource: ImporterFileSource): string => {
// For local files, the file name is extracted from the path
// Example: /**/**/**:fileName;**.fileExtension
if (fileSource === ImporterFileSource.LOCAL) {
const match = filePath.match(/:(.*?);/);
return match?.[1] ?? '';
}
const sanitizeTableName = (name: string): string => {
return name
.replace(/[^a-zA-Z0-9]/g, '_') // replace non-alphanumeric characters with underscores
.replace(/_+/g, '_') // replace multiple underscores with a single underscore
.replace(/^_+|_+$/g, ''); // remove leading and trailing underscores
};

// For Remote, remove extension and replace '.' with '_'
// Example: file.name.fileExtension -> file_name
const fileName = getLastDirOrFileNameFromPath(filePath);
if (fileName.split('.').length === 1) {
// If there is no extension, return the file name as is
return fileName;
}
return fileName.split('.').slice(0, -1).join('_');
const getLastDirOrFileNameWithoutExtension = (fileName: string): string => {
return fileName.split('.').length > 1 ? fileName.split('.').slice(0, -1).join('.') : fileName;
};

export const getDefaultTableName = (fileMetaData: FileMetaData): string => {
const rawFileName =
fileMetaData.source === ImporterFileSource.LOCAL
? (fileMetaData.fileName ?? '')
: getLastDirOrFileNameFromPath(fileMetaData.path);

const fileNameWithoutExtension = getLastDirOrFileNameWithoutExtension(rawFileName);

return sanitizeTableName(fileNameWithoutExtension);
};