fix(loader): preserve mapping and file semantics
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.test.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.test.js index 24e8dc3..c362e8c 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.test.js
@@ -15,8 +15,10 @@ * limitations under the License. */ -import {fireEvent, render, screen} from '@testing-library/react'; +import {Form} from 'antd'; +import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import FieldForm from './index'; +import * as api from '../../../api'; jest.mock('../../../api', () => ({manage: {getDatasourceSchema: jest.fn()}})); jest.mock('react-i18next', () => ({ @@ -28,12 +30,17 @@ })); beforeEach(() => { + jest.clearAllMocks(); window.matchMedia = jest.fn().mockImplementation(query => ({ matches: false, media: query, addListener: jest.fn(), removeListener: jest.fn(), })); + api.manage.getDatasourceSchema.mockResolvedValue({ + status: 200, + data: ['name'], + }); }); it('exposes custom source-field deletion as a named keyboard-focusable button', () => { @@ -48,3 +55,74 @@ expect(remove).toHaveAttribute('type', 'button'); expect(remove).toHaveAttribute('title', 'Delete field customer_id'); }); + +it('focuses a visible error until at least one source field is selected', async () => { + const onFormFinish = jest.fn(); + render( + <Form.Provider onFormFinish={onFormFinish}> + <FieldForm visible prev={jest.fn()} datasourceID='9' /> + </Form.Provider> + ); + + expect(await screen.findByText('name')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'common.action.next'})); + + const errorMessage = await screen.findByText('task.edit.select_source_fields'); + const error = errorMessage.closest('[role="alert"]'); + await waitFor(() => expect(error.parentElement).toHaveFocus()); + expect(onFormFinish).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('tree') + .querySelector('.ant-tree-checkbox')); + const move = document.querySelector('.ant-transfer-operation button'); + fireEvent.click(move); + + await waitFor(() => expect( + screen.queryByText('task.edit.select_source_fields') + ).not.toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', {name: 'common.action.next'})); + await waitFor(() => expect(onFormFinish).toHaveBeenCalledWith( + 'field_form', + expect.objectContaining({ + values: expect.objectContaining({target_keys: ['name']}), + }) + )); +}); + +it('clears the selection error before entering with a different data source', async () => { + api.manage.getDatasourceSchema.mockImplementation(id => Promise.resolve({ + status: 200, + data: [id === 'A' ? 'old_name' : 'new_name'], + })); + const onFormFinish = jest.fn(); + const view = datasourceID => ( + <Form.Provider onFormFinish={onFormFinish}> + <FieldForm visible prev={jest.fn()} datasourceID={datasourceID} /> + </Form.Provider> + ); + const {rerender} = render(view('A')); + + expect(await screen.findByText('old_name')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', {name: 'common.action.next'})); + const oldError = await screen.findByText('task.edit.select_source_fields'); + await waitFor(() => expect( + oldError.closest('[role="alert"]').parentElement + ).toHaveFocus()); + + rerender( + <Form.Provider onFormFinish={onFormFinish}> + <FieldForm visible={false} prev={jest.fn()} datasourceID='A' /> + </Form.Provider> + ); + rerender(view('B')); + + expect(await screen.findByText('new_name')).toBeInTheDocument(); + await waitFor(() => expect( + screen.queryByText('task.edit.select_source_fields') + ).not.toBeInTheDocument()); + expect(document.querySelector('.ant-transfer')).not.toHaveClass( + 'ant-transfer-status-error' + ); + expect(document.activeElement).not.toBe(oldError.parentElement); + expect(onFormFinish).not.toHaveBeenCalled(); +});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/Vertex.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/Vertex.js index 99409ee..65d5260 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/Vertex.js +++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/Vertex.js
@@ -48,6 +48,8 @@ label: item.name, value: item.name, })), [selectLabel]); + const requiresIdColumn = Boolean(selectLabel.id_strategy) + && !['PRIMARY_KEY', 'AUTOMATIC'].includes(selectLabel.id_strategy); const autoSelect = useCallback(() => { const list = vertexForm.getFieldValue('attr') ?? []; @@ -207,7 +209,7 @@ /> </Form.Item> <Form.Item - // required={selectLabel.id_strategy !== 'PRIMARY_KEY'} + required={requiresIdColumn} label={( <FormHelpLabel label={t('task.edit.id_column')} @@ -217,7 +219,7 @@ /> )} name={'id'} - rules={[!['PRIMARY_KEY', 'AUTOMATIC'].includes(selectLabel.id_strategy) ? rules.required() : null]} + rules={requiresIdColumn ? [rules.required()] : []} > <Select disabled={['PRIMARY_KEY', 'AUTOMATIC'].includes(selectLabel.id_strategy)
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/row-validation.test.js b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/row-validation.test.js index aace58d..1f2c374 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/row-validation.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/MappingForm/row-validation.test.js
@@ -71,6 +71,70 @@ await waitFor(() => expect(onCancel).not.toHaveBeenCalled()); }); +it('validates the ID column only after the selected vertex strategy requires it', async () => { + render( + <VertexForm + open + onCancel={jest.fn()} + sourceField={[ + { + name: 'custom_vertex', + id_strategy: 'CUSTOMIZE_STRING', + properties: [], + }, + ]} + targetField={['id']} + vertexList={[]} + index={-1} + /> + ); + + fireEvent.click(screen.getByRole('button', {name: 'common.action.confirm'})); + await waitFor(() => expect( + document.querySelectorAll('.ant-form-item-explain-error') + ).toHaveLength(1)); + const idItem = document.querySelector('#vertex_form_id').closest('.ant-form-item'); + expect(idItem).not.toHaveClass('ant-form-item-has-error'); + + const labelSelect = document.querySelector('#vertex_form_label'); + fireEvent.mouseDown(labelSelect); + fireEvent.click(await screen.findByText('custom_vertex', { + selector: '.ant-select-item-option-content', + })); + fireEvent.click(screen.getByRole('button', {name: 'common.action.confirm'})); + await waitFor(() => expect(idItem).toHaveClass('ant-form-item-has-error')); +}); + +it.each(['PRIMARY_KEY', 'AUTOMATIC'])( + 'does not require an ID column for the %s vertex strategy', + async idStrategy => { + const onCancel = jest.fn(); + const label = idStrategy.toLowerCase(); + render( + <VertexForm + open + onCancel={onCancel} + sourceField={[{ + name: label, + id_strategy: idStrategy, + properties: [], + }]} + targetField={['id']} + vertexList={[]} + index={-1} + /> + ); + + fireEvent.mouseDown(document.querySelector('#vertex_form_label')); + fireEvent.click(await screen.findByText(label, { + selector: '.ant-select-item-option-content', + })); + expect(document.querySelector('#vertex_form_id')).toBeDisabled(); + fireEvent.click(screen.getByRole('button', {name: 'common.action.confirm'})); + await waitFor(() => expect(onCancel).toHaveBeenCalledTimes(1)); + } +); + it('does not submit an edge drawer with a half-filled value mapping row', async () => { const onCancel = jest.fn(); render(
diff --git a/hugegraph-loader/src/main/java/org/apache/hugegraph/loader/executor/LoadOptions.java b/hugegraph-loader/src/main/java/org/apache/hugegraph/loader/executor/LoadOptions.java index 0c685be..dee50de 100644 --- a/hugegraph-loader/src/main/java/org/apache/hugegraph/loader/executor/LoadOptions.java +++ b/hugegraph-loader/src/main/java/org/apache/hugegraph/loader/executor/LoadOptions.java
@@ -366,7 +366,8 @@ field.get(this); LOG.info(" {}={}", field.getName(), value); } catch (IllegalAccessException e) { - e.printStackTrace(); + LOG.warn("Failed to read loader parameter '{}'", + field.getName(), e); } } }