Browse Source

[ui-importer] destination setting component and hook (#4157)

Ram Prasad Agarwal 5 tháng trước cách đây
mục cha
commit
277a48c845

+ 36 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.scss

@@ -0,0 +1,36 @@
+// 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 {
+  .importer-destination-settings {
+    display: flex;
+    gap: vars.$cdl-spacing-s;
+
+    .ant-form-item {
+      margin-bottom: 0;
+    }
+
+    &__select-dropdown,
+    &__input {
+      border: 1px solid vars.$fluidx-gray-600;
+      border-radius: vars.$border-radius-base;
+      width: min(15vw, 250px);
+      height: 32px;
+    }
+  }
+}

+ 249 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.test.tsx

@@ -0,0 +1,249 @@
+// 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, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import DestinationSettings from './DestinationSettings';
+import { DestinationConfig } from '../../types';
+import { useDataCatalog } from '../../../../utils/hooks/useDataCatalog/useDataCatalog';
+
+const mockUseDataCatalog = {
+  loading: false,
+  databases: ['database1', 'database2'],
+  database: 'database1',
+  connectors: [
+    { id: 'connector1', displayName: 'Connector 1' },
+    { id: 'connector2', displayName: 'Connector 2' }
+  ],
+  connector: { id: 'connector1', displayName: 'Connector 1' },
+  computes: [
+    { id: 'compute1', name: 'Compute 1' },
+    { id: 'compute2', name: 'Compute 2' }
+  ],
+  compute: { id: 'compute1', name: 'Compute 1' },
+  setCompute: jest.fn(),
+  setConnector: jest.fn(),
+  setDatabase: jest.fn()
+};
+
+jest.mock('../../../../utils/hooks/useDataCatalog/useDataCatalog', () => ({
+  useDataCatalog: jest.fn()
+}));
+
+const defaultProps = {
+  defaultValues: {
+    database: 'database1',
+    tableName: 'test_table',
+    connectorId: 'connector1',
+    computeId: 'compute1'
+  } as DestinationConfig,
+  onChange: jest.fn()
+};
+
+describe('DestinationSettings Component', () => {
+  beforeEach(() => {
+    jest.clearAllMocks();
+    (useDataCatalog as jest.Mock).mockReturnValue(mockUseDataCatalog);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should render all form fields correctly', () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(screen.getByLabelText('Engine')).toBeInTheDocument();
+    expect(screen.getByLabelText('Compute')).toBeInTheDocument();
+    expect(screen.getByLabelText('Database')).toBeInTheDocument();
+    expect(screen.getByLabelText('Table Name')).toBeInTheDocument();
+  });
+
+  it('should display correct initial values', () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(screen.getByDisplayValue('test_table')).toBeInTheDocument();
+  });
+
+  it('should hide compute field when only one compute is available', () => {
+    const mockUseDataCatalogSingleCompute = {
+      ...mockUseDataCatalog,
+      computes: [{ id: 'compute1', name: 'Compute 1' }]
+    };
+
+    (useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogSingleCompute);
+
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(screen.queryByLabelText('Compute')).not.toBeInTheDocument();
+  });
+
+  it('should show compute field when multiple computes are available', () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(screen.getByLabelText('Compute')).toBeInTheDocument();
+  });
+
+  it('should call onChange when engine dropdown changes', async () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    const engineSelect = screen.getByLabelText('Engine');
+    fireEvent.mouseDown(engineSelect);
+
+    await waitFor(() => {
+      const option = screen.getByText('Connector 2');
+      fireEvent.click(option);
+    });
+
+    expect(mockUseDataCatalog.setConnector).toHaveBeenCalledWith({
+      id: 'connector2',
+      displayName: 'Connector 2'
+    });
+    expect(defaultProps.onChange).toHaveBeenCalledWith('engine', 'connector2');
+  });
+
+  it('should call onChange when database dropdown changes', async () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    const databaseSelect = screen.getByLabelText('Database');
+    fireEvent.mouseDown(databaseSelect);
+
+    await waitFor(() => {
+      const options = screen.getAllByText('database2');
+      const option = options.find(el => el.closest('.ant-select-item'));
+      if (option) {
+        fireEvent.click(option);
+      }
+    });
+
+    expect(mockUseDataCatalog.setDatabase).toHaveBeenCalledWith('database2');
+    expect(defaultProps.onChange).toHaveBeenCalledWith('database', 'database2');
+  });
+
+  it('should call onChange when compute dropdown changes', async () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    const computeSelect = screen.getByLabelText('Compute');
+    fireEvent.mouseDown(computeSelect);
+
+    await waitFor(() => {
+      const option = screen.getByText('Compute 2');
+      fireEvent.click(option);
+    });
+
+    expect(mockUseDataCatalog.setCompute).toHaveBeenCalledWith({
+      id: 'compute2',
+      name: 'Compute 2'
+    });
+    expect(defaultProps.onChange).toHaveBeenCalledWith('compute', 'compute2');
+  });
+
+  it('should update table name input and not call onChange immediately', () => {
+    render(<DestinationSettings {...defaultProps} />);
+
+    const tableNameInput = screen.getByLabelText('Table Name');
+    fireEvent.change(tableNameInput, { target: { value: 'new_table_name' } });
+
+    expect(screen.getByDisplayValue('new_table_name')).toBeInTheDocument();
+    expect(defaultProps.onChange).not.toHaveBeenCalled();
+  });
+
+  it('should show loader in select dropdown when loading state is true', () => {
+    const mockUseDataCatalogLoading = {
+      ...mockUseDataCatalog,
+      loading: true
+    };
+
+    (useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogLoading);
+
+    render(<DestinationSettings {...defaultProps} />);
+
+    const selectDropdowns = document.querySelectorAll('.ant-select');
+    selectDropdowns.forEach(select => {
+      expect(select).toBeInTheDocument();
+      expect(select).toHaveClass('ant-select-loading');
+    });
+  });
+
+  it('should not show loader in select dropdown when loading state is false', () => {
+    const mockUseDataCatalogLoading = {
+      ...mockUseDataCatalog,
+      loading: false
+    };
+
+    (useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogLoading);
+
+    render(<DestinationSettings {...defaultProps} />);
+
+    const selectDropdowns = document.querySelectorAll('.ant-select');
+    selectDropdowns.forEach(select => {
+      expect(select).toBeInTheDocument();
+      expect(select).not.toHaveClass('ant-select-loading');
+    });
+  });
+
+  it('should set default values from props on component mount', () => {
+    const mockSetConnector = jest.fn();
+    const mockSetDatabase = jest.fn();
+    const mockSetCompute = jest.fn();
+
+    const mockUseDataCatalogWithSetters = {
+      ...mockUseDataCatalog,
+      setConnector: mockSetConnector,
+      setDatabase: mockSetDatabase,
+      setCompute: mockSetCompute
+    };
+
+    (useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogWithSetters);
+
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(mockSetConnector).toHaveBeenCalledWith({
+      id: 'connector1',
+      displayName: 'Connector 1'
+    });
+    expect(mockSetDatabase).toHaveBeenCalledWith('database1');
+    expect(mockSetCompute).toHaveBeenCalledWith({
+      id: 'compute1',
+      name: 'Compute 1'
+    });
+  });
+
+  it('should not call setters when no matching items found in defaultValues', () => {
+    const mockSetConnector = jest.fn();
+    const mockSetDatabase = jest.fn();
+    const mockSetCompute = jest.fn();
+
+    const mockUseDataCatalogNoMatch = {
+      ...mockUseDataCatalog,
+      connectors: [{ id: 'different_connector', displayName: 'Different Connector' }],
+      databases: ['different_database'],
+      computes: [{ id: 'different_compute', name: 'Different Compute' }],
+      setConnector: mockSetConnector,
+      setDatabase: mockSetDatabase,
+      setCompute: mockSetCompute
+    };
+
+    (useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogNoMatch);
+
+    render(<DestinationSettings {...defaultProps} />);
+
+    expect(mockSetConnector).not.toHaveBeenCalled();
+    expect(mockSetDatabase).not.toHaveBeenCalled();
+    expect(mockSetCompute).not.toHaveBeenCalled();
+  });
+});

+ 179 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.tsx

@@ -0,0 +1,179 @@
+// 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, { useEffect } from 'react';
+import { Form, Input, Select } from 'antd';
+import { i18nReact } from '../../../../utils/i18nReact';
+import { useDataCatalog } from '../../../../utils/hooks/useDataCatalog/useDataCatalog';
+import { DestinationConfig } from '../../types';
+
+import './DestinationSettings.scss';
+
+interface DestinationSettingsProps {
+  defaultValues: DestinationConfig;
+  onChange: (name: string, value: string) => void;
+}
+
+const DestinationSettings = ({
+  defaultValues,
+  onChange
+}: DestinationSettingsProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+  const [tableName, setTableName] = React.useState<string | undefined>(defaultValues?.tableName);
+
+  const {
+    loading,
+    databases,
+    database,
+    connectors,
+    connector,
+    computes,
+    compute,
+    setCompute,
+    setConnector,
+    setDatabase
+  } = useDataCatalog();
+
+  const inputConfig = [
+    {
+      label: t('Engine'),
+      name: 'engine',
+      type: 'select',
+      options: connectors.map(connector => ({
+        label: connector.displayName,
+        value: connector.id
+      }))
+    },
+    {
+      label: t('Compute'),
+      name: 'compute',
+      type: 'select',
+      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
+      }))
+    },
+    {
+      label: t('Table Name'),
+      name: 'tableName',
+      type: 'input'
+    }
+  ].filter(({ hidden }) => !hidden);
+
+  const handleDropdownChange = (name: string, value: string) => {
+    if (name === 'engine') {
+      const selectedConnector = connectors?.find(connector => connector.id === value);
+      if (selectedConnector) {
+        setConnector(selectedConnector);
+      }
+    } else if (name === 'database') {
+      const selectedDatabase = databases?.find(database => database === value);
+      if (selectedDatabase) {
+        setDatabase(selectedDatabase);
+      }
+    } else if (name === 'compute') {
+      const selectedCompute = computes?.find(compute => compute.id === value);
+      if (selectedCompute) {
+        setCompute(selectedCompute);
+      }
+    }
+
+    onChange(name, value);
+  };
+
+  const handleTableChange = (value: string) => {
+    setTableName(value);
+  };
+
+  useEffect(() => {
+    if (defaultValues?.connectorId && connectors?.length) {
+      const selectedConnector = connectors.find(conn => conn.id === defaultValues.connectorId);
+      if (selectedConnector) {
+        setConnector(selectedConnector);
+      }
+    }
+    if (defaultValues?.database && databases?.length) {
+      const selectedDatabase = databases.find(db => db === defaultValues.database);
+      if (selectedDatabase) {
+        setDatabase(selectedDatabase);
+      }
+    }
+    if (defaultValues?.computeId && computes?.length) {
+      const selectedCompute = computes.find(comp => comp.id === defaultValues.computeId);
+      if (selectedCompute) {
+        setCompute(selectedCompute);
+      }
+    }
+  }, [defaultValues, connectors, databases, computes, setConnector, setDatabase, setCompute]);
+
+  const selectedSettings = {
+    engine: connector?.id,
+    compute: compute?.id,
+    database: database,
+    tableName: tableName
+  };
+
+  return (
+    <div className="importer-destination-settings">
+      {inputConfig.map(({ label, name, type, options }) => {
+        if (type === 'select') {
+          return (
+            <Form layout="vertical" key={name}>
+              <Form.Item key={name} label={label} htmlFor={name}>
+                <Select
+                  getPopupContainer={triggerNode => triggerNode.parentElement}
+                  options={options}
+                  id={name}
+                  loading={loading}
+                  value={selectedSettings[name]}
+                  className="importer-destination-settings__select-dropdown"
+                  onChange={value => handleDropdownChange(name, value)}
+                />
+              </Form.Item>
+            </Form>
+          );
+        }
+        if (type === 'input') {
+          return (
+            <Form layout="vertical" key={name}>
+              <Form.Item key={name} label={label} htmlFor={name}>
+                <Input
+                  id={name}
+                  value={tableName}
+                  className="importer-destination-settings__input"
+                  onChange={e => handleTableChange(e.target.value)}
+                />
+              </Form.Item>
+            </Form>
+          );
+        }
+        return <></>;
+      })}
+    </div>
+  );
+};
+
+export default DestinationSettings;

+ 0 - 1
desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/ImporterFilePreview.scss

@@ -49,7 +49,6 @@
 
     &__metadata {
       background-color: white;
-      height: 68px;
       padding: 16px;
       font-size: 12px;
       color: #5a656d;

+ 21 - 9
desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/ImporterFilePreview.tsx

@@ -17,6 +17,7 @@
 import React, { useEffect, useState } from 'react';
 import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
 import {
+  DestinationConfig,
   FileFormatResponse,
   FileMetaData,
   GuessFieldTypesResponse,
@@ -29,6 +30,7 @@ import PaginatedTable from '../../../reactComponents/PaginatedTable/PaginatedTab
 import { GUESS_FORMAT_URL, GUESS_FIELD_TYPES_URL, FINISH_IMPORT_URL } from '../api';
 import SourceConfiguration from './SourceConfiguration/SourceConfiguration';
 import EditColumnsModal from './EditColumns/EditColumnsModal';
+import DestinationSettings from './DestinationSettings/DestinationSettings';
 
 import './ImporterFilePreview.scss';
 
@@ -40,7 +42,16 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
   const { t } = i18nReact.useTranslation();
   const [fileFormat, setFileFormat] = useState<FileFormatResponse | undefined>();
   const [isEditColumnsOpen, setIsEditColumnsOpen] = useState(false);
-  const defaultTableName = getDefaultTableName(fileMetaData.path, fileMetaData.source);
+  const [destinationConfig, setDestinationConfig] = useState<DestinationConfig>({
+    tableName: getDefaultTableName(fileMetaData.path, fileMetaData.source)
+  });
+
+  const handleDestinationSettingsChange = (name: string, value: string) => {
+    setDestinationConfig(prevConfig => ({
+      ...prevConfig,
+      [name]: value
+    }));
+  };
 
   const { save: guessFormat, loading: guessingFormat } = useSaveData<FileFormatResponse>(
     GUESS_FORMAT_URL,
@@ -87,21 +98,17 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
   }, [fileMetaData.path, fileFormat]);
 
   const handleFinishImport = () => {
-    // TODO: take the hardcoded values from the form once implemented
-    const dialect = 'impala';
-    const database = 'default';
-
     const source = {
       inputFormat: fileMetaData.source,
       path: fileMetaData.path,
       format: fileFormat,
-      sourceType: dialect
+      sourceType: destinationConfig.connectorId
     };
     const destination = {
       outputFormat: 'table',
       nonDefaultLocation: fileMetaData.path,
-      name: `${database}.${defaultTableName}`,
-      sourceType: dialect,
+      name: `${destinationConfig.database}.${destinationConfig.tableName}`,
+      sourceType: destinationConfig.connectorId,
       columns: previewData?.columns
     };
 
@@ -128,7 +135,12 @@ const ImporterFilePreview = ({ fileMetaData }: ImporterFilePreviewProps): JSX.El
           </PrimaryButton>
         </div>
       </div>
-      <div className="hue-importer-preview-page__metadata">{t('DESTINATION')}</div>
+      <div className="hue-importer-preview-page__metadata">
+        <DestinationSettings
+          defaultValues={destinationConfig}
+          onChange={handleDestinationSettingsChange}
+        />
+      </div>
       <div className="hue-importer-preview-page__main-section">
         <div className="hue-importer-preview-page__header-section">
           <SourceConfiguration fileFormat={fileFormat} setFileFormat={setFileFormat} />

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

@@ -14,7 +14,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-export const enum ImporterFileTypes {
+export enum ImporterFileTypes {
   CSV = 'csv',
   JSON = 'json',
   EXCEL = 'excel'
@@ -73,3 +73,10 @@ export interface ImporterTableData {
   importerDataKey: string;
   [key: string]: string | number;
 }
+
+export interface DestinationConfig {
+  database?: string;
+  tableName?: string;
+  connectorId?: string;
+  computeId?: string;
+}

+ 193 - 0
desktop/core/src/desktop/js/utils/hooks/useDataCatalog/useDataCatalog.test.tsx

@@ -0,0 +1,193 @@
+// 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 { act, renderHook, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { useDataCatalog } from './useDataCatalog';
+import { filterEditorConnectors } from '../../../config/hueConfig';
+import { getNamespaces } from '../../../catalog/contextCatalog';
+
+const mockFilterEditorConnectors = filterEditorConnectors as jest.Mock;
+const mockGetNamespaces = getNamespaces as jest.Mock;
+
+jest.mock('../../../config/hueConfig', () => ({
+  filterEditorConnectors: jest.fn()
+}));
+jest.mock('../../../catalog/contextCatalog', () => ({
+  getNamespaces: jest.fn()
+}));
+jest.mock('../../../../../desktop/js/catalog/dataCatalog', () => ({
+  DataCatalog: jest.fn().mockImplementation(() => ({
+    getEntry: jest.fn().mockResolvedValue({
+      getSourceMeta: jest.fn().mockResolvedValue({ databases: ['db1', 'db2'] })
+    })
+  }))
+}));
+
+const mockConnectors = [
+  { is_sql: true, id: 'c1' },
+  { is_sql: false, id: 'c2' }
+];
+const mockNamespaces = {
+  namespaces: [
+    {
+      computes: [{ id: 'compute1' }],
+      id: 'namespace1'
+    }
+  ]
+};
+
+beforeEach(() => {
+  mockFilterEditorConnectors.mockReset().mockReturnValue(mockConnectors);
+  mockGetNamespaces.mockReset().mockResolvedValue(mockNamespaces);
+});
+
+describe('useDataCatalog', () => {
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should initialize with default values', () => {
+    mockFilterEditorConnectors.mockReturnValue([]);
+    mockGetNamespaces.mockResolvedValue({ namespaces: [] });
+    const { result } = renderHook(() => useDataCatalog());
+    expect(result.current.loading).toBe(true);
+    expect(result.current.connectors).toEqual([]);
+    expect(result.current.connector).toBeNull();
+    expect(result.current.namespace).toBeNull();
+    expect(result.current.computes).toEqual([]);
+    expect(result.current.compute).toBeNull();
+    expect(result.current.databases).toEqual([]);
+  });
+
+  it('should load connectors, namespaces, computes, and databases on mount', async () => {
+    const { result } = renderHook(() => useDataCatalog());
+
+    await waitFor(() => {
+      expect(result.current.connectors).toEqual(mockConnectors);
+      expect(result.current.connector).toEqual(mockConnectors[0]);
+      expect(result.current.namespace).toEqual(mockNamespaces.namespaces[0]);
+      expect(result.current.computes).toEqual(mockNamespaces.namespaces[0].computes);
+      expect(result.current.compute).toEqual(mockNamespaces.namespaces[0].computes[0]);
+      expect(result.current.databases).toEqual(['db1', 'db2']);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should handle namespaces with no computes', async () => {
+    mockFilterEditorConnectors.mockReturnValue([{ is_sql: true, id: 'c1' }]);
+    mockGetNamespaces.mockResolvedValue({
+      namespaces: [
+        {
+          computes: [],
+          id: 'namespace1'
+        }
+      ]
+    });
+    const { result } = renderHook(() => useDataCatalog());
+    await waitFor(() => {
+      expect(result.current.namespace).toEqual({ computes: [], id: 'namespace1' });
+      expect(result.current.computes).toEqual([]);
+      expect(result.current.compute).toBeNull();
+      expect(result.current.databases).toEqual([]);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should handle errors gracefully', async () => {
+    mockFilterEditorConnectors.mockReturnValue([{ is_sql: true }]);
+    mockGetNamespaces.mockRejectedValue(new Error('Failed to load namespaces'));
+    const { result } = renderHook(() => useDataCatalog());
+    await waitFor(() => {
+      expect(result.current.loading).toBe(false);
+      expect(result.current.namespace).toBeNull();
+      expect(result.current.computes).toEqual([]);
+      expect(result.current.databases).toEqual([]);
+    });
+  });
+
+  it('should allow manual setting of connector, namespace, compute, and databases', async () => {
+    mockFilterEditorConnectors.mockReturnValue([
+      {
+        is_sql: true,
+        id: 'c1',
+        name: 'c1',
+        is_batchable: false,
+        optimizer: '',
+        dialect: '',
+        displayName: '',
+        buttonName: '',
+        page: '',
+        tooltip: '',
+        type: ''
+      }
+    ]);
+    mockGetNamespaces.mockResolvedValue({
+      namespaces: [
+        {
+          computes: [{ id: 'compute1', name: 'Compute 1', type: 'spark' }],
+          id: 'namespace1',
+          name: 'Namespace 1',
+          status: 'active'
+        }
+      ]
+    });
+    const { result } = renderHook(() => useDataCatalog());
+    await waitFor(() => expect(result.current.connectors.length).toBeGreaterThan(0));
+    act(() => {
+      result.current.setConnector({
+        id: 'c2',
+        displayName: 'Connector 2',
+        buttonName: 'Button',
+        page: '',
+        tooltip: '',
+        type: '',
+        dialect: '',
+        optimizer: ''
+      });
+      result.current.setNamespace({
+        computes: [],
+        id: 'namespace2',
+        name: 'Namespace 2',
+        status: 'active'
+      });
+      result.current.setCompute({ id: 'compute2', name: 'Compute 2', type: 'spark' });
+      result.current.setDatabase('db2');
+    });
+    expect(result.current.connector).toEqual({
+      id: 'c2',
+      displayName: 'Connector 2',
+      buttonName: 'Button',
+      page: '',
+      tooltip: '',
+      type: '',
+      dialect: '',
+      optimizer: ''
+    });
+    expect(result.current.namespace).toEqual({
+      computes: [],
+      id: 'namespace2',
+      name: 'Namespace 2',
+      status: 'active'
+    });
+    expect(result.current.compute).toEqual({
+      id: 'compute2',
+      name: 'Compute 2',
+      type: 'spark'
+    });
+    expect(result.current.databases).toEqual(['db1', 'db2']);
+  });
+});

+ 163 - 0
desktop/core/src/desktop/js/utils/hooks/useDataCatalog/useDataCatalog.tsx

@@ -0,0 +1,163 @@
+// 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 { useState, useEffect } from 'react';
+import { Connector, Compute, Namespace, EditorInterpreter } from '../../../config/types';
+import { DataCatalog } from '../../../catalog/dataCatalog';
+import { filterEditorConnectors } from '../../../config/hueConfig';
+import { getNamespaces } from '../../../catalog/contextCatalog';
+
+const fetchSourceMeta = async (
+  connector: Connector,
+  namespace: Namespace,
+  compute: Compute,
+  path: string[] = []
+): Promise<{ databases?: string[]; tables_meta?: string[] }> => {
+  const dataCatalog = new DataCatalog(connector);
+  const dataEntry = await dataCatalog.getEntry({
+    namespace,
+    compute,
+    path,
+    definition: { type: 'source' }
+  });
+  return dataEntry.getSourceMeta();
+};
+
+interface UseDataCatalog {
+  loading: boolean;
+  connectors: EditorInterpreter[];
+  connector: Connector | null;
+  namespace: Namespace | null;
+  computes: Compute[];
+  compute: Compute | null;
+  database: string | null;
+  databases: string[];
+  tables: string[];
+  setConnector: (connector: Connector) => void;
+  setNamespace: (namespace: Namespace) => void;
+  setCompute: (compute: Compute) => void;
+  setDatabase: (database: string | null) => void;
+}
+
+export const useDataCatalog = (): UseDataCatalog => {
+  const [connectors, setConnectors] = useState<EditorInterpreter[]>([]);
+  const [computes, setComputes] = useState<Compute[]>([]);
+  const [connector, setConnector] = useState<Connector | null>(null);
+  const [namespace, setNamespace] = useState<Namespace | null>(null);
+  const [compute, setCompute] = useState<Compute | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [databases, setDatabases] = useState<string[]>([]);
+  const [database, setDatabase] = useState<string | null>(null);
+  const [tables, setTables] = useState<string[]>([]);
+
+  const loadDatabases = async (
+    namespace: Namespace,
+    compute: Compute,
+    connector: Connector
+  ): Promise<void> => {
+    try {
+      setLoading(true);
+      const databaseEntries = await fetchSourceMeta(connector, namespace, compute);
+      setDatabases(databaseEntries?.databases ?? []);
+      setDatabase(databaseEntries.databases?.[0] ?? null);
+      await loadTables(connector, namespace, compute, databaseEntries.databases?.[0]);
+    } catch (error) {
+      console.error('Error loading databases:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const loadTables = async (
+    connector: Connector,
+    namespace: Namespace,
+    compute: Compute,
+    database?: string
+  ): Promise<void> => {
+    if (!database) {
+      return;
+    }
+    try {
+      setLoading(true);
+      const tableEntries = await fetchSourceMeta(connector, namespace, compute, [database]);
+      setTables(tableEntries?.tables_meta ?? []);
+    } catch (error) {
+      console.error('Error loading tables:', error);
+      setTables([]);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const loadNamespaces = async (connector: Connector) => {
+    try {
+      setLoading(true);
+      const { namespaces } = await getNamespaces({ connector });
+
+      const namespacesWithCompute = namespaces.filter(namespace => namespace.computes.length);
+
+      if (namespacesWithCompute.length) {
+        const namespace = namespacesWithCompute[0];
+        setNamespace(namespace);
+
+        const computes = namespace.computes;
+        const compute = computes[0];
+        setComputes(computes);
+        setCompute(computes[0]);
+
+        await loadDatabases(namespace, compute, connector);
+      } else {
+        setNamespace(namespaces[0]);
+      }
+    } catch (error) {
+      console.error('Error loading namespaces:', error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (connector && namespace && compute && database) {
+      loadTables(connector, namespace, compute, database);
+    }
+  }, [namespace, compute, connector, database]);
+
+  useEffect(() => {
+    const availableConnectors = filterEditorConnectors(connector => connector.is_sql);
+
+    if (availableConnectors.length > 0) {
+      setConnectors(availableConnectors);
+      setConnector(availableConnectors[0]);
+      loadNamespaces(availableConnectors[0]);
+    }
+  }, []);
+
+  return {
+    loading,
+    connectors,
+    connector,
+    namespace,
+    computes,
+    compute,
+    databases,
+    database,
+    tables,
+    setConnector,
+    setNamespace,
+    setCompute,
+    setDatabase
+  };
+};