+
);
diff --git a/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx
index 4db660ff9..f651285a6 100644
--- a/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx
+++ b/src/subapps/dataExplorer/DataExplorer-utils.spec.tsx
@@ -36,8 +36,8 @@ describe('DataExplorerSpec-Utils', () => {
},
},
];
+ const actual = getAllPaths(resources);
const expectedPaths = [
- '@id',
'contributors',
'contributors.name',
'contributors.name.firstname',
@@ -50,8 +50,9 @@ describe('DataExplorerSpec-Utils', () => {
'distribution.filename',
'distribution.label',
'distribution.name',
+ '@id',
];
- expect(getAllPaths(resources)).toEqual(expectedPaths);
+ expect(actual).toEqual(expectedPaths);
});
it('sorts path starting with underscore at the end of the list', () => {
@@ -65,17 +66,18 @@ describe('DataExplorerSpec-Utils', () => {
_updatedAt: 'some time ago',
name: 'anotherNameValue',
_createdAt: '12 September 2020',
- project: 'secret project',
+ _project: 'secret project',
},
];
const expectedPaths = [
'name',
- 'project',
+ '_createdAt',
+ '_project',
+ '_updatedAt',
+
'_author',
'_author.designation',
'_author.name',
- '_createdAt',
- '_updatedAt',
];
const receivedPaths = getAllPaths(resources);
@@ -627,4 +629,55 @@ describe('DataExplorerSpec-Utils', () => {
)
).toEqual(true);
});
+
+ it('does not throw when checking for non existence on a path when resource has primitve value', () => {
+ const resource = {
+ '@context': [
+ 'https://bluebrain.github.io/nexus/contexts/metadata.json',
+ {
+ '1Point': {
+ '@id': 'nsg:1Point',
+ },
+ '2DContour': {
+ '@id': 'nsg:2DContour',
+ },
+ '3DContour': {
+ '@id': 'nsg:3DContour',
+ },
+ '3Point': {
+ '@id': 'nsg:3Point',
+ },
+ '@vocab':
+ 'https://bbp-nexus.epfl.ch/vocabs/bbp/neurosciencegraph/core/v0.1.0/',
+ Derivation: {
+ '@id': 'prov:Derivation',
+ },
+ xsd: 'http://www.w3.org/2001/XMLSchema#',
+ },
+ ],
+ '@id': 'https://bbp.epfl.ch/nexus/search/neuroshapes',
+ _constrainedBy:
+ 'https://bluebrain.github.io/nexus/schemas/unconstrained.json',
+ _createdAt: '2019-02-11T14:15:14.020Z',
+ _createdBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman',
+ _deprecated: false,
+ _incoming:
+ 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/incoming',
+ _outgoing:
+ 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes/outgoing',
+ _project:
+ 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public',
+ _rev: 1,
+ _schemaProject:
+ 'https://bbp.epfl.ch/nexus/v1/projects/webapps/search-app-prod-public',
+ _self:
+ 'https://bbp.epfl.ch/nexus/v1/resources/webapps/search-app-prod-public/_/neuroshapes',
+ _updatedAt: '2019-02-11T14:15:14.020Z',
+ _updatedBy: 'https://bbp.epfl.ch/nexus/v1/realms/bbp/users/pirman',
+ };
+
+ expect(
+ checkPathExistence(resource, '@context.@vocab', 'does-not-exist')
+ ).toEqual(true);
+ });
});
diff --git a/src/subapps/dataExplorer/DataExplorer.spec.tsx b/src/subapps/dataExplorer/DataExplorer.spec.tsx
index a88c8a032..4c7d09950 100644
--- a/src/subapps/dataExplorer/DataExplorer.spec.tsx
+++ b/src/subapps/dataExplorer/DataExplorer.spec.tsx
@@ -6,9 +6,10 @@ import userEvent from '@testing-library/user-event';
import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup';
import {
dataExplorerPageHandler,
- defaultMockResult,
filterByProjectHandler,
+ getCompleteResources,
getMockResource,
+ sourceResourceHandler,
} from '__mocks__/handlers/DataExplorer/handlers';
import { deltaPath } from '__mocks__/handlers/handlers';
import { setupServer } from 'msw/node';
@@ -19,7 +20,6 @@ import { AllProjects } from './ProjectSelector';
import { getColumnTitle } from './DataExplorerTable';
import {
CONTAINS,
- DEFAULT_OPTION,
DOES_NOT_CONTAIN,
DOES_NOT_EXIST,
EXISTS,
@@ -29,13 +29,21 @@ import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { Provider } from 'react-redux';
import configureStore from '../../shared/store';
+import { ALWAYS_DISPLAYED_COLUMNS, isNexusMetadata } from './DataExplorerUtils';
describe('DataExplorer', () => {
const defaultTotalResults = 500_123;
+ const mockResourcesOnPage1: Resource[] = getCompleteResources();
+ const mockResourcesForPage2: Resource[] = [
+ getMockResource('self1', { author: 'piggy', edition: 1 }),
+ getMockResource('self2', { author: ['iggy', 'twinky'] }),
+ getMockResource('self3', { year: 2013 }),
+ ];
const server = setupServer(
- dataExplorerPageHandler(defaultMockResult, defaultTotalResults),
- filterByProjectHandler(defaultMockResult)
+ dataExplorerPageHandler(undefined, defaultTotalResults),
+ sourceResourceHandler(),
+ filterByProjectHandler()
);
const history = createMemoryHistory({});
@@ -83,7 +91,7 @@ describe('DataExplorer', () => {
const DropdownSelector = '.ant-select-dropdown';
const DropdownOptionSelector = 'div.ant-select-item-option-content';
- const TypeOptionSelector = 'div.ant-select-item-option-content > span';
+ const CustomOptionSelector = 'div.ant-select-item-option-content > span';
const PathMenuLabel = 'path-selector';
const PredicateMenuLabel = 'predicate-selector';
@@ -101,22 +109,30 @@ describe('DataExplorer', () => {
const expectColumHeaderToExist = async (name: string) => {
const nameReg = new RegExp(getColumnTitle(name), 'i');
const header = await screen.getByText(nameReg, {
- selector: 'th',
+ selector: 'th .ant-table-column-title',
exact: false,
});
expect(header).toBeInTheDocument();
return header;
};
+ const getColumnSorter = async (colName: string) => {
+ const column = await expectColumHeaderToExist(colName);
+ return column.closest('.ant-table-column-sorters');
+ };
+
+ const getTotalColumns = () => {
+ return Array.from(container.querySelectorAll('th'));
+ };
+
+ const expectColumHeaderToNotExist = async (name: string) => {
+ expect(expectColumHeaderToExist(name)).rejects.toThrow();
+ };
+
const getTextForColumn = async (resource: Resource, colName: string) => {
- const selfCell = await screen.getAllByText(
- new RegExp(resource._self, 'i'),
- {
- selector: 'td',
- }
- );
+ const row = await screen.getByTestId(resource._self);
- const allCellsForRow = Array.from(selfCell[0].parentElement!.childNodes);
+ const allCellsForRow = Array.from(row.childNodes);
const colIndex = Array.from(
container.querySelectorAll('th')
).findIndex(header =>
@@ -126,13 +142,7 @@ describe('DataExplorer', () => {
};
const getRowForResource = async (resource: Resource) => {
- const selfCell = await screen.getAllByText(
- new RegExp(resource._self, 'i'),
- {
- selector: 'td',
- }
- );
- const row = selfCell[0].parentElement;
+ const row = await screen.getByTestId(resource._self);
expect(row).toBeInTheDocument();
return row!;
};
@@ -168,6 +178,11 @@ describe('DataExplorer', () => {
return typeColumn?.textContent;
};
+ const columnTextFromRow = (row: Element, colName: string) => {
+ const column = row.querySelector(`td.data-explorer-column-${colName}`);
+ return column?.textContent;
+ };
+
const visibleTableRows = () => {
return container.querySelectorAll('table tbody tr.data-explorer-row');
};
@@ -190,7 +205,10 @@ describe('DataExplorer', () => {
resources: Resource[],
total: number = 300
) => {
- server.use(dataExplorerPageHandler(resources, total));
+ server.use(
+ sourceResourceHandler(resources),
+ dataExplorerPageHandler(resources, total)
+ );
const pageInput = await screen.getByRole('listitem', { name: '2' });
expect(pageInput).toBeInTheDocument();
@@ -200,10 +218,21 @@ describe('DataExplorer', () => {
await expectRowCountToBe(3);
};
- const openMenuFor = async (ariaLabel: string) => {
- const menuInput = await screen.getByLabelText(ariaLabel, {
+ const getInputForLabel = async (label: string) => {
+ return (await screen.getByLabelText(label, {
selector: 'input',
- });
+ })) as HTMLInputElement;
+ };
+
+ const getSelectedValueInMenu = async (menuLabel: string) => {
+ const input = await getInputForLabel(menuLabel);
+ return input
+ .closest('.ant-select-selector')
+ ?.querySelector('.ant-select-selection-item')?.innerHTML;
+ };
+
+ const openMenuFor = async (ariaLabel: string) => {
+ const menuInput = await getInputForLabel(ariaLabel);
await userEvent.click(menuInput, { pointerEventsCheck: 0 });
await act(async () => {
fireEvent.mouseDown(menuInput);
@@ -213,6 +242,14 @@ describe('DataExplorer', () => {
return menuDropdown;
};
+ const selectPath = async (path: string) => {
+ await selectOptionFromMenu(PathMenuLabel, path, CustomOptionSelector);
+ };
+
+ const selectPredicate = async (predicate: string) => {
+ await selectOptionFromMenu(PredicateMenuLabel, predicate);
+ };
+
const selectOptionFromMenu = async (
menuAriaLabel: string,
optionLabel: string,
@@ -228,11 +265,11 @@ describe('DataExplorer', () => {
* NOTE: Since antd menus use virtual scroll, not all options inside the menu are visible.
* This function only returns those options that are visible.
*/
- const getVisibleOptionsFromMenu = () => {
+ const getVisibleOptionsFromMenu = (
+ selector: string = DropdownOptionSelector
+ ) => {
const menuDropdown = document.querySelector(DropdownSelector);
- return Array.from(
- menuDropdown?.querySelectorAll(DropdownOptionSelector) ?? []
- );
+ return Array.from(menuDropdown?.querySelectorAll(selector) ?? []);
};
const getTotalSizeOfDataset = async (expectedCount: string) => {
@@ -264,7 +301,9 @@ describe('DataExplorer', () => {
return filteredCount;
};
- const updateResourcesShownInTable = async (resources: Resource[]) => {
+ const updateResourcesShownInTable = async (
+ resources: Resource[] = mockResourcesForPage2
+ ) => {
await expectRowCountToBe(10);
await getRowsForNextPage(resources);
await expectRowCountToBe(resources.length);
@@ -274,20 +313,77 @@ describe('DataExplorer', () => {
return await screen.getByTestId('reset-project-button');
};
+ const showMetadataSwitch = async () =>
+ await screen.getByLabelText('Show metadata');
+
+ const showEmptyDataCellsSwitch = async () =>
+ await screen.getByLabelText('Show empty data cells');
+
+ const resetPredicate = async () => {
+ const resetPredicateButton = await screen.getByRole('button', {
+ name: /reset predicate/i,
+ });
+ await userEvent.click(resetPredicateButton);
+ };
+
+ const expectRowsInOrder = async (expectedOrder: Resource[]) => {
+ for await (const [index, row] of visibleTableRows().entries()) {
+ const text = await columnTextFromRow(row, 'author');
+ if (expectedOrder[index].author) {
+ expect(text).toMatch(JSON.stringify(expectedOrder[index].author));
+ } else {
+ expect(text).toMatch(/No data/i);
+ }
+ }
+ };
+
+ it('shows columns for fields that are only in source data', async () => {
+ await expectRowCountToBe(10);
+ const column = await expectColumHeaderToExist('userProperty1');
+ expect(column).toBeInTheDocument();
+ });
+
it('shows rows for all fetched resources', async () => {
await expectRowCountToBe(10);
});
- it('shows columns for each top level property in resources', async () => {
+ it('shows only user columns for each top level property by default', async () => {
await expectRowCountToBe(10);
const seenColumns = new Set();
- for (const mockResource of defaultMockResult) {
+ for (const mockResource of mockResourcesOnPage1) {
for (const topLevelProperty of Object.keys(mockResource)) {
if (!seenColumns.has(topLevelProperty)) {
seenColumns.add(topLevelProperty);
- const header = getColumnTitle(topLevelProperty);
- await expectColumHeaderToExist(header);
+
+ if (ALWAYS_DISPLAYED_COLUMNS.has(topLevelProperty)) {
+ await expectColumHeaderToExist(getColumnTitle(topLevelProperty));
+ } else if (isNexusMetadata(topLevelProperty)) {
+ expect(
+ expectColumHeaderToExist(getColumnTitle(topLevelProperty))
+ ).rejects.toThrow();
+ } else {
+ await expectColumHeaderToExist(getColumnTitle(topLevelProperty));
+ }
+ }
+ }
+ }
+
+ expect(seenColumns.size).toBeGreaterThan(1);
+ });
+
+ it('shows user columns for all top level properties when show user metadata clicked', async () => {
+ await expectRowCountToBe(10);
+ const showMetadataButton = await showMetadataSwitch();
+ await userEvent.click(showMetadataButton);
+
+ const seenColumns = new Set();
+
+ for (const mockResource of mockResourcesOnPage1) {
+ for (const topLevelProperty of Object.keys(mockResource)) {
+ if (!seenColumns.has(topLevelProperty)) {
+ seenColumns.add(topLevelProperty);
+ await expectColumHeaderToExist(getColumnTitle(topLevelProperty));
}
}
}
@@ -343,7 +439,7 @@ describe('DataExplorer', () => {
it('shows No data text when values are missing for a column', async () => {
await expectRowCountToBe(10);
- const resourceWithMissingProperty = defaultMockResult.find(
+ const resourceWithMissingProperty = mockResourcesOnPage1.find(
res => !('specialProperty' in res)
)!;
const textForSpecialProperty = await getTextForColumn(
@@ -355,7 +451,7 @@ describe('DataExplorer', () => {
it('shows No data text when values is undefined', async () => {
await expectRowCountToBe(10);
- const resourceWithUndefinedProperty = defaultMockResult.find(
+ const resourceWithUndefinedProperty = mockResourcesOnPage1.find(
res => res.specialProperty === undefined
)!;
const textForSpecialProperty = await getTextForColumn(
@@ -367,7 +463,7 @@ describe('DataExplorer', () => {
it('does not show No data text when values is null', async () => {
await expectRowCountToBe(10);
- const resourceWithUndefinedProperty = defaultMockResult.find(
+ const resourceWithUndefinedProperty = mockResourcesOnPage1.find(
res => res.specialProperty === null
)!;
const textForSpecialProperty = await getTextForColumn(
@@ -380,7 +476,7 @@ describe('DataExplorer', () => {
it('does not show No data when value is empty string', async () => {
await expectRowCountToBe(10);
- const resourceWithEmptyString = defaultMockResult.find(
+ const resourceWithEmptyString = mockResourcesOnPage1.find(
res => res.specialProperty === ''
)!;
@@ -394,7 +490,7 @@ describe('DataExplorer', () => {
it('does not show No data when value is empty array', async () => {
await expectRowCountToBe(10);
- const resourceWithEmptyArray = defaultMockResult.find(
+ const resourceWithEmptyArray = mockResourcesOnPage1.find(
res =>
Array.isArray(res.specialProperty) && res.specialProperty.length === 0
)!;
@@ -409,7 +505,7 @@ describe('DataExplorer', () => {
it('does not show No data when value is empty object', async () => {
await expectRowCountToBe(10);
- const resourceWithEmptyObject = defaultMockResult.find(
+ const resourceWithEmptyObject = mockResourcesOnPage1.find(
res =>
typeof res.specialProperty === 'object' &&
res.specialProperty !== null &&
@@ -469,7 +565,7 @@ describe('DataExplorer', () => {
it('shows resources filtered by the selected type', async () => {
await expectRowCountToBe(10);
- await selectOptionFromMenu(TypeMenuLabel, 'file', TypeOptionSelector);
+ await selectOptionFromMenu(TypeMenuLabel, 'file', CustomOptionSelector);
visibleTableRows().forEach(row =>
expect(typeFromRow(row)).toMatch(/file/i)
@@ -478,29 +574,32 @@ describe('DataExplorer', () => {
it('only shows types that exist in selected project in type autocomplete', async () => {
await openMenuFor(TypeMenuLabel);
- const optionBefore = await getDropdownOption('Dataset', TypeOptionSelector);
+ const optionBefore = await getDropdownOption(
+ 'Dataset',
+ CustomOptionSelector
+ );
expect(optionBefore).toBeInTheDocument();
await selectOptionFromMenu(ProjectMenuLabel, 'unhcr');
await openMenuFor(TypeMenuLabel);
expect(
- getDropdownOption('Dataset', TypeOptionSelector)
+ getDropdownOption('Dataset', CustomOptionSelector)
).rejects.toThrowError();
});
it('shows paths as options in a select menu of path selector', async () => {
+ await expectRowCountToBe(10);
await openMenuFor('path-selector');
- const pathOptions = getVisibleOptionsFromMenu();
+ const pathOptions = getVisibleOptionsFromMenu(CustomOptionSelector);
- const expectedPaths = getAllPaths(defaultMockResult);
+ const expectedPaths = getAllPaths(mockResourcesOnPage1);
expect(expectedPaths.length).toBeGreaterThanOrEqual(
- Object.keys(defaultMockResult[0]).length
+ Object.keys(mockResourcesOnPage1[0]).length
);
- expect(pathOptions[0].innerHTML).toMatch(DEFAULT_OPTION);
- pathOptions.slice(1).forEach((path, index) => {
+ pathOptions.forEach((path, index) => {
expect(path.innerHTML).toMatch(
new RegExp(`${expectedPaths[index]}$`, 'i')
);
@@ -516,11 +615,11 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST);
await expectRowCountToBe(1);
- await selectOptionFromMenu(PathMenuLabel, 'edition');
+ await selectPath('edition');
await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_EXIST);
await expectRowCountToBe(2);
});
@@ -532,10 +631,10 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await userEvent.click(container);
await selectOptionFromMenu(PredicateMenuLabel, CONTAINS);
- const valueInput = await screen.getByPlaceholderText('type the value...');
+ const valueInput = await screen.getByPlaceholderText('Search for...');
await userEvent.type(valueInput, 'iggy');
await expectRowCountToBe(2);
@@ -552,7 +651,7 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await userEvent.click(container);
await selectOptionFromMenu(PredicateMenuLabel, CONTAINS);
await expectRowCountToBe(3);
@@ -565,7 +664,7 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await userEvent.click(container);
await selectOptionFromMenu(PredicateMenuLabel, EXISTS);
await expectRowCountToBe(2);
@@ -578,10 +677,10 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await userEvent.click(container);
await selectOptionFromMenu(PredicateMenuLabel, DOES_NOT_CONTAIN);
- const valueInput = await screen.getByPlaceholderText('type the value...');
+ const valueInput = await screen.getByPlaceholderText('Search for...');
await userEvent.type(valueInput, 'iggy');
await expectRowCountToBe(2);
@@ -603,7 +702,7 @@ describe('DataExplorer', () => {
expect(history.location.pathname).not.toContain('self1');
- const firstDataRow = await getRowForResource(defaultMockResult[0]);
+ const firstDataRow = await getRowForResource(mockResourcesOnPage1[0]);
await userEvent.click(firstDataRow);
expect(history.location.pathname).toContain('self1');
@@ -650,7 +749,7 @@ describe('DataExplorer', () => {
getMockResource('self3', { year: 2013 }),
]);
- await selectOptionFromMenu(PathMenuLabel, 'author');
+ await selectPath('author');
await userEvent.click(container);
await selectOptionFromMenu(PredicateMenuLabel, EXISTS);
await expectRowCountToBe(2);
@@ -658,4 +757,85 @@ describe('DataExplorer', () => {
const totalFromFrontendAfter = await getFilteredResultsCount(2);
expect(totalFromFrontendAfter).toBeVisible();
});
+
+ it('shows column for metadata path even if toggle for show metadata is off', async () => {
+ const metadataProperty = '_createdBy';
+ await expectRowCountToBe(10);
+
+ await expectColumHeaderToNotExist(metadataProperty);
+
+ const originalColumns = getTotalColumns().length;
+
+ await selectPath(metadataProperty);
+ await selectOptionFromMenu(PredicateMenuLabel, EXISTS);
+
+ await expectColumHeaderToExist(metadataProperty);
+ expect(getTotalColumns().length).toEqual(originalColumns + 1);
+
+ await resetPredicate();
+ expect(getTotalColumns().length).toEqual(originalColumns);
+ });
+
+ it('resets predicate fields when reset predicate clicked', async () => {
+ await updateResourcesShownInTable(mockResourcesForPage2);
+
+ await selectPath('author');
+ await selectPredicate(EXISTS);
+
+ const selectedPathBefore = await getSelectedValueInMenu(PathMenuLabel);
+ expect(selectedPathBefore).toMatch(/author/);
+
+ await expectRowCountToBe(2);
+
+ await resetPredicate();
+
+ await expectRowCountToBe(3);
+
+ const selectedPathAfter = await getSelectedValueInMenu(PathMenuLabel);
+ expect(selectedPathAfter).toBeFalsy();
+ });
+
+ it('only shows predicate menu if path is selected', async () => {
+ await expectRowCountToBe(10);
+ expect(openMenuFor(PredicateMenuLabel)).rejects.toThrow();
+ await selectPath('@type');
+ expect(openMenuFor(PredicateMenuLabel)).resolves.not.toThrow();
+ });
+
+ it('sorts table columns', async () => {
+ const dataSource = [
+ getMockResource('self1', { author: 'tweaty', edition: 1 }),
+ getMockResource('self2', { edition: 2001 }),
+ getMockResource('self3', { year: 2013, author: 'piggy' }),
+ ];
+ await updateResourcesShownInTable(dataSource);
+
+ await expectRowsInOrder(dataSource);
+
+ const authorColumnSorter = await getColumnSorter('author');
+ await userEvent.click(authorColumnSorter!);
+
+ await expectRowsInOrder([dataSource[1], dataSource[2], dataSource[0]]);
+ });
+
+ it('does not show "No data" cell if "Show empty data cells" toggle is turned off', async () => {
+ await expectRowCountToBe(10);
+ const resourceWithMissingProperty = mockResourcesOnPage1.find(
+ res => !('specialProperty' in res)
+ )!;
+ const textForSpecialProperty = await getTextForColumn(
+ resourceWithMissingProperty,
+ 'specialProperty'
+ );
+ expect(textForSpecialProperty).toMatch(/No data/i);
+
+ const button = await showEmptyDataCellsSwitch();
+ await userEvent.click(button);
+
+ const textForSpecialPropertyAfter = await getTextForColumn(
+ resourceWithMissingProperty,
+ 'specialProperty'
+ );
+ expect(textForSpecialPropertyAfter).toMatch('');
+ });
});
diff --git a/src/subapps/dataExplorer/DataExplorer.tsx b/src/subapps/dataExplorer/DataExplorer.tsx
index 9a001daa3..66a692e3a 100644
--- a/src/subapps/dataExplorer/DataExplorer.tsx
+++ b/src/subapps/dataExplorer/DataExplorer.tsx
@@ -1,29 +1,34 @@
import { Resource } from '@bbp/nexus-sdk';
-import { useNexusContext } from '@bbp/react-nexus';
-import { notification } from 'antd';
-import { isString } from 'lodash';
-import React, { useReducer } from 'react';
-import { useQuery } from 'react-query';
+import { Switch } from 'antd';
+import React, { useMemo, useReducer, useState } from 'react';
import { DataExplorerTable } from './DataExplorerTable';
-import './styles.less';
+import {
+ columnFromPath,
+ isUserColumn,
+ sortColumns,
+ usePaginatedExpandedResources,
+} from './DataExplorerUtils';
import { ProjectSelector } from './ProjectSelector';
import { PredicateSelector } from './PredicateSelector';
import { DatasetCount } from './DatasetCount';
import { TypeSelector } from './TypeSelector';
+import './styles.less';
export interface DataExplorerConfiguration {
pageSize: number;
offset: number;
orgAndProject?: [string, string];
type: string | undefined;
- predicateFilter: ((resource: Resource) => boolean) | null;
+ predicate: ((resource: Resource) => boolean) | null;
+ selectedPath: string | null;
}
export const DataExplorer: React.FC<{}> = () => {
- const nexus = useNexusContext();
+ const [showMetadataColumns, setShowMetadataColumns] = useState(false);
+ const [showEmptyDataCells, setShowEmptyDataCells] = useState(true);
const [
- { pageSize, offset, orgAndProject, predicateFilter, type },
+ { pageSize, offset, orgAndProject, predicate, type, selectedPath },
updateTableConfiguration,
] = useReducer(
(
@@ -35,45 +40,34 @@ export const DataExplorer: React.FC<{}> = () => {
offset: 0,
orgAndProject: undefined,
type: undefined,
- predicateFilter: null,
+ predicate: null,
+ selectedPath: null,
}
);
- const { data: resources, isLoading } = useQuery({
- queryKey: ['data-explorer', { pageSize, offset, orgAndProject, type }],
- retry: false,
- queryFn: () => {
- return nexus.Resource.list(orgAndProject?.[0], orgAndProject?.[1], {
- type,
- from: offset,
- size: pageSize,
- });
- },
- onError: error => {
- notification.error({
- message: 'Error loading data from the server',
- description: isString(error) ? (
- error
- ) : isObject(error) ? (
-
-
{(error as any)['@type']}
-
{(error as any)['details']}
-
- ) : (
- ''
- ),
- });
- },
+ const { data: resources, isLoading } = usePaginatedExpandedResources({
+ pageSize,
+ offset,
+ orgAndProject,
+ type,
});
const currentPageDataSource: Resource[] = resources?._results || [];
- const displayedDataSource = predicateFilter
- ? currentPageDataSource.filter(resource => {
- return predicateFilter(resource);
- })
+ const displayedDataSource = predicate
+ ? currentPageDataSource.filter(predicate)
: currentPageDataSource;
+ const memoizedColumns = useMemo(
+ () =>
+ columnsFromDataSource(
+ currentPageDataSource,
+ showMetadataColumns,
+ selectedPath
+ ),
+ [currentPageDataSource, showMetadataColumns, selectedPath]
+ );
+
return (
@@ -101,38 +95,67 @@ export const DataExplorer: React.FC<{}> = () => {
{!isLoading && (
-
+
+
+
+ setShowMetadataColumns(isChecked)}
+ id="show-metadata-columns"
+ className="data-explorer-toggle"
+ />
+
+
+ setShowEmptyDataCells(isChecked)}
+ id="show-empty-data-cells"
+ className="data-explorer-toggle"
+ />
+
+
+
)}
);
};
-export const isObject = (value: any) => {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-};
-
-export const columnsFromDataSource = (resources: Resource[]): string[] => {
+export const columnsFromDataSource = (
+ resources: Resource[],
+ showMetadataColumns: boolean,
+ selectedPath: string | null
+): string[] => {
const columnNames = new Set
();
resources.forEach(resource => {
Object.keys(resource).forEach(key => columnNames.add(key));
});
- return Array.from(columnNames);
+ if (showMetadataColumns) {
+ return Array.from(columnNames).sort(sortColumns);
+ }
+
+ const selectedMetadataColumn = columnFromPath(selectedPath);
+ return Array.from(columnNames)
+ .filter(
+ colName => isUserColumn(colName) || colName === selectedMetadataColumn
+ )
+ .sort(sortColumns);
};
diff --git a/src/subapps/dataExplorer/DataExplorerTable.tsx b/src/subapps/dataExplorer/DataExplorerTable.tsx
index c27dfce2f..c2141c4e7 100644
--- a/src/subapps/dataExplorer/DataExplorerTable.tsx
+++ b/src/subapps/dataExplorer/DataExplorerTable.tsx
@@ -19,6 +19,7 @@ interface TDataExplorerTable {
offset: number;
updateTableConfiguration: React.Dispatch>;
columns: string[];
+ showEmptyDataCells: boolean;
}
type TColumnNameToConfig = Map>;
@@ -31,6 +32,7 @@ export const DataExplorerTable: React.FC = ({
pageSize,
offset,
updateTableConfiguration,
+ showEmptyDataCells,
}: TDataExplorerTable) => {
const history = useHistory();
const location = useLocation();
@@ -40,6 +42,7 @@ export const DataExplorerTable: React.FC = ({
const tablePaginationConfig: TablePaginationConfig = {
pageSize,
total: allowedTotal,
+ pageSizeOptions: [10, 20, 50],
position: ['bottomLeft'],
defaultPageSize: 50,
defaultCurrent: 0,
@@ -64,11 +67,12 @@ export const DataExplorerTable: React.FC = ({
return (
- columns={columnsConfig(columns)}
+ columns={columnsConfig(columns, showEmptyDataCells)}
dataSource={dataSource}
rowKey={record => record._self}
onRow={resource => ({
onClick: _ => goToResource(resource),
+ 'data-testid': resource._self,
})}
loading={isLoading}
bordered={false}
@@ -81,6 +85,7 @@ export const DataExplorerTable: React.FC = ({
},
}}
pagination={tablePaginationConfig}
+ sticky={{ offsetHeader: 52 }}
/>
);
};
@@ -89,15 +94,18 @@ export const DataExplorerTable: React.FC = ({
* For each resource in the resources array, it creates column configuration for all its keys (if the column config for that key does not already exist).
*/
export const columnsConfig = (
- columnNames: string[]
+ columnNames: string[],
+ showEmptyDataCells: boolean
): ColumnType[] => {
const colNameToConfig = new Map(
- columnNames.length === 0 ? [] : initialTableConfig()
+ columnNames.length === 0 ? [] : initialTableConfig(showEmptyDataCells)
);
for (const columnName of columnNames) {
if (!colNameToConfig.has(columnName)) {
- colNameToConfig.set(columnName, { ...defaultColumnConfig(columnName) });
+ colNameToConfig.set(columnName, {
+ ...defaultColumnConfig(columnName, showEmptyDataCells),
+ });
}
}
@@ -107,15 +115,22 @@ export const columnsConfig = (
export const getColumnTitle = (colName: string) =>
startCase(colName).toUpperCase();
-const defaultColumnConfig = (colName: string): ColumnType => {
+const defaultColumnConfig = (
+ colName: string,
+ showEmptyDataCells: boolean
+): ColumnType => {
return {
key: colName,
title: getColumnTitle(colName),
dataIndex: colName,
className: `data-explorer-column data-explorer-column-${colName}`,
- sorter: false,
+ sorter: (a, b) => {
+ return JSON.stringify(a[colName] ?? '').localeCompare(
+ JSON.stringify(b[colName] ?? '')
+ );
+ },
render: text => {
- if (text === undefined) {
+ if (text === undefined && showEmptyDataCells) {
// Text will also be undefined if a certain resource does not have `colName` as its property
return ;
}
@@ -124,24 +139,30 @@ const defaultColumnConfig = (colName: string): ColumnType => {
};
};
-const initialTableConfig = () => {
+const initialTableConfig = (showEmptyDataCells: boolean) => {
const colNameToConfig: TColumnNameToConfig = new Map();
const projectKey = '_project';
const typeKey = '@type';
const projectConfig: ColumnType = {
- ...defaultColumnConfig(projectKey),
+ ...defaultColumnConfig(projectKey, showEmptyDataCells),
title: 'PROJECT',
render: text => {
if (text) {
const { org, project } = makeOrgProjectTuple(text);
return `${org}/${project}`;
}
- return ;
+ return showEmptyDataCells && ;
+ },
+ sorter: (a, b) => {
+ const tupleA = makeOrgProjectTuple(a[projectKey] ?? '');
+ const tupleB = makeOrgProjectTuple(b[projectKey] ?? '');
+
+ return (tupleA.project ?? '').localeCompare(tupleB.project);
},
};
const typeConfig: ColumnType = {
- ...defaultColumnConfig(typeKey),
+ ...defaultColumnConfig(typeKey, showEmptyDataCells),
title: 'TYPE',
render: text => {
let types = '';
@@ -161,7 +182,7 @@ const initialTableConfig = () => {
{types}
) : (
-
+ showEmptyDataCells &&
);
},
};
diff --git a/src/subapps/dataExplorer/DataExplorerUtils.tsx b/src/subapps/dataExplorer/DataExplorerUtils.tsx
index e48b91df1..73fe2562f 100644
--- a/src/subapps/dataExplorer/DataExplorerUtils.tsx
+++ b/src/subapps/dataExplorer/DataExplorerUtils.tsx
@@ -1,6 +1,86 @@
+import { Resource } from '@bbp/nexus-sdk';
import { useNexusContext } from '@bbp/react-nexus';
+import PromisePool from '@supercharge/promise-pool';
import { notification } from 'antd';
import { useQuery } from 'react-query';
+import { makeOrgProjectTuple } from '../../shared/molecules/MyDataTable/MyDataTable';
+import { isString } from 'lodash';
+
+export const usePaginatedExpandedResources = ({
+ pageSize,
+ offset,
+ orgAndProject,
+ type,
+}: PaginatedResourcesParams) => {
+ const nexus = useNexusContext();
+
+ return useQuery({
+ queryKey: ['data-explorer', { pageSize, offset, orgAndProject, type }],
+ retry: false,
+ queryFn: async () => {
+ const resultWithPartialResources = await nexus.Resource.list(
+ orgAndProject?.[0],
+ orgAndProject?.[1],
+ {
+ type,
+ from: offset,
+ size: pageSize,
+ }
+ );
+
+ // If we failed to fetch the expanded source for some resources, we can use the compact/partial resource as a fallback.
+ const fallbackResources: Resource[] = [];
+ const { results: expandedResources } = await PromisePool.withConcurrency(
+ 4
+ )
+ .for(resultWithPartialResources._results)
+ .handleError(async (err, partialResource) => {
+ console.log(
+ `@@error in fetching resource with id: ${partialResource['@id']}`,
+ err
+ );
+ fallbackResources.push(partialResource);
+ return;
+ })
+ .process(async partialResource => {
+ if (partialResource._project) {
+ const { org, project } = makeOrgProjectTuple(
+ partialResource._project
+ );
+
+ return (await nexus.Resource.get(
+ org,
+ project,
+ encodeURIComponent(partialResource['@id']),
+ { annotate: true }
+ )) as Resource;
+ }
+
+ return partialResource;
+ });
+ return {
+ ...resultWithPartialResources,
+ _results: [...expandedResources, ...fallbackResources],
+ };
+ },
+ onError: error => {
+ notification.error({
+ message: 'Error loading data from the server',
+ description: isString(error) ? (
+ error
+ ) : isObject(error) ? (
+
+
{(error as any)['@type']}
+
{(error as any)['details']}
+
+ ) : (
+ ''
+ ),
+ });
+ },
+ staleTime: Infinity,
+ });
+};
export const useAggregations = (
bucketName: 'projects' | 'types',
@@ -24,9 +104,61 @@ export const useAggregations = (
onError: error => {
notification.error({ message: 'Aggregations could not be fetched' });
},
+ staleTime: Infinity,
});
};
+export const sortColumns = (a: string, b: string) => {
+ // Sorts paths alphabetically. Additionally all paths starting with an underscore are sorted at the end of the list (because they represent metadata).
+ const columnA = columnFromPath(a);
+ const columnB = columnFromPath(b);
+
+ if (!isUserColumn(columnA) && !isUserColumn(columnB)) {
+ return a.localeCompare(b);
+ }
+ if (!isUserColumn(columnA)) {
+ return 1;
+ }
+ if (!isUserColumn(columnB)) {
+ return -1;
+ }
+ // Neither a, nor b are userColumns. Now, we want to "ALWAYS_SORTED_COLUMNS" to appear below other user defined columns like "contributions"
+ if (ALWAYS_DISPLAYED_COLUMNS.has(a) && ALWAYS_DISPLAYED_COLUMNS.has(b)) {
+ return a.localeCompare(b);
+ }
+ if (ALWAYS_DISPLAYED_COLUMNS.has(a)) {
+ return 1;
+ }
+ if (ALWAYS_DISPLAYED_COLUMNS.has(b)) {
+ return -1;
+ }
+ return a.localeCompare(b);
+};
+
+export const columnFromPath = (path: string | null) =>
+ path?.split('.')[0] ?? '';
+
+export const isUserColumn = (colName: string) => {
+ return ALWAYS_DISPLAYED_COLUMNS.has(colName) || !isNexusMetadata(colName);
+};
+
+export const ALWAYS_DISPLAYED_COLUMNS = new Set([
+ '_project',
+ '_createdAt',
+ '_updatedAt',
+]);
+
+const UNDERSCORE = '_';
+
+const METADATA_COLUMNS = new Set(['@id', '@context']);
+
+export const isNexusMetadata = (colName: string) =>
+ METADATA_COLUMNS.has(colName) || colName.startsWith(UNDERSCORE);
+
+export const isObject = (value: any) => {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+};
+
export interface AggregationsResult {
'@context': string;
total: number;
@@ -43,3 +175,10 @@ export type AggregatedProperty = {
};
export type AggregatedBucket = { key: string; doc_count: number };
+
+interface PaginatedResourcesParams {
+ pageSize: number;
+ offset: number;
+ orgAndProject?: string[];
+ type?: string;
+}
diff --git a/src/subapps/dataExplorer/PredicateSelector.tsx b/src/subapps/dataExplorer/PredicateSelector.tsx
index 5d8b9cc47..6208c688c 100644
--- a/src/subapps/dataExplorer/PredicateSelector.tsx
+++ b/src/subapps/dataExplorer/PredicateSelector.tsx
@@ -1,10 +1,18 @@
-import { Input, Select } from 'antd';
-import React, { useState } from 'react';
-import { DataExplorerConfiguration, isObject } from './DataExplorer';
-import './styles.less';
+import { UndoOutlined } from '@ant-design/icons';
import { Resource } from '@bbp/nexus-sdk';
+import { Button, Form, Input, Select } from 'antd';
+import { FormInstance } from 'antd/es/form';
+import { DefaultOptionType } from 'antd/lib/cascader';
+import React, { useMemo, useRef } from 'react';
import { normalizeString } from '../../utils/stringUtils';
-import { clsx } from 'clsx';
+import { DataExplorerConfiguration } from './DataExplorer';
+import {
+ columnFromPath,
+ isObject,
+ isUserColumn,
+ sortColumns,
+} from './DataExplorerUtils';
+import './styles.less';
interface Props {
dataSource: Resource[];
@@ -15,138 +23,204 @@ export const PredicateSelector: React.FC = ({
dataSource,
onPredicateChange,
}: Props) => {
- const [path, setPath] = useState(DEFAULT_OPTION);
-
- const [predicate, setPredicate] = useState(
- DEFAULT_OPTION
- );
- const [searchTerm, setSearchTerm] = useState(null);
+ const formRef = useRef(null);
- const pathOptions = [
- { value: DEFAULT_OPTION },
- ...getAllPaths(dataSource).map(path => ({ value: path })),
- ];
const predicateFilterOptions: PredicateFilterOptions[] = [
- { value: DEFAULT_OPTION },
{ value: EXISTS },
{ value: DOES_NOT_EXIST },
{ value: CONTAINS },
{ value: DOES_NOT_CONTAIN },
];
+ const allPathOptions = useMemo(
+ () => pathOptions([...getAllPaths(dataSource)]),
+ [dataSource]
+ );
+
const predicateSelected = (
path: string,
- predicate: PredicateFilterOptions['value'],
+ predicate: PredicateFilterOptions['value'] | null,
searchTerm: string | null
) => {
- if (path === DEFAULT_OPTION || predicate === DEFAULT_OPTION) {
- onPredicateChange({ predicateFilter: null });
+ if (!path || !predicate) {
+ onPredicateChange({ predicate: null, selectedPath: null });
}
switch (predicate) {
case EXISTS:
onPredicateChange({
- predicateFilter: (resource: Resource) =>
+ predicate: (resource: Resource) =>
checkPathExistence(resource, path, 'exists'),
+ selectedPath: path,
});
break;
case DOES_NOT_EXIST:
onPredicateChange({
- predicateFilter: (resource: Resource) =>
+ predicate: (resource: Resource) =>
checkPathExistence(resource, path, 'does-not-exist'),
+ selectedPath: path,
});
break;
case CONTAINS:
if (searchTerm) {
onPredicateChange({
- predicateFilter: (resource: Resource) =>
+ predicate: (resource: Resource) =>
doesResourceContain(resource, path, searchTerm, 'contains'),
+ selectedPath: path,
});
} else {
- onPredicateChange({ predicateFilter: null });
+ onPredicateChange({ predicate: null, selectedPath: null });
}
break;
case DOES_NOT_CONTAIN:
if (searchTerm) {
onPredicateChange({
- predicateFilter: (resource: Resource) =>
+ predicate: (resource: Resource) =>
doesResourceContain(
resource,
path,
searchTerm,
'does-not-contain'
),
+ selectedPath: path,
});
} else {
- onPredicateChange({ predicateFilter: null });
+ onPredicateChange({ predicate: null, selectedPath: null });
}
break;
default:
- onPredicateChange({ predicateFilter: null });
+ onPredicateChange({ predicate: null, selectedPath: null });
+ }
+ };
+
+ const getFormFieldValue = (fieldName: string) => {
+ return formRef.current?.getFieldValue(fieldName) ?? '';
+ };
+
+ const setFormField = (fieldName: string, fieldValue: string) => {
+ if (formRef.current) {
+ formRef.current.setFieldValue(fieldName, fieldValue);
}
};
+ const onReset = () => {
+ const form = formRef.current;
+ if (form) {
+ form.resetFields();
+ }
+
+ onPredicateChange({ predicate: null, selectedPath: null });
+ };
+
const shouldShowValueInput =
- predicate === CONTAINS || predicate === DOES_NOT_CONTAIN;
+ getFormFieldValue(PREDICATE_FIELD) === CONTAINS ||
+ getFormFieldValue(PREDICATE_FIELD) === DOES_NOT_CONTAIN;
return (
-
+
+
+
+
);
};
-export const DEFAULT_OPTION = '-';
export const DOES_NOT_EXIST = 'Does not exist';
export const EXISTS = 'Exists';
export const CONTAINS = 'Contains';
export const DOES_NOT_CONTAIN = 'Does not contain';
+const PATH_FIELD = 'path';
+const PREDICATE_FIELD = 'predicate';
+const SEARCH_TERM_FIELD = 'searchTerm';
+
export type PredicateFilterT =
| typeof DOES_NOT_EXIST
| typeof EXISTS
@@ -155,32 +229,39 @@ export type PredicateFilterT =
| null;
type PredicateFilterOptions = {
- value: Exclude | typeof DEFAULT_OPTION;
+ value: Exclude;
};
-export const pathOptions = (paths: string[]) => [
- { value: DEFAULT_OPTION },
- paths.map(path => ({ value: path })),
-];
+// Creates element for each path. Also adds a class of "first-metadata-path" for the first path generated for a metadata column.
+export const pathOptions = (paths: string[]) => {
+ let firstMetadataFound = false;
+ const pathOptions: DefaultOptionType[] = [];
-const UNDERSCORE = '_';
+ paths.forEach(path => {
+ const column = columnFromPath(path);
+ const isFirstMetadataPath = !isUserColumn(column) && !firstMetadataFound;
-export const getAllPaths = (objects: { [key: string]: any }[]): string[] => {
- return Array.from(getPathsForResource(objects, '')).sort(
- (a: string, b: string) => {
- // Sorts paths alphabetically. Additionally all paths starting with an underscore are sorted at the end of the list (because they represent metadata).
- if (a.startsWith(UNDERSCORE) && b.startsWith(UNDERSCORE)) {
- return a.localeCompare(b);
- }
- if (a.startsWith(UNDERSCORE)) {
- return 1;
- }
- if (b.startsWith(UNDERSCORE)) {
- return -1;
- }
- return a.localeCompare(b);
+ pathOptions.push({
+ value: path,
+ label: (
+
+ {path}
+
+ ),
+ });
+
+ if (isFirstMetadataPath) {
+ firstMetadataFound = true;
}
- );
+ });
+ return pathOptions;
+};
+
+export const getAllPaths = (objects: { [key: string]: any }[]): string[] => {
+ return Array.from(getPathsForResource(objects, '')).sort(sortColumns);
};
const getPathsForResource = (
@@ -206,7 +287,7 @@ export const checkPathExistence = (
path: string,
criteria: 'exists' | 'does-not-exist' = 'exists'
): boolean => {
- if (path in resource) {
+ if (isObject(resource) && path in resource) {
return criteria === 'exists' ? true : false;
}
@@ -215,7 +296,7 @@ export const checkPathExistence = (
for (const subpath of subpaths) {
const valueAtSubpath = resource[subpath];
const remainingPath = subpaths.slice(1);
- if (!(subpath in resource)) {
+ if (isObject(resource) && !(subpath in resource)) {
return criteria === 'exists' ? false : true;
}
diff --git a/src/subapps/dataExplorer/styles.less b/src/subapps/dataExplorer/styles.less
index e162e228a..4dd17543b 100644
--- a/src/subapps/dataExplorer/styles.less
+++ b/src/subapps/dataExplorer/styles.less
@@ -12,16 +12,52 @@
margin-bottom: 28px;
}
+ .flex-container {
+ display: flex;
+ max-width: 90vw;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ }
+
.data-explorer-count {
color: @fusion-neutral-7;
margin-left: 20px;
- margin-bottom: 28px;
span {
margin-right: 24px;
}
}
+ .data-explorer-toggles {
+ label {
+ margin-left: 6px;
+ color: @fusion-blue-8;
+ }
+ .data-explorer-toggle {
+ border: 1px solid @fusion-blue-8;
+ box-sizing: content-box;
+ margin-left: 30px;
+
+ &[aria-checked='true'] {
+ background-color: @fusion-blue-8;
+ .ant-switch-handle::before {
+ background-color: white;
+ }
+ }
+ &[aria-checked='false'] {
+ background-color: transparent;
+ .ant-switch-handle::before {
+ background-color: @fusion-blue-8;
+ }
+ }
+ }
+ }
+
+ .text-button {
+ color: @fusion-gray-7;
+ }
+
.form-container {
display: flex;
align-items: center;
@@ -29,6 +65,10 @@
max-width: fit-content;
margin: 0 10px;
+ .ant-form-item {
+ margin-bottom: 0;
+ }
+
.label {
font-size: 12px;
font-weight: 300;
@@ -57,6 +97,15 @@
}
}
+ .ant-select-selection-item {
+ span {
+ color: @fusion-blue-8 !important;
+ border: none;
+ background: @fusion-main-bg;
+ font-weight: 700;
+ }
+ }
+
input:focus {
border: none !important;
}
@@ -89,6 +138,12 @@
font-weight: 700;
}
}
+ .select-menu.reduced-width {
+ .ant-select-selector {
+ min-width: 140px;
+ width: max-content;
+ }
+ }
}
.select-menu.greyed-out {
@@ -121,18 +176,19 @@
}
.predicate-value-input {
- border-bottom: 1px solid @medium-gray;
color: @fusion-blue-8;
- &::placeholder {
- color: @fusion-blue-8;
- }
+ background: @fusion-main-bg;
+ width: 200px;
}
}
.data-explorer-table {
table {
width: auto;
- min-width: unset !important;
+ min-width: 90vw;
+ }
+ .ant-table {
+ background: @fusion-main-bg;
}
.ant-table-thead > tr > th.data-explorer-column {
background-color: #f5f5f5 !important;
@@ -170,5 +226,12 @@
.search-menu {
.ant-select-item-option-content {
color: @fusion-blue-8;
+ width: 100%;
+ .first-metadata-path {
+ width: 100%;
+ display: block;
+ border-top: 1px solid @medium-gray;
+ padding-top: 4px;
+ }
}
}