Skip to content

Commit fe0789a

Browse files
[ui-importer] add table name validation (#4194)
1 parent fcde6da commit fe0789a

File tree

5 files changed

+243
-148
lines changed

5 files changed

+243
-148
lines changed

desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.scss

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@
1919
.antd.cuix {
2020
.importer-destination-settings {
2121
display: flex;
22+
flex-direction: column;
2223
gap: vars.$cdl-spacing-s;
2324

2425
.ant-form-item {
2526
margin-bottom: 0;
2627
}
2728

28-
&__select-dropdown,
29+
&__form-container {
30+
display: flex;
31+
gap: vars.$cdl-spacing-s;
32+
}
33+
2934
&__input {
3035
border: 1px solid vars.$fluidx-gray-600;
3136
border-radius: vars.$border-radius-base;

desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.test.tsx

Lines changed: 68 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ import { DestinationConfig } from '../../types';
2222
import { useDataCatalog } from '../../../../utils/hooks/useDataCatalog/useDataCatalog';
2323

2424
const mockUseDataCatalog = {
25-
loading: false,
25+
loading: {
26+
connector: false,
27+
compute: false,
28+
database: false,
29+
table: false
30+
},
2631
databases: ['database1', 'database2'],
2732
database: 'database1',
2833
connectors: [
@@ -35,6 +40,7 @@ const mockUseDataCatalog = {
3540
{ id: 'compute2', name: 'Compute 2' }
3641
],
3742
compute: { id: 'compute1', name: 'Compute 1' },
43+
tables: [],
3844
setCompute: jest.fn(),
3945
setConnector: jest.fn(),
4046
setDatabase: jest.fn()
@@ -79,20 +85,26 @@ describe('DestinationSettings Component', () => {
7985
expect(screen.getByDisplayValue('test_table')).toBeInTheDocument();
8086
});
8187

82-
it('should hide compute field when only one compute is available', () => {
88+
it('should not have compute field when only one compute is available', async () => {
8389
const mockUseDataCatalogSingleCompute = {
8490
...mockUseDataCatalog,
85-
computes: [{ id: 'compute1', name: 'Compute 1' }]
91+
computes: [{ id: 'compute1', name: 'Compute 1' }],
92+
compute: { id: 'compute1', name: 'Compute 1' }
8693
};
8794

88-
(useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogSingleCompute);
95+
(useDataCatalog as jest.Mock).mockReturnValue(mockUseDataCatalogSingleCompute);
8996

9097
render(<DestinationSettings {...defaultProps} />);
9198

92-
expect(screen.queryByLabelText('Compute')).not.toBeInTheDocument();
99+
await waitFor(() => {
100+
expect(screen.getByLabelText('Engine')).toBeInTheDocument();
101+
expect(screen.getByLabelText('Database')).toBeInTheDocument();
102+
expect(screen.getByLabelText('Table Name')).toBeInTheDocument();
103+
expect(screen.queryByLabelText('Compute')).not.toBeInTheDocument();
104+
});
93105
});
94106

95-
it('should show compute field when multiple computes are available', () => {
107+
it('should show compute field when multiple computes are available', async () => {
96108
render(<DestinationSettings {...defaultProps} />);
97109

98110
expect(screen.getByLabelText('Compute')).toBeInTheDocument();
@@ -149,54 +161,20 @@ describe('DestinationSettings Component', () => {
149161
id: 'compute2',
150162
name: 'Compute 2'
151163
});
152-
expect(defaultProps.onChange).toHaveBeenCalledWith('compute', 'compute2');
164+
expect(defaultProps.onChange).toHaveBeenCalledWith('computeId', 'compute2');
153165
});
154166

155-
it('should update table name input and not call onChange immediately', () => {
167+
it('should update table name input and call onChange when input changes', async () => {
156168
render(<DestinationSettings {...defaultProps} />);
157169

158170
const tableNameInput = screen.getByLabelText('Table Name');
159171
fireEvent.change(tableNameInput, { target: { value: 'new_table_name' } });
160172

161173
expect(screen.getByDisplayValue('new_table_name')).toBeInTheDocument();
162-
expect(defaultProps.onChange).not.toHaveBeenCalled();
174+
expect(defaultProps.onChange).toHaveBeenCalledWith('tableName', 'new_table_name');
163175
});
164176

165-
it('should show loader in select dropdown when loading state is true', () => {
166-
const mockUseDataCatalogLoading = {
167-
...mockUseDataCatalog,
168-
loading: true
169-
};
170-
171-
(useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogLoading);
172-
173-
render(<DestinationSettings {...defaultProps} />);
174-
175-
const selectDropdowns = document.querySelectorAll('.ant-select');
176-
selectDropdowns.forEach(select => {
177-
expect(select).toBeInTheDocument();
178-
expect(select).toHaveClass('ant-select-loading');
179-
});
180-
});
181-
182-
it('should not show loader in select dropdown when loading state is false', () => {
183-
const mockUseDataCatalogLoading = {
184-
...mockUseDataCatalog,
185-
loading: false
186-
};
187-
188-
(useDataCatalog as jest.Mock).mockReturnValueOnce(mockUseDataCatalogLoading);
189-
190-
render(<DestinationSettings {...defaultProps} />);
191-
192-
const selectDropdowns = document.querySelectorAll('.ant-select');
193-
selectDropdowns.forEach(select => {
194-
expect(select).toBeInTheDocument();
195-
expect(select).not.toHaveClass('ant-select-loading');
196-
});
197-
});
198-
199-
it('should set default values from props on component mount', () => {
177+
it('should set default values from props on component mount', async () => {
200178
const mockSetConnector = jest.fn();
201179
const mockSetDatabase = jest.fn();
202180
const mockSetCompute = jest.fn();
@@ -223,7 +201,7 @@ describe('DestinationSettings Component', () => {
223201
});
224202
});
225203

226-
it('should not call setters when no matching items found in defaultValues', () => {
204+
it('should not call setters when no matching items found in defaultValues', async () => {
227205
const mockSetConnector = jest.fn();
228206
const mockSetDatabase = jest.fn();
229207
const mockSetCompute = jest.fn();
@@ -246,4 +224,49 @@ describe('DestinationSettings Component', () => {
246224
expect(mockSetDatabase).not.toHaveBeenCalled();
247225
expect(mockSetCompute).not.toHaveBeenCalled();
248226
});
227+
228+
it('should show alert when table name already exists', async () => {
229+
const mockUseDataCatalogWithExistingTables = {
230+
...mockUseDataCatalog,
231+
tables: [{ name: 'existing_table' }, { name: 'another_table' }]
232+
};
233+
234+
(useDataCatalog as jest.Mock).mockReturnValue(mockUseDataCatalogWithExistingTables);
235+
236+
render(<DestinationSettings {...defaultProps} />);
237+
238+
const tableNameInput = screen.getByLabelText('Table Name');
239+
240+
fireEvent.change(tableNameInput, { target: { value: 'existing_table' } });
241+
242+
await waitFor(() => {
243+
expect(screen.getByRole('alert')).toBeInTheDocument();
244+
expect(screen.getByText('Table name already exists in the database')).toBeInTheDocument();
245+
});
246+
});
247+
248+
it('should hide alert when table name is unique', async () => {
249+
const mockUseDataCatalogWithExistingTables = {
250+
...mockUseDataCatalog,
251+
tables: [{ name: 'existing_table' }, { name: 'another_table' }]
252+
};
253+
254+
(useDataCatalog as jest.Mock).mockReturnValue(mockUseDataCatalogWithExistingTables);
255+
256+
render(<DestinationSettings {...defaultProps} />);
257+
258+
const tableNameInput = screen.getByLabelText('Table Name');
259+
260+
fireEvent.change(tableNameInput, { target: { value: 'existing_table' } });
261+
262+
await waitFor(() => {
263+
expect(screen.getByRole('alert')).toBeInTheDocument();
264+
});
265+
266+
fireEvent.change(tableNameInput, { target: { value: 'unique_table_name' } });
267+
268+
await waitFor(() => {
269+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
270+
});
271+
});
249272
});

desktop/core/src/desktop/js/apps/newimporter/ImporterFilePreview/DestinationSettings/DestinationSettings.tsx

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
// limitations under the License.
1616

1717
import React, { useEffect } from 'react';
18-
import { Form, Input, Select } from 'antd';
18+
import { Alert, Form } from 'antd';
1919
import { i18nReact } from '../../../../utils/i18nReact';
2020
import { useDataCatalog } from '../../../../utils/hooks/useDataCatalog/useDataCatalog';
2121
import { DestinationConfig } from '../../types';
2222

2323
import './DestinationSettings.scss';
24+
import FormInput, { FieldType } from '../../../../reactComponents/FormInput/FormInput';
2425

2526
interface DestinationSettingsProps {
2627
defaultValues: DestinationConfig;
@@ -32,6 +33,9 @@ const DestinationSettings = ({
3233
onChange
3334
}: DestinationSettingsProps): JSX.Element => {
3435
const { t } = i18nReact.useTranslation();
36+
const [error, setError] = React.useState<{
37+
tableName?: string;
38+
}>({});
3539
const [tableName, setTableName] = React.useState<string | undefined>(defaultValues?.tableName);
3640

3741
const {
@@ -42,6 +46,7 @@ const DestinationSettings = ({
4246
connector,
4347
computes,
4448
compute,
49+
tables,
4550
setCompute,
4651
setConnector,
4752
setDatabase
@@ -51,16 +56,18 @@ const DestinationSettings = ({
5156
{
5257
label: t('Engine'),
5358
name: 'connectorId',
54-
type: 'select',
59+
type: FieldType.SELECT,
60+
tooltip: t('Select the engine to use for the destination'),
5561
options: connectors.map(connector => ({
5662
label: connector.displayName,
5763
value: connector.id
5864
}))
5965
},
6066
{
6167
label: t('Compute'),
62-
name: 'compute',
63-
type: 'select',
68+
name: 'computeId',
69+
type: FieldType.SELECT,
70+
tooltip: t('Select the compute to use for the destination'),
6471
options:
6572
computes?.map(compute => ({
6673
label: compute.name,
@@ -71,7 +78,8 @@ const DestinationSettings = ({
7178
{
7279
label: t('Database'),
7380
name: 'database',
74-
type: 'select',
81+
type: FieldType.SELECT,
82+
tooltip: t('Select the database to use for the destination'),
7583
options:
7684
databases?.map(database => ({
7785
label: database,
@@ -81,7 +89,8 @@ const DestinationSettings = ({
8189
{
8290
label: t('Table Name'),
8391
name: 'tableName',
84-
type: 'input'
92+
type: FieldType.INPUT,
93+
tooltip: t('Enter the name of the table to use for the destination')
8594
}
8695
].filter(({ hidden }) => !hidden);
8796

@@ -91,23 +100,27 @@ const DestinationSettings = ({
91100
if (selectedConnector) {
92101
setConnector(selectedConnector);
93102
}
94-
} else if (name === 'database') {
95-
const selectedDatabase = databases?.find(database => database === value);
96-
if (selectedDatabase) {
97-
setDatabase(selectedDatabase);
98-
}
99-
} else if (name === 'compute') {
103+
} else if (name === 'computeId') {
100104
const selectedCompute = computes?.find(compute => compute.id === value);
101105
if (selectedCompute) {
102106
setCompute(selectedCompute);
103107
}
108+
} else if (name === 'database') {
109+
setDatabase(value);
110+
} else if (name === 'tableName') {
111+
setTableName(value);
104112
}
105113

106114
onChange(name, value);
107115
};
108116

109-
const handleTableChange = (value: string) => {
110-
setTableName(value);
117+
const validateTableName = (name: string) => {
118+
const tableExists = tables.some(table => table.name.toLowerCase() === name.toLowerCase());
119+
if (tableExists) {
120+
setError(prev => ({ ...prev, tableName: t('Table name already exists in the database') }));
121+
} else {
122+
setError(prev => ({ ...prev, tableName: undefined }));
123+
}
111124
};
112125

113126
useEffect(() => {
@@ -164,49 +177,46 @@ const DestinationSettings = ({
164177
}
165178
}, [defaultValues?.tableName]);
166179

167-
const selectedSettings = {
180+
useEffect(() => {
181+
if (tableName) {
182+
validateTableName(tableName);
183+
}
184+
}, [tableName, tables]);
185+
186+
const selectedSettings: DestinationConfig = {
168187
connectorId: connector?.id,
169-
compute: compute?.id,
188+
computeId: compute?.id,
170189
database: database,
171190
tableName: tableName
172191
};
173192

193+
const loadingState = {
194+
connectorId: loading.connector,
195+
computeId: loading.compute,
196+
database: loading.database,
197+
tableName: loading.table
198+
};
199+
174200
return (
175201
<div className="importer-destination-settings">
176-
{inputConfig.map(({ label, name, type, options }) => {
177-
if (type === 'select') {
178-
return (
179-
<Form layout="vertical" key={name}>
180-
<Form.Item key={name} label={label} htmlFor={name}>
181-
<Select
182-
getPopupContainer={triggerNode => triggerNode.parentElement}
183-
options={options}
184-
id={name}
185-
loading={loading}
186-
value={selectedSettings[name]}
187-
className="importer-destination-settings__select-dropdown"
188-
onChange={value => handleDropdownChange(name, value)}
189-
/>
190-
</Form.Item>
191-
</Form>
192-
);
193-
}
194-
if (type === 'input') {
195-
return (
196-
<Form layout="vertical" key={name}>
197-
<Form.Item key={name} label={label} htmlFor={name}>
198-
<Input
199-
id={name}
200-
value={tableName}
201-
className="importer-destination-settings__input"
202-
onChange={e => handleTableChange(e.target.value)}
203-
/>
204-
</Form.Item>
205-
</Form>
206-
);
207-
}
208-
return <></>;
209-
})}
202+
{error && Object.values(error).some(Boolean) && (
203+
<Alert message={error.tableName} type="error" showIcon />
204+
)}
205+
<div className="importer-destination-settings__form-container">
206+
{inputConfig.map(field => (
207+
<Form key={field.name} layout="vertical">
208+
<FormInput<string, DestinationConfig>
209+
field={field}
210+
defaultValue={selectedSettings[field.name]}
211+
value={selectedSettings[field.name]}
212+
onChange={handleDropdownChange}
213+
className="importer-destination-settings__input"
214+
loading={loadingState[field.name]}
215+
error={error[field.name]}
216+
/>
217+
</Form>
218+
))}
219+
</div>
210220
</div>
211221
);
212222
};

0 commit comments

Comments
 (0)