Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ test.describe('Ontology Explorer', () => {
await waitForGraphLoaded(page);
await applyRelationTypeFilter(page, 'Synonym');

await expect(page.getByTestId('ontology-graph-empty')).toBeVisible();
await expect(
page.getByTestId('ontology-graph-no-relations')
).toBeVisible();
});
});

Expand Down Expand Up @@ -523,6 +525,7 @@ test.describe('Ontology Explorer', () => {
.getByTestId('ontology-graph-search')
.locator('input');
await searchInput.fill(term1.data.name);
// Search does not re-run layout, so read existing positions without clearing.
const filteredCount = Object.keys(await readNodePositions(page)).length;

await searchInput.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,106 +261,6 @@ test.describe('Ontology Explorer - Cross Glossary Edges', () => {
});
});

test.describe('Ontology Explorer - Search Filtering - Node Visibility', () => {
const highlightGlossary = new Glossary();
const termAlpha = new GlossaryTerm(highlightGlossary);
const termBeta = new GlossaryTerm(highlightGlossary);
const termGamma = new GlossaryTerm(highlightGlossary);

test.beforeAll(async ({ browser }) => {
const { page, apiContext } = await createApiContext(browser);
await highlightGlossary.create(apiContext);
await termAlpha.create(apiContext);
await termBeta.create(apiContext);
await termGamma.create(apiContext);
// alpha — relatedTo → beta; gamma stays isolated
await addTermRelation(apiContext, termAlpha, termBeta, 'relatedTo');
await disposeApiContext(page, apiContext);
});

test.afterAll(async ({ browser }) => {
const { page, apiContext } = await createApiContext(browser);
await deleteEntities(
apiContext,
termAlpha,
termBeta,
termGamma,
highlightGlossary
);
await disposeApiContext(page, apiContext);
});

test('searching by a term name shows only that term and its connected neighbour', async ({
page,
}) => {
await navigateAndFilterByGlossary(page, highlightGlossary.responseData.id);

const searchInput = page
.getByTestId('ontology-graph-search')
.locator('input');
await searchInput.fill(termAlpha.data.name);

const positions = await readNodePositions(page);

expect(
positions,
'termAlpha must be visible — it matches the search query'
).toHaveProperty(termAlpha.responseData.id);
expect(
positions,
'termBeta must be visible — it is directly connected to termAlpha'
).toHaveProperty(termBeta.responseData.id);
expect(
positions,
'termGamma must be hidden — it is unrelated to the search query'
).not.toHaveProperty(termGamma.responseData.id);
});

test('searching by the isolated term shows only that term', async ({
page,
}) => {
await navigateAndFilterByGlossary(page, highlightGlossary.responseData.id);

const searchInput = page
.getByTestId('ontology-graph-search')
.locator('input');
await searchInput.fill(termGamma.data.name);

const positions = await readNodePositions(page);

expect(
positions,
'termGamma must be visible — it matches the search query'
).toHaveProperty(termGamma.responseData.id);
expect(
positions,
'termAlpha must be hidden — it does not match and has no edge to termGamma'
).not.toHaveProperty(termAlpha.responseData.id);
expect(
positions,
'termBeta must be hidden — it does not match and has no edge to termGamma'
).not.toHaveProperty(termBeta.responseData.id);
});

test('clearing the search restores all three terms to the graph', async ({
page,
}) => {
await navigateAndFilterByGlossary(page, highlightGlossary.responseData.id);

const searchInput = page
.getByTestId('ontology-graph-search')
.locator('input');
await searchInput.fill(termAlpha.data.name);

await searchInput.clear();

const positions = await readNodePositions(page);
expect(positions).toHaveProperty(termAlpha.responseData.id);
expect(positions).toHaveProperty(termBeta.responseData.id);
expect(positions).toHaveProperty(termGamma.responseData.id);
});
});

test.describe('Ontology Explorer - Data Mode Stats', () => {
const dataModeGlossary = new Glossary();
const dataTerm1 = new GlossaryTerm(dataModeGlossary);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Download01 } from '@untitledui/icons';
import React, { useState } from 'react';
import type { Key } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { showErrorToast } from '../../utils/ToastUtils';

export enum ExportFormat {
PNG = 'png',
Expand Down Expand Up @@ -46,6 +47,7 @@ const ExportGraphPanel: React.FC<ExportGraphPanelProps> = ({
}) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [exporting, setExporting] = useState(false);

const items = [
{ id: ExportFormat.PNG, label: t('label.png-uppercase') },
Expand All @@ -69,18 +71,24 @@ const ExportGraphPanel: React.FC<ExportGraphPanelProps> = ({
: items.filter((item) => supportedExports.includes(item.id));

const handleAction = async (key: Key) => {
setOpen(false);

if (key === ExportFormat.PNG) {
await onExportPng();
} else if (key === ExportFormat.SVG) {
await onExportSvg?.();
} else if (key === ExportFormat.JSONLD) {
await onExportJsonLd?.();
} else if (key === ExportFormat.TURTLE) {
await onExportTurtle?.();
} else if (key === ExportFormat.RDFXML) {
await onExportRdfXml?.();
setExporting(true);
try {
if (key === ExportFormat.PNG) {
await onExportPng();
} else if (key === ExportFormat.SVG) {
await onExportSvg?.();
} else if (key === ExportFormat.JSONLD) {
await onExportJsonLd?.();
} else if (key === ExportFormat.TURTLE) {
await onExportTurtle?.();
} else if (key === ExportFormat.RDFXML) {
await onExportRdfXml?.();
}
setOpen(false);
} catch (error) {
showErrorToast(String(error), t('server.entity-fetch-error'));
} finally {
setExporting(false);
}
};

Expand All @@ -90,6 +98,8 @@ const ExportGraphPanel: React.FC<ExportGraphPanelProps> = ({
color="secondary"
data-testid={testId}
iconLeading={<Download01 height={20} width={20} />}
isDisabled={exporting}
isLoading={exporting}
size="sm">
{t('label.export-graph')}
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
onFiltersChange,
onViewModeChange,
onClearAll,
onLoadMore,
viewModeDisabled = false,
isLoading = false,
isLoadingMore = false,
hasMoreTerms = false,
loadedTermCount,
totalTermCount,
}) => {
const { t } = useTranslation();

Expand Down Expand Up @@ -147,11 +153,13 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({

return (
<div className="tw:flex tw:w-full tw:items-center tw:gap-5 tw:pl-2">
{/* View Mode dropdown — disabled in data mode */}
{/* View Mode dropdown — disabled in data mode or while loading */}
<div
className={
'tw:flex tw:shrink-0 tw:items-center tw:gap-2' +
(viewModeDisabled ? ' tw:pointer-events-none tw:opacity-50' : '')
(viewModeDisabled || isLoading
? ' tw:pointer-events-none tw:opacity-50'
: '')
}>
<Typography
as="span"
Expand All @@ -164,7 +172,7 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
className="tw:w-36"
data-testid="view-mode-select"
fontSize="sm"
isDisabled={viewModeDisabled}
isDisabled={viewModeDisabled || isLoading}
items={viewModeItems}
size="sm"
value={filters.viewMode}
Expand All @@ -184,7 +192,10 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({

{/* Glossary filter */}
<div
className="tw:flex tw:shrink-0 tw:items-center"
className={
'tw:flex tw:shrink-0 tw:items-center' +
(isLoading ? ' tw:pointer-events-none tw:opacity-50' : '')
}
data-testid="glossary-filter-section">
<SearchDropdown
hideCounts
Expand All @@ -200,7 +211,10 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
</div>

<div
className="tw:flex tw:shrink-0 tw:items-center"
className={
'tw:flex tw:shrink-0 tw:items-center' +
(isLoading ? ' tw:pointer-events-none tw:opacity-50' : '')
}
data-testid="relation-type-filter-section">
<SearchDropdown
hideCounts
Expand All @@ -215,9 +229,10 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
/>
</div>

{/* Isolated toggle */}
{/* Isolated toggle — disabled while loading or when Cross Glossary view removes all non-connected nodes */}
<Toggle
data-testid="ontology-isolated-toggle"
isDisabled={isLoading || filters.showCrossGlossaryOnly}
isSelected={filters.showIsolatedNodes}
label={t('label.isolated')}
size="sm"
Expand All @@ -226,18 +241,43 @@ const FilterToolbar: React.FC<FilterToolbarProps> = ({
}
/>

{onClearAll && hasActiveFilters && (
<>
<div className="tw:ml-auto" />
<div className="tw:ml-auto tw:flex tw:items-center">
{onLoadMore !== undefined &&
loadedTermCount !== undefined &&
totalTermCount !== undefined && (
<Typography
as="span"
className="tw:whitespace-nowrap tw:pr-1 tw:text-(--color-text-tertiary)"
size="text-sm">
{t('label.loaded-x-of-y-entity', {
loaded: loadedTermCount,
total: totalTermCount,
entity: t('label.term-plural'),
})}
</Typography>
)}
{onLoadMore !== undefined && (
<Button
className="tw:text-brand-600"
color="tertiary"
data-testid="ontology-load-more-btn"
isDisabled={!hasMoreTerms || isLoading || isLoadingMore}
size="sm"
onClick={onLoadMore}>
{t('label.load-more')}
</Button>
)}
{onClearAll && hasActiveFilters && (
<Button
color="tertiary"
data-testid="ontology-clear-all-btn"
isDisabled={isLoading}
size="sm"
onClick={onClearAll}>
{t('label.clear-entity', { entity: t('label.all-lowercase') })}
</Button>
</>
)}
)}
</div>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,13 @@ export interface FilterToolbarProps {
onFiltersChange: (filters: GraphFilters) => void;
onViewModeChange?: (viewMode: GraphViewMode) => void;
onClearAll?: () => void;
onLoadMore?: () => void;
viewModeDisabled?: boolean;
isLoading?: boolean;
isLoadingMore?: boolean;
hasMoreTerms?: boolean;
loadedTermCount?: number;
totalTermCount?: number;
}

export interface GraphSettingsPanelProps {
Expand Down
Loading
Loading