Просмотр исходного кода

[ui-importer] add Partitions tab (#4214)

Ram Prasad Agarwal 3 месяцев назад
Родитель
Сommit
d432af8d0c

+ 2 - 2
desktop/core/src/desktop/js/apps/newimporter/FileImportTabs/FileImportTabs.scss

@@ -22,8 +22,8 @@
     flex-direction: column;
     height: 100%;
     overflow: auto;
-    gap: vars.$cdl-spacing-s;
-    padding: vars.$cdl-spacing-s vars.$cdl-spacing-m vars.$cdl-spacing-s vars.$cdl-spacing-m;
+    gap: vars.$cdl-spacing-xxs;
+    padding: vars.$cdl-spacing-xxs vars.$cdl-spacing-m vars.$cdl-spacing-s vars.$cdl-spacing-m;
 
     &__header {
       display: flex;

+ 1 - 1
desktop/core/src/desktop/js/apps/newimporter/FileImportTabs/FileImportTabs.test.tsx

@@ -94,7 +94,7 @@ describe('FileImportTabs', () => {
   });
 
   it('starts with custom default active tab', async () => {
-    render(<FileImportTabs {...defaultProps} defaultActiveKey="settings" />);
+    render(<FileImportTabs {...defaultProps} />);
 
     await waitFor(() => {
       expect(screen.getByText('Settings')).toBeInTheDocument();

+ 5 - 3
desktop/core/src/desktop/js/apps/newimporter/FileImportTabs/FileImportTabs.tsx

@@ -17,7 +17,7 @@
 import React, { useState } from 'react';
 import { Tabs } from 'antd';
 import { i18nReact } from '../../../utils/i18nReact';
-import { DestinationConfig, FileMetaData } from '../types';
+import { DestinationConfig, FileMetaData, Partition } from '../types';
 
 import './FileImportTabs.scss';
 import FilePreviewTab from '../FilePreviewTab/FilePreviewTab';
@@ -27,6 +27,7 @@ import useSaveData from '../../../utils/hooks/useSaveData/useSaveData';
 import { FINISH_IMPORT_URL } from '../api';
 import { getDefaultTableName } from '../utils/utils';
 import SettingsTab from '../SettingsTab/SettingsTab';
+import PartitionsTab from '../PartitionsTab/PartitionsTab';
 import { ImporterSettings, StoreLocation, TableFormat } from '../types';
 
 interface FileImportTabsProps {
@@ -57,6 +58,7 @@ const FileImportTabs = ({ fileMetaData }: FileImportTabsProps): JSX.Element => {
     arrayMapDelimiter: '',
     structDelimiter: ''
   });
+  const [partitions, setPartitions] = useState<Partition[]>([]);
 
   const handleDestinationConfigChange = (name: string, value: string) => {
     setDestinationConfig(prevConfig => ({
@@ -114,8 +116,8 @@ const FileImportTabs = ({ fileMetaData }: FileImportTabsProps): JSX.Element => {
     },
     {
       label: t('Partitions'),
-      key: 'partition'
-      // children: <PartitionsTab />
+      key: 'partition',
+      children: <PartitionsTab partitions={partitions} onPartitionsChange={setPartitions} />
     }
   ];
 

+ 2 - 0
desktop/core/src/desktop/js/apps/newimporter/ImporterSourceSelector/ImporterSourceSelector.tsx

@@ -34,6 +34,7 @@ import { i18nReact } from '../../../utils/i18nReact';
 import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
 
 import './ImporterSourceSelector.scss';
+import { getLastDirOrFileNameFromPath } from '../../../reactComponents/PathBrowser/PathBrowser.util';
 
 const getFileSystems = t => {
   return {
@@ -98,6 +99,7 @@ const ImporterSourceSelector = ({ setFileMetaData }: ImporterSourceSelectorProps
   const handleFileSelection = async (destinationPath: string) => {
     setFileMetaData({
       path: destinationPath,
+      fileName: getLastDirOrFileNameFromPath(destinationPath),
       source: ImporterFileSource.REMOTE
     });
   };

+ 37 - 0
desktop/core/src/desktop/js/apps/newimporter/PartitionsTab/PartitionsTab.scss

@@ -0,0 +1,37 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+@use 'variables' as vars;
+
+.antd.cuix {
+  .hue-partitions-tab {
+    display: flex;
+    flex: 1;
+    gap: vars.$cdl-spacing-m;
+    flex-direction: column;
+    padding: vars.$cdl-spacing-s;
+    background-color: vars.$cdl-white;
+
+    &__add-button {
+      text-align: left;
+      font-weight: 500;
+    }
+
+    &__delete-button {
+      color: vars.$cdl-blue-600;
+    }
+  }
+}

+ 209 - 0
desktop/core/src/desktop/js/apps/newimporter/PartitionsTab/PartitionsTab.test.tsx

@@ -0,0 +1,209 @@
+// 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 } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import '@testing-library/jest-dom';
+
+import PartitionsTab from './PartitionsTab';
+import { Partition } from '../types';
+
+describe('PartitionsTab', () => {
+  const mockOnPartitionsChange = jest.fn();
+
+  const defaultPartitions: Partition[] = [];
+
+  const partitionsWithData: Partition[] = [
+    {
+      id: 'partition_1',
+      name: 'year',
+      type: 'int',
+      value: '2023'
+    },
+    {
+      id: 'partition_2',
+      name: 'month',
+      type: 'string',
+      value: 'january'
+    }
+  ];
+
+  const defaultProps = {
+    partitions: defaultPartitions,
+    onPartitionsChange: mockOnPartitionsChange
+  };
+
+  beforeEach(() => {
+    mockOnPartitionsChange.mockClear();
+  });
+
+  describe('Component Rendering', () => {
+    it('should render add button when no partitions exist', () => {
+      render(<PartitionsTab {...defaultProps} />);
+
+      expect(screen.getByRole('button', { name: /add partitions/i })).toBeInTheDocument();
+      expect(screen.queryByText('Partition 1')).not.toBeInTheDocument();
+    });
+
+    it('should render partitions when they exist', async () => {
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      await waitFor(() => {
+        expect(screen.getByDisplayValue('year')).toBeInTheDocument();
+        expect(screen.getByDisplayValue('month')).toBeInTheDocument();
+        expect(screen.getByDisplayValue('2023')).toBeInTheDocument();
+        expect(screen.getByDisplayValue('january')).toBeInTheDocument();
+      });
+    });
+
+    it('should render delete buttons for each partition', async () => {
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const deleteButtons = screen.getAllByRole('button', { name: 'minus-circle' });
+
+      await waitFor(() => {
+        expect(deleteButtons).toHaveLength(2);
+      });
+    });
+  });
+
+  describe('Adding Partitions', () => {
+    it('should call onPartitionsChange when adding a partition', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} />);
+
+      const addButton = screen.getByRole('button', { name: /add partitions/i });
+      await user.click(addButton);
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalledWith([
+          expect.objectContaining({
+            id: expect.stringMatching(/^partition_\d+$/),
+            name: '',
+            type: 'string',
+            value: ''
+          })
+        ]);
+      });
+    });
+
+    it('should add new partition to existing ones', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const addButton = screen.getByRole('button', { name: /add partitions/i });
+      await user.click(addButton);
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalledWith([
+          ...partitionsWithData,
+          expect.objectContaining({
+            id: expect.stringMatching(/^partition_\d+$/),
+            name: '',
+            type: 'string',
+            value: ''
+          })
+        ]);
+      });
+    });
+  });
+
+  describe('Removing Partitions', () => {
+    it('should call onPartitionsChange when removing a partition', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const deleteButtons = screen.getAllByRole('button', { name: 'minus-circle' });
+      await user.click(deleteButtons[0]);
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalledWith([partitionsWithData[1]]);
+      });
+    });
+
+    it('should remove correct partition when multiple exist', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const deleteButtons = screen.getAllByRole('button', { name: 'minus-circle' });
+      await user.click(deleteButtons[1]);
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalledWith([partitionsWithData[0]]);
+      });
+    });
+  });
+
+  describe('Editing Partition Fields', () => {
+    it('should call onPartitionsChange when partition name changes', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const nameInput = screen.getByDisplayValue('year');
+      await user.clear(nameInput);
+      await user.type(nameInput, 'x');
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalled();
+      });
+
+      const lastCall =
+        mockOnPartitionsChange.mock.calls[mockOnPartitionsChange.mock.calls.length - 1];
+
+      expect(Array.isArray(lastCall[0])).toBe(true);
+    });
+
+    it('should call onPartitionsChange when partition value changes', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const valueInput = screen.getByDisplayValue('2023');
+      await user.clear(valueInput);
+      await user.type(valueInput, '1');
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalled();
+      });
+
+      const lastCall =
+        mockOnPartitionsChange.mock.calls[mockOnPartitionsChange.mock.calls.length - 1];
+      expect(Array.isArray(lastCall[0])).toBe(true);
+    });
+
+    it('should call onPartitionsChange when partition type changes', async () => {
+      const user = userEvent.setup();
+      render(<PartitionsTab {...defaultProps} partitions={partitionsWithData} />);
+
+      const typeSelects = screen.getAllByRole('combobox');
+      const firstTypeSelect = typeSelects[0];
+
+      await user.click(firstTypeSelect);
+      const bigintOption = screen.getByText('Big Integer');
+      await user.click(bigintOption);
+
+      await waitFor(() => {
+        expect(mockOnPartitionsChange).toHaveBeenCalledWith([
+          {
+            ...partitionsWithData[0],
+            type: 'bigint'
+          },
+          partitionsWithData[1]
+        ]);
+      });
+    });
+  });
+});

+ 162 - 0
desktop/core/src/desktop/js/apps/newimporter/PartitionsTab/PartitionsTab.tsx

@@ -0,0 +1,162 @@
+// 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 { Button } from 'antd';
+import { MinusCircleOutlined } from '@ant-design/icons';
+import { ColumnProps } from 'cuix/dist/components/Table';
+import { i18nReact } from '../../../utils/i18nReact';
+import FormInput, { FieldType } from '../../../reactComponents/FormInput/FormInput';
+import { Partition } from '../types';
+import PaginatedTable from '../../../reactComponents/PaginatedTable/PaginatedTable';
+
+import './PartitionsTab.scss';
+import { LinkButton } from 'cuix/dist/components/Button';
+
+interface PartitionsTabProps {
+  partitions: Partition[];
+  onPartitionsChange: (partitions: Partition[]) => void;
+}
+
+// TODO: check if this comes from API call based on dialect
+const PARTITION_TYPE_OPTIONS = [
+  { value: 'string', label: 'String' },
+  { value: 'int', label: 'Integer' },
+  { value: 'bigint', label: 'Big Integer' },
+  { value: 'float', label: 'Float' },
+  { value: 'double', label: 'Double' },
+  { value: 'boolean', label: 'Boolean' },
+  { value: 'timestamp', label: 'Timestamp' },
+  { value: 'date', label: 'Date' }
+];
+
+const PartitionsTab = ({ partitions, onPartitionsChange }: PartitionsTabProps): JSX.Element => {
+  const { t } = i18nReact.useTranslation();
+
+  const handlePartitionChange = (partitionId: string, fieldName: string, value: string) => {
+    const updatedPartitions = partitions.map(partition =>
+      partition.id === partitionId ? { ...partition, [fieldName]: value } : partition
+    );
+
+    onPartitionsChange(updatedPartitions);
+  };
+
+  const handleAddPartition = () => {
+    const newPartition: Partition = {
+      id: `partition_${Date.now()}`,
+      name: '',
+      type: 'string',
+      value: ''
+    };
+
+    onPartitionsChange([...partitions, newPartition]);
+  };
+
+  const handleRemovePartition = (partitionId: string) => {
+    const updatedPartitions = partitions.filter(partition => partition.id !== partitionId);
+
+    onPartitionsChange(updatedPartitions);
+  };
+
+  const getTableColumns = (): ColumnProps<Partition>[] => [
+    {
+      title: t('Name'),
+      dataIndex: 'name',
+      key: 'name',
+      render: (_, partition: Partition) => (
+        <FormInput
+          field={{
+            name: 'name',
+            type: FieldType.INPUT,
+            placeholder: t('Partition name')
+          }}
+          value={partition.name}
+          onChange={(fieldName, value) =>
+            handlePartitionChange(partition.id, fieldName, value as string)
+          }
+        />
+      )
+    },
+    {
+      title: t('Type'),
+      dataIndex: 'type',
+      key: 'type',
+      render: (_, partition: Partition) => (
+        <FormInput
+          field={{
+            name: 'type',
+            type: FieldType.SELECT,
+            placeholder: t('Choose type'),
+            options: PARTITION_TYPE_OPTIONS
+          }}
+          value={partition.type}
+          onChange={(fieldName, value) =>
+            handlePartitionChange(partition.id, fieldName, value as string)
+          }
+        />
+      )
+    },
+    {
+      title: t('Value'),
+      dataIndex: 'value',
+      key: 'value',
+      render: (_, partition: Partition) => (
+        <FormInput
+          field={{
+            name: 'value',
+            type: FieldType.INPUT,
+            placeholder: t('Partition value')
+          }}
+          value={partition.value}
+          onChange={(fieldName, value) =>
+            handlePartitionChange(partition.id, fieldName, value as string)
+          }
+        />
+      )
+    },
+    {
+      title: '',
+      key: 'actions',
+      width: 50,
+      render: (_, partition: Partition) => (
+        <Button
+          type="text"
+          icon={<MinusCircleOutlined />}
+          onClick={() => handleRemovePartition(partition.id)}
+          className="hue-partitions-tab__delete-button"
+        />
+      )
+    }
+  ];
+
+  return (
+    <div className="hue-partitions-tab">
+      <LinkButton className="hue-partitions-tab__add-button" onClick={handleAddPartition}>
+        {`+ ${t('Add partitions')}`}
+      </LinkButton>
+
+      {partitions.length > 0 && (
+        <PaginatedTable
+          data={partitions}
+          columns={getTableColumns()}
+          rowKey={(record: Partition) => record.id}
+        />
+      )}
+    </div>
+  );
+};
+
+export default PartitionsTab;

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

@@ -113,7 +113,17 @@ export interface ImporterSettings {
   structDelimiter: string;
 }
 
-// TODO: verify if ImporterSettings can be used as context
+export interface Partition {
+  id: string;
+  name: string;
+  type: string;
+  value: string;
+}
+
+export interface PartitionConfig {
+  partitions: Partition[];
+}
+
 export interface SettingsContext extends ImporterSettings {
   isRemoteTable: boolean;
   isIcebergEnabled: boolean;

+ 3 - 3
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.scss

@@ -18,13 +18,13 @@
 @use 'mixins';
 
 .antd.cuix {
-  .hue-table-container {
+  .hue-paginated-table-container {
     display: flex;
     flex-direction: column;
     flex: 1;
   }
 
-  .hue-table-container__placeholder--hidden {
+  .hue-paginated-table-container__placeholder--hidden {
     // In CUIX implementation, label.loading is rendering along with spinner
     // this ensures the placeholder is hidden when loading is true
     .ant-table-placeholder {
@@ -32,7 +32,7 @@
     }
   }
 
-  .hue-table {
+  .hue-paginated-table {
     .ant-table-selection-column {
       // This prevents the eplipses from being applied to the selection column
       text-overflow: initial;

+ 2 - 2
desktop/core/src/desktop/js/reactComponents/PaginatedTable/PaginatedTable.tsx

@@ -123,11 +123,11 @@ const PaginatedTable = <T extends object>({
   return (
     <div
       ref={tableRef}
-      className={`hue-table-container ${loading ? 'hue-table-container__placeholder--hidden' : ''}`}
+      className={`hue-paginated-table-container ${loading ? 'hue-paginated-table-container__placeholder--hidden' : ''}`}
     >
       <Table
         title={title}
-        className="hue-table"
+        className="hue-paginated-table"
         onChange={onColumnClick}
         columns={getColumnsFromConfig(columns)}
         dataSource={data}