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 a4927da34379a2f9165cc6d489e1cea5b11e3bff Author: dark <[email protected]> AuthorDate: Fri Aug 14 12:39:02 2026 +0800 test(hubble): cover graph creation and number cards --- .../src/modules/component/NumberCard/index.test.js | 168 ++++++++++++++++ hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js | 1 + .../hubble-fe/src/pages/Graph/Card.test.js | 67 ++++++- .../hubble-fe/src/pages/Graph/EditLayer.js | 218 +++++++++++++++------ 4 files changed, 389 insertions(+), 65 deletions(-) diff --git a/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js new file mode 100644 index 000000000..068fe5393 --- /dev/null +++ b/hugegraph-hubble/hubble-fe/src/modules/component/NumberCard/index.test.js @@ -0,0 +1,168 @@ +/* + * 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 {render, screen} from '@testing-library/react'; +import NumberCard from './index'; +import enAnalysis from '../../../i18n/resources/en-US/modules/analysis.json'; +import zhAnalysis from '../../../i18n/resources/zh-CN/modules/analysis.json'; + +let mockLanguage = 'en'; +const mockResources = { + en: enAnalysis.analysis.canvas.number_card, + zh: zhAnalysis.analysis.canvas.number_card, +}; + +const mockInterpolate = (value, values = {}) => Object.entries(values).reduce( + (text, [key, replacement]) => text.replace(`{{${key}}}`, replacement), + value +); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, values) => { + const numberCard = mockResources[mockLanguage]; + const value = numberCard[key.split('.').pop()] ?? key; + return mockInterpolate(value, values); + }, + }), +})); + +const counts = { + currentGraphNodesNum: 1, + currentGraphEdgesNum: 2, + allGraphNodesNum: 3, + allGraphEdgesNum: 8, +}; + +beforeEach(() => { + mockLanguage = 'en'; +}); + +test('explains current-result and full-graph counts in natural English', () => { + render(<NumberCard pathNum={4} data={counts} />); + + expect(screen.getByText('Paths')).toBeInTheDocument(); + expect(screen.getByText('Nodes')).toBeInTheDocument(); + expect(screen.getByText('Edges')).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: 'Nodes: 1 in this result, 3 in the full graph', + })).toHaveAttribute('title', 'Nodes: 1 in this result, 3 in the full graph'); + expect(screen.getByRole('group', { + name: 'Edges: 2 in this result, 8 in the full graph', + })).toBeInTheDocument(); +}); + +test('localizes labels and count semantics in Chinese', () => { + mockLanguage = 'zh'; + render(<NumberCard data={counts} />); + + expect(screen.getByText('节点')).toBeInTheDocument(); + expect(screen.getByText('边')).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: '节点:当前结果 1,全图 3', + })).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: '边:当前结果 2,全图 8', + })).toBeInTheDocument(); +}); + +test('keeps current-result counts while full-graph totals are loading', () => { + render( + <NumberCard + data={{ + currentGraphNodesNum: 1, + currentGraphEdgesNum: 2, + allGraphNodesNum: -1, + allGraphEdgesNum: -1, + }} + /> + ); + + expect(screen.getAllByText('Loading')).toHaveLength(2); + expect(screen.getByText('1')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.queryByText('-1')).not.toBeInTheDocument(); + expect(screen.getByRole('group', { + name: 'Nodes: 1 in this result, Loading in the full graph', + })).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: 'Edges: 2 in this result, Loading in the full graph', + })).toBeInTheDocument(); +}); + +test('describes each loading sentinel independently in Chinese', () => { + mockLanguage = 'zh'; + render( + <NumberCard + data={{ + currentGraphNodesNum: -1, + currentGraphEdgesNum: 2, + allGraphNodesNum: 3, + allGraphEdgesNum: -1, + }} + /> + ); + + expect(screen.getByRole('group', { + name: '节点:当前结果 加载中,全图 3', + })).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: '边:当前结果 2,全图 加载中', + })).toBeInTheDocument(); +}); + +test('marks missing and invalid counts unavailable instead of rendering blanks', () => { + render( + <NumberCard + data={{ + currentGraphNodesNum: undefined, + currentGraphEdgesNum: 'invalid', + allGraphNodesNum: null, + allGraphEdgesNum: '', + }} + /> + ); + + expect(screen.getAllByText('Unavailable')).toHaveLength(4); + expect(screen.getByRole('group', { + name: 'Nodes: Unavailable in this result, Unavailable in the full graph', + })).toBeInTheDocument(); + expect(screen.getByRole('group', { + name: 'Edges: Unavailable in this result, Unavailable in the full graph', + })).toBeInTheDocument(); +}); + +test('ships symmetric Chinese and English NumberCard copy', () => { + expect(zhAnalysis.analysis.canvas.number_card).toEqual({ + paths: '路径', + nodes: '节点', + edges: '边', + loading: '加载中', + unavailable: '不可用', + node_summary: '节点:当前结果 {{current}},全图 {{total}}', + edge_summary: '边:当前结果 {{current}},全图 {{total}}', + }); + expect(enAnalysis.analysis.canvas.number_card).toEqual({ + paths: 'Paths', + nodes: 'Nodes', + edges: 'Edges', + loading: 'Loading', + unavailable: 'Unavailable', + node_summary: 'Nodes: {{current}} in this result, {{total}} in the full graph', + edge_summary: 'Edges: {{current}} in this result, {{total}} in the full graph', + }); +}); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js index 2a2817b53..07bfe2e18 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.js @@ -171,6 +171,7 @@ const GraphCard = ({item, menus}) => { onKeyDown={handleSchemaKeyDown} role='button' tabIndex={0} + aria-label={t('graph.card.view_schema', {graph: graphName})} > <GraphView data={graphinData} diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js index caab2afc0..02b095313 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/Card.test.js @@ -16,16 +16,22 @@ * limitations under the License. */ +import fs from 'fs'; +import path from 'path'; import {fireEvent, render, screen} from '@testing-library/react'; import {MemoryRouter, useLocation} from 'react-router-dom'; import GraphCard from './Card'; import {formatToGraphInData} from '../../utils/formatGraphInData'; -const mockT = (key, values) => ( - key === 'graph.card.element_counts' - ? `${key}:${values.vertices}/${values.edges}` - : key -); +const mockT = (key, values) => { + if (key === 'graph.card.element_counts') { + return `${key}:${values.vertices}/${values.edges}`; + } + if (key === 'graph.card.view_schema') { + return `${key}:${values.graph}`; + } + return key; +}; jest.mock('react-i18next', () => ({ useTranslation: () => ({t: mockT}), @@ -130,6 +136,8 @@ test('ships the requested graph creation and schema labels', () => { expect(en.graph.form.schema).toBe('Graph Schema'); expect(zh.graph.form.name_help).toContain(' / '); expect(en.graph.form.name_help).toContain(' / '); + expect(zh.graph.card.view_schema).toBe('查看 {{graph}} 的 Schema'); + expect(en.graph.card.view_schema).toBe('View Schema for {{graph}}'); }); test('opens Schema from the graph preview and keeps Gremlin as the footer action', () => { @@ -162,12 +170,59 @@ test('opens Schema from the graph preview and keeps Gremlin as the footer action expect(screen.getByRole('link', {name: 'graph.card.query_graph'})) .toHaveAttribute('href', '/gremlin/DEFAULT/hugegraph'); - fireEvent.click(screen.getByText('graph preview').closest('[role="button"]')); + const schemaPreview = screen.getByRole('button', { + name: 'graph.card.view_schema:HugeGraph', + }); + expect(schemaPreview).toHaveAttribute('tabindex', '0'); + fireEvent.click(schemaPreview); expect(screen.getByTestId('location')).toHaveTextContent( '/graphspace/DEFAULT/graph/hugegraph/meta' ); }); +test.each(['Enter', ' '])( + 'opens Schema from the named graph preview with the %p key', + key => { + formatToGraphInData.mockReturnValue({nodes: [{id: 'person'}], edges: []}); + render( + <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: true}}> + <GraphCard + item={{ + name: 'hugegraph', + nickname: 'HugeGraph', + graphspace: 'DEFAULT', + graphspace_nickname: 'Default', + storage: 1024, + create_time: '2026-07-12', + schemaview: {vertices: [{name: 'person'}], edges: []}, + }} + menus={[]} + /> + <LocationProbe /> + </MemoryRouter> + ); + + const schemaPreview = screen.getByRole('button', { + name: 'graph.card.view_schema:HugeGraph', + }); + fireEvent.keyDown(schemaPreview, {key}); + + expect(screen.getByTestId('location')).toHaveTextContent( + '/graphspace/DEFAULT/graph/hugegraph/meta' + ); + } +); + +test('gives the keyboard-focusable schema preview a visible design-system focus ring', () => { + const stylesheet = fs.readFileSync(path.join(__dirname, 'index.module.scss'), 'utf8'); + const previewRule = stylesheet.match( + /\.card_content\s*\{[\s\S]*?&:focus-visible\s*\{([^}]*)\}/ + )?.[1] || ''; + + expect(previewRule).toContain('outline: 2px solid #1890ff'); + expect(previewRule).toContain('outline-offset: -2px'); +}); + test('does not invent point and edge counts when the list API omits them', () => { render( <MemoryRouter future={{v7_startTransition: true, v7_relativeSplatPath: true}}> diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.js index c6632389c..13eba60fa 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.js @@ -16,7 +16,7 @@ * under the License. */ -import {Alert, Button, Modal, Form, Input, Select, Spin, message} from 'antd'; +import {Alert, Button, Modal, Form, Input, Radio, Select, Spin, message} from 'antd'; import {useState, useEffect, useCallback, useMemo, useRef} from 'react'; import {useTranslation} from 'react-i18next'; import * as api from '../../api/index'; @@ -46,7 +46,26 @@ const isDuplicateSchemaError = error => ( (error?.message || error?.response?.data?.message) === DUPLICATE_SCHEMA_TEMPLATE ); -const EditLayer = ({visible, onCancel, refresh, graphspace, graph}) => { +export const validateGraphFields = async form => { + try { + return await form.validateFields(); + } + catch (error) { + if (Array.isArray(error?.errorFields)) { + return null; + } + throw error; + } +}; + +const EditLayer = ({ + visible, + onCancel, + refresh, + graphspace, + graph, + validateForm = validateGraphFields, +}) => { const [schemaList, setSchemaList] = useState([]); const [schemaLoading, setSchemaLoading] = useState(false); const [schemaError, setSchemaError] = useState(false); @@ -134,83 +153,148 @@ const EditLayer = ({visible, onCancel, refresh, graphspace, graph}) => { }).catch(() => setSchemaError(true)) .finally(() => setSchemaLoading(false)); }, [graphspace, pdMode]); + const chooseSchema = useCallback(() => { + form.setFieldValue('sample', undefined); + }, [form]); + const chooseSample = useCallback(() => { + form.setFieldValue('schema', undefined); + }, [form]); + + const onFinish = useCallback(async () => { + let values; + try { + values = await validateForm(form); + } + catch { + message.error(t('common.msg.operation_failed')); + return; + } + if (!values) { + return; + } + setLoading(true); - const onFinish = useCallback(() => { - form.validateFields().then(values => { - setLoading(true); + if (graph) { + api.manage.updateGraph( + graphspace, + graph, + {nickname: values.nickname}, + PAGE_ERROR_CONFIG + ).then(res => { + setLoading(false); + if (res.status === 200) { + message.success(t('graph.form.update_success')); + onCancel(); + refresh(); + return; + } + message.error(res.message); + }).catch(() => { + setLoading(false); + message.error(t('common.msg.operation_failed')); + }); + return; + } - if (graph) { - api.manage.updateGraph(graphspace, graph, {nickname: values.nickname}).then(res => { - setLoading(false); - if (res.status === 200) { - message.success(t('graph.form.update_success')); - onCancel(); - refresh(); - return; - } - message.error(res.message); - }); - return; + const sample = values.sample === 'none' ? undefined : values.sample; + const builtinSchema = BUILTIN_SCHEMA_TEMPLATES[values.schema]; + const ensureTemplate = pdMode && builtinSchema + ? ensureBuiltinTemplate(values.schema, builtinSchema) + : Promise.resolve(); + + ensureTemplate.then(async () => { + const graphRequest = {...values, auth: false, graphspace}; + delete graphRequest.sample; + if (!pdMode) { + delete graphRequest.schema; } - - const builtinSchema = BUILTIN_SCHEMA_TEMPLATES[values.schema]; - const ensureTemplate = pdMode && builtinSchema - ? ensureBuiltinTemplate(values.schema, builtinSchema) - : Promise.resolve(); - - ensureTemplate.then(async () => { - const graphRequest = {...values, auth: false, graphspace}; - if (!pdMode) { - delete graphRequest.schema; - } - const result = await api.manage.addGraph(graphspace, graphRequest); - if (result.status !== 200 || pdMode || !builtinSchema) { - return result; - } + const result = await api.manage.addGraph(graphspace, graphRequest); + if (result.status === 200 && sample) { try { - const schemaResult = await api.manage.addGraphSchema( + const sampleResult = await api.manage.loadSampleGraph( graphspace, values.graph, - {'schema-groovy': toGraphSchemaGroovy(builtinSchema)}, + sample, PAGE_ERROR_CONFIG ); - if (schemaResult.status !== 200) { - throw new Error(schemaResult.message - || t('graph.form.schema_apply_failed')); + if (sampleResult.status !== 200) { + throw new Error(sampleResult.message + || t('graph.form.sample_apply_failed')); } + result.sampleResult = sampleResult; } catch (error) { - const messageText = errorMessage(error) - || t('graph.form.schema_apply_failed'); const partialError = error instanceof Error ? error - : new Error(messageText); - if (!partialError.message) { - partialError.message = messageText; - } + : new Error(errorMessage(error) || t('graph.form.sample_apply_failed')); partialError.graphCreated = true; throw partialError; } + } + if (result.status !== 200 || pdMode || !builtinSchema) { return result; - }).then(res => { - setLoading(false); - if (res.status === 200) { - message.success(t('graph.form.create_success')); - onCancel(); - refresh(); - return; + } + try { + const schemaResult = await api.manage.addGraphSchema( + graphspace, + values.graph, + {'schema-groovy': toGraphSchemaGroovy(builtinSchema)}, + PAGE_ERROR_CONFIG + ); + if (schemaResult.status !== 200) { + throw new Error(schemaResult.message + || t('graph.form.schema_apply_failed')); } - message.error(res.message); - }).catch(error => { - setLoading(false); - if (error.graphCreated) { - onCancel(); - refresh(); + } + catch (error) { + const messageText = errorMessage(error) + || t('graph.form.schema_apply_failed'); + const partialError = error instanceof Error + ? error + : new Error(messageText); + if (!partialError.message) { + partialError.message = messageText; } - message.error(error.message || t('common.msg.operation_failed')); - }); + partialError.graphCreated = true; + throw partialError; + } + return result; + }).then(res => { + setLoading(false); + if (res.status === 200) { + if (res.sampleResult?.data) { + message.success(t('graph.sample.success', { + vertices: res.sampleResult.data.vertices, + edges: res.sampleResult.data.edges, + })); + } + else { + message.success(t('graph.form.create_success')); + } + onCancel(); + refresh(); + return; + } + message.error(res.message); + }).catch(error => { + setLoading(false); + if (error.graphCreated) { + onCancel(); + refresh(); + } + message.error(error.message || t('common.msg.operation_failed')); }); - }, [form, graphspace, graph, refresh, onCancel, ensureBuiltinTemplate, pdMode, t]); + }, [ + ensureBuiltinTemplate, + form, + graph, + graphspace, + onCancel, + pdMode, + refresh, + t, + validateForm, + ]); useEffect(() => { if (!visible) { @@ -297,10 +381,26 @@ const EditLayer = ({visible, onCancel, refresh, graphspace, graph}) => { <Select loading={schemaLoading} disabled={schemaLoading || schemaError} + allowClear placeholder={t('graph.form.schema_placeholder')} options={schemaOptions} + onChange={chooseSchema} /> </Form.Item> + <Form.Item + label={t('graph.form.sample')} + name='sample' + extra={t('graph.form.sample_hint')} + > + <Radio.Group + onChange={chooseSample} + > + <Radio value='hlm'>{t('graph.form.sample_hlm')}</Radio> + <Radio value='rank'>{t('graph.form.sample_rank')}</Radio> + <Radio value='loader'>{t('graph.form.sample_loader')}</Radio> + <Radio value='none'>{t('graph.form.sample_none')}</Radio> + </Radio.Group> + </Form.Item> </> )} </Form>
