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);
                 }
             }
         }