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>

Reply via email to