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'))]}
