diff --git a/gui/src/components/archive/ArchiveBrowser.js b/gui/src/components/archive/ArchiveBrowser.js index 22fda995e9b477f90bb8fbf3cef761dd7f3199ef..6aa6b2da78941871f9f68a951cc24a655abb624f 100644 --- a/gui/src/components/archive/ArchiveBrowser.js +++ b/gui/src/components/archive/ArchiveBrowser.js @@ -829,6 +829,8 @@ const QuantityValue = React.memo(function QuantityValue({value, def, ...more}) { return <Compartment title='hdf5'> <H5Web upload_id={h5UploadId} filename={h5Path.groups.filename} initialPath={h5Path.groups.path} source={h5Source} sidebarOpen={false}></H5Web> </Compartment> + } else if (def?.type?.type_kind === 'custom' && def?.type?.type_data === 'nomad.datamodel.data.Query') { + return <Query value={value} def={def}/> } else { const [finalValue] = getRenderValue(value) return <Typography>{typeof finalValue === 'object' ? JSON.stringify(finalValue) : finalValue?.toString()}</Typography> @@ -1628,6 +1630,31 @@ Quantity.propTypes = ({ ]) }) +function Query({value, def}) { + return <Content> + <Compartment title="filters"> + <QuantityValue + value={value?.filters ? Object.entries(value.filters).map(([key, value]) => `${key}: ${value}`) : []} + def={def} + /> + </Compartment> + {value?.data && <Compartment title="results"> + <QuantityValue + value={value?.data ? value.data.map(entry => entry.mainfile || entry.entry_id) : []} + def={def} + /> + </Compartment>} + <Compartment title="query"> + <SourceApiCall body={value} response={value}/> + </Compartment> + </Content> +} + +Query.propTypes = ({ + value: PropTypes.any, + def: PropTypes.object.isRequired +}) + function Attribute({value, def}) { return <Content> <ArchiveTitle def={def} data={value} kindLabel="attribute"/> diff --git a/gui/src/components/editQuantity/EditQuantity.js b/gui/src/components/editQuantity/EditQuantity.js index 58222c3ad02d2c886187ef783322ac2a8a50e568..b0522110d39f9ccd28394d73f0de3914aca28f1d 100644 --- a/gui/src/components/editQuantity/EditQuantity.js +++ b/gui/src/components/editQuantity/EditQuantity.js @@ -16,17 +16,18 @@ * limitations under the License. */ -import {DateEditQuantity, DateTimeEditQuantity, TimeEditQuantity} from '../editQuantity/DateTimeEditQuantity' -import { StringEditQuantity, URLEditQuantity } from '../editQuantity/StringEditQuantity' -import {NumberEditQuantity} from '../editQuantity/NumberEditQuantity' -import {EnumEditQuantity} from '../editQuantity/EnumEditQuantity' -import {AutocompleteEditQuantity} from '../editQuantity/AutocompleteEditQuantity' -import {BoolEditQuantity} from '../editQuantity/BoolEditQuantity' -import FileEditQuantity from '../editQuantity/FileEditQuantity' -import RichTextEditQuantity from '../editQuantity/RichTextEditQuantity' -import ReferenceEditQuantity from '../editQuantity/ReferenceEditQuantity' -import AuthorEditQuantity from '../editQuantity/AuthorEditQuantity' -import { RadioEnumEditQuantity } from '../editQuantity/RadioEnumEditQuantity' +import {DateEditQuantity, DateTimeEditQuantity, TimeEditQuantity} from './DateTimeEditQuantity' +import { StringEditQuantity, URLEditQuantity } from './StringEditQuantity' +import {NumberEditQuantity} from './NumberEditQuantity' +import {EnumEditQuantity} from './EnumEditQuantity' +import {AutocompleteEditQuantity} from './AutocompleteEditQuantity' +import {BoolEditQuantity} from './BoolEditQuantity' +import FileEditQuantity from './FileEditQuantity' +import RichTextEditQuantity from './RichTextEditQuantity' +import ReferenceEditQuantity from './ReferenceEditQuantity' +import AuthorEditQuantity from './AuthorEditQuantity' +import { RadioEnumEditQuantity } from './RadioEnumEditQuantity' +import QueryEditQuantity from "./QueryEditQuantity" export const editQuantityComponents = { NumberEditQuantity: NumberEditQuantity, @@ -43,5 +44,6 @@ export const editQuantityComponents = { TimeEditQuantity: TimeEditQuantity, RichTextEditQuantity: RichTextEditQuantity, ReferenceEditQuantity: ReferenceEditQuantity, - AuthorEditQuantity: AuthorEditQuantity + AuthorEditQuantity: AuthorEditQuantity, + QueryEditQuantity: QueryEditQuantity } diff --git a/gui/src/components/editQuantity/EditQuantityExamples.js b/gui/src/components/editQuantity/EditQuantityExamples.js index 9a0b730313212abbb8f54d4969024dc1b8478864..da89da51e0d019cec88d7f83d10dde4ba221dd8a 100644 --- a/gui/src/components/editQuantity/EditQuantityExamples.js +++ b/gui/src/components/editQuantity/EditQuantityExamples.js @@ -31,6 +31,7 @@ import ListEditQuantity from './ListEditQuantity' import { Code } from '../buttons/SourceDialogButton' import { stripIndent } from '../../utils' import AuthorEditQuantity from './AuthorEditQuantity' +import QueryEditQuantity from "./QueryEditQuantity" const enumValues = [ 'Vapor deposition', 'Chemical vapor deposition', 'Metalorganic vapour phase epitaxy', 'Electrostatic spray assisted vapour deposition (ESAVD)', 'Sherardizing', @@ -71,6 +72,7 @@ export function EditQuantityExamples() { propsRef.current[name] = { quantityDef: { name: name, + _qualifiedName: `${name}-qualifiedName`, description: ` This is **MARKDOWN** help text. `, @@ -440,6 +442,21 @@ export function EditQuantityExamples() { <AuthorEditQuantity {...createDefaultProps('Author')} /> </Example> </Grid> + <Grid item> + <Example + code={` + query: + type: Query + m_annotations: + eln: + component: QueryEditQuantity`} + > + <QueryEditQuantity + {...createDefaultProps('myQuery', {value: {}})} + storeInArchive={true} + /> + </Example> + </Grid> </Grid> </Box> </CardContent> diff --git a/gui/src/components/editQuantity/QueryEditQuantity.js b/gui/src/components/editQuantity/QueryEditQuantity.js new file mode 100644 index 0000000000000000000000000000000000000000..aa2e5ce550e2c4483bbd06e482ad9607079d3736 --- /dev/null +++ b/gui/src/components/editQuantity/QueryEditQuantity.js @@ -0,0 +1,305 @@ +/* + * 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, {useCallback, useEffect, useMemo, useState} from 'react' +import PropTypes from 'prop-types' +import {cloneDeep} from 'lodash' +import { + Chip, + Dialog, DialogContent, IconButton, TextField, Tooltip +} from "@material-ui/core" +import {ManualSearchContext, useSearchContext} from "../search/SearchContext" +import {ui} from "../../config" +import DialogActions from "@material-ui/core/DialogActions" +import Button from "@material-ui/core/Button" +import SearchPage from "../search/SearchPage" +import SearchIcon from "@material-ui/icons/Search" +import {useRecoilValue} from "recoil" +import {configState} from "../archive/ArchiveBrowser" +import {getDisplayLabel, pluralize} from "../../utils" +import Autocomplete from "@material-ui/lab/Autocomplete" +import {ItemButton, useLane} from "../archive/Browser" +import ClearIcon from "@material-ui/icons/Clear" + +const context = cloneDeep(ui?.apps?.options?.eln) + +const shownColumns = [ + 'entry_name', + 'entry_type', + 'authors', + 'upload_name' +] + +const columns = context?.columns +const rows = context?.rows +columns.selected = shownColumns +rows.details = {enabled: false} +rows.actions = {enabled: false} + +function SearchDialog({open, filters, pageSize, onCancel, onQueryChanged}) { + const {filters: queryFilters, useFilters, useSetFilters, useResults, useApiData} = useSearchContext() + const setFilters = useSetFilters() + const filterValues = useFilters(queryFilters) + const {data, setPagination} = useResults() + const apiData = useApiData() + + const updateFilters = useCallback(() => { + const newValue = {} + for (const key in filters) { + if (Object.hasOwnProperty.call(filters, key)) { + newValue[key] = Array.isArray(filters[key]) || filters[key] instanceof Set ? new Set(filters[key]) : filters[key] + } + } + if (setPagination && pageSize) { + setPagination(old => { + return {...old, page_size: pageSize} + }) + } + setFilters(newValue) + }, [pageSize, setFilters, filters, setPagination]) + + useEffect(() => { + if (open) { + updateFilters() + } + }, [updateFilters, open]) + + const newFilters = useMemo(() => { + const filters = {...filterValues} + for (const key in filters) { + if (key in filters && filters[key] === undefined) { + delete filters[key] + } + } + return filters + }, [filterValues]) + + const results = useMemo(() => { + if (!data) { + return undefined + } + return data.map(entry => ({entry_id: entry.entry_id, mainfile: entry.mainfile})) + }, [data]) + + const handleQueryChanged = useCallback(() => { + if (onQueryChanged) { + onQueryChanged(newFilters, apiData, results) + } + }, [onQueryChanged, newFilters, apiData, results]) + + return <Dialog + open={open} + PaperProps={{ + style: { + maxWidth: '1800px', + maxHeight: '1200px', + width: '1800px', + height: '1200px' + } + }} + data-testid='search-dialog' + > + <DialogContent> + <SearchPage/> + </DialogContent> + <DialogActions> + <span style={{flexGrow: 1}} /> + <Button onClick={() => onCancel()} color="secondary"> + Cancel + </Button> + <Button onClick={() => handleQueryChanged()} color="secondary" data-testid='search-dialog-ok'> + OK + </Button> + </DialogActions> + </Dialog> +} +SearchDialog.propTypes = { + open: PropTypes.bool, + filters: PropTypes.object, + pageSize: PropTypes.number, + onCancel: PropTypes.func, + onQueryChanged: PropTypes.func +} + +function QueryEditQuantity({quantityDef, onChange, value, storeInArchive, index, maxData}) { + const config = useRecoilValue(configState) + const label = getDisplayLabel(quantityDef, true, config?.showMeta) + const [open, setOpen] = useState(false) + const lane = useLane() + + const filters = useMemo(() => value?.filters || {}, [value]) + + const handleCancel = useCallback(() => { + setOpen(false) + }, []) + + const handleQueryChanged = useCallback((filters, query, results) => { + if (onChange) { + const newFilters = {} + for (const key in filters) { + if (Object.hasOwnProperty.call(filters, key)) { + newFilters[key] = Array.isArray(filters[key]) || filters[key] instanceof Set ? [...filters[key]] : filters[key] + } + } + const newValue = { + owner: query.response.owner, + query: query.response.query, + pagination: query.response.pagination, + filters: newFilters + } + if (storeInArchive) { + newValue.data = results + } + onChange(newValue) + } + setOpen(false) + }, [onChange, storeInArchive]) + + const tags = useMemo(() => { + let tags = [] + for (const key in filters) { + const filterValue = filters[key] + if (filterValue) { + if (Array.isArray(filterValue[key])) { + tags = tags.concat([...filterValue].map(value => ({key: key, value: value, tag: `${key}:${value}`}))) + } else { + tags = tags.concat({key: key, value: filterValue, tag: `${key}:${filterValue}`}) + } + } + } + return tags + }, [filters]) + + const itemKey = useMemo(() => { + if (!isNaN(index)) { + return `${quantityDef.name}:${index}` + } else { + return quantityDef.name + } + }, [quantityDef, index]) + + const handleClearResults = useCallback(() => { + if (onChange) { + onChange(undefined) + } + }, [onChange]) + + const actions = useMemo(() => { + const actions = [] + if (value) { + actions.push( + <IconButton + key={'clear'} + color="seconadry" + size="small" + onClick={handleClearResults} + > + <Tooltip title="Clear results"> + <ClearIcon/> + </Tooltip> + </IconButton> + ) + } + actions.push(<IconButton + key={'search'} + color="seconadry" + size="small" + onClick={() => setOpen(true)} + > + <Tooltip title="Search dialog"> + <SearchIcon/> + </Tooltip> + </IconButton>) + if (lane && value) { + actions.push(<ItemButton key={'navigate'} size="small" itemKey={itemKey}/>) + } + return actions + }, [value, lane, handleClearResults, itemKey]) + + return <React.Fragment> + <Autocomplete + multiple + open={false} + options={[]} + getOptionLabel={(option) => option.tag} + limitTags={1} + value={tags} + inputValue={''} + renderTags={(value, getTagProps) => { + return value.map((option, index) => { + return <Chip + key={index} + {...getTagProps({ index })} + label={option.tag} + size="small" + color="primary" + onDelete={undefined} + /> + }) + }} + renderInput={(params) => ( + <TextField + {...params} + label={label} + variant="filled" + placeholder={value?.results && Array.isArray(value.results) && value.results.length > 0 + ? pluralize('result', value.results.length, true) + : "no results"} + InputProps={{ + ...params.InputProps, + endAdornment: React.cloneElement(params.InputProps.endAdornment, {}, actions) + }} + /> + )} + /> + <ManualSearchContext + resource={context?.resource} + initialPagination={context?.pagination} + initialColumns={columns} + initialRows={rows} + initialFilterMenus={context?.filter_menus} + initialFiltersLocked={undefined} + initialFilterValues={filters} + initialSearchSyntaxes={context?.search_syntaxes} + id={`queryeditquantity-${quantityDef._qualifiedName}`} + > + <SearchDialog + open={open} + filters={filters} + onCancel={handleCancel} + onQueryChanged={handleQueryChanged} + pageSize={maxData || 100} + /> + </ManualSearchContext> + </React.Fragment> +} +QueryEditQuantity.propTypes = { + // The quantity definition + quantityDef: PropTypes.object, + // The event when the searched results have been changed + onChange: PropTypes.func, + // The searched value + value: PropTypes.string, + // To store the search results in the value + storeInArchive: PropTypes.bool, + // The index of the quantity which repeats + index: PropTypes.number, + // The maximum number of searched data + maxData: PropTypes.number +} + +export default QueryEditQuantity diff --git a/gui/src/components/editQuantity/QueryEditQuantity.spec.js b/gui/src/components/editQuantity/QueryEditQuantity.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dd55a9cfa0263cad17721f10762edc1fe62ae286 --- /dev/null +++ b/gui/src/components/editQuantity/QueryEditQuantity.spec.js @@ -0,0 +1,104 @@ +/* + * 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 {closeAPI, render, screen, startAPI, waitForGUI} from '../conftest.spec' +import QueryEditQuantity from "./QueryEditQuantity" +import {waitFor, within} from "@testing-library/dom" +import {act} from "react-dom/test-utils" +import userEvent from '@testing-library/user-event' + +const handleChange = jest.fn(value => {}) +const quantityDef = { + name: 'myQuery', + _qualifiedName: `myQuery-qualifiedName`, + description: ` + This is **MARKDOWN** help text. + ` +} + +const testSearchDialogCancelButton = async () => { + const dialog = screen.getByTestId('search-dialog') + await waitFor(() => expect(screen.queryByText('visibility=visible')).toBeInTheDocument()) + + // cancel the search + await userEvent.click(within(dialog).getByRole('button', {name: /cancel/i})) + await waitFor(() => expect(screen.queryByTestId('search-dialog')).not.toBeInTheDocument()) +} + +const testSearchDialogOkButton = async () => { + const dialog = screen.getByTestId('search-dialog') + await waitFor(() => expect(screen.queryByText('visibility=visible')).toBeInTheDocument()) + + // accept the search + await userEvent.click(within(dialog).getByTestId('search-dialog-ok')) + await waitFor(() => expect(screen.queryByTestId('search-dialog')).not.toBeInTheDocument()) +} + +test('Test QueryEditQuantity', async () => { + await startAPI('tests.states.entry.eln', 'tests/data/editquantity/query', 'test', 'password') + render(<QueryEditQuantity + quantityDef={quantityDef} + storeInArchive={true} + value={{ + filters: {visibility: 'visible'}, + results: [ + {entry_id: '1', mainfile: 'a'}, + {entry_id: '2', mainfile: 'b'}, + {entry_id: '3', mainfile: 'c'} + ]}} + onChange={handleChange} + />) + + const input = screen.getByRole('textbox') + expect(input.value).toBe('') + + screen.queryByText('visibility:visible') + screen.queryByText('3 results') + + const searchDialogButton = screen.getByTitle('Search dialog').closest('button') + expect(searchDialogButton).toBeEnabled() + + await act(async () => { userEvent.click(searchDialogButton) }) + await waitForGUI(1000, true) + + await testSearchDialogCancelButton() + screen.getByText('visibility:visible') + expect(input).toHaveAttribute('placeholder', '3 results') + + await act(async () => { userEvent.click(searchDialogButton) }) + await waitForGUI(1000, true) + + await testSearchDialogOkButton() + screen.getByText('visibility:visible') + + // Assert the new results + await waitFor(() => expect(handleChange.mock.calls[0][0].data[0].entry_id).toBe('bC7byHvWJp62Sn9uiuJUB38MT5j-')) + await waitFor(() => expect(handleChange.mock.calls[0][0].data[0].mainfile).toBe('sample.archive.json')) + await waitFor(() => expect(handleChange.mock.calls[0][0].data[1].entry_id).toBe('83DS7AzwqTKFVwlrdVeaL3kMSLU_')) + await waitFor(() => expect(handleChange.mock.calls[0][0].data[1].mainfile).toBe('schema.archive.yaml')) + + // Clear results + const clearResultsButton = screen.getByTitle('Clear results').closest('button') + expect(clearResultsButton).toBeEnabled() + await act(async () => { userEvent.click(clearResultsButton) }) + + await waitFor(() => expect(handleChange.mock.calls[1][0]).toBe(undefined)) + + closeAPI() +}) diff --git a/gui/src/components/search/SearchContext.js b/gui/src/components/search/SearchContext.js index 3a34a0e0a36bad672bd07aa61ad4477dbfa81d4b..f7333a8aa21a239694d50099631c15027f07ea24 100644 --- a/gui/src/components/search/SearchContext.js +++ b/gui/src/components/search/SearchContext.js @@ -1806,6 +1806,15 @@ export function useSearchContext() { return useContext(searchContext) } +export const ManualSearchContext = withFilters(SearchContextRaw) + +/** + * Hook to control the current SearchContext manually. + */ +export function useManualSearchContext() { + return useContext(ManualSearchContext) +} + /** * Parses a single filter value into a form that is supported by the GUI. This includes: * - Arrays are are transformed into Sets diff --git a/gui/tests/data/editquantity/query.json b/gui/tests/data/editquantity/query.json new file mode 100644 index 0000000000000000000000000000000000000000..fefdd8088923b9fba69b30233530c809ad8ee589 --- /dev/null +++ b/gui/tests/data/editquantity/query.json @@ -0,0 +1,495 @@ +{ + "afa783d98e040c74b68e5cfd3d82f3f0": [ + { + "request": { + "url": "http://localhost:8000/fairdi/nomad/latest/api/v1/entries/query", + "method": "POST", + "body": { + "owner": "visible", + "query": {}, + "aggregations": {}, + "pagination": { + "order_by": "upload_create_time", + "order": "desc", + "page_size": 20 + }, + "required": { + "exclude": [ + "quantities", + "sections", + "files" + ] + } + }, + "headers": { + "accept": "application/json, text/plain, */*", + "content-type": "application/json", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJSWFFIV1YxSEJ6cmh5U3h3UmRDdkhCcUF1WVNKRzZWSEJSZXg0TW5oX293In0.eyJleHAiOjE3MTkwMTA0MTMsImlhdCI6MTcxODk3NDQyNiwianRpIjoiMWE5ZDcyMjItYzI0Ni00NjdjLTgyZTUtYTMyMTBlMjVmZTIxIiwiaXNzIjoiaHR0cHM6Ly9ub21hZC1sYWIuZXUvZmFpcmRpL2tleWNsb2FrL2F1dGgvcmVhbG1zL2ZhaXJkaV9ub21hZF90ZXN0IiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjY4ODc4YWY3LTY4NDUtNDZjMC1iMmMxLTI1MGQ0ZDhlYjQ3MCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im5vbWFkX2d1aV9kZXYiLCJzZXNzaW9uX3N0YXRlIjoiZDIyNjc5MzUtNTZiNS00NzU5LWFlNzktOTA0NTg3Yjg2NDdiIiwiYWxsb3dlZC1vcmlnaW5zIjpbIioiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6ImVtYWlsIHByb2ZpbGUiLCJzaWQiOiJkMjI2NzkzNS01NmI1LTQ3NTktYWU3OS05MDQ1ODdiODY0N2IiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6Ik1hcmt1cyBTY2hlaWRnZW4iLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJ0ZXN0IiwiZ2l2ZW5fbmFtZSI6Ik1hcmt1cyIsImZhbWlseV9uYW1lIjoiU2NoZWlkZ2VuIiwiZW1haWwiOiJtYXJrdXMuc2NoZWlkZ2VuQGZoaS1iZXJsaW4uZGUifQ.hiDY95D_2Ti4UXmirl88lIiPjVtLOddaP9QyGLdqDE2OM2jgnMT7QdnjBpBARrhVAuq78OOinwGDqXnh4gPzlIF4AEeLw06vFSpWqoKOKY5WN9I25jqXzC6AvsfDGmQKRSQfxeULFH4g1KbYYBE9GWArBvtkLq6BwkMYMDUB-Qz3MC5YcrA1sBIhKYQO3zvN2Mt5q7RuTWQqcS_ueLVQ9WhIpoDFumHBxWA1XHSHL8NxcxF4enku1SkEXeOfw26FEyruMMFx6cxm_htrqbkdkeNwf8WqfLIhPgrO6J4Sm-OIt1YiyqpdnVFyt8e_Wabm6qQ3SGBMrR2DSdVof_sJWg", + "cookie": null + } + }, + "response": { + "status": 200, + "body": { + "owner": "visible", + "query": {}, + "pagination": { + "page_size": 20, + "order_by": "upload_create_time", + "order": "desc", + "total": 2 + }, + "required": { + "exclude": [ + "quantities", + "sections", + "files" + ] + }, + "data": [ + { + "upload_id": "eln_upload_id", + "references": [], + "origin": "Markus Scheidgen", + "text_search_contents": [ + "ELN example sample", + "001", + "PVDProcess.csv", + "<p>A simple example for an \"sample\" that demonstrates how to combine different data entities.</p>\n<p>The sample it-self defines a few properties (involved chemicals, used substrate) and uses inherited default properties (formula, name, lab id, ...)</p>\n<p>But the sample also contains sub-sections that prodivde inforamtion about proccessed that were applied to this sample (PVD evaporation, hotplate annealing). </p>\n<p>The sample also show references to other entries (chemicals, instruments).", + "SLG", + "project" + ], + "datasets": [], + "n_quantities": 68, + "nomad_version": "1.3.3.dev67+g6dbdb317b", + "upload_create_time": "2024-06-21T12:53:41.020000+00:00", + "nomad_commit": "", + "section_defs": [ + { + "used_directly": true, + "definition_id": "7e0331d2df29682413535f00d4aad156783fee2e", + "definition_qualified_name": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Process" + }, + { + "used_directly": true, + "definition_id": "030133d881170f0d9e31da6086ed5641f0c4403c", + "definition_qualified_name": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "used_directly": true, + "definition_id": "f0086b987424b1001666c6f5df9ad1071164a27e", + "definition_qualified_name": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes" + }, + { + "used_directly": true, + "definition_id": "d39f4397900226398e6c17eb00467e874e5955c4", + "definition_qualified_name": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes.HotplateAnnealing" + }, + { + "used_directly": true, + "definition_id": "c640c9585e8d4549f248582763427a7dd7c417a0", + "definition_qualified_name": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes.PvdEvaporation" + }, + { + "used_directly": true, + "definition_id": "7047cbff9980abff17cce4b1b6b0d1c783505b7f", + "definition_qualified_name": "nomad.datamodel.data.ArchiveSection" + }, + { + "used_directly": true, + "definition_id": "538f52fd8d52b29372066f878319c6aeb03b74d2", + "definition_qualified_name": "nomad.datamodel.data.EntryData" + }, + { + "used_directly": true, + "definition_id": "1067a7855c86bc74d57bfbe3c95e7b5ca97cc403", + "definition_qualified_name": "nomad.datamodel.datamodel.EntryArchive" + }, + { + "used_directly": true, + "definition_id": "08e24de48352035bcc932ed9934875ed9b10188e", + "definition_qualified_name": "nomad.datamodel.datamodel.EntryMetadata" + }, + { + "used_directly": true, + "definition_id": "265ae33dbc9ace82b50260c1050d30c59ef9cb3c", + "definition_qualified_name": "nomad.datamodel.datamodel.RFC3161Timestamp" + }, + { + "used_directly": false, + "definition_id": "fc2735d177bf36f9718ca66a764a56fc0c6200a0", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.Activity" + }, + { + "used_directly": false, + "definition_id": "add2edfa25a61ff3bbfdebacc870181f64f41634", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.BaseSection" + }, + { + "used_directly": true, + "definition_id": "c7254c9b461a4baec86cb3179e2f84513f5c9053", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.CompositeSystem" + }, + { + "used_directly": false, + "definition_id": "7ce6bdcaa183a9685582b275e1c2b2ea3139c74d", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.Entity" + }, + { + "used_directly": false, + "definition_id": "8d81e1f95c60f39a6a8c9f954143a55e6f4c984c", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.Process" + }, + { + "used_directly": false, + "definition_id": "3ab3a09d615ab58aefbaa3acdd8d6af4e73aaae5", + "definition_qualified_name": "nomad.datamodel.metainfo.basesections.System" + }, + { + "used_directly": true, + "definition_id": "55ef08f33dcf9fb374aea217458bd34a23bec57b", + "definition_qualified_name": "nomad.datamodel.metainfo.plot.Figure" + }, + { + "used_directly": true, + "definition_id": "7bb17e35ed91dca88bf6238fc51ed212693593d0", + "definition_qualified_name": "nomad.datamodel.metainfo.plot.PlotSection" + }, + { + "used_directly": true, + "definition_id": "491cdc3c5ea21cdc576275a5c1e600cdaa4256c1", + "definition_qualified_name": "nomad.datamodel.metainfo.plot.PlotlyFigure" + }, + { + "used_directly": true, + "definition_id": "eeed330bd18d40f3a2a00cc35ba6f58808d0c543", + "definition_qualified_name": "nomad.datamodel.results.ELN" + }, + { + "used_directly": true, + "definition_id": "f6d62b2664a0c37e0c82d4cb570e5e177e445e05", + "definition_qualified_name": "nomad.datamodel.results.Properties" + }, + { + "used_directly": true, + "definition_id": "2820e376005366caae1a7662ad21cfacefc34abb", + "definition_qualified_name": "nomad.datamodel.results.Results" + }, + { + "used_directly": true, + "definition_id": "4408b02aafbcd5a196e33346021b9da4e64a84e8", + "definition_qualified_name": "nomad.parsing.tabular.TableData" + } + ], + "processing_errors": [], + "results": { + "eln": { + "names": [ + "ELN example sample" + ], + "methods": [ + "PvdEvaporation", + "HotplateAnnealing" + ], + "descriptions": [ + "<p>A simple example for an \"sample\" that demonstrates how to combine different data entities.</p>\n<p>The sample it-self defines a few properties (involved chemicals, used substrate) and uses inherited default properties (formula, name, lab id, ...)</p>\n<p>But the sample also contains sub-sections that prodivde inforamtion about proccessed that were applied to this sample (PVD evaporation, hotplate annealing). </p>\n<p>The sample also show references to other entries (chemicals, instruments).</p>\n<p> </p>" + ], + "sections": [ + "PvdEvaporation", + "HotplateAnnealing", + "Sample" + ], + "lab_ids": [ + "001" + ], + "tags": [ + "project" + ] + }, + "properties": { + "available_properties": [] + } + }, + "entry_name": "ELN example sample", + "last_processing_time": "2024-06-21T12:53:41.238000+00:00", + "parser_name": "parsers/archive", + "calc_id": "bC7byHvWJp62Sn9uiuJUB38MT5j-", + "published": false, + "writers": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + } + ], + "writer_groups": [], + "processed": true, + "mainfile": "sample.archive.json", + "main_author": { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + "viewers": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + }, + { + "user_id": "54cb1f64-f84e-4815-9ade-440ce0b5430f", + "name": "Test Tester" + } + ], + "viewer_groups": [], + "entry_create_time": "2024-06-21T12:53:41.108000+00:00", + "with_embargo": false, + "search_quantities": [ + { + "path_archive": "data.name", + "str_value": "ELN example sample", + "definition": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.name", + "id": "data.name#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.lab_id", + "str_value": "001", + "definition": "nomad.datamodel.metainfo.basesections.BaseSection.lab_id", + "id": "data.lab_id#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.description", + "str_value": "<p>A simple example for an \"sample\" that demonstrates how to combine different data entities.</p>\n<p>The sample it-self defines a few properties (involved chemicals, used substrate) and uses inherited default properties (formula, name, lab id, ...)</p>\n<p>But the sample also contains sub-sections that prodivde inforamtion about proccessed that were applied to this sample (PVD evaporation, hotplate annealing). </p>\n<p>The sample also show references to other entries (chemicals, instruments).</p>\n<p> </p>", + "definition": "nomad.datamodel.metainfo.basesections.BaseSection.description", + "id": "data.description#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.substrate_type", + "str_value": "SLG", + "definition": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.substrate_type", + "id": "data.substrate_type#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.pvd_evaporation.datetime", + "datetime_value": "2022-05-10T07:20:00+00:00", + "definition": "nomad.datamodel.metainfo.basesections.Activity.datetime", + "id": "data.processes.pvd_evaporation.datetime#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.pvd_evaporation.data_file", + "str_value": "PVDProcess.csv", + "definition": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes.PvdEvaporation.data_file", + "id": "data.processes.pvd_evaporation.data_file#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.pvd_evaporation.fill_archive_from_datafile", + "bool_value": false, + "definition": "nomad.parsing.tabular.TableData.fill_archive_from_datafile", + "id": "data.processes.pvd_evaporation.fill_archive_from_datafile#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.hotplate_annealing.datetime", + "datetime_value": "2022-05-10T07:22:00+00:00", + "definition": "nomad.datamodel.metainfo.basesections.Activity.datetime", + "id": "data.processes.hotplate_annealing.datetime#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.hotplate_annealing.set_temperature", + "definition": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes.HotplateAnnealing.set_temperature", + "float_value": 373.15, + "id": "data.processes.hotplate_annealing.set_temperature#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.processes.hotplate_annealing.duration", + "definition": "entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample.Processes.HotplateAnnealing.duration", + "float_value": 60, + "id": "data.processes.hotplate_annealing.duration#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + }, + { + "path_archive": "data.datetime", + "datetime_value": "2024-06-21T14:53:41.294628+00:00", + "definition": "nomad.datamodel.metainfo.basesections.BaseSection.datetime", + "id": "data.datetime#entry_id:83DS7AzwqTKFVwlrdVeaL3kMSLU_.Sample" + } + ], + "entry_type": "Sample", + "entry_id": "bC7byHvWJp62Sn9uiuJUB38MT5j-", + "authors": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + } + ], + "license": "CC BY 4.0", + "data": { + "name": "ELN example sample", + "lab_id": "001", + "description": "<p>A simple example for an \"sample\" that demonstrates how to combine different data entities.</p>\n<p>The sample it-self defines a few properties (involved chemicals, used substrate) and uses inherited default properties (formula, name, lab id, ...)</p>\n<p>But the sample also contains sub-sections that prodivde inforamtion about proccessed that were applied to this sample (PVD evaporation, hotplate annealing). </p>\n<p>The sample also show references to other entries (chemicals, instruments).</p>\n<p> </p>", + "substrate_type": "SLG", + "processes": { + "pvd_evaporation": { + "datetime": "2022-05-10T07:20:00+00:00", + "data_file": "PVDProcess.csv", + "fill_archive_from_datafile": false + }, + "hotplate_annealing": { + "datetime": "2022-05-10T07:22:00+00:00", + "set_temperature": 373.15, + "duration": 60 + } + }, + "datetime": "2024-06-21T14:53:41.294628+00:00" + } + }, + { + "upload_id": "eln_upload_id", + "references": [], + "origin": "Markus Scheidgen", + "text_search_contents": [], + "datasets": [], + "n_quantities": 155, + "nomad_version": "1.3.3.dev67+g6dbdb317b", + "upload_create_time": "2024-06-21T12:53:41.020000+00:00", + "nomad_commit": "", + "section_defs": [ + { + "used_directly": true, + "definition_id": "7047cbff9980abff17cce4b1b6b0d1c783505b7f", + "definition_qualified_name": "nomad.datamodel.data.ArchiveSection" + }, + { + "used_directly": true, + "definition_id": "1067a7855c86bc74d57bfbe3c95e7b5ca97cc403", + "definition_qualified_name": "nomad.datamodel.datamodel.EntryArchive" + }, + { + "used_directly": true, + "definition_id": "08e24de48352035bcc932ed9934875ed9b10188e", + "definition_qualified_name": "nomad.datamodel.datamodel.EntryMetadata" + }, + { + "used_directly": true, + "definition_id": "265ae33dbc9ace82b50260c1050d30c59ef9cb3c", + "definition_qualified_name": "nomad.datamodel.datamodel.RFC3161Timestamp" + }, + { + "used_directly": true, + "definition_id": "f6d62b2664a0c37e0c82d4cb570e5e177e445e05", + "definition_qualified_name": "nomad.datamodel.results.Properties" + }, + { + "used_directly": true, + "definition_id": "2820e376005366caae1a7662ad21cfacefc34abb", + "definition_qualified_name": "nomad.datamodel.results.Results" + }, + { + "used_directly": true, + "definition_id": "f6617f133366ffb8cd1bde4bf643ca5bae1db7f8", + "definition_qualified_name": "nomad.metainfo.metainfo.Definition" + }, + { + "used_directly": true, + "definition_id": "0c6464110f52fa3f556ae8e95e2f0272e201525a", + "definition_qualified_name": "nomad.metainfo.metainfo.Package" + }, + { + "used_directly": true, + "definition_id": "28f841a88474d266eded1141318ea4c42d3aff3f", + "definition_qualified_name": "nomad.metainfo.metainfo.Property" + }, + { + "used_directly": true, + "definition_id": "d12b9e6f4aed521dc6feeb67239a929542f78b8d", + "definition_qualified_name": "nomad.metainfo.metainfo.Quantity" + }, + { + "used_directly": true, + "definition_id": "f293173ca54be29603fb12b9b138f8a76130a25c", + "definition_qualified_name": "nomad.metainfo.metainfo.Section" + }, + { + "used_directly": true, + "definition_id": "19bf6b5bf2ffa69e6585a1fb1c6b0961d4a89738", + "definition_qualified_name": "nomad.metainfo.metainfo.SubSection" + } + ], + "processing_errors": [], + "results": { + "properties": { + "available_properties": [] + } + }, + "entry_name": "Electronic Lab Notebook example schema", + "last_processing_time": "2024-06-21T12:53:41.242000+00:00", + "parser_name": "parsers/archive", + "calc_id": "83DS7AzwqTKFVwlrdVeaL3kMSLU_", + "published": false, + "writers": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + } + ], + "writer_groups": [], + "processed": true, + "mainfile": "schema.archive.yaml", + "main_author": { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + "viewers": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + }, + { + "user_id": "54cb1f64-f84e-4815-9ade-440ce0b5430f", + "name": "Test Tester" + } + ], + "viewer_groups": [], + "entry_create_time": "2024-06-21T12:53:41.111000+00:00", + "with_embargo": false, + "entry_type": "Schema", + "entry_id": "83DS7AzwqTKFVwlrdVeaL3kMSLU_", + "authors": [ + { + "user_id": "68878af7-6845-46c0-b2c1-250d4d8eb470", + "name": "Markus Scheidgen" + }, + { + "user_id": "a03af8b6-3aa7-428a-b3b1-4a6317e576b6", + "name": "Sheldon Cooper" + } + ], + "license": "CC BY 4.0" + } + ] + }, + "headers": { + "connection": "close", + "content-length": "18373", + "content-type": "application/json", + "server": "uvicorn" + } + } + } + ] +} \ No newline at end of file diff --git a/nomad/datamodel/data.py b/nomad/datamodel/data.py index 4c9d27f43350011551f5b2fc92f13edbf5a56b9a..6fcfd9080d6d3e8a3110a70a450358dc5ff6fc95 100644 --- a/nomad/datamodel/data.py +++ b/nomad/datamodel/data.py @@ -20,6 +20,9 @@ import os.path from cachetools import TTLCache, cached +from typing import Dict, Any, Optional +from pydantic import Field + from nomad.config import config from nomad.metainfo.elasticsearch_extension import Elasticsearch, material_entry_type from nomad.metainfo.metainfo import ( @@ -32,6 +35,7 @@ from nomad.metainfo.metainfo import ( Section, Datetime, Reference, + JSON, ) from nomad.metainfo.pydantic_extension import PydanticModel @@ -230,4 +234,25 @@ class AuthorReference(Reference): author_reference = AuthorReference() + + +class Query(JSON): + """ + To represent a search query, including the applied filters and the results. + + filters : dict + A dictionary of filters applied to the search. Keys are filter names, and values are the filter values. + query : MetadataResponse + A dictionary of the used query in the current search. + """ + + def _normalize_impl(self, value, **kwargs): + from nomad.app.v1.models import MetadataResponse + + class QueryResult(MetadataResponse): + filters: Optional[Dict[str, Any]] = Field(None) + + return QueryResult().parse_obj(value).dict() + + Schema = EntryData diff --git a/nomad/datamodel/metainfo/annotations.py b/nomad/datamodel/metainfo/annotations.py index 6599c26aab59e01a3272c84ab9e7a27859c635cc..8d10975aaab5cf3d76c56db87118880a46803966 100644 --- a/nomad/datamodel/metainfo/annotations.py +++ b/nomad/datamodel/metainfo/annotations.py @@ -26,6 +26,7 @@ from pydantic.main import BaseModel from nomad.utils import strip from nomad.metainfo import AnnotationModel, MEnum, MTypes, Datetime, Reference, Quantity from .plot import PlotlyError +from ..data import Query from ...metainfo.data_type import Datatype @@ -46,6 +47,7 @@ class ELNComponentEnum(str, Enum): ReferenceEditQuantity = 'ReferenceEditQuantity' UserEditQuantity = 'UserEditQuantity' AuthorEditQuantity = 'AuthorEditQuantity' + QueryEditQuantity = 'QueryEditQuantity' valid_eln_types = { @@ -65,6 +67,7 @@ valid_eln_types = { 'user': ['User'], 'author': ['Author'], 'reference': [''], + 'query': ['Query'], } @@ -94,6 +97,7 @@ valid_eln_components = { 'user': [ELNComponentEnum.AuthorEditQuantity], 'author': [ELNComponentEnum.AuthorEditQuantity], 'reference': [ELNComponentEnum.ReferenceEditQuantity], + 'query': [ELNComponentEnum.QueryEditQuantity], } @@ -437,6 +441,10 @@ class ELNAnnotation(AnnotationModel): ) elif type_.standard_type().startswith('enum'): assert_component(component, name, 'enum', valid_eln_components['enum']) + elif isinstance(type_, Query): + assert_component( + component, name, type(type_).__name__, valid_eln_components['query'] + ) elif isinstance(type_, type): if type_.__name__ == 'str': assert_component( @@ -454,6 +462,10 @@ class ELNAnnotation(AnnotationModel): assert_component( component, name, type_.__name__, valid_eln_components['author'] ) + elif type_.__name__ == 'Query': + assert_component( + component, name, type_.__name__, valid_eln_components['query'] + ) elif type_ == Datetime: assert_component( diff --git a/nomad/metainfo/data_type.py b/nomad/metainfo/data_type.py index 801097f1f8028e39b1b4fbcb2577a1cf12612fc0..282a0dda20a71ff5a2e27b6d0f7724529f03dcd8 100644 --- a/nomad/metainfo/data_type.py +++ b/nomad/metainfo/data_type.py @@ -1055,6 +1055,11 @@ def normalize_type(value): if value.endswith('json'): return JSON() + if value.endswith('query'): + from nomad.datamodel.data import Query + + return Query() + if value.endswith('datetime'): return Datetime() diff --git a/tests/datamodel/test_schema.py b/tests/datamodel/test_schema.py index 549cfa85bc7a85d8d59087d972614a4ef2a6fe56..d67ee3b0a29a1588052eb53fd57601da38bda9cb 100644 --- a/tests/datamodel/test_schema.py +++ b/tests/datamodel/test_schema.py @@ -22,7 +22,7 @@ import pytest from nomad.metainfo import MetainfoError from nomad.datamodel.context import ServerContext from nomad.datamodel.datamodel import EntryArchive, EntryMetadata -from nomad.datamodel.data import UserReference, AuthorReference +from nomad.datamodel.data import UserReference, AuthorReference, Query from nomad.datamodel.metainfo.annotations import valid_eln_types, valid_eln_components from nomad.metainfo.data_type import Datatype from nomad.parsing.parser import ArchiveParser @@ -162,3 +162,27 @@ def test_user_author_yaml_deserialization(): assert des_my_author.name == 'my_author' assert isinstance(des_my_user.type, UserReference) assert isinstance(des_my_author.type, AuthorReference) + + +def test_query_yaml_deserialization(): + des_m_package = yaml_to_package( + strip( + """ + m_def: 'nomad.metainfo.metainfo.Package' + sections: + Sample: + base_section: 'nomad.datamodel.metainfo.measurements.Sample' + quantities: + my_query: + type: Query + m_annotations: + eln: + component: QueryEditQuantity + """ + ) + ) + des_sample = des_m_package['section_definitions'][0] + des_my_query = des_sample.quantities[0] + + assert des_my_query.name == 'my_query' + assert isinstance(des_my_query.type, Query)