Explorar el Código

[ui-importer] fix: settings tab for max width in input fields (#4288)

Ram Prasad Agarwal hace 1 semana
padre
commit
96baf7e22b

+ 9 - 0
desktop/core/src/desktop/js/apps/newimporter/SettingsTab/SettingsTab.scss

@@ -28,6 +28,11 @@
       display: flex;
       flex-direction: column;
       gap: vars.$cdl-spacing-m;
+      max-width: 1024px;
+
+      @media (width <= 1024px) {
+        width: 100%;
+      }
     }
 
     &__section {
@@ -47,6 +52,10 @@
         flex: 1;
         width: 100%;
         gap: vars.$cdl-spacing-m;
+
+        @media (width <= 640px) {
+          flex-direction: column;
+        }
       }
 
       &__fields {

+ 2 - 5
desktop/core/src/desktop/js/apps/newimporter/SettingsTab/SettingsTabConfig.ts

@@ -51,7 +51,7 @@ export const ADVANCED_SETTINGS_CONFIG: Record<string, SettingsFieldConfig[]> = {
   description: [
     {
       name: 'description',
-      type: FieldType.INPUT,
+      type: FieldType.TEXTAREA,
       label: 'Description',
       placeholder: "A table to store customer data imported from the marketing team's CRM.",
       tooltip:
@@ -118,7 +118,7 @@ export const ADVANCED_SETTINGS_CONFIG: Record<string, SettingsFieldConfig[]> = {
     },
     {
       name: 'externalLocation',
-      type: FieldType.INPUT,
+      type: FieldType.FILECHOOSER,
       placeholder: 'External location',
       isHidden: (context: SettingsContext) => !context.useExternalLocation
     }
@@ -139,7 +139,6 @@ export const ADVANCED_SETTINGS_CONFIG: Record<string, SettingsFieldConfig[]> = {
       label: 'Field',
       placeholder: 'Choose an option',
       options: DELIMITER_OPTIONS,
-      tooltip: 'Field delimiter',
       isHidden: (context: SettingsContext) => !context.customCharDelimiters
     },
     {
@@ -148,7 +147,6 @@ export const ADVANCED_SETTINGS_CONFIG: Record<string, SettingsFieldConfig[]> = {
       label: 'Array Map',
       placeholder: 'Choose an option',
       options: DELIMITER_OPTIONS,
-      tooltip: 'Array map delimiter',
       isHidden: (context: SettingsContext) => !context.customCharDelimiters
     },
     {
@@ -157,7 +155,6 @@ export const ADVANCED_SETTINGS_CONFIG: Record<string, SettingsFieldConfig[]> = {
       label: 'Struct',
       placeholder: 'Choose an option',
       options: DELIMITER_OPTIONS,
-      tooltip: 'Struct delimiter',
       isHidden: (context: SettingsContext) => !context.customCharDelimiters
     }
   ]

+ 39 - 0
desktop/core/src/desktop/js/reactComponents/FormInput/FileChooserInput/FileChooserInput.scss

@@ -0,0 +1,39 @@
+// 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-form-input__file-chooser {
+    display: flex;
+    align-items: stretch;
+    position: relative;
+
+    input {
+      flex: 1;
+      border-top-right-radius: 0;
+      border-bottom-right-radius: 0;
+    }
+
+    &__button {
+      white-space: nowrap;
+      flex-shrink: 0;
+      border-top-left-radius: 0;
+      border-bottom-left-radius: 0;
+      margin-left: -1px;
+    }
+  }
+}

+ 242 - 0
desktop/core/src/desktop/js/reactComponents/FormInput/FileChooserInput/FileChooserInput.test.tsx

@@ -0,0 +1,242 @@
+// 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 FileChooserInput from './FileChooserInput';
+
+jest.mock('../../../utils/hooks/useLoadData/useLoadData', () => ({
+  __esModule: true,
+  default: jest.fn()
+}));
+
+import useLoadData from '../../../utils/hooks/useLoadData/useLoadData';
+
+const mockUseLoadData = useLoadData as jest.MockedFunction<typeof useLoadData>;
+
+describe('FileChooserInput', () => {
+  const mockOnChange = jest.fn();
+
+  const mockDirectoryData = {
+    files: [
+      {
+        path: '/test/directory/file1.txt',
+        type: 'file',
+        size: 1024,
+        mtime: Date.now(),
+        user: 'testuser',
+        group: 'testgroup',
+        rwx: 'rw-r--r--',
+        mode: 644,
+        atime: Date.now(),
+        blockSize: 4096,
+        replication: 3
+      },
+      {
+        path: '/test/directory/file2.txt',
+        type: 'file',
+        size: 2048,
+        mtime: Date.now(),
+        user: 'testuser',
+        group: 'testgroup',
+        rwx: 'rw-r--r--',
+        mode: 644,
+        atime: Date.now(),
+        blockSize: 4096,
+        replication: 3
+      },
+      {
+        path: '/test/directory/subfolder',
+        type: 'dir',
+        size: 0,
+        mtime: Date.now(),
+        user: 'testuser',
+        group: 'testgroup',
+        rwx: 'rwxr-xr-x',
+        mode: 755,
+        atime: Date.now(),
+        blockSize: 0,
+        replication: 0
+      }
+    ],
+    page: {
+      number: 1,
+      numPages: 1,
+      startIndex: 0,
+      endIndex: 3,
+      totalRecords: 3,
+      pageSize: 1000,
+      previousPage: 1,
+      nextPage: 1
+    }
+  };
+
+  beforeEach(() => {
+    jest.clearAllMocks();
+    mockUseLoadData.mockReturnValue({
+      data: mockDirectoryData,
+      loading: false,
+      error: undefined,
+      reloadData: jest.fn()
+    });
+  });
+
+  it('should render input field with value', () => {
+    render(<FileChooserInput value="/test/path" onChange={mockOnChange} />);
+
+    const input = screen.getByRole('textbox');
+    expect(input).toBeInTheDocument();
+    expect(input).toHaveValue('/test/path');
+  });
+
+  it('should render Choose button', () => {
+    render(<FileChooserInput value="" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    expect(button).toBeInTheDocument();
+    expect(button).toHaveTextContent('Choose');
+  });
+
+  it('should render with placeholder', () => {
+    render(<FileChooserInput value="" onChange={mockOnChange} placeholder="Enter file path" />);
+
+    const input = screen.getByRole('textbox');
+    expect(input).toHaveAttribute('placeholder', 'Enter file path');
+  });
+
+  it('should call onChange when typing in input field', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="" onChange={mockOnChange} />);
+
+    const input = screen.getByRole('textbox');
+    await user.type(input, '/new/path');
+
+    expect(mockOnChange).toHaveBeenCalled();
+  });
+
+  it('should show error status when error prop is true', () => {
+    render(<FileChooserInput value="" onChange={mockOnChange} error={true} />);
+
+    const input = screen.getByRole('textbox');
+    expect(input).toHaveClass('ant-input-status-error');
+  });
+
+  it('should open FileChooserModal when Choose button is clicked', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="/initial/path" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    await user.click(button);
+
+    await waitFor(() => {
+      expect(screen.getByText('Choose a file')).toBeInTheDocument();
+    });
+  });
+
+  it('should update value and close modal when file is selected', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    await user.click(button);
+
+    await waitFor(() => {
+      expect(screen.getByText('Choose a file')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(screen.getByText('file1.txt')).toBeInTheDocument();
+    });
+
+    const fileRow = screen.getByText('file1.txt');
+    await user.click(fileRow);
+
+    await waitFor(() => {
+      expect(mockOnChange).toHaveBeenCalledWith('/test/directory/file1.txt');
+    });
+
+    await waitFor(() => {
+      expect(screen.queryByText('Choose a file')).not.toBeInTheDocument();
+    });
+  });
+
+  it('should close modal when cancel is clicked', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    await user.click(button);
+
+    await waitFor(() => {
+      expect(screen.getByText('Choose a file')).toBeInTheDocument();
+    });
+
+    const cancelButton = screen.getByRole('button', { name: 'Cancel' });
+    await user.click(cancelButton);
+
+    await waitFor(() => {
+      expect(screen.queryByText('Choose a file')).not.toBeInTheDocument();
+    });
+  });
+
+  it('should use root path "/" as default sourcePath when value is empty', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    await user.click(button);
+
+    await waitFor(() => {
+      expect(screen.getByText('Choose a file')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(mockUseLoadData).toHaveBeenCalledWith(
+        expect.any(String),
+        expect.objectContaining({
+          params: expect.objectContaining({
+            path: '/'
+          })
+        })
+      );
+    });
+  });
+
+  it('should use existing value as sourcePath when available', async () => {
+    const user = userEvent.setup();
+    render(<FileChooserInput value="/existing/path" onChange={mockOnChange} />);
+
+    const button = screen.getByRole('button', { name: 'Choose' });
+    await user.click(button);
+
+    await waitFor(() => {
+      expect(screen.getByText('Choose a file')).toBeInTheDocument();
+    });
+
+    await waitFor(() => {
+      expect(mockUseLoadData).toHaveBeenCalledWith(
+        expect.any(String),
+        expect.objectContaining({
+          params: expect.objectContaining({
+            path: '/existing/path'
+          })
+        })
+      );
+    });
+  });
+});

+ 85 - 0
desktop/core/src/desktop/js/reactComponents/FormInput/FileChooserInput/FileChooserInput.tsx

@@ -0,0 +1,85 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useState } from 'react';
+import Input from 'cuix/dist/components/Input';
+import PrimaryButton from 'cuix/dist/components/Button/PrimaryButton';
+import { i18nReact } from '../../../utils/i18nReact';
+import FileChooserModal from '../../../apps/storageBrowser/FileChooserModal/FileChooserModal';
+import './FileChooserInput.scss';
+
+export interface FileChooserInputProps {
+  value?: string;
+  onChange: (value: string) => void;
+  placeholder?: string;
+  error?: boolean;
+  name?: string;
+}
+
+const FileChooserInput: React.FC<FileChooserInputProps> = ({
+  value,
+  onChange,
+  placeholder,
+  error,
+  name
+}) => {
+  const { t } = i18nReact.useTranslation();
+  const [showModal, setShowModal] = useState(false);
+  const [selectedPath, setSelectedPath] = useState(value);
+
+  const handleFileChoose = async (selectedPath: string) => {
+    onChange(selectedPath);
+    setSelectedPath(selectedPath);
+
+    if (showModal) {
+      setShowModal(false);
+    }
+  };
+
+  return (
+    <>
+      <div className="hue-form-input__file-chooser">
+        <Input
+          value={selectedPath}
+          onChange={e => handleFileChoose(e.target.value)}
+          placeholder={placeholder ? t(placeholder) : undefined}
+          status={error ? 'error' : undefined}
+          name={name}
+        />
+        <PrimaryButton
+          onClick={() => setShowModal(true)}
+          className="hue-form-input__file-chooser__button"
+        >
+          {t('Choose')}
+        </PrimaryButton>
+      </div>
+      {showModal && (
+        <FileChooserModal
+          showModal={showModal}
+          onClose={() => setShowModal(false)}
+          onSubmit={handleFileChoose}
+          title={t('Choose a file')}
+          sourcePath={selectedPath || '/'}
+          isFileSelectionAllowed={true}
+          submitText={t('Select')}
+          cancelText={t('Cancel')}
+        />
+      )}
+    </>
+  );
+};
+
+export default FileChooserInput;

+ 14 - 1
desktop/core/src/desktop/js/reactComponents/FormInput/FormInput.tsx

@@ -20,6 +20,7 @@ import Select from 'cuix/dist/components/Select';
 import { InfoCircleOutlined } from '@ant-design/icons';
 import { i18nReact } from '../../utils/i18nReact';
 import { Form, Radio, Tooltip, Input as AntdInput } from 'antd';
+import FileChooserInput from './FileChooserInput/FileChooserInput';
 import './FormInput.scss';
 
 export enum FieldType {
@@ -27,7 +28,8 @@ export enum FieldType {
   INPUT = 'input',
   TEXTAREA = 'textarea',
   SELECT = 'select',
-  RADIO = 'radio'
+  RADIO = 'radio',
+  FILECHOOSER = 'fileChooser'
 }
 
 export interface FieldOption {
@@ -164,6 +166,17 @@ const FormInput = <T,>({
           </Radio.Group>
         );
 
+      case FieldType.FILECHOOSER:
+        return (
+          <FileChooserInput
+            value={value as string}
+            onChange={newValue => onChange(field.name, newValue as T)}
+            placeholder={field.placeholder}
+            error={error}
+            name={field.name}
+          />
+        );
+
       default:
         return <></>;
     }