diff --git a/desktop/core/src/desktop/js/apps/admin/Components/utils.ts b/desktop/core/src/desktop/js/apps/admin/Components/utils.ts index db7e9507b58..6490b0ffe0f 100644 --- a/desktop/core/src/desktop/js/apps/admin/Components/utils.ts +++ b/desktop/core/src/desktop/js/apps/admin/Components/utils.ts @@ -18,5 +18,6 @@ export const SERVER_LOGS_API_URL = '/api/v1/logs'; export const CHECK_CONFIG_EXAMPLES_API_URL = '/api/v1/check_config'; export const INSTALL_APP_EXAMPLES_API_URL = '/api/v1/install_app_examples'; export const INSTALL_AVAILABLE_EXAMPLES_API_URL = '/api/v1/available_app_examples'; +export const SQL_TYPE_MAPPING_API_URL = '/api/v1/importer/sql_type_mapping'; export const HUE_DOCS_CONFIG_URL = 'https://docs.gethue.com/administrator/configuration/'; export const USAGE_ANALYTICS_API_URL = '/api/v1/usage_analytics'; diff --git a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumnsModal.tsx b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumnsModal.tsx deleted file mode 100644 index b5524d25be1..00000000000 --- a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumnsModal.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to Cloudera, Inc. under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. Cloudera, Inc. licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import React from 'react'; -import { i18nReact } from '../../../../utils/i18nReact'; -import Modal from 'cuix/dist/components/Modal'; -import './EditColumns.scss'; - -interface EditColumnsModalProps { - isOpen: boolean; - closeModal: () => void; -} - -const EditColumnsModal = ({ isOpen, closeModal }: EditColumnsModalProps): JSX.Element => { - const { t } = i18nReact.useTranslation(); - - return ( - - ); -}; - -export default EditColumnsModal; diff --git a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumns.scss b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.scss similarity index 68% rename from desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumns.scss rename to desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.scss index 634e33c0b9c..247f47a64dc 100644 --- a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumns/EditColumns.scss +++ b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.scss @@ -16,8 +16,26 @@ @use 'variables' as vars; -.antd.cuix { - .hue-importer-edit-columns { - padding: vars.$fluidx-spacing-s; +.hue-importer-edit-columns-modal { + .ant-modal-content { + width: 800px; + } + + &__input-title { + width: 100px; + } + + &__type-select { + width: 100px; + + .ant-select-selector { + border: 1px solid vars.$fluidx-gray-600; + border-radius: vars.$border-radius-base; + } + } + + &__no-sample { + color: vars.$fluidx-gray-500; + font-style: italic; } } diff --git a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.test.tsx b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.test.tsx new file mode 100644 index 00000000000..3b0e0447c92 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.test.tsx @@ -0,0 +1,419 @@ +// Licensed to Cloudera, Inc. under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. Cloudera, Inc. licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React from 'react'; +import { render, screen, waitFor, RenderResult } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import '@testing-library/jest-dom'; +import EditColumnsModal, { Column } from './EditColumnsModal'; + +interface MockLoadDataReturn { + data: string[] | null; + loading: boolean; + error: Error | null; +} + +const mockUseLoadData = jest.fn(); + +jest.mock('../../../../utils/hooks/useLoadData/useLoadData', () => { + return jest.fn().mockImplementation(() => mockUseLoadData()); +}); + +jest.mock('../../../../utils/i18nReact', () => ({ + i18nReact: { + useTranslation: () => ({ + t: (key: string, params?: Record) => { + if (params) { + return Object.entries(params).reduce((str, [param, value]) => { + return str.replace(new RegExp(`{{${param}}}`, 'g'), String(value)); + }, key); + } + return key; + }, + ready: true + }) + } +})); + +describe('EditColumnsModal', () => { + const DEFAULT_COLUMNS: Column[] = [ + { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + const DEFAULT_SAMPLE = { importerDataKey: 'row1', col1: 'val1', col2: 42 }; + + const DEFAULT_SQL_TYPES = ['STRING', 'INT', 'FLOAT']; + + const MOCK_STATES = { + success: { + data: DEFAULT_SQL_TYPES, + loading: false, + error: null + }, + loading: { + data: null, + loading: true, + error: null + }, + error: { + data: null, + loading: false, + error: new Error('Failed to fetch SQL types') + }, + empty: { + data: [] as string[], + loading: false, + error: null + }, + invalidData: { + data: 'invalid-string-data' as unknown as string[], + loading: false, + error: null + } + }; + + interface RenderModalOptions { + columns?: Column[]; + sample?: typeof DEFAULT_SAMPLE; + sqlDialect?: string; + setColumns?: jest.Mock; + closeModal?: jest.Mock; + } + + const renderModal = ({ + columns = DEFAULT_COLUMNS, + sample = DEFAULT_SAMPLE, + sqlDialect = 'hive', + setColumns = jest.fn(), + closeModal = jest.fn() + }: RenderModalOptions = {}): RenderResult & { setColumns: jest.Mock; closeModal: jest.Mock } => { + const result = render( + + ); + + return { ...result, setColumns, closeModal }; + }; + + const getColumnTypeSelects = () => + screen.getAllByLabelText('Column type').filter(el => el.tagName === 'DIV'); + + beforeEach(() => { + mockUseLoadData.mockReturnValue(MOCK_STATES.success); + }); + + describe('Basic functionality', () => { + it('should list existing modal columns as expected', () => { + renderModal(); + + expect(screen.getByDisplayValue('col1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('col2')).toBeInTheDocument(); + + expect(screen.getByText('STRING')).toBeInTheDocument(); + expect(screen.getByText('INT')).toBeInTheDocument(); + + expect(screen.getByText('val1')).toBeInTheDocument(); + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByDisplayValue('comment1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('comment2')).toBeInTheDocument(); + }); + + it('should call setColumns with modified data from the table when Done is clicked', async () => { + const { setColumns, closeModal } = renderModal(); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByDisplayValue('col1')).toBeInTheDocument(); + }); + + const nameInput = screen.getByDisplayValue('col1'); + await user.clear(nameInput); + await user.type(nameInput, 'newCol1'); + + const commentTextarea = screen.getByDisplayValue('comment1'); + await user.clear(commentTextarea); + await user.type(commentTextarea, 'new comment'); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + await user.click(doneButton); + + await waitFor(() => { + expect(setColumns).toHaveBeenCalledWith([ + { ...DEFAULT_COLUMNS[0], title: 'newCol1', type: 'STRING', comment: 'new comment' }, + { ...DEFAULT_COLUMNS[1], type: 'INT' } + ]); + expect(closeModal).toHaveBeenCalled(); + }); + }); + + it('should display SQL type options in select dropdown', async () => { + renderModal(); + + await waitFor(() => { + expect(screen.getByText('STRING')).toBeInTheDocument(); + expect(screen.getByText('INT')).toBeInTheDocument(); + }); + + const typeSelects = getColumnTypeSelects(); + expect(typeSelects).toHaveLength(2); + }); + }); + + describe('Edge cases with column data', () => { + it('should handle empty columns array', () => { + renderModal({ columns: [] }); + + expect(screen.getByText('Edit Columns')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('col1')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('col2')).not.toBeInTheDocument(); + }); + + it('should prevent saving when duplicate column names exist', async () => { + const duplicateColumns: Column[] = [ + { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + const { setColumns } = renderModal({ columns: duplicateColumns }); + + await waitFor(() => { + expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).toBeDisabled(); + + const user = userEvent.setup(); + await user.click(doneButton); + + expect(setColumns).not.toHaveBeenCalled(); + }); + + it('should prevent saving when empty column names exist', async () => { + const columnsWithEmpty: Column[] = [ + { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + const { setColumns } = renderModal({ columns: columnsWithEmpty }); + + await waitFor(() => { + expect(screen.getByText('1 column(s) have empty names')).toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).toBeDisabled(); + + const user = userEvent.setup(); + await user.click(doneButton); + + expect(setColumns).not.toHaveBeenCalled(); + }); + + it('should allow saving when duplicate names are fixed', async () => { + const duplicateColumns: Column[] = [ + { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + const { setColumns } = renderModal({ columns: duplicateColumns }); + const user = userEvent.setup(); + + // Initially should show error and disable button + await waitFor(() => { + expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Done' })).toBeDisabled(); + + // Fix the duplicate by changing one name + const nameInputs = screen.getAllByDisplayValue('col1'); + await user.clear(nameInputs[1]); + await user.type(nameInputs[1], 'col2_fixed'); + + // Error should disappear and button should be enabled + await waitFor(() => { + expect(screen.queryByText('Column name "col1" must be unique')).not.toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).not.toBeDisabled(); + + // Should now allow saving + await user.click(doneButton); + + await waitFor(() => { + expect(setColumns).toHaveBeenCalledWith([ + { ...duplicateColumns[0], title: 'col1', type: 'STRING' }, + { ...duplicateColumns[1], title: 'col2_fixed', type: 'INT' } + ]); + }); + }); + + it('should show error status on inputs with validation errors', async () => { + const duplicateColumns: Column[] = [ + { title: 'col1', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + renderModal({ columns: duplicateColumns }); + + await waitFor(() => { + const nameInputs = screen.getAllByDisplayValue('col1'); + // Both inputs should have error status since they're duplicates + expect(nameInputs[0].closest('.ant-input')).toHaveClass('ant-input-status-error'); + expect(nameInputs[1].closest('.ant-input')).toHaveClass('ant-input-status-error'); + }); + }); + + it('should allow saving when empty names are fixed', async () => { + const columnsWithEmpty: Column[] = [ + { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col2', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + const { setColumns } = renderModal({ columns: columnsWithEmpty }); + const user = userEvent.setup(); + + await waitFor(() => { + expect(screen.getByText('1 column(s) have empty names')).toBeInTheDocument(); + }); + + const emptyNameInput = screen.getAllByLabelText('Column title')[0]; + await user.type(emptyNameInput, 'fixed_name'); + + await waitFor(() => { + expect(screen.queryByText('1 column(s) have empty names')).not.toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).not.toBeDisabled(); + + await user.click(doneButton); + + await waitFor(() => { + expect(setColumns).toHaveBeenCalledWith([ + { ...columnsWithEmpty[0], title: 'fixed_name', type: 'STRING' }, + { ...columnsWithEmpty[1], type: 'INT' } + ]); + }); + }); + + it('should handle multiple validation errors simultaneously', async () => { + const problematicColumns: Column[] = [ + { title: '', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'duplicate', dataIndex: 'col2', type: 'int', comment: 'comment2' }, + { title: 'duplicate', dataIndex: 'col3', type: 'string', comment: 'comment3' } + ]; + + const { setColumns } = renderModal({ columns: problematicColumns }); + + await waitFor(() => { + expect( + screen.getByText('Column name "duplicate" must be unique. 1 column(s) have empty names') + ).toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).toBeDisabled(); + + const user = userEvent.setup(); + await user.click(doneButton); + + expect(setColumns).not.toHaveBeenCalled(); + }); + + it('should trim whitespace from column names during validation', async () => { + const columnsWithWhitespace: Column[] = [ + { title: ' col1 ', dataIndex: 'col1', type: 'string', comment: 'comment1' }, + { title: 'col1', dataIndex: 'col2', type: 'int', comment: 'comment2' } + ]; + + renderModal({ columns: columnsWithWhitespace }); + + await waitFor(() => { + expect(screen.getByText('Column name "col1" must be unique')).toBeInTheDocument(); + }); + + const doneButton = screen.getByRole('button', { name: 'Done' }); + expect(doneButton).toBeDisabled(); + }); + }); + + describe('SQL types error handling', () => { + it('should handle SQL type loading error and display error message', async () => { + mockUseLoadData.mockReturnValue(MOCK_STATES.error); + + renderModal(); + + await waitFor(() => { + expect( + screen.getByText( + 'Failed to fetch SQL types for engine hive, make sure the engine is properly configured in Hue.' + ) + ).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument(); + }); + + it('should handle empty SQL types response and display error message', async () => { + mockUseLoadData.mockReturnValue(MOCK_STATES.empty); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('No SQL types returned from server.')).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument(); + }); + + it('should handle SQL types loading state', () => { + mockUseLoadData.mockReturnValue(MOCK_STATES.loading); + + renderModal(); + + const typeSelects = getColumnTypeSelects(); + expect(typeSelects).toHaveLength(2); + + typeSelects.forEach(select => { + expect(select).toHaveClass('ant-select-disabled'); + expect(select).toHaveClass('ant-select-loading'); + }); + + expect(screen.getByText('Edit Columns')).toBeInTheDocument(); + }); + + it('should handle invalid SQL type data format', async () => { + mockUseLoadData.mockReturnValue(MOCK_STATES.invalidData); + + renderModal(); + + await waitFor(() => { + expect(screen.getByText('No SQL types returned from server.')).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText('Column type')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.tsx b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.tsx new file mode 100644 index 00000000000..0ceb124ec49 --- /dev/null +++ b/desktop/core/src/desktop/js/apps/newimporter/FilePreviewTab/EditColumnsModal/EditColumnsModal.tsx @@ -0,0 +1,321 @@ +// Licensed to Cloudera, Inc. under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. Cloudera, Inc. licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import React, { useState, useEffect, ChangeEvent, useMemo } from 'react'; +import { i18nReact } from '../../../../utils/i18nReact'; +import Modal from 'cuix/dist/components/Modal'; +import Table from 'cuix/dist/components/Table'; +import Input from 'cuix/dist/components/Input'; +import Select from 'cuix/dist/components/Select'; +import { Alert } from 'antd'; +import { SQL_TYPE_MAPPING_API_URL } from '../../../admin/Components/utils'; +import useLoadData from '../../../../utils/hooks/useLoadData/useLoadData'; +import LoadingErrorWrapper from '../../../../reactComponents/LoadingErrorWrapper/LoadingErrorWrapper'; + +import './EditColumnsModal.scss'; +import { ImporterTableData, BaseColumnProperties } from '../../types'; + +export interface Column extends BaseColumnProperties { + title: string; + dataIndex: string; +} + +interface EditableRow extends Required { + key: number; + title: string; + type: string; + sample: string; + comment: string; +} + +interface EditColumnsModalProps { + isOpen: boolean; + closeModal: () => void; + columns: Column[]; + setColumns: (cols: Column[]) => void; + sample?: ImporterTableData; + sqlDialect?: string; + fileFormat?: { + type?: string; + fieldSeparator?: string; + hasHeader?: boolean; + }; +} + +const EditColumnsModal = ({ + isOpen, + closeModal, + columns, + setColumns, + sample, + sqlDialect = 'hive', + fileFormat +}: EditColumnsModalProps): JSX.Element => { + const { t } = i18nReact.useTranslation(); + const [editableRows, setEditableRows] = useState([]); + + const { + data: sqlTypesData, + loading: sqlTypesLoading, + error: sqlTypesError + } = useLoadData(`${SQL_TYPE_MAPPING_API_URL}?sql_dialect=${sqlDialect}`); + + const sqlTypes = useMemo(() => { + if (sqlTypesData && Array.isArray(sqlTypesData) && sqlTypesData.length > 0) { + return sqlTypesData; + } + return []; + }, [sqlTypesData]); + + const validateColumnNames = useMemo(() => { + const errors = new Set(); + const titleCounts = new Map(); + + editableRows.forEach((row, index) => { + const title = row.title.trim(); + + if (!title) { + errors.add(index); + } + + if (!titleCounts.has(title)) { + titleCounts.set(title, []); + } + titleCounts.get(title)!.push(index); + }); + + titleCounts.forEach(indices => { + if (indices.length > 1) { + indices.forEach(index => errors.add(index)); + } + }); + + return { errors, duplicates: titleCounts }; + }, [editableRows]); + + const sampleDataAnalysis = useMemo(() => { + const missingSampleCount = editableRows.filter(row => !row.sample.trim()).length; + + if (missingSampleCount === 0) { + return { recommendations: [] }; + } + + const isCompletelyMissing = missingSampleCount === editableRows.length; + const fileType = fileFormat?.type; + + const getFileSpecificGuidance = () => { + if (fileType === 'csv') { + return t('Check CSV settings: field separator, quote character, or "Has Header" option.'); + } + if (fileType === 'excel') { + return t('Try different Excel sheet or verify sheet contains data.'); + } + if (fileType === 'json') { + return t('Verify JSON structure and data format.'); + } + return t('Check file format settings and data structure.'); + }; + + const message = isCompletelyMissing + ? `${t('No sample data detected.')} ${getFileSpecificGuidance()} ${t('Import may fail.')}` + : `${t('{{count}} columns missing data.', { count: missingSampleCount })} ${t('Verify column types before importing.')}`; + + return { + recommendations: [message], + hasNoSampleData: isCompletelyMissing, + hasPartialSampleData: !isCompletelyMissing + }; + }, [editableRows, fileFormat, t]); + + const hasValidationErrors = validateColumnNames.errors.size > 0; + + const getValidationErrorMessages = (): string[] => { + const messages: string[] = []; + const duplicateNames = new Set(); + + validateColumnNames.duplicates.forEach((indices, title) => { + if (indices.length > 1 && title.trim()) { + duplicateNames.add(title.trim()); + } + }); + + if (duplicateNames.size > 0) { + duplicateNames.forEach(name => { + messages.push(t('Column name "{{name}}" must be unique', { name })); + }); + } + + const emptyCount = Array.from(validateColumnNames.errors).filter( + index => !editableRows[index]?.title.trim() + ).length; + + if (emptyCount > 0) { + messages.push(t('{{count}} column(s) have empty names', { count: emptyCount })); + } + + return messages; + }; + + const errors = [ + { + enabled: !!sqlTypesError, + message: t( + 'Failed to fetch SQL types for engine {{engine}}, make sure the engine is properly configured in Hue.', + { engine: sqlDialect } + ) + }, + { + enabled: !sqlTypesLoading && sqlTypes.length === 0, + message: t('No SQL types returned from server.') + } + ]; + + useEffect(() => { + setEditableRows( + columns.map((col, idx) => ({ + key: idx, + title: col.title, + type: (col.type || 'string').toUpperCase(), + sample: sample && sample[col.dataIndex] !== undefined ? String(sample[col.dataIndex]) : '', + comment: col.comment || '' + })) + ); + }, [columns, sample]); + + const handleChange = (rowIndex: number, field: keyof EditableRow, value: string) => { + setEditableRows(rows => + rows.map((row, i) => (i === rowIndex ? { ...row, [field]: value } : row)) + ); + }; + + const handleDone = async () => { + if (hasValidationErrors) { + return; + } + + const updatedColumns = editableRows.map(row => ({ + ...columns[row.key], + title: row.title.trim(), + type: row.type, + comment: row.comment + })); + setColumns(updatedColumns); + closeModal(); + }; + + const modalColumns = [ + { + title: t('Title'), + dataIndex: 'title', + render: (text: string, _: EditableRow, rowIndex: number) => ( + ) => + handleChange(rowIndex, 'title', e.target.value) + } + aria-label={t('Column title')} + status={validateColumnNames.errors.has(rowIndex) ? 'error' : undefined} + /> + ) + }, + { + title: t('Type'), + dataIndex: 'type', + render: (value: string, _: EditableRow, rowIndex: number) => ( +