diff --git a/gui/src/components/entry/properties/SampleHistoryCard.js b/gui/src/components/entry/properties/SampleHistoryCard.js index 5b2e7db2e19728198d50c79e8ef1b3b9eeeb8366..b2da5ba428912cc56c3f8f2816a43cfea4b54179 100644 --- a/gui/src/components/entry/properties/SampleHistoryCard.js +++ b/gui/src/components/entry/properties/SampleHistoryCard.js @@ -21,7 +21,7 @@ import React, { memo, useMemo } from 'react' import PropTypes from 'prop-types' import { PropertyCard } from './PropertyCard' import { SearchContext, useSearchContext } from "../../search/SearchContext" -import SearchResults from "../../search/SearchResults" +import { SearchResultsWithContext } from "../../search/SearchResults" import { cloneDeep } from "lodash" import { ui } from "../../../config" @@ -55,7 +55,7 @@ const HistoryCard = memo(() => { return ( <PropertyCard title='History'> - <SearchResults + <SearchResultsWithContext title='activity' multiSelect={false} PaperProps={{elevation: 0}} diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index a7ee7fa8762a9355f7a766a043d39bbab0b28ce4..b6c87d255f16b72071fb4e5a604b5d8c36bce7d4 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -57,7 +57,8 @@ import { parseQuantityName, rsplit, parseOperator, - getSuggestions + getSuggestions, + DType } from '../../utils' import { Quantity } from '../units/Quantity' import { Unit } from '../units/Unit' @@ -246,13 +247,18 @@ export const SearchContextRaw = React.memo(({ // Add unit information if one is defined. This unit is currently fixed and // not affected by global unit system. const config = cloneDeep(initialColumns) - let options = config?.options ? Object.values(config.options) : [] + let options = config?.options + ? Object.entries(config.options).map(([key, value]) => ({key, ...value})) + : [] options.forEach(option => { - const unit = option.unit + const storageUnit = initialFilterData[option.quantity || option.key]?.unit + const displayUnit = option.unit option.label = option.label || initialFilterData[option.quantity || option.key]?.label - if (unit) { - option.unit = new Unit(unit) - option.label = `${option.label} (${option.unit.label()})` + if (storageUnit || displayUnit) { + const finalUnit = displayUnit + ? new Unit(displayUnit) + : new Unit(storageUnit).toSystem(units) + option.label = `${option.label} (${finalUnit.label()})` } }) @@ -264,7 +270,6 @@ export const SearchContextRaw = React.memo(({ const transform = (value) => { const key = option.key - const unit = option.unit const format = option.format const dtype = initialFilterData[key]?.dtype @@ -272,11 +277,15 @@ export const SearchContextRaw = React.memo(({ return formatTimestamp(value, format?.mode) } - if (unit) { - const originalUnit = initialFilterData[key]?.unit - value = new Quantity(value, originalUnit).to(unit).value() + const storageUnit = initialFilterData[option.quantity || option.key]?.unit + const displayUnit = option.unit + if (storageUnit || displayUnit) { + const finalUnit = displayUnit + ? new Unit(displayUnit) + : new Unit(storageUnit).toSystem(units) + value = new Quantity(value, storageUnit).to(finalUnit).value() } - if (format) { + if (dtype === DType.Int || dtype === DType.Float) { value = formatNumber(value, dtype, format?.mode, format?.decimals) } return value @@ -359,7 +368,7 @@ export const SearchContextRaw = React.memo(({ })) return config - }, [initialFilterData, initialColumns, user]) + }, [initialFilterData, initialColumns, user, units]) // The final row configuration const rows = useMemo(() => { diff --git a/gui/src/components/search/SearchContext.spec.js b/gui/src/components/search/SearchContext.spec.js index 34a776cfdcd9d637d01c48a71436db832c8424f5..6d8aa5d15e8c96f5c7db247a8b10e17f6c71028d 100644 --- a/gui/src/components/search/SearchContext.spec.js +++ b/gui/src/components/search/SearchContext.spec.js @@ -15,14 +15,30 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import React from 'react' import { renderHook, act } from '@testing-library/react-hooks' import { WrapperSearch } from './conftest.spec' -import { useSearchContext } from './SearchContext' +import { useSearchContext, SearchContextRaw } from './SearchContext' import { Quantity } from '../units/Quantity' import { isEqualWith } from 'lodash' +import { Filter } from '../search/Filter' import { SearchSuggestion, SuggestionType } from './SearchSuggestion' import { WrapperDefault } from '../conftest.spec' +/** + * Can be used to wrap the test in a specific search context. + */ +const Wrapper = (props) => { + return <WrapperDefault> + <SearchContextRaw + resource="entries" + id='entries' + {...props} + ></SearchContextRaw> + </WrapperDefault> +} + describe('parseQuery', function() { test.each([ ['unit not specified', 'results.material.topology.cell.a', '1', new Quantity(1, 'angstrom'), undefined], @@ -108,3 +124,21 @@ describe('reading query from URL', function() { expect(query).toMatchObject(expected_query) }) }) + +describe('test that final column information is generated correctly', function() { + test.each([ + ['default unit, default label', {}, new Filter({name: 'test_filter'}, {group: 'test', unit: 'joule'}), 'Test filter (eV)'], + ['custom label, default unit', {label: 'Testing'}, new Filter(undefined, {group: 'test', unit: 'joule'}), 'Testing (eV)'], + ['default label, custom unit', {}, new Filter({name: 'test_filter'}, {group: 'test', unit: 'joule'}), 'Test filter (eV)'], + ['custom label, custom unit', {label: 'Testing', unit: 'Ha'}, new Filter(undefined, {group: 'test', unit: 'joule'}), 'Testing (Ha)'] + ])('%s', async (name, column, filter, label) => { + const key = 'test_filter' + const { result: resultUseSearchContext } = renderHook(() => useSearchContext(), { wrapper: (props) => <Wrapper + initialFilterData={{[key]: filter}} + initialColumns={{options: {[key]: column}}} {...props} + />}) + const columns = resultUseSearchContext.current.columns + expect(columns.options[key].label).toBe(label) + } + ) +}) diff --git a/gui/src/components/search/SearchPage.js b/gui/src/components/search/SearchPage.js index fcab3b702325455c3899441a0b2c10a1b3c8244c..4845cb1cf41ec2968da080bd79bca6a7674e13f4 100644 --- a/gui/src/components/search/SearchPage.js +++ b/gui/src/components/search/SearchPage.js @@ -23,7 +23,7 @@ import { makeStyles } from '@material-ui/core/styles' import FilterMainMenu from './menus/FilterMainMenu' import { collapsedMenuWidth } from './menus/FilterMenu' import SearchBar from './SearchBar' -import SearchResults from './SearchResults' +import { SearchResultsWithContext } from './SearchResults' import Dashboard from './widgets/Dashboard' import { useSearchContext } from './SearchContext' @@ -114,7 +114,7 @@ const SearchPage = React.memo(({ <Dashboard/> </Box> <Box position="relative" zIndex={1}> - <SearchResults/> + <SearchResultsWithContext/> </Box> <div className={clsx(styles.shadow, isMenuOpen && styles.shadowVisible)}></div> </Box> diff --git a/gui/src/components/search/SearchPage.spec.js b/gui/src/components/search/SearchPage.spec.js index a426eb3fa89b31765b35979a084cac5907dc508c..bf0936a71182b7ed176b297d3653c8eb062e9eea 100644 --- a/gui/src/components/search/SearchPage.spec.js +++ b/gui/src/components/search/SearchPage.spec.js @@ -30,9 +30,8 @@ describe('', () => { }) afterAll(() => closeAPI()) - test.each( - Object.entries(ui.apps.options) - )('renders search page correctly, context: %s', async (key, context) => { + test('renders search page correctly', async () => { + const context = ui.apps.options.entries render( <SearchContext resource={context.resource} diff --git a/gui/src/components/search/SearchResults.js b/gui/src/components/search/SearchResults.js index d474dd8d2e72461c277dd68a9b4734fb275b2ca4..42602fcaa71ddb2283862a13c1c1e9f804f11367 100644 --- a/gui/src/components/search/SearchResults.js +++ b/gui/src/components/search/SearchResults.js @@ -48,18 +48,27 @@ export const ActionURL = React.memo(({action, data}) => { </Tooltip> }) ActionURL.propTypes = { - // Action configuration from app config - action: PropTypes.object.isRequired, - // ES index data - data: PropTypes.object.isRequired + action: PropTypes.object.isRequired, // Action configuration from app config + data: PropTypes.object.isRequired // ES index data } /** * Displays the list of search results. */ -const SearchResults = React.memo((props) => { - const {noAction, onSelectedChanged, defaultUncollapsedEntryID, title, 'data-testid': testID, PaperProps, ...otherProps} = props - const {columns, resource, rows, useResults, useApiQuery} = useSearchContext() +export const SearchResults = React.memo(({ + columns, + resource, + rows, + useResults, + useApiQuery, + noAction, + onSelectedChanged, + defaultUncollapsedEntryID, + title, + 'data-testid': testID, + PaperProps, + ...otherProps +}) => { const {data, pagination, setPagination} = useResults() const apiQuery = useApiQuery() const [selected, setSelected] = useState(new Set()) @@ -148,6 +157,11 @@ const SearchResults = React.memo((props) => { }) SearchResults.propTypes = { + columns: PropTypes.object, + resource: PropTypes.string, + rows: PropTypes.object, + useResults: PropTypes.func, + useApiQuery: PropTypes.func, noAction: PropTypes.bool, PaperProps: PropTypes.object, onSelectedChanged: PropTypes.func, @@ -160,4 +174,18 @@ SearchResults.defaultProps = { 'data-testid': 'search-results' } -export default SearchResults +/** + * Displays search results from the current search context. + */ +export const SearchResultsWithContext = React.memo((props) => { + const {columns, resource, rows, useResults, useApiQuery} = useSearchContext() + + return <SearchResults + columns={columns} + resource={resource} + rows={rows} + useResults={useResults} + useApiQuery={useApiQuery} + {...props} + /> +}) diff --git a/gui/src/components/search/SearchResults.spec.js b/gui/src/components/search/SearchResults.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..927cd456cc22b151e050f948ccc4184d7259b812 --- /dev/null +++ b/gui/src/components/search/SearchResults.spec.js @@ -0,0 +1,38 @@ +/* + * Copyright The NOMAD Authors. + * + * This file is part of NOMAD. See https://nomad-lab.eu for further info. + * + * 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 React from 'react' +import { renderNoAPI, screen } from '../conftest.spec' +import { SearchResults } from './SearchResults' + +describe('test header', () => { + test.each([ + ['default label', {options: {hello: {key: 'default', render: () => ''}}}, 'Default'], + ['custom label', {options: {hello: {key: 'hello', label: 'custom', render: () => ''}}}, 'custom'] + ])('%s', async (name, columns, expected) => { + renderNoAPI(<SearchResults + columns={columns} + useResults={() => ({ + data: [{}], + pagination: {total: 1} + })} + useApiQuery={() => ({})} + />) + expect(screen.getByText(expected)).toBeInTheDocument() + }) +}) diff --git a/gui/src/components/search/conftest.spec.js b/gui/src/components/search/conftest.spec.js index 3dba928ee3c5fbc2f4f081ae5eecb07f34e4ef73..2052e282981591deee38acb6a5071cb989dfb13c 100644 --- a/gui/src/components/search/conftest.spec.js +++ b/gui/src/components/search/conftest.spec.js @@ -336,7 +336,7 @@ export async function expectSearchResults(context, root = screen) { const columnConfig = context.columns const columnLabels = columnConfig.selected.map(key => { const config = columnConfig.options[key] - const unit = config.unit + const unit = config.unit || defaultFilterData[key]?.unit const label = config.label || defaultFilterData[key]?.label || getDisplayLabel({name: key.split('.').slice(-1)[0]}) return unit ? `${label} (${new Unit(unit).label()})` diff --git a/gui/src/components/uploads/SectionSelectDialog.js b/gui/src/components/uploads/SectionSelectDialog.js index 19bfd72f2d70f54624d0e90d132b07416bb7903c..f9c6200b1b604feae2bd9000a36d9eaf6dc5844c 100644 --- a/gui/src/components/uploads/SectionSelectDialog.js +++ b/gui/src/components/uploads/SectionSelectDialog.js @@ -31,7 +31,7 @@ import {useUploadPageContext} from './UploadPageContext' import {useEntryStore} from '../entry/EntryContext' import {traverse, useGlobalMetainfo} from '../archive/metainfo' import { defaultFilterGroups, quantityNameSearch } from '../search/FilterRegistry' -import SearchResults from '../search/SearchResults' +import { SearchResultsWithContext } from '../search/SearchResults' import {useDataStore} from '../DataStore' import {pluralize, resolveNomadUrlNoThrow} from "../../utils" import {Check} from '@material-ui/icons' @@ -382,7 +382,7 @@ function SearchBox({open, onCancel, onSelectedChanged, selected}) { {definedFilters.length > 0 && <Chip label={`and ${definedFilters.length} more ${pluralize('filter', definedFilters.length, false)}`} color="primary" onDelete={() => handleResetSearch()}/>} </Box> <div className={classes.resultsTable}> - <SearchResults + <SearchResultsWithContext defaultUncollapsedEntryID={selected?.entry_id} multiSelect={false} noAction diff --git a/nomad/config/models/ui.py b/nomad/config/models/ui.py index 7bcc67aa6da030ea6441a3962e1bdbe5d0752371..388209989e303a85e28e89d3fdc185b210e42fae 100644 --- a/nomad/config/models/ui.py +++ b/nomad/config/models/ui.py @@ -224,7 +224,7 @@ class Format(ConfigBaseModel): """Value formatting options.""" decimals: int = Field(3, description='Number of decimals to show for numbers.') - mode: ModeEnum = Field('standard', description='Display mode for numbers.') + mode: ModeEnum = Field(ModeEnum.SCIENTIFIC, description='Display mode for numbers.') class AlignEnum(str, Enum):