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 e65f2191ba2a8a561c278ef1dda4a5ed43a68a68 Author: dark <[email protected]> AuthorDate: Fri Aug 14 12:39:02 2026 +0800 fix(hubble): restore graph sample workflows --- .../hubble-fe/src/pages/Graph/EditLayer.test.js | 288 ++++++++++++++++++++- .../src/pages/Graph/default-card-actions.test.js | 61 ++++- .../hubble-fe/src/pages/Graph/index.js | 164 ++++++++---- 3 files changed, 459 insertions(+), 54 deletions(-) diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.test.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.test.js index 764b6a348..320332f2b 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/EditLayer.test.js @@ -18,7 +18,8 @@ import {fireEvent, render, screen, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {EditLayer, ViewLayer} from './EditLayer'; +import {message} from 'antd'; +import {EditLayer, validateGraphFields, ViewLayer} from './EditLayer'; import * as api from '../../api'; import {BUILTIN_SCHEMA_TEMPLATES} from '../Schema/builtinSchemaTemplates'; @@ -36,7 +37,9 @@ jest.mock('../../api', () => ({ addSchema: jest.fn(), addGraphSchema: jest.fn(), addGraph: jest.fn(), + loadSampleGraph: jest.fn(), getGraph: jest.fn(), + updateGraph: jest.fn(), }, })); @@ -143,11 +146,220 @@ test('offers built-in Graph Schema choices and a direct create-template route', expect(await screen.findByRole('link', {name: 'graph.form.schema_create'})) .toHaveAttribute('href', '/graphspace/DEFAULT/schema?create=true'); await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); + await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); await userEvent.click(screen.getByRole('combobox')); expect(screen.getByText('schema_template.builtin.people_network')).toBeInTheDocument(); expect(screen.getByText('schema_template.builtin.product_catalog')).toBeInTheDocument(); }); +test('keeps empty create errors in the form without an unhandled rejection or API call', async () => { + api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); + const unhandledRejection = jest.fn(event => event.preventDefault()); + window.addEventListener('unhandledrejection', unhandledRejection); + render( + <EditLayer + visible + onCancel={jest.fn()} + refresh={jest.fn()} + graphspace='DEFAULT' + /> + ); + + await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); + await userEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(screen.getAllByRole('alert').length).toBeGreaterThan(0)); + expect(api.manage.addGraph).not.toHaveBeenCalled(); + expect(api.manage.updateGraph).not.toHaveBeenCalled(); + expect(unhandledRejection).not.toHaveBeenCalled(); + window.removeEventListener('unhandledrejection', unhandledRejection); +}); + +test('reports unexpected validation rejection at the Modal event boundary', async () => { + api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); + const validateForm = jest.fn().mockRejectedValue(new Error('validation failed')); + const unhandledRejection = jest.fn(event => event.preventDefault()); + const messageError = jest.spyOn(message, 'error').mockImplementation(() => undefined); + window.addEventListener('unhandledrejection', unhandledRejection); + render( + <EditLayer + visible + onCancel={jest.fn()} + refresh={jest.fn()} + graphspace='DEFAULT' + validateForm={validateForm} + /> + ); + + await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); + await userEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(messageError).toHaveBeenCalledWith( + 'common.msg.operation_failed' + )); + expect(api.manage.addGraph).not.toHaveBeenCalled(); + expect(api.manage.updateGraph).not.toHaveBeenCalled(); + expect(unhandledRejection).not.toHaveBeenCalled(); + window.removeEventListener('unhandledrejection', unhandledRejection); + messageError.mockRestore(); +}); + +test('recognizes only Ant Design field-validation rejection as a form error', async () => { + const form = { + validateFields: jest.fn().mockRejectedValue({ + errorFields: [{name: ['graph'], errors: ['Required']}], + }), + }; + + await expect(validateGraphFields(form)).resolves.toBeNull(); +}); + +test('submits a valid create after field validation passes', async () => { + api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); + api.manage.addGraph.mockResolvedValue({status: 200}); + render( + <EditLayer + visible + onCancel={jest.fn()} + refresh={jest.fn()} + graphspace='DEFAULT' + /> + ); + + await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); + await userEvent.type( + screen.getByPlaceholderText('graph.form.name_placeholder'), + 'valid_graph' + ); + await userEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(api.manage.addGraph).toHaveBeenCalledWith( + 'DEFAULT', + expect.objectContaining({ + graph: 'valid_graph', + auth: false, + graphspace: 'DEFAULT', + }) + )); +}); + +test('submits a valid alias update after field validation passes', async () => { + api.manage.getGraph.mockResolvedValue({ + status: 200, + data: {name: 'movie_graph', nickname: 'Movies'}, + }); + api.manage.updateGraph.mockResolvedValue({status: 200}); + const onCancel = jest.fn(); + const refresh = jest.fn(); + render( + <EditLayer + visible + onCancel={onCancel} + refresh={refresh} + graphspace='DEFAULT' + graph='movie_graph' + /> + ); + + const alias = screen.getByPlaceholderText('graph.form.nickname_placeholder'); + await waitFor(() => expect(alias).toHaveValue('Movies')); + await userEvent.clear(alias); + await userEvent.type(alias, 'Movie_Catalog'); + await userEvent.click(screen.getByRole('button', {name: 'OK'})); + + await waitFor(() => expect(api.manage.updateGraph).toHaveBeenCalledWith( + 'DEFAULT', + 'movie_graph', + {nickname: 'Movie_Catalog'}, + {suppressBusinessErrorToast: true} + )); + await waitFor(() => expect(onCancel).toHaveBeenCalled()); + expect(refresh).toHaveBeenCalled(); +}); + +test('recovers from an update request rejection and allows retry', async () => { + api.manage.getGraph.mockResolvedValue({ + status: 200, + data: {name: 'movie_graph', nickname: 'Movies'}, + }); + api.manage.updateGraph + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce({status: 200}); + const onCancel = jest.fn(); + const refresh = jest.fn(); + const unhandledRejection = jest.fn(event => event.preventDefault()); + const messageError = jest.spyOn(message, 'error').mockImplementation(() => undefined); + window.addEventListener('unhandledrejection', unhandledRejection); + render( + <EditLayer + visible + onCancel={onCancel} + refresh={refresh} + graphspace='DEFAULT' + graph='movie_graph' + /> + ); + + const alias = screen.getByPlaceholderText('graph.form.nickname_placeholder'); + await waitFor(() => expect(alias).toHaveValue('Movies')); + await userEvent.clear(alias); + await userEvent.type(alias, 'Movie_Catalog'); + const confirm = screen.getByRole('button', {name: 'OK'}); + await userEvent.click(confirm); + + await waitFor(() => expect(messageError).toHaveBeenCalledWith( + 'common.msg.operation_failed' + )); + expect(confirm).not.toHaveClass('ant-btn-loading'); + expect(onCancel).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + expect(unhandledRejection).not.toHaveBeenCalled(); + + await userEvent.click(confirm); + + await waitFor(() => expect(api.manage.updateGraph).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(onCancel).toHaveBeenCalled()); + expect(refresh).toHaveBeenCalled(); + window.removeEventListener('unhandledrejection', unhandledRejection); + messageError.mockRestore(); +}); + +test('keeps the resolved update business-error message and resets loading', async () => { + api.manage.getGraph.mockResolvedValue({ + status: 200, + data: {name: 'movie_graph', nickname: 'Movies'}, + }); + api.manage.updateGraph.mockResolvedValue({ + status: 409, + message: 'nickname conflict', + }); + const onCancel = jest.fn(); + const refresh = jest.fn(); + const messageError = jest.spyOn(message, 'error').mockImplementation(() => undefined); + render( + <EditLayer + visible + onCancel={onCancel} + refresh={refresh} + graphspace='DEFAULT' + graph='movie_graph' + /> + ); + + const alias = screen.getByPlaceholderText('graph.form.nickname_placeholder'); + await waitFor(() => expect(alias).toHaveValue('Movies')); + const confirm = screen.getByRole('button', {name: 'OK'}); + await userEvent.click(confirm); + + await waitFor(() => expect(messageError).toHaveBeenCalledWith( + 'nickname conflict' + )); + expect(confirm).not.toHaveClass('ant-btn-loading'); + expect(onCancel).not.toHaveBeenCalled(); + expect(refresh).not.toHaveBeenCalled(); + messageError.mockRestore(); +}); + test('persists a selected built-in template before creating the graph', async () => { api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); api.manage.addSchema.mockResolvedValue({status: 200}); @@ -419,3 +631,77 @@ test('does not leak an old graph export failure into the next graph', async () = expect(screen.getByRole('button', {name: 'graph.schema_view.export'})) .not.toHaveClass('ant-btn-loading'); }); + +test('creates the graph before importing a selected example dataset', async () => { + api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); + api.manage.addGraph.mockResolvedValue({status: 200, data: {name: 'demo_hlm'}}); + api.manage.loadSampleGraph.mockResolvedValue({ + status: 200, data: {vertices: 14, edges: 15}, + }); + const onCancel = jest.fn(); + + render( + <EditLayer + visible + onCancel={onCancel} + refresh={jest.fn()} + graphspace='DEFAULT' + /> + ); + + await userEvent.type( + screen.getByPlaceholderText('graph.form.name_placeholder'), + 'demo_hlm' + ); + await waitFor(() => expect(screen.getByRole('combobox')).toBeEnabled()); + await userEvent.click(screen.getByRole('combobox')); + await userEvent.click(screen.getByText('schema_template.builtin.people_network')); + await userEvent.click(screen.getByRole('radio', { + name: 'graph.form.sample_hlm', + })); + fireEvent.click(document.querySelector('.ant-modal-footer .ant-btn-primary')); + + await waitFor(() => expect(api.manage.addGraph).toHaveBeenCalled()); + expect(api.manage.addGraph).toHaveBeenCalledWith( + 'DEFAULT', + expect.not.objectContaining({schema: 'people_network'}) + ); + expect(api.manage.loadSampleGraph).toHaveBeenCalledWith( + 'DEFAULT', + 'demo_hlm', + 'hlm', + {suppressBusinessErrorToast: true} + ); + expect(onCancel).toHaveBeenCalled(); +}); + +test('lets users clear the optional example dataset choice', async () => { + api.manage.getSchemaList.mockResolvedValue({status: 200, data: {records: []}}); + api.manage.addGraph.mockResolvedValue({status: 200, data: {name: 'empty_graph'}}); + const onCancel = jest.fn(); + + render( + <EditLayer + visible + onCancel={onCancel} + refresh={jest.fn()} + graphspace='DEFAULT' + /> + ); + + await userEvent.type( + screen.getByPlaceholderText('graph.form.name_placeholder'), + 'empty_graph' + ); + await userEvent.click(screen.getByRole('radio', { + name: 'graph.form.sample_hlm', + })); + await userEvent.click(screen.getByRole('radio', { + name: 'graph.form.sample_none', + })); + fireEvent.click(document.querySelector('.ant-modal-footer .ant-btn-primary')); + + await waitFor(() => expect(api.manage.addGraph).toHaveBeenCalled()); + expect(api.manage.loadSampleGraph).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalled(); +}); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/default-card-actions.test.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/default-card-actions.test.js index 13a4b8ca4..33e1d4783 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/default-card-actions.test.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/default-card-actions.test.js @@ -20,6 +20,12 @@ import {fireEvent, render, screen, waitFor, within} from '@testing-library/react import Graph from './index'; import * as api from '../../api'; +let mockAuthContext; + +jest.mock('../../auth/AuthContext', () => ({ + useAuthContext: () => mockAuthContext, +})); + jest.mock('react-i18next', () => ({ useTranslation: () => ({t: key => key}), })); @@ -91,6 +97,9 @@ beforeEach(() => { jest.clearAllMocks(); installMatchMedia(); sessionStorage.setItem('hubble_config_', JSON.stringify({pd_enabled: true})); + mockAuthContext = { + context: {role: 'SUPERADMIN', scopes: {all_graphspaces: true}}, + }; api.manage.getGraphSpace.mockResolvedValue({ status: 200, data: {name: 'space', nickname: 'Space'}, @@ -170,18 +179,66 @@ test('shows clone as unavailable instead of exposing a failing action', async () expect(clone.closest('a')).toBeNull(); }); -test('keeps exactly the five requested graph card actions', async () => { +test('keeps the graph card actions including example datasets', async () => { render(<Graph />); const menu = await screen.findByTestId('graph-card-menu'); - expect(within(menu).getAllByRole('menuitem')).toHaveLength(5); + expect(within(menu).getAllByRole('menuitem')).toHaveLength(8); expect(within(menu).getByText('graph.menu.clear_graph')).toBeInTheDocument(); expect(within(menu).getByText('graph.menu.set_default')).toBeInTheDocument(); expect(within(menu).getByText('common.action.edit')).toBeInTheDocument(); + expect(within(menu).getByText('graph.menu.load_hlm_sample')).toBeInTheDocument(); + expect(within(menu).getByText('graph.menu.load_rank_sample')).toBeInTheDocument(); + expect(within(menu).getByText('graph.menu.load_loader_sample')).toBeInTheDocument(); expect(within(menu).getByText('common.action.delete')).toBeInTheDocument(); expect(within(menu).getByText('graph.menu.clone')).toBeInTheDocument(); }); +test('disables example dataset writes for the protected built-in graphspace', async () => { + api.manage.getGraphList.mockResolvedValue({ + status: 200, + data: { + records: [{ + name: 'protected-graph', + nickname: 'Protected graph', + graphspace: 'neizhianli', + default: false, + }], + total: 1, + }, + }); + + render(<Graph />); + + const menu = await screen.findByTestId('graph-card-menu'); + [ + 'graph.menu.load_hlm_sample', + 'graph.menu.load_rank_sample', + 'graph.menu.load_loader_sample', + ].forEach(label => { + expect(within(menu).getByText(label).closest('[role="menuitem"]')) + .toHaveAttribute('aria-disabled', 'true'); + }); +}); + +test('disables example dataset writes without graphspace update permission', async () => { + mockAuthContext = { + context: {role: 'USER', scopes: {admin_graphspaces: []}}, + }; + + render(<Graph />); + + const menu = await screen.findByTestId('graph-card-menu'); + [ + 'graph.menu.load_hlm_sample', + 'graph.menu.load_rank_sample', + 'graph.menu.load_loader_sample', + ].forEach(label => { + expect(within(menu).getByText(label).closest('[role="menuitem"]')) + .toHaveAttribute('aria-disabled', 'true'); + }); +}); + test('places the new-graph card after existing graphs', async () => { render(<Graph />); diff --git a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js index a07696bfa..770fdbca6 100644 --- a/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js +++ b/hugegraph-hubble/hubble-fe/src/pages/Graph/index.js @@ -103,6 +103,7 @@ const Graph = () => { const [loading, setLoading] = useState(false); const [listUnavailable, setListUnavailable] = useState(false); const [clearSelection, setClearSelection] = useState(null); + const [sampleLoading, setSampleLoading] = useState(''); const {graphspace} = useParams(); const navigate = useNavigate(); const {context: authContext} = useAuthContext(); @@ -172,6 +173,40 @@ const Graph = () => { return api.manage.clearGraph(graphspace, clearSelection.graph); }, [clearSelection, graphspace]); + const loadSample = useCallback((graph, dataset) => { + Modal.confirm({ + title: t(`graph.sample.${dataset}_title`), + content: t(`graph.sample.${dataset}_description`, {graph}), + okText: t('graph.sample.confirm'), + cancelText: t('common.action.cancel'), + onOk: async () => { + setSampleLoading(`${graph}:${dataset}`); + try { + const res = await api.manage.loadSampleGraph( + graphspace, + graph, + dataset, + {suppressBusinessErrorToast: true} + ); + if (res.status !== 200) { + throw new Error(res.message || t('graph.sample.failed')); + } + message.success(t('graph.sample.success', { + vertices: res.data.vertices, + edges: res.data.edges, + })); + setRefresh(value => !value); + } + catch (error) { + message.error(error.message || t('graph.sample.failed')); + } + finally { + setSampleLoading(''); + } + }, + }); + }, [graphspace, t]); + const showSchema = useCallback(graph => { setViewLayer(true); setSelectGraph(graph); @@ -421,57 +456,84 @@ const Graph = () => { }, ]; - const getMenus = item => [ - { - key: 'clear', - danger: true, - disabled: item.default, - label: item.default - ? <span className={style.disable}>{t('graph.menu.clear_graph')}</span> - : t('graph.menu.clear_graph'), - onClick: item.default ? undefined : () => clearGraph(item.name), - }, - graphDefaultMutationEnabled && { - key: 'default', - disabled: item.default, - label: item.default - ? <span className={style.disable}>{t('graph.menu.set_default')}</span> - : t('graph.menu.set_default'), - onClick: item.default ? undefined : () => handleSetDefault(item.name), - }, - { - key: 'edit', - disabled: item.graphspace === 'neizhianli', - label: item.graphspace === 'neizhianli' - ? <span className={style.disable}>{t('common.action.edit')}</span> - : t('common.action.edit'), - onClick: item.graphspace === 'neizhianli' - ? undefined : () => editGraph(item.name), - }, - { - key: 'delete', - danger: true, - disabled: item.graphspace === 'neizhianli', - label: item.graphspace === 'neizhianli' - ? <span className={style.disable}>{t('common.action.delete')}</span> - : t('common.action.delete'), - onClick: item.graphspace === 'neizhianli' - ? undefined : () => deleteGraph(item.name), - }, - graphCreateEnabled && { - key: 'clone', - disabled: true, - label: ( - <Tooltip title={t('graph.clone.unavailable')}> - <span - aria-label={`${t('graph.menu.clone')}: ${t('graph.clone.unavailable')}`} - > - {t('graph.menu.clone')} - </span> - </Tooltip> - ), - }, - ].filter(Boolean); + const getMenus = item => { + const itemGraphspace = item.graphspace || graphspace; + const immutable = itemGraphspace === 'neizhianli'; + const readOnly = !canUpdateGraphspace(itemGraphspace); + const sampleDisabled = immutable || readOnly || Boolean(sampleLoading); + return [ + { + key: 'clear', + danger: true, + disabled: item.default, + label: item.default + ? <span className={style.disable}>{t('graph.menu.clear_graph')}</span> + : t('graph.menu.clear_graph'), + onClick: item.default ? undefined : () => clearGraph(item.name), + }, + graphDefaultMutationEnabled && { + key: 'default', + disabled: item.default, + label: item.default + ? <span className={style.disable}>{t('graph.menu.set_default')}</span> + : t('graph.menu.set_default'), + onClick: item.default ? undefined : () => handleSetDefault(item.name), + }, + { + key: 'edit', + disabled: immutable, + label: immutable + ? <span className={style.disable}>{t('common.action.edit')}</span> + : t('common.action.edit'), + onClick: immutable + ? undefined : () => editGraph(item.name), + }, + { + key: 'sample-hlm', + disabled: sampleDisabled, + label: sampleLoading === `${item.name}:hlm` + ? t('graph.sample.loading') : t('graph.menu.load_hlm_sample'), + onClick: sampleDisabled ? undefined : () => loadSample(item.name, 'hlm'), + }, + { + key: 'sample-rank', + disabled: sampleDisabled, + label: sampleLoading === `${item.name}:rank` + ? t('graph.sample.loading') : t('graph.menu.load_rank_sample'), + onClick: sampleDisabled ? undefined : () => loadSample(item.name, 'rank'), + }, + { + key: 'sample-loader', + disabled: sampleDisabled, + label: sampleLoading === `${item.name}:loader` + ? t('graph.sample.loading') : t('graph.menu.load_loader_sample'), + onClick: sampleDisabled ? undefined : () => loadSample(item.name, 'loader'), + }, + { + key: 'delete', + danger: true, + disabled: immutable, + label: immutable + ? <span className={style.disable}>{t('common.action.delete')}</span> + : t('common.action.delete'), + onClick: immutable + ? undefined : () => deleteGraph(item.name), + }, + graphCreateEnabled && { + key: 'clone', + disabled: true, + label: ( + <Tooltip title={t('graph.clone.unavailable')}> + <span + aria-label={`${t('graph.menu.clone')}: ${t('graph.clone.unavailable')}`} + > + {t('graph.menu.clone')} + </span> + </Tooltip> + ), + }, + ].filter(Boolean); + }; useEffect(() => { setLoading(true);
