diff --git a/docs/reference/annotations.md b/docs/reference/annotations.md index 224c32ca9394849022cb7eec002c7366ac24b265..0f985c5232dc0596422f068fa3683cf5cecfad5b 100644 --- a/docs/reference/annotations.md +++ b/docs/reference/annotations.md @@ -20,6 +20,43 @@ Many annotations control the representation of data in the GUI. This can be for {{ pydantic_model('nomad.datamodel.metainfo.annotations.BrowserAnnotation', heading='## browser') }} +### `display unit system` +The unit system to override the global or the unit system determined by parent. It could be used in the level of Package, Section, or Quantity. +One can use the supported [unit systems](./config.md#unitsystems) such as `SI` or `AU`. + +```yaml +definitions: + name: 'Sun' + m_annotations: + display: + unit_system: SI + sections: + SolarSystem: + quantities: + total_energy: + type: float + description: The total energy in SI unit system, energy unit + sub_sections: + Electrons: + section: + m_annotations: + display: + unit_system: AU + quantities: + free_energy: + type: float + description: The electron energy in AU unit system, energy unit + energy: + m_annotations: + display: + unit: erg + type: float + description: The electron energy in a custom unit +``` + +### `display unit` +To determine the desired unit to override the global or the unit determined by parent. See the example in [display unit system](#display_unit_system). + ### `label_quantity` This annotation goes in the section that we want to be filled with tabular data, not in the single quantities. diff --git a/gui/src/components/archive/ArchiveBrowser.js b/gui/src/components/archive/ArchiveBrowser.js index fbdc870188ee237acd941fa351cb5a833fb535b2..8bdc333d4dd6478b11798407c17fd33224db27e2 100644 --- a/gui/src/components/archive/ArchiveBrowser.js +++ b/gui/src/components/archive/ArchiveBrowser.js @@ -36,7 +36,7 @@ import { Matrix, Number } from './visualizations' import Markdown from '../Markdown' import { Overview } from './Overview' import { Quantity as Q } from '../units/Quantity' -import { useUnitContext } from '../units/UnitContext' +import {useUnitContext} from '../units/UnitContext' import ArrowRightIcon from '@material-ui/icons/ArrowRight' import ArrowDownIcon from '@material-ui/icons/ArrowDropDown' import DownloadIcon from '@material-ui/icons/CloudDownload' @@ -61,7 +61,7 @@ import { EntryButton } from '../nav/Routes' import NavigateIcon from '@material-ui/icons/ArrowForward' import ReloadIcon from '@material-ui/icons/Replay' import UploadIcon from '@material-ui/icons/CloudUpload' -import { apiBase } from '../../config' +import {apiBase} from '../../config' import { Alert } from '@material-ui/lab' import { complex, format } from 'mathjs' import ReactJson from 'react-json-view' @@ -648,8 +648,8 @@ const convertComplexArray = (real, imag) => { : format(complex(real, imag), {notation: 'auto', precision: 4, lowerExp: -999, upperExp: 999}) } -function QuantityItemPreview({value, def}) { - const {units} = useUnitContext() +export function QuantityItemPreview({value, def}) { + const {units, unitSystems, isReset} = useUnitContext() if (isReference(def)) { return <Box component="span" fontStyle="italic"> <Typography component="span">reference ...</Typography> @@ -709,7 +709,19 @@ function QuantityItemPreview({value, def}) { let finalUnit if (def.unit) { - const a = new Q(finalValue, def.unit).toSystem(units) + let a + const section_default_unit_system = def?._parent?.m_annotations?.display?.[0]?.unit_system + const quantity_default_unit = def?.m_annotations?.display?.[0]?.unit + if (isReset && (section_default_unit_system || quantity_default_unit)) { + if (section_default_unit_system && unitSystems[section_default_unit_system]) { + a = new Q(finalValue, def.unit).toSystem(unitSystems[section_default_unit_system].units) + } + if (quantity_default_unit) { + a = new Q(finalValue, def.unit).to(quantity_default_unit) + } + } else { + a = new Q(finalValue, def.unit).toSystem(units) + } finalValue = a.value() finalUnit = a.label() } @@ -910,6 +922,7 @@ export function Section({section, def, parentRelation, sectionIsEditable, sectio const [showJson, setShowJson] = useState(false) const lane = useContext(laneContext) const history = useHistory() + useUnitContext(def._package.name, def?._package?.m_annotations?.display?.[0]?.unit_system) const isEditable = useMemo(() => { let editableExcluded = false diff --git a/gui/src/components/archive/QuantityItemPreview.spec.js b/gui/src/components/archive/QuantityItemPreview.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e23099e11fbe6eaf4ace43d85a3591f78bc81985 --- /dev/null +++ b/gui/src/components/archive/QuantityItemPreview.spec.js @@ -0,0 +1,64 @@ +/* + * 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 {render} from "../conftest.spec" +import {QuantityItemPreview} from "./ArchiveBrowser" + +describe('Test QuantityItemPreview', () => { + it('without default display unit', async () => { + render( + <QuantityItemPreview + def={{ + unit: 'meter', + type: {type_kind: 'python', type_data: 'float'}, + shape: [], + m_annotations: {} + }} + value={3.5} + /> + ) + // should be rendered in default unit system + const span = document.querySelector('span') + expect(span.textContent).toContain('3.5') + expect(span.textContent).toContain('·10') + const sup = document.querySelector('sup') + expect(sup.textContent).toContain('+10') + }) + + it('with default display unit', async () => { + render( + <QuantityItemPreview + def={{ + unit: 'meter', + type: {type_kind: 'python', type_data: 'float'}, + shape: [], + m_annotations: { + display: [{ + unit: 'mm' + }] + } + }} + value={3.5} + /> + ) + // should be rendered in default unit provided by schema + const span = document.querySelector('span') + expect(span.textContent).toContain('3500 mm') + }) +}) diff --git a/gui/src/components/archive/Section.spec.js b/gui/src/components/archive/Section.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1994faf6c2747c68c561e2e88e023437ed8365d2 --- /dev/null +++ b/gui/src/components/archive/Section.spec.js @@ -0,0 +1,194 @@ +/* + * 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 {render, screen} from "../conftest.spec" +import {within} from "@testing-library/dom" +import {waitFor} from "@testing-library/react" +import {Section} from "./ArchiveBrowser" +import {Metainfo} from "./metainfo" +import {systemMetainfoUrl} from "../../utils" +import {laneContext} from './Browser' +import {TestAdaptor} from "./Browser.spec" + +async function createMetainfo(data, parent, url = systemMetainfoUrl) { + data._url = url + data._metainfo = new Metainfo(parent, data, null, {}, {}) + return await data._metainfo._result +} + +const mockPackage = ({ + packages: [ + { + name: 'testPackage', + m_annotations: { + display: [{ + unit_system: 'AU' + }] + }, + section_definitions: [ + { + name: 'TestSection', + quantities: [ + { + name: 'value1', + type: { type_kind: 'python', type_data: 'float' }, + unit: 'meter', + m_parent_sub_section: 'quantities' + }, + { + name: 'value2', + m_annotations: { + eln: [ + { + component: "NumberEditQuantity" + } + ] + }, + type: { type_kind: 'python', type_data: 'float' }, + unit: 'meter', + m_parent_sub_section: 'quantities' + } + ] + }, + { + name: 'TestSection', + m_annotations: { + display: [{ + unit_system: 'SI' + }] + }, + quantities: [ + { + name: 'value3', + type: { type_kind: 'python', type_data: 'float' }, + unit: 'meter', + m_parent_sub_section: 'quantities' + }, + { + name: 'value4', + m_annotations: { + eln: [ + { + component: "NumberEditQuantity" + } + ] + }, + type: { type_kind: 'python', type_data: 'float' }, + unit: 'meter', + m_parent_sub_section: 'quantities' + } + ] + } + ] + } + ] +}) + +describe('Test interaction between unit menu and quantities', () => { + it('Package with display unit_system', async () => { + const metainfo = await createMetainfo(mockPackage) + const defsByName = await metainfo.getDefsByName() + const def = defsByName.TestSection[0] + const adaptor = new TestAdaptor('', 'Data') + + render( + <laneContext.Provider value={{next: {}, adaptor: adaptor}}> + <Section + section={{value1: 7.5}} + def={def} + sectionIsInEln={false} + sectionIsEditable={false} + /> + </laneContext.Provider> + ) + // should be rendered in package default unit system + const numberWithUnit = document.querySelector('[data-testid="scientific-number-with-unit"]') + const span = numberWithUnit.querySelector('span') + expect(span.textContent).toContain('1.41729459') + expect(span.textContent).toContain('·10') + const sup = document.querySelector('sup') + expect(sup.textContent).toContain('+11') + }) + + it('Editable package with display unit_system', async () => { + const metainfo = await createMetainfo(mockPackage) + const defsByName = await metainfo.getDefsByName() + const def = defsByName.TestSection[0] + const adaptor = new TestAdaptor('', 'Data') + + render( + <laneContext.Provider value={{next: {}, adaptor: adaptor}}> + <Section + section={{value2: 8.5}} + def={def} + sectionIsInEln={true} + sectionIsEditable={true} + /> + </laneContext.Provider> + ) + // should be rendered in package default unit system + const numberFieldValue = screen.getByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + // should be rendered in default unit system + await waitFor(() => expect(numberFieldValueInput.value).toEqual('160626720592.894')) + }) + + it('Section with display unit_system', async () => { + const metainfo = await createMetainfo(mockPackage) + const defsByName = await metainfo.getDefsByName() + const def = defsByName.TestSection[1] + const adaptor = new TestAdaptor('', 'Data') + + render( + <laneContext.Provider value={{next: {}, adaptor: adaptor}}> + <Section + section={{value3: 7.5}} + def={def} + sectionIsInEln={false} + sectionIsEditable={false} + /> + </laneContext.Provider> + ) + // Should be rendered in section default unit system, The determined package default unit system should be overridden. + const numberWithUnit = document.querySelector('[data-testid="scientific-number-with-unit"]') + expect(numberWithUnit.textContent).toContain('7.50000') + }) + + it('Editable Section with display unit_system', async () => { + const metainfo = await createMetainfo(mockPackage) + const defsByName = await metainfo.getDefsByName() + const def = defsByName.TestSection[1] + const adaptor = new TestAdaptor('', 'Data') + + render( + <laneContext.Provider value={{next: {}, adaptor: adaptor}}> + <Section + section={{value4: 8.5}} + def={def} + sectionIsInEln={true} + sectionIsEditable={true} + /> + </laneContext.Provider> + ) + // Should be rendered in section default unit system, The determined package default unit system should be overridden. + const numberFieldValue = screen.getByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + await waitFor(() => expect(numberFieldValueInput.value).toEqual('8.5')) + }) +}) diff --git a/gui/src/components/archive/visualizations.js b/gui/src/components/archive/visualizations.js index cc6fccf82462465daaaa80546c6bb908ce3c2881..236ab6a964dfd9afd06cbb170df43ca50e3640bb 100644 --- a/gui/src/components/archive/visualizations.js +++ b/gui/src/components/archive/visualizations.js @@ -39,7 +39,7 @@ export function Number({value, exp, variant, unit, ...props}) { html = value.toString() } } - return <Typography {...props} variant={variant} >{html}{unit && <span> {unit}</span>}</Typography> + return <Typography {...props} variant={variant} data-testid="scientific-number-with-unit">{html}{unit && <span> {unit}</span>}</Typography> } Number.propTypes = ({ value: PropTypes.any, diff --git a/gui/src/components/editQuantity/NumberEditQuantity.js b/gui/src/components/editQuantity/NumberEditQuantity.js index 262affa808076eef318924288104d53d1fd592ca..7b51b4084c0da8711589d0eb17f5bafc258297f9 100644 --- a/gui/src/components/editQuantity/NumberEditQuantity.js +++ b/gui/src/components/editQuantity/NumberEditQuantity.js @@ -24,7 +24,7 @@ import {Unit} from '../units/Unit' import {useUnitContext, getUnits} from '../units/UnitContext' import {debounce, isNil} from 'lodash' import {TextFieldWithHelp, getFieldProps} from './StringEditQuantity' -import {useErrors} from '../errors' +import {useDisplayUnit} from "../units/useDisplayUnit" export const NumberField = React.memo((props) => { const {onChange, onInputChange, dimension, value, dataType, minValue, unit, maxValue, displayUnit, convertInPlace, debounceTime, ...otherProps} = props @@ -205,35 +205,19 @@ NumberField.propTypes = { export const NumberEditQuantity = React.memo((props) => { const {quantityDef, value, onChange, ...otherProps} = props - const {units} = useUnitContext() - const {raiseError} = useErrors() + const {units, isReset} = useUnitContext() const defaultUnit = useMemo(() => quantityDef.unit && new Unit(quantityDef.unit), [quantityDef]) const dimension = defaultUnit && defaultUnit.dimension(false) const [checked, setChecked] = useState(true) const [displayedValue, setDisplayedValue] = useState(true) - const {defaultDisplayUnit, ...fieldProps} = getFieldProps(quantityDef) - - // Try to parse defaultDisplayUnit - const defaultDisplayUnitObj = useMemo(() => { - let defaultDisplayUnitObj - if (defaultDisplayUnit) { - try { - defaultDisplayUnitObj = new Unit(defaultDisplayUnit) - } catch (e) { - raiseError(`The provided defaultDisplayUnit for ${quantityDef.name} field is not valid.`) - } - if (defaultDisplayUnitObj.dimension(false) !== dimension) { - raiseError(`The provided defaultDisplayUnit for ${quantityDef.name} has incorrect dimensionality for this field.`) - } - } - return defaultDisplayUnitObj - }, [defaultDisplayUnit, dimension, quantityDef.name, raiseError]) - - const [unit, setUnit] = useState( - defaultDisplayUnitObj || - (units[dimension]?.definition && new Unit(units[dimension]?.definition)) || - defaultUnit - ) + const {defaultDisplayUnit: deprecatedDefaultDisplayUnit, ...fieldProps} = getFieldProps(quantityDef) + const {displayUnit: defaultDisplayUnitObj} = useDisplayUnit(undefined, quantityDef) + + const [unit, setUnit] = useState(defaultDisplayUnitObj) + + useEffect(() => { + setUnit(isReset ? defaultDisplayUnitObj : new Unit(defaultUnit).toSystem(units)) + }, [defaultDisplayUnitObj, defaultUnit, dimension, isReset, units]) // Get a list of unit options for this field const options = useMemo(() => { diff --git a/gui/src/components/editQuantity/NumberEditQuantity.spec.js b/gui/src/components/editQuantity/NumberEditQuantity.spec.js index 55ba2bc1d783c0e551de709fae2f0a4e7e6bacc2..88d5b8becac8848e1ddfd02afa638ac1e161480e 100644 --- a/gui/src/components/editQuantity/NumberEditQuantity.spec.js +++ b/gui/src/components/editQuantity/NumberEditQuantity.spec.js @@ -29,24 +29,124 @@ const changeValue = (input, newValue) => { const handleChange = jest.fn(value => {}) -test('Test numberEditQuantity', async () => { - render(<NumberEditQuantity - quantityDef={{ - name: 'name', - description: `This is **MARKDOWN** help text.` - }} - onChange={handleChange} - type={{type_kind: 'python', type_data: 'int'}} - value={10} - />) - const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') - const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') - await waitFor(() => expect(numberFieldValueInput.value).toEqual('10')) - - await changeValue(numberFieldValueInput, '5') - await changeValue(numberFieldValueInput, '') - - await waitFor(() => expect(handleChange.mock.calls).toHaveLength(2)) - await waitFor(() => expect(handleChange.mock.calls[0][0]).toBe(5)) - await waitFor(() => expect(handleChange.mock.calls[1][0]).toBe(undefined)) +describe('Test numberEditQuantity', () => { + it('functionality', async () => { + render(<NumberEditQuantity + quantityDef={{ + name: 'name', + description: `This is **MARKDOWN** help text.` + }} + onChange={handleChange} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + />) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + await waitFor(() => expect(numberFieldValueInput.value).toEqual('10')) + + await changeValue(numberFieldValueInput, '5') + await changeValue(numberFieldValueInput, '') + + await waitFor(() => expect(handleChange.mock.calls).toHaveLength(2)) + await waitFor(() => expect(handleChange.mock.calls[0][0]).toBe(5)) + await waitFor(() => expect(handleChange.mock.calls[1][0]).toBe(undefined)) + }) + + it('without default display unit', async () => { + render( + <NumberEditQuantity + quantityDef={{ + name: 'name', + unit: 'meter' + }} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + /> + ) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + // should be rendered in default unit system + await waitFor(() => expect(numberFieldValueInput.value).toEqual('100000000000')) + }) + + it('with default display unit', async () => { + render( + <NumberEditQuantity + quantityDef={{ + name: 'name', + unit: 'meter', + m_annotations: { + display: [{ + unit: 'mm' + }] + } + }} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + /> + ) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + // should be rendered in default unit provided by schema + await waitFor(() => expect(numberFieldValueInput.value).toEqual('10000')) + }) + + it('complex unit', async () => { + render( + <NumberEditQuantity + quantityDef={{ + name: 'name', + unit: 'm**2 / second**2' + }} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + /> + ) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + await waitFor(() => expect(numberFieldValueInput.value).toEqual('1e-9')) + }) + + it('complex unit with display unit', async () => { + render( + <NumberEditQuantity + quantityDef={{ + name: 'name', + unit: 'm**2 / second**2', + m_annotations: { + display: [{ + unit: 'm**2 / second**2' + }] + } + }} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + /> + ) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + await waitFor(() => expect(numberFieldValueInput.value).toEqual('10')) + }) + + it('with default display unit (deprecated version)', async () => { + render( + <NumberEditQuantity + quantityDef={{ + name: 'name', + unit: 'meter', + m_annotations: { + eln: [{ + defaultDisplayUnit: 'mm' + }] + } + }} + type={{type_kind: 'python', type_data: 'int'}} + value={10} + /> + ) + const numberFieldValue = screen.queryByTestId('number-edit-quantity-value') + const numberFieldValueInput = within(numberFieldValue).getByRole('textbox') + // should be rendered in default unit provided by schema + await waitFor(() => expect(numberFieldValueInput.value).toEqual('10000')) + }) }) diff --git a/gui/src/components/entry/OverviewView.js b/gui/src/components/entry/OverviewView.js index 4a6c6e6a7a1f7dce2f7f523c9656830e4e34dcbf..060e677ceec1fbb17a921bf4f114d5701f87af20 100644 --- a/gui/src/components/entry/OverviewView.js +++ b/gui/src/components/entry/OverviewView.js @@ -52,6 +52,7 @@ import ReferenceUsingCard from "./properties/ReferenceCard" import SampleHistoryUsingCard from "./properties/SampleHistoryCard" import { useEntryStore, useEntryContext, useIndex } from './EntryContext' import DeleteEntriesButton from '../uploads/DeleteEntriesButton' +import {useUnitContext} from "../units/UnitContext" function MetadataSection({title, children}) { return <Box marginTop={2} marginBottom={2}> @@ -143,6 +144,8 @@ const OverviewView = React.memo(() => { const { cards } = useEntryContext() const { data: index, response: indexApiData } = useIndex() const { url, exists, editable, archive: archiveTmp, archiveApiData } = useEntryStore(required) + const [sections, setSections] = useState([]) + useUnitContext(sections?.[0]?.sectionDef?._package?.name, sections?.[0]?.sectionDef?._package?.m_annotations?.display?.[0]?.unit_system) // The archive is accepted only once it is synced with the index. Notice that // we need to get the entry_id from data.entry_id, as some older entries will @@ -152,7 +155,6 @@ const OverviewView = React.memo(() => { : undefined const classes = useStyles() - const [sections, setSections] = useState([]) const {raiseError} = useErrors() const m_def = archive?.data?.m_def_id ? `${archive.data.m_def}@${archive.data.m_def_id}` : archive?.data?.m_def const dataMetainfoDefUrl = url && resolveNomadUrlNoThrow(m_def, url) @@ -179,7 +181,9 @@ const OverviewView = React.memo(() => { }) return sections } - getSections().then(setSections).catch(raiseError) + getSections().then((sections) => { + setSections(sections) + }).catch(raiseError) }, [archive, dataMetainfoDef, setSections, raiseError]) // Determine the cards to show diff --git a/gui/src/components/units/UnitContext.js b/gui/src/components/units/UnitContext.js index 5fff965cb2d0e27b6295eaccef38bd1130546f90..f31b3a5cb24e27cb979953905108f4b43fa5bd8b 100644 --- a/gui/src/components/units/UnitContext.js +++ b/gui/src/components/units/UnitContext.js @@ -16,7 +16,7 @@ * limitations under the License. */ -import React, { useState, useMemo, useContext, useCallback } from 'react' +import React, {useState, useMemo, useContext, useCallback, useEffect} from 'react' import PropTypes from 'prop-types' import { isNil, isFunction, startCase, toLower, cloneDeep } from 'lodash' import { Unit as UnitMathJS, createUnit } from 'mathjs' @@ -107,33 +107,76 @@ export function getUnits(dimension) { export const unitContext = React.createContext() export const UnitProvider = React.memo(({initialUnitSystems, initialSelected, children}) => { const resetUnitSystems = useState(cloneDeep(initialUnitSystems))[0] + const [isReset, setIsReset] = useState(true) const [unitSystems, setUnitSystems] = useState(cloneDeep(initialUnitSystems)) - const [selected, setSelected] = useState(initialSelected) + const [selectedSystem, setSelectedSystem] = useState(initialSelected) + const [currentScope, setCurrentScope] = useState() + const [scopes, setScopes] = useState({}) + const [scope, setScope] = useState() const reset = useCallback(() => { setUnitSystems(cloneDeep(resetUnitSystems)) - }, [resetUnitSystems]) + setIsReset(true) + if (currentScope && scopes?.[currentScope]?.system) { + setSelectedSystem(scopes[currentScope].system) + } + }, [currentScope, scopes, resetUnitSystems]) + + const setScopeInfo = useCallback((scopeKey, unit) => { + if (!(scopeKey in scopes)) { + scopes[scopeKey] = {system: unit || initialSelected, unitSystems: unitSystems} + setScopes({...scopes}) + } + setCurrentScope(scopeKey) + }, [scopes, initialSelected, unitSystems]) + + useEffect(() => { + if (currentScope in scopes) { + setSelectedSystem(scopes[currentScope].system) + setUnitSystems(scopes[currentScope].unitSystems) + } + }, [currentScope, scopes]) + + const setSelected = useCallback((unitSystem) => { + setIsReset(false) + setSelectedSystem(unitSystem) + setScopes(scopes => { + scopes[currentScope].system = unitSystem + return {...scopes} + }) + }, [currentScope]) + + const setUnits = useCallback((value) => { + setIsReset(false) + setUnitSystems(old => { + const newSystems = {...old} + newSystems[selectedSystem].units = isFunction(value) + ? value(newSystems[selectedSystem].units) + : value + setScopes(scopes => { + scopes[currentScope].unitSystems = newSystems + return {...scopes} + }) + return newSystems + }) + }, [currentScope, selectedSystem]) const values = useMemo(() => { return { - units: unitSystems[selected].units, - setUnits: (value) => { - setUnitSystems(old => { - const newSystems = {...old} - newSystems[selected].units = isFunction(value) - ? value(newSystems[selected].units) - : value - return newSystems - }) - }, + units: unitSystems[selectedSystem].units, + setUnits: setUnits, unitSystems, unitMap, dimensionMap, - selected, + selected: selectedSystem, setSelected, - reset + reset, + isReset, + setScopeInfo, + scope, + setScope } - }, [unitSystems, selected, reset]) + }, [unitSystems, selectedSystem, setUnits, setSelected, reset, isReset, setScopeInfo, scope, setScope]) return <unitContext.Provider value={values}> {children} @@ -152,6 +195,21 @@ UnitProvider.propTypes = { * @returns Object containing the currently set units for each dimension (e.g. * {energy: 'joule'}) */ -export const useUnitContext = () => { - return useContext(unitContext) +export const useUnitContext = (scopeKey, defaultUnitSystem) => { + const context = useContext(unitContext) + const {setScopeInfo, scope, setScope, ...others} = context + + useEffect(() => { + setScope(!scopeKey ? scopeKey : 'Schema') + }, [scopeKey, setScope]) + + useEffect(() => { + if (scopeKey && scope === 'Schema') { + setScopeInfo(scopeKey, defaultUnitSystem) + } else { + setScopeInfo('Global') + } + }, [scopeKey, scope, setScopeInfo, defaultUnitSystem]) + + return {...others, scope, setScope} } diff --git a/gui/src/components/units/UnitContext.spec.js b/gui/src/components/units/UnitContext.spec.js index 3654a17d47761c06f1b3b9ab5fae10aaddf9c729..e6700bd1f9d628cb9901ff761127ca4a7cd9807e 100644 --- a/gui/src/components/units/UnitContext.spec.js +++ b/gui/src/components/units/UnitContext.spec.js @@ -23,7 +23,7 @@ import { UnitProvider, useUnitContext } from './UnitContext' const wrapper = ({ children }) => <UnitProvider initialUnitSystems={{ SI: {units: {'length': {'definition': 'meter'}}}, - AU: {units: {'length': {'definition': 'meter'}}} + AU: {units: {'length': {'definition': 'ang'}}} }} initialSelected='SI' > @@ -55,3 +55,81 @@ test('updating units works', () => { }) expect(result.current.units.length.definition).toBe('mm') }) + +test('switching between scopes', () => { + const { result, rerender } = renderHook( + ({ scopeKey, defaultUnitSystem }) => useUnitContext(scopeKey, defaultUnitSystem), + { + wrapper: wrapper, + initialProps: { + scopeKey: 'schemaId', + defaultUnitSystem: 'AU' + } + } + ) + // In first render of a schema the default unit system should appear + expect(result.current.selected).toBe('AU') + expect(result.current.units.length.definition).toBe('ang') + // In schema the scope `Schema` should be selected as default + expect(result.current.scope).toBe('Schema') + + // Go to another scope i.e. search + rerender({ + scopeKey: undefined, + defaultUnitSystem: undefined + }) + expect(result.current.selected).toBe('SI') + expect(result.current.units.length.definition).toBe('meter') + expect(result.current.scope).toBe(undefined) + + // Change in schema scope + rerender({ + scopeKey: 'schemaId', + defaultUnitSystem: 'AU' + }) + act(() => { + result.current.setSelected('SI') + }) + expect(result.current.selected).toBe('SI') + expect(result.current.units.length.definition).toBe('meter') + act(() => { + result.current.setUnits(old => ({...old, length: {definition: 'mm'}})) + }) + expect(result.current.units.length.definition).toBe('mm') + + // Change in global scope + rerender({ + scopeKey: undefined, + defaultUnitSystem: undefined + }) + expect(result.current.selected).toBe('SI') + act(() => { + result.current.setSelected('AU') + }) + expect(result.current.selected).toBe('AU') + expect(result.current.units.length.definition).toBe('ang') + expect(result.current.scope).toBe(undefined) + + // Test the last units in schema scope + rerender({ + scopeKey: 'schemaId', + defaultUnitSystem: 'AU' + }) + expect(result.current.selected).toBe('SI') + expect(result.current.units.length.definition).toBe('mm') + expect(result.current.scope).toBe('Schema') + act(() => { + result.current.setScope('Global') + }) + expect(result.current.scope).toBe('Global') + expect(result.current.selected).toBe('AU') + expect(result.current.units.length.definition).toBe('ang') + + // Test the last units in global scope + rerender({ + scopeKey: undefined, + defaultUnitSystem: undefined + }) + expect(result.current.selected).toBe('AU') + expect(result.current.units.length.definition).toBe('ang') +}) diff --git a/gui/src/components/units/UnitMenu.js b/gui/src/components/units/UnitMenu.js index 81d90f110c6cd687d28dc8178f9f96c927b95378..3fd90ff31a3c379b18f529c460587404526faac5 100644 --- a/gui/src/components/units/UnitMenu.js +++ b/gui/src/components/units/UnitMenu.js @@ -16,7 +16,9 @@ * limitations under the License. */ import React, { useCallback, useState, useMemo } from 'react' -import { Box, Button, Menu, FormLabel, makeStyles, Typography } from '@material-ui/core' +import { + Box, Button, Menu, FormLabel, makeStyles, Typography, Select, MenuItem, InputLabel, FormControl +} from '@material-ui/core' import SettingsIcon from '@material-ui/icons/Settings' import ReplayIcon from '@material-ui/icons/Replay' import PropTypes from 'prop-types' @@ -47,7 +49,7 @@ const UnitMenu = React.memo(({ onUnitChange, onSystemChange }) => { - const {units, dimensionMap, reset} = useUnitContext() + const {units, dimensionMap, reset, scope, setScope} = useUnitContext() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) const styles = useStyles() @@ -150,6 +152,17 @@ const UnitMenu = React.memo(({ </Action> </Actions> <Box mt={1} /> + <FormControl variant="filled" fullWidth disabled={!scope}> + <InputLabel>Scope</InputLabel> + <Select + value={scope || 'Global'} + onChange={(event) => setScope(event.target.value)} + > + <MenuItem value={'Global'}>Global</MenuItem> + <MenuItem value={'Schema'}>Schema</MenuItem> + </Select> + </FormControl> + <Box mt={1} /> <FormLabel component="legend">Select unit system</FormLabel> <UnitSystemSelect onChange={onSystemChange}/> <Box mt={1} /> diff --git a/gui/src/components/units/useDisplayUnit.js b/gui/src/components/units/useDisplayUnit.js new file mode 100644 index 0000000000000000000000000000000000000000..a74c7396927831d57c2721fd324fec36e74185ef --- /dev/null +++ b/gui/src/components/units/useDisplayUnit.js @@ -0,0 +1,59 @@ +import {useErrors} from "../errors" +import {useMemo} from "react" +import {Unit} from "./Unit" +import {useUnitContext} from "./UnitContext" +import {getFieldProps} from "../editQuantity/StringEditQuantity" + +function getUnitSystem(def) { + let unit_system + if (def?.m_def === "nomad.metainfo.metainfo.Quantity") { + unit_system = def?._parent?.m_annotations?.display?.[0]?.unit_system + if (!unit_system && def?._section?._parentSections?.[0]) { + unit_system = getUnitSystem(def._section._parentSections[0]) + } + } else if (def?.m_def === "nomad.metainfo.metainfo.Section") { + unit_system = def?.m_annotations?.display?.[0]?.unit_system + if (!unit_system && def?._parentSections?.[0]) { + unit_system = getUnitSystem(def._parentSections[0]) + } + } + return unit_system +} + +export function useDisplayUnit(quantity, quantityDef) { + const {units, isReset, unitSystems} = useUnitContext() + const {raiseError} = useErrors() + const defaultUnit = useMemo(() => quantityDef.unit && new Unit(quantityDef.unit), [quantityDef]) + const dimension = defaultUnit && defaultUnit.dimension(false) + const {defaultDisplayUnit: deprecatedDefaultDisplayUnit} = getFieldProps(quantityDef) + const defaultDisplayUnit = quantityDef?.m_annotations?.display?.[0]?.unit || deprecatedDefaultDisplayUnit + + const displayUnitObj = useMemo(() => { + if (!dimension) { + return + } + let defaultDisplayUnitObj + const section_default_unit_system = getUnitSystem(quantityDef) + + if (isReset && (section_default_unit_system || defaultDisplayUnit)) { + if (section_default_unit_system && unitSystems[section_default_unit_system]) { + defaultDisplayUnitObj = new Unit(unitSystems[section_default_unit_system].units[dimension].definition) + } + if (defaultDisplayUnit) { + try { + defaultDisplayUnitObj = new Unit(defaultDisplayUnit) + } catch (e) { + raiseError(`The provided defaultDisplayUnit for ${quantityDef.name} field is not valid.`) + } + if (defaultDisplayUnitObj.dimension(false) !== dimension) { + raiseError(`The provided defaultDisplayUnit for ${quantityDef.name} has incorrect dimensionality for this field.`) + } + } + } else { + defaultDisplayUnitObj = new Unit(defaultUnit).toSystem(units) + } + return defaultDisplayUnitObj + }, [defaultDisplayUnit, defaultUnit, dimension, isReset, quantityDef, raiseError, unitSystems, units]) + + return {displayUnit: displayUnitObj} +} diff --git a/nomad/datamodel/metainfo/annotations.py b/nomad/datamodel/metainfo/annotations.py index c08c0e8705bc256979185a5b283c9fe60f54432e..f0c3b7953ab9d5268cefa1a56ea3f109459e6619 100644 --- a/nomad/datamodel/metainfo/annotations.py +++ b/nomad/datamodel/metainfo/annotations.py @@ -104,8 +104,8 @@ class Filter(BaseModel): ) -class SectionProperties(BaseModel): - """A filter defined by an include list or and exclude list of the quantities and subsections.""" +class DisplayAnnotation(BaseModel): + """The display settings defined by an include list or an exclude list of the quantities and subsections.""" visible: Optional[Filter] = Field( 1, @@ -123,6 +123,32 @@ class SectionProperties(BaseModel): """ ), ) + + +class QuantityDisplayAnnotation(DisplayAnnotation): + """The display settings for quantities.""" + + display_unit: Optional[str] = Field( + None, + description=strip( + """ + To determine the default display unit for quantity. + """ + ), + ) + + +class SectionDisplayAnnotation(DisplayAnnotation): + """The display settings for sections and packages.""" + + display_unit_system: Optional[str] = Field( + None, + description=strip( + """ + To determine the default display unit system for section. + """ + ), + ) order: Optional[List[str]] = Field( None, description=strip( @@ -133,6 +159,35 @@ class SectionProperties(BaseModel): ) +class SectionProperties(BaseModel): + """The display settings for quantities and subsections. (Deprecated)""" + + visible: Optional[Filter] = Field( + 1, + description=strip( + """ + Defines the visible quantities and subsections. (Deprecated) + """ + ), + ) + editable: Optional[Filter] = Field( + None, + description=strip( + """ + Defines the editable quantities and subsections. (Deprecated) + """ + ), + ) + order: Optional[List[str]] = Field( + None, + description=strip( + """ + To customize the order of the quantities and subsections. (Deprecated) + """ + ), + ) + + class ELNAnnotation(AnnotationModel): """ These annotations control how data can be entered and edited.