This is an automated email from the ASF dual-hosted git repository.

imbajin pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git

commit ecffb44b3034584342420ae44076e0da35bd2131
Author: dark <[email protected]>
AuthorDate: Fri Aug 14 12:39:02 2026 +0800

    fix(task): validate names and mapping fields
---
 .../hubble-fe/src/pages/TaskEdit/BaseForm/index.js |   9 ++
 .../TaskEdit/BaseForm/name-validation.test.js      | 125 +++++++++++++++++++++
 .../src/pages/TaskEdit/FieldForm/index.js          |  30 ++++-
 3 files changed, 162 insertions(+), 2 deletions(-)

diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js 
b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
index c1357c3db..26f1a7009 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/index.js
@@ -328,6 +328,7 @@ const BaseForm = ({cancel, visible, loading}) => {
 
     const loadHlmDemo = useCallback(() => confirmDemo('hlm'), [confirmDemo]);
     const loadLoaderDemo = useCallback(() => confirmDemo('loader'), 
[confirmDemo]);
+    const loadRankDemo = useCallback(() => confirmDemo('rank'), [confirmDemo]);
     const retryDemo = useCallback(() => prepareDemo(lastDemo), [lastDemo, 
prepareDemo]);
 
     return (
@@ -388,6 +389,7 @@ const BaseForm = ({cancel, visible, loading}) => {
                         />
                     )}
                     name='task_name'
+                    validateFirst
                     validateTrigger={['onBlur', 'onChange']}
                     rules={[
                         rules.required(),
@@ -497,6 +499,13 @@ const BaseForm = ({cancel, visible, loading}) => {
                             >
                                 {t('graph.menu.load_loader_sample')}
                             </Button>
+                            <Button
+                                loading={demoLoading === 'rank'}
+                                disabled={!selectedGraph || 
Boolean(demoLoading)}
+                                onClick={loadRankDemo}
+                            >
+                                {t('graph.menu.load_rank_sample')}
+                            </Button>
                         </Space>
                     )}
                 />
diff --git 
a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
 
b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
new file mode 100644
index 000000000..c5d0d0e63
--- /dev/null
+++ 
b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/BaseForm/name-validation.test.js
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with this
+ * work for additional information regarding copyright ownership. The ASF
+ * 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 {Form} from 'antd';
+import {fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {MemoryRouter} from 'react-router-dom';
+import BaseForm from './index';
+import * as api from '../../../api';
+import {isPdEnabled} from '../../../utils/config';
+
+jest.mock('../../../api', () => ({
+    manage: {
+        getDatasourceList: jest.fn(),
+        getGraphList: jest.fn(),
+        getTaskList: jest.fn(),
+    },
+}));
+jest.mock('../../../utils/config', () => ({isPdEnabled: jest.fn()}));
+jest.mock('../../../i18n', () => ({
+    t: key => key,
+}));
+jest.mock('react-i18next', () => ({
+    useTranslation: () => ({t: key => key}),
+}));
+
+beforeAll(() => {
+    window.matchMedia = window.matchMedia || (() => ({
+        matches: false,
+        addListener: jest.fn(),
+        removeListener: jest.fn(),
+    }));
+});
+
+beforeEach(() => {
+    jest.clearAllMocks();
+    isPdEnabled.mockReturnValue(false);
+    api.manage.getDatasourceList.mockResolvedValue({
+        status: 200,
+        data: {records: [{datasource_id: '9', datasource_name: 
'fixture.csv'}]},
+    });
+    api.manage.getGraphList.mockResolvedValue({
+        status: 200,
+        data: {records: [{
+            name: 'hugegraph',
+            schemaview: {vertices: [{name: 'person'}], edges: []},
+        }]},
+    });
+    api.manage.getTaskList.mockResolvedValue({status: 200, data: {total: 0}});
+});
+
+const renderForm = onFormFinish => render(
+    <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: 
true}}>
+        <Form.Provider onFormFinish={onFormFinish}>
+            <BaseForm visible cancel={jest.fn()} loading={false} />
+        </Form.Provider>
+    </MemoryRouter>
+);
+
+it('shows only the required error and skips duplicate lookup for an empty 
name', async () => {
+    renderForm(jest.fn());
+
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+
+    const nameItem = screen.getByPlaceholderText('task.edit.name_placeholder')
+        .closest('.ant-form-item');
+    await waitFor(() => 
expect(nameItem).toHaveTextContent('common.validation.required'));
+    expect(nameItem).not.toHaveTextContent('task.edit.name_rule');
+    expect(nameItem).not.toHaveTextContent('task.edit.duplicate_name');
+    expect(api.manage.getTaskList).not.toHaveBeenCalled();
+});
+
+it('keeps the duplicate-name error for an existing non-empty task name', async 
() => {
+    api.manage.getTaskList.mockResolvedValue({
+        status: 200,
+        data: {records: [{task_name: 'existing_task'}], total: 1},
+    });
+    renderForm(jest.fn());
+
+    const input = screen.getByPlaceholderText('task.edit.name_placeholder');
+    fireEvent.change(input, {target: {value: 'existing_task'}});
+    fireEvent.blur(input);
+
+    expect(await 
screen.findByText('task.edit.duplicate_name')).toBeInTheDocument();
+    expect(api.manage.getTaskList).toHaveBeenCalledWith({
+        query: 'existing_task',
+        page_size: -1,
+    });
+});
+
+it('submits the first step when a legal new task name and required options are 
set', async () => {
+    const onFormFinish = jest.fn();
+    renderForm(onFormFinish);
+
+    await screen.findByText('fixture.csv', {selector: 
'.ant-select-selection-item'});
+    const graphSelect = 
document.querySelector('#base_form_ingestion_option_graph');
+    fireEvent.mouseDown(graphSelect);
+    fireEvent.click(await screen.findByText('hugegraph', {
+        selector: '.ant-select-item-option-content',
+    }));
+    
fireEvent.change(screen.getByPlaceholderText('task.edit.name_placeholder'), {
+        target: {value: 'new_task'},
+    });
+    fireEvent.click(screen.getByRole('button', {name: 'common.action.next'}));
+
+    await waitFor(() => expect(onFormFinish).toHaveBeenCalledWith(
+        'base_form',
+        expect.objectContaining({
+            values: expect.objectContaining({task_name: 'new_task'}),
+        })
+    ));
+});
diff --git a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.js 
b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.js
index 8fdbfeaad..3ad0510cb 100644
--- a/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.js
+++ b/hugegraph-hubble/hubble-fe/src/pages/TaskEdit/FieldForm/index.js
@@ -28,7 +28,7 @@ import {
     Popconfirm,
 } from 'antd';
 import {PlusOutlined, MinusSquareOutlined} from '@ant-design/icons';
-import {useCallback, useEffect, useState} from 'react';
+import {useCallback, useEffect, useRef, useState} from 'react';
 import {useTranslation} from 'react-i18next';
 import * as api from '../../../api';
 import * as rules from '../../../utils/rules';
@@ -97,6 +97,7 @@ const FieldForm = ({visible, prev, datasourceID}) => {
     const [loadError, setLoadError] = useState(false);
     const [retry, setRetry] = useState(0);
     const [fieldForm] = Form.useForm();
+    const selectionError = useRef(null);
 
     const setSourceData = useCallback(data => {
         setData(data);
@@ -181,6 +182,17 @@ const FieldForm = ({visible, prev, datasourceID}) => {
 
     const renderField = useCallback(item => item.key, []);
     const retryFields = useCallback(() => setRetry(value => value + 1), []);
+    const handleFinishFailed = useCallback(({errorFields}) => {
+        if (errorFields.some(({name}) => name[0] === 'target_keys')) {
+            setTransferStatus('error');
+        }
+    }, []);
+
+    useEffect(() => {
+        if (transferStatus === 'error') {
+            selectionError.current?.focus();
+        }
+    }, [transferStatus]);
 
     useEffect(() => {
         if (!datasourceID) {
@@ -191,6 +203,7 @@ const FieldForm = ({visible, prev, datasourceID}) => {
         setSourceData([]);
         setTargetKeys([]);
         fieldForm.setFieldValue('target_keys', []);
+        setTransferStatus('');
         setLoadError(false);
         api.manage.getDatasourceSchema(datasourceID).then(res => {
             if (!active) {
@@ -211,7 +224,11 @@ const FieldForm = ({visible, prev, datasourceID}) => {
 
     return (
         <div style={{display: visible ? '' : 'none'}} 
className={style.transfer}>
-            <Form form={fieldForm} name='field_form'>
+            <Form
+                form={fieldForm}
+                name='field_form'
+                onFinishFailed={handleFinishFailed}
+            >
                 <Typography.Title 
level={5}>{t('task.edit.step_source_fields')}</Typography.Title>
                 <Alert
                     showIcon
@@ -260,6 +277,15 @@ const FieldForm = ({visible, prev, datasourceID}) => {
                         }}
                     </Transfer>
                 </Form.Item>
+                {transferStatus === 'error' && (
+                    <div ref={selectionError} tabIndex={-1}>
+                        <Alert
+                            showIcon
+                            type='error'
+                            message={t('task.edit.select_source_fields')}
+                        />
+                    </div>
+                )}
                 <Form.Item
                     name='target_keys'
                     
rules={[rules.required(t('task.edit.select_source_fields'))]}

Reply via email to