This is an automated email from the ASF dual-hosted git repository. imbajin pushed a commit to branch hubble-dev in repository https://gitbox.apache.org/repos/asf/hugegraph-toolchain.git
commit f35ea2a3bc617f63b5eb586e9a9588cc49f17c1e Author: imbajin <[email protected]> AuthorDate: Sun Jul 12 14:44:08 2026 +0800 fix(hubble): harden frontend validation --- .../analysis/QueryBar/ContentCommon/index.js | 5 +- .../analysis/QueryBar/ContentCommon/index.test.js | 62 ++++++++++++++++++++++ .../modules/navigation/ConsoleItem/index.test.js | 12 +++++ .../hubble-fe/src/modules/navigation/Item/index.js | 10 ++++ .../src/modules/navigation/Item/index.module.scss | 7 +++ hugegraph-hubble/hubble-fe/src/utils/rules.js | 19 ++++++- hugegraph-hubble/hubble-fe/src/utils/rules.test.js | 12 +++++ 7 files changed, 124 insertions(+), 3 deletions(-) diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.js index 46195c1c..a06e2d03 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.js +++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.js @@ -27,6 +27,7 @@ import {UpOutlined, DownOutlined, QuestionCircleOutlined} from '@ant-design/icon import {GREMLIN_EXECUTES_MODE} from '../../../../utils/constants'; import GraphAnalysisContext from '../../../Context'; import classnames from 'classnames'; +import {isValidFavoriteName} from '../../../../utils/rules'; import c from './index.module.scss'; import * as api from '../../../../api/index'; import KeyboardAction from '../../../../components/KeyboardAction'; @@ -139,7 +140,7 @@ const ContentCommon = props => { e => { const favoriteName = e.target.value; setFavoriteName(favoriteName); - favoriteName ? setDisabledFavorite(false) : setDisabledFavorite(true); + setDisabledFavorite(!isValidFavoriteName(favoriteName)); }, [] ); @@ -152,6 +153,8 @@ const ContentCommon = props => { maxLength={48} value={favoriteName} onChange={onChangeFavoraiteName} + status={favoriteName && !isValidFavoriteName(favoriteName) + ? 'error' : undefined} /> <Space style={{marginTop: '24px'}}> <Button type='primary' onClick={onOkFavorite} disabled={disabledFavorite}> diff --git a/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.test.js new file mode 100644 index 00000000..5a72884c --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/modules/analysis/QueryBar/ContentCommon/index.test.js @@ -0,0 +1,62 @@ +/* + * 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. + */ + +import {fireEvent, render, screen} from '@testing-library/react'; + +import * as api from '../../../../api/index'; +import GraphAnalysisContext from '../../../Context'; +import ContentCommon from './index'; + +jest.mock('../../../../api/index', () => ({ + analysis: {addFavoriate: jest.fn().mockResolvedValue({status: 200})}, +})); +jest.mock('antd', () => ({ + ...jest.requireActual('antd'), + message: {success: jest.fn(), error: jest.fn()}, +})); +jest.mock('react-i18next', () => ({ + initReactI18next: {type: '3rdParty', init: jest.fn()}, + useTranslation: () => ({t: key => key}), +})); + +const renderContent = () => render( + <GraphAnalysisContext.Provider value={{graphSpace: 'DEFAULT', graph: 'hugegraph'}}> + <ContentCommon + codeEditorContent='g.V()' + setCodeEditorContent={jest.fn()} + executeMode='QUERY' + onExecuteModeChange={jest.fn()} + activeTab='Gremlin' + onExecute={jest.fn()} + onRefresh={jest.fn()} + isEmptyQuery={false} + favoriteCardVisible + setFavoriteCardVisible={jest.fn()} + /> + </GraphAnalysisContext.Provider> +); + +beforeEach(() => { + api.analysis.addFavoriate.mockResolvedValue({status: 200}); +}); + +test('keeps favorite submission disabled until the name is backend-compatible', () => { + renderContent(); + const input = screen.getByPlaceholderText('analysis.query.favorite_name_placeholder'); + const submit = screen.getAllByRole('button', {name: 'analysis.query.favorite'}) + .find(button => button.closest('.ant-popover')); + + fireEvent.change(input, {target: {value: 'query-name'}}); + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(api.analysis.addFavoriate).not.toHaveBeenCalled(); + + fireEvent.change(input, {target: {value: 'query_name'}}); + expect(submit).toBeEnabled(); + fireEvent.click(submit); + expect(api.analysis.addFavoriate).toHaveBeenCalledTimes(1); +}); diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js index 19be67bb..420f240c 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/ConsoleItem/index.test.js @@ -102,3 +102,15 @@ test('reports a blocked popup without probing the Dashboard', async () => { 'navigation_page.dashboard_popup_blocked' ); }); + +test('shows why operations are disabled when Dashboard is unavailable', async () => { + api.auth.getDashboard.mockResolvedValue({status: 500}); + render( + <MemoryRouter future={{v7_relativeSplatPath: true, v7_startTransition: true}}> + <ConsoleItem /> + </MemoryRouter> + ); + + expect(await screen.findByText('navigation_page.dashboard_unavailable')) + .toBeInTheDocument(); +}); diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.js b/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.js index e0d8def7..61501b8f 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.js +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.js @@ -77,6 +77,16 @@ const Item = props => { ); res.push(content); } + const reasons = [...new Set(listData + .filter(item => item.disabled && item.reason) + .map(item => item.reason))]; + if (reasons.length > 0) { + res.push( + <div className={style.reason} role='status' key='disabled-reason'> + {reasons.join('; ')} + </div> + ); + } return res; }; return ( diff --git a/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.module.scss b/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.module.scss index 7925371f..a4004cad 100644 --- a/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.module.scss +++ b/hugegraph-hubble/hubble-fe/src/modules/navigation/Item/index.module.scss @@ -31,3 +31,10 @@ width: 160px; margin-bottom: 10px; } + +.reason { + width: 160px; + color: #8c8c8c; + font-size: 12px; + line-height: 18px; +} diff --git a/hugegraph-hubble/hubble-fe/src/utils/rules.js b/hugegraph-hubble/hubble-fe/src/utils/rules.js index 5752a197..cacf1cf1 100644 --- a/hugegraph-hubble/hubble-fe/src/utils/rules.js +++ b/hugegraph-hubble/hubble-fe/src/utils/rules.js @@ -149,15 +149,28 @@ const isAccountName = msg => ({ let res2 = /.*_$/.test(value); if (!res || res1 || res2) { - return Promise.reject( + return Promise.reject(new Error( typeof msg === 'string' ? msg : validationMessage('account_name_rule') - ); + )); } return Promise.resolve(); }, }); +const isValidFavoriteName = value => /^[A-Za-z0-9_]{1,48}$/.test(value); + +const isFavoriteName = msg => ({ + validator(_, value) { + if (isValidFavoriteName(value)) { + return Promise.resolve(); + } + return Promise.reject(new Error( + typeof msg === 'string' ? msg : 'Use letters, numbers, or underscores only' + )); + }, +}); + // UUID validation const isUUID = () => ({ validator(_, value) { @@ -192,6 +205,8 @@ export { isNoramlName, isJDBC, isAccountName, + isFavoriteName, + isValidFavoriteName, isUUID, isInt, }; diff --git a/hugegraph-hubble/hubble-fe/src/utils/rules.test.js b/hugegraph-hubble/hubble-fe/src/utils/rules.test.js index fca659fd..4aaeafe5 100644 --- a/hugegraph-hubble/hubble-fe/src/utils/rules.test.js +++ b/hugegraph-hubble/hubble-fe/src/utils/rules.test.js @@ -140,6 +140,18 @@ describe('rules i18n defaults', () => { expect(await validate(rules.isAccountName('custom account'))).toBe('custom account'); }); + it('rejects invalid account names with an Error object', async () => { + await expect(rules.isAccountName().validator(null, 'name_too_long_123')) + .rejects.toBeInstanceOf(Error); + }); + + it('accepts only backend-compatible favorite names', async () => { + await expect(rules.isFavoriteName().validator(null, 'query_2026')) + .resolves.toBeUndefined(); + await expect(rules.isFavoriteName().validator(null, 'query-2026')) + .rejects.toBeInstanceOf(Error); + }); + it('uses Chinese messages when the active language is Chinese', async () => { await i18n.changeLanguage('zh-CN');
