diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json index 8fe29b015095..7afc0caf2cc2 100644 --- a/.github/playwright/impact-map.generated.json +++ b/.github/playwright/impact-map.generated.json @@ -7165,6 +7165,14 @@ "playwright/e2e/Features/OntologyStudioIsolatedToggle.spec.ts" ] }, + { + "sources": [ + "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyLibrary.tsx" + ], + "specs": [ + "playwright/e2e/Features/OntologyStudio.spec.ts" + ] + }, { "sources": [ "openmetadata-ui/src/main/resources/ui/src/components/OntologyExplorer/OntologyModelingWorkbench.tsx" diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyStudio.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyStudio.spec.ts index c60b3f56c5b6..e76cf62bd37d 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyStudio.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/OntologyStudio.spec.ts @@ -20,6 +20,8 @@ import { disposeApiContext, navigateToOntologyStudio, readGraphEdges, + readGraphZoom, + readNodePositions, releaseOntologyEditLease, waitForGraphLoaded, } from '../../utils/ontologyStudio'; @@ -76,6 +78,62 @@ test.describe('Ontology Studio', () => { await expect(page.locator('.ontology-g6-container')).toBeVisible(); }); + test('preserves the loaded graph through View, Edit, Query, Edit and View', async ({ + page, + }) => { + const graph = page.locator('.ontology-g6-container'); + const stats = page.getByTestId('ontology-explorer-stats'); + const nodesBefore = Object.keys(await readNodePositions(page)).sort(); + const edgesBefore = await readGraphEdges(page, 0); + const statsBefore = await stats.innerText(); + const zoomBefore = await readGraphZoom(page); + await expect( + page.getByTestId('ontology-glossary-menu-trigger') + ).toHaveAttribute('data-selected-glossary-id', ''); + + // Once loaded, mode changes must work even when the catalog cannot be fetched again. + await page.route('**/api/v1/glossaries?*', (route) => + route.abort('failed') + ); + + await test.step('Visit Query and return to the loaded graph', async () => { + await page.getByTestId('mode-tab-edit').click(); + await expect(graph).toBeVisible(); + await page.getByTestId('mode-tab-query').click(); + await expect(page.getByTestId('mode-tab-query')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect(graph).not.toBeVisible(); + await page.getByTestId('mode-tab-edit').click(); + await expect(graph).toBeVisible(); + await expect( + page.getByTestId('ontology-graph-loading') + ).not.toBeVisible(); + await expect(stats).toHaveText(statsBefore); + await page.getByTestId('mode-tab-view').click(); + await expect(graph).toBeVisible(); + await expect(stats).toHaveText(statsBefore); + }); + + await test.step('Opening and closing Library leaves the graph usable', async () => { + await page.getByTestId('ontology-library-trigger').click(); + await expect(page.getByTestId('ontology-library')).toBeVisible(); + await page.getByTestId('ontology-library-close').click(); + await expect(page.getByTestId('ontology-library')).not.toBeVisible(); + await expect(graph).toBeVisible(); + }); + + expect(Object.keys(await readNodePositions(page)).sort()).toEqual( + nodesBefore + ); + expect(await readGraphEdges(page, 0)).toEqual(edgesBefore); + expect(await readGraphZoom(page)).toBeCloseTo(zoomBefore); + await expect( + page.getByTestId('ontology-graph-render-error') + ).not.toBeVisible(); + }); + test('scopes the Studio graph and stats to a glossary', async ({ page }) => { await page.getByTestId('ontology-glossary-menu-trigger').click(); await expect( @@ -187,14 +245,29 @@ test.describe('Ontology Studio', () => { } }); - test('searches the Model graph and clears the query', async ({ page }) => { + test('preserves the scoped Model graph search across Query and clears it', async ({ + page, + }) => { await applyGlossaryFilter(page, PageData.glossary.responseData.id); await waitForGraphLoaded(page); const searchInput = page.getByTestId('ontology-graph-search'); + const edgesBefore = await readGraphEdges(page); await searchInput.fill(PageData.term1.data.name); await expect(searchInput).toHaveValue(PageData.term1.data.name); + await page.getByTestId('mode-tab-query').click(); + await expect(searchInput).not.toBeVisible(); + await page.getByTestId('mode-tab-view').click(); + await expect(searchInput).toHaveValue(PageData.term1.data.name); + await expect( + page.getByTestId('ontology-glossary-menu-trigger') + ).toHaveAttribute( + 'data-selected-glossary-id', + PageData.glossary.responseData.id + ); + expect(await readGraphEdges(page)).toEqual(edgesBefore); + await searchInput.clear(); await expect(searchInput).toHaveValue(''); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx new file mode 100644 index 000000000000..3e6edf35fe0c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.integration.test.tsx @@ -0,0 +1,260 @@ +/* + * Copyright 2026 Collate. + * Licensed 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 { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { OntologyNode } from '../../components/OntologyExplorer/OntologyExplorer.interface'; +import { ProjectionState, RDFStatus } from '../../generated/api/rdf/rdfStatus'; +import { Glossary } from '../../generated/entity/data/glossary'; +import { GlossaryTerm } from '../../generated/entity/data/glossaryTerm'; +import { + getGlossariesList, + getGlossaryTerms, + getOntologySummary, +} from '../../rest/glossaryAPI'; +import { getMetrics } from '../../rest/metricsAPI'; +import { listRelationshipTypes } from '../../rest/ontologyAPI'; +import { + checkRdfEnabled, + fetchRdfConfig, + getSavedSparqlQueries, + getSparqlQueryTemplates, +} from '../../rest/rdfAPI'; +import OntologyExplorerPage from './OntologyExplorerPage'; + +jest.mock('../../hooks/authHooks', () => ({ + useAuth: () => ({ isAdminUser: true, isFirstTimeUser: false }), +})); +jest.mock('../../hooks/useApplicationStore', () => ({ + useApplicationStore: () => ({ currentUser: { name: 'admin' } }), +})); +jest.mock('../../context/PermissionProvider/PermissionProvider', () => ({ + usePermissionProvider: () => ({ permissions: {} }), +})); +jest.mock('../../components/common/DocumentTitle/DocumentTitle', () => ({ + __esModule: true, + default: () => null, +})); + +// jsdom cannot draw the G6 canvas; keep the real explorer, data hook and graph builders. +jest.mock('../../components/OntologyExplorer/OntologyGraphG6', () => { + const { forwardRef } = jest.requireActual('react'); + + return { + __esModule: true, + default: forwardRef(({ nodes }: { nodes: OntologyNode[] }, _ref) => ( +
+ {nodes.map((node) => ( + {node.label} + ))} +
+ )), + }; +}); +jest.mock('react-codemirror2', () => ({ + Controlled: () =>
, +})); +jest.mock('../../rest/glossaryAPI'); +jest.mock('../../rest/metricsAPI'); +jest.mock('../../rest/ontologyAPI'); +jest.mock('../../rest/rdfAPI'); + +const mockGetGlossariesList = getGlossariesList as jest.MockedFunction< + typeof getGlossariesList +>; +const mockGetGlossaryTerms = getGlossaryTerms as jest.MockedFunction< + typeof getGlossaryTerms +>; +const mockGetMetrics = getMetrics as jest.MockedFunction; +const mockListRelationshipTypes = listRelationshipTypes as jest.MockedFunction< + typeof listRelationshipTypes +>; +const mockGetSavedSparqlQueries = getSavedSparqlQueries as jest.MockedFunction< + typeof getSavedSparqlQueries +>; +const mockGetSparqlQueryTemplates = + getSparqlQueryTemplates as jest.MockedFunction< + typeof getSparqlQueryTemplates + >; +const mockGetOntologySummary = getOntologySummary as jest.MockedFunction< + typeof getOntologySummary +>; +const mockCheckRdfEnabled = checkRdfEnabled as jest.MockedFunction< + typeof checkRdfEnabled +>; +const mockFetchRdfConfig = fetchRdfConfig as jest.MockedFunction< + typeof fetchRdfConfig +>; + +const glossary: Glossary = { + description: 'Financial concepts', + id: '10000000-0000-4000-8000-000000000001', + name: 'Finance', + fullyQualifiedName: 'Finance', + termCount: 2, +}; +const rdfStatus: RDFStatus = { + askCollateEnabled: false, + baseUri: 'https://open-metadata.org/', + enabled: true, + inference: { + availableLevels: ['none'], + defaultLevel: 'none', + enabled: false, + }, + projectionState: ProjectionState.Ready, + storageType: 'FUSEKI', +}; +const terms: GlossaryTerm[] = ['Account', 'Loan'].map((name, index) => ({ + description: name, + id: `20000000-0000-4000-8000-00000000000${index + 1}`, + name, + fullyQualifiedName: `${glossary.name}.${name}`, + glossary: { id: glossary.id, name: glossary.name, type: 'glossary' }, +})); +terms[0].relatedTerms = [ + { + relationType: 'relatedTo', + term: { + id: terms[1].id, + type: 'glossaryTerm', + name: terms[1].name, + fullyQualifiedName: terms[1].fullyQualifiedName, + }, + }, +]; + +describe('Ontology Studio mode-switch integration', () => { + beforeEach(() => { + mockGetGlossariesList.mockResolvedValue({ + data: [glossary], + paging: { total: 1 }, + }); + mockGetGlossaryTerms.mockResolvedValue({ + data: terms, + paging: { total: terms.length }, + }); + mockGetMetrics.mockResolvedValue({ data: [], paging: { total: 0 } }); + mockListRelationshipTypes.mockResolvedValue({ data: [], paging: {} }); + mockGetSavedSparqlQueries.mockResolvedValue([]); + mockGetSparqlQueryTemplates.mockResolvedValue([]); + mockGetOntologySummary.mockResolvedValue({ + connectedPercentage: 100, + isolatedPreview: [], + isolatedTerms: 0, + paging: { limit: 5, offset: 0, total: 0 }, + totalRelations: 1, + totalTerms: 2, + }); + }); + + it.each([true, false])( + 'keeps loaded terms, relations and search when Query is visited (RDF enabled: %s)', + async (rdfEnabled) => { + mockCheckRdfEnabled.mockResolvedValue(rdfEnabled); + mockFetchRdfConfig.mockResolvedValue({ + ...rdfStatus, + enabled: rdfEnabled, + }); + render( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('graph-nodes')).toHaveTextContent('Account') + ); + const graph = screen.getByTestId('graph-nodes'); + const stats = screen.getByTestId('ontology-explorer-stats'); + const search = screen.getByTestId('ontology-graph-search'); + + expect(graph).toHaveTextContent('Loan'); + expect(stats).toHaveTextContent( + '2 label.term-plural · 1 label.relation-plural' + ); + + fireEvent.change(search, { target: { value: 'Account' } }); + + // A tab change must not depend on a second catalog fetch completing. + mockGetGlossariesList.mockImplementation( + () => new Promise(() => undefined) + ); + + fireEvent.click(screen.getByTestId('mode-tab-edit')); + fireEvent.click(screen.getByTestId('mode-tab-query')); + await waitFor(() => + expect( + screen.getByTestId( + rdfEnabled + ? 'ontology-studio-query-console' + : 'ontology-rdf-disabled-notice' + ) + ).toBeVisible() + ); + fireEvent.click(screen.getByTestId('mode-tab-edit')); + fireEvent.click(screen.getByTestId('mode-tab-view')); + + expect( + screen.queryByTestId('ontology-graph-loading') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('graph-nodes')).toBe(graph); + expect(graph).toBeVisible(); + expect(stats).toHaveTextContent( + '2 label.term-plural · 1 label.relation-plural' + ); + expect(screen.getByTestId('ontology-graph-search')).toHaveValue( + 'Account' + ); + expect(getGlossariesList).toHaveBeenCalledTimes(1); + } + ); + + it('finishes the original load after leaving and returning to the graph', async () => { + mockCheckRdfEnabled.mockResolvedValue(false); + mockFetchRdfConfig.mockResolvedValue({ ...rdfStatus, enabled: false }); + let resolveTerms: + | ((value: Awaited>) => void) + | undefined; + mockGetGlossaryTerms.mockReturnValue( + new Promise((resolve) => { + resolveTerms = resolve; + }) + ); + render( + + + + ); + await waitFor(() => expect(getGlossaryTerms).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByTestId('mode-tab-query')); + + await act(async () => + resolveTerms?.({ data: terms, paging: { total: terms.length } }) + ); + fireEvent.click(screen.getByTestId('mode-tab-view')); + + expect( + screen.queryByTestId('ontology-graph-loading') + ).not.toBeInTheDocument(); + expect(screen.getByTestId('graph-nodes')).toHaveTextContent('Account'); + expect(screen.getByTestId('graph-nodes')).toHaveTextContent('Loan'); + expect(getGlossariesList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx index c450ef94655d..7f850f259866 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.test.tsx @@ -102,7 +102,11 @@ jest.mock('../../components/OntologyExplorer', () => ({ OntologyExplorer: jest.fn((props: ExplorerMockProps) => { mockOntologyExplorer(props); - return
; + return ( +
+ +
+ ); }), })); @@ -210,7 +214,7 @@ describe('OntologyExplorerPage', () => { fireEvent.click(screen.getByTestId('mode-tab-query')); expect(screen.getByTestId('sparql-query-console')).toBeInTheDocument(); - expect(screen.queryByTestId('ontology-explorer')).not.toBeInTheDocument(); + expect(screen.getByTestId('ontology-explorer')).not.toBeVisible(); }); it('explains that SPARQL is unavailable when the knowledge graph is disabled', () => { @@ -378,6 +382,50 @@ describe('OntologyExplorerPage', () => { ).not.toBeInTheDocument(); }); + it.each([ + ['Query to View', ['mode-tab-query', 'mode-tab-view']], + [ + 'the recorded mode sequence', + ['mode-tab-edit', 'mode-tab-query', 'mode-tab-edit', 'mode-tab-view'], + ], + ['AI to View', ['mode-tab-ai', 'mode-tab-view']], + ])('preserves the explorer and its search through %s', (_scenario, tabs) => { + mockUseOntologyAiCapability.mockReturnValue({ + isEnabled: true, + isLoading: false, + isRdfEnabled: true, + }); + render(); + const explorer = screen.getByTestId('ontology-explorer'); + const search = screen.getByRole('textbox', { name: 'Concept search' }); + fireEvent.change(search, { target: { value: 'Account' } }); + + for (const tab of tabs) { + fireEvent.click(screen.getByTestId(tab)); + } + + expect(screen.getByTestId('ontology-explorer')).toBe(explorer); + expect(explorer).toBeVisible(); + expect(screen.getByRole('textbox', { name: 'Concept search' })).toHaveValue( + 'Account' + ); + }); + + it('reloads the explorer after the Model workbench where concepts can change', () => { + render(); + const explorer = screen.getByTestId('ontology-explorer'); + + fireEvent.click(screen.getByTestId('mode-tab-edit')); + fireEvent.click(screen.getByTestId('submode-tab-model')); + + expect(screen.queryByTestId('ontology-explorer')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('submode-tab-graph')); + + expect(screen.getByTestId('ontology-explorer')).toBeVisible(); + expect(screen.getByTestId('ontology-explorer')).not.toBe(explorer); + }); + it('opens the ontology library from the dedicated header action', () => { render(); @@ -453,7 +501,7 @@ describe('OntologyExplorerPage', () => { fireEvent.click(screen.getByTestId('mode-tab-ai')); expect(screen.getByTestId('ontology-ai-assistant')).toBeInTheDocument(); - expect(screen.queryByTestId('ontology-explorer')).not.toBeInTheDocument(); + expect(screen.getByTestId('ontology-explorer')).not.toBeVisible(); }); it('opens generated SPARQL in the console without executing it', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx index b3d25c2e2b53..dcf139b5eaff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/OntologyExplorerPage/OntologyExplorerPage.tsx @@ -549,6 +549,9 @@ const OntologyExplorerPage: React.FC = () => { isCapabilityLoading ); + const showDefaultSurface = + !showAiAssistant && !showQuerySurface && !showRdfDisabledNotice; + const defaultModeContent = showModelingWorkbench ? ( { ); } - return defaultModeContent; + return null; } function renderMainSection() { @@ -816,6 +819,15 @@ const OntologyExplorerPage: React.FC = () => { ? 'tw:bg-secondary' : 'tw:bg-primary' )}> + {/* Query and AI must not discard the loaded graph or restart its requests. */} + {renderMainContent()} );