From 7d77ebfae77725ae7b3e5c2bc199edf73201c448 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Tue, 28 Jan 2025 15:22:38 +0100 Subject: [PATCH 01/17] Editable EntrySection. --- .../ui_demonstration/entry-data.archive.yaml | 11 ++ .../schema_packages/values_test_schema.py | 6 + src/components/archive/Section.tsx | 85 ++++++++++++ src/components/archive/SectionProvider.tsx | 14 ++ src/components/archive/useArchive.test.tsx | 5 +- src/components/archive/useArchive.tsx | 129 +++++++++++++++--- .../{editor => archive}/useSubSectionPath.tsx | 0 src/components/editor/ImagePreviewItem.tsx | 2 +- src/components/editor/PlotEditor.tsx | 2 +- src/components/editor/QuantityEditor.tsx | 2 +- src/components/editor/RichTextEditor.tsx | 2 +- src/components/editor/SubSectionEditor.tsx | 16 +-- .../editor/SubSectionTableEditor.tsx | 2 +- src/components/page/Page.tsx | 2 +- src/components/routing/Routes.tsx | 9 +- src/components/routing/loader.ts | 4 +- src/components/routing/types.ts | 12 ++ src/components/routing/useDataForRoute.tsx | 12 ++ src/hooks/useData.tsx | 11 +- src/hooks/useImmediateEffect.test.tsx | 81 +++++++++++ src/hooks/useImmediateEffect.tsx | 39 ++++++ src/pages/entry/EntryOverview.tsx | 75 +++------- src/pages/entry/EntryPageTitle.tsx | 65 +++++++++ src/pages/entry/EntrySection.tsx | 63 ++++----- src/pages/entry/entryRoute.tsx | 5 +- 25 files changed, 520 insertions(+), 134 deletions(-) create mode 100644 src/components/archive/Section.tsx create mode 100644 src/components/archive/SectionProvider.tsx rename src/components/{editor => archive}/useSubSectionPath.tsx (100%) create mode 100644 src/hooks/useImmediateEffect.test.tsx create mode 100644 src/hooks/useImmediateEffect.tsx create mode 100644 src/pages/entry/EntryPageTitle.tsx diff --git a/infra/src/nomad_plugin_gui/example_uploads/ui_demonstration/entry-data.archive.yaml b/infra/src/nomad_plugin_gui/example_uploads/ui_demonstration/entry-data.archive.yaml index b78a6314..2ae880cd 100644 --- a/infra/src/nomad_plugin_gui/example_uploads/ui_demonstration/entry-data.archive.yaml +++ b/infra/src/nomad_plugin_gui/example_uploads/ui_demonstration/entry-data.archive.yaml @@ -19,3 +19,14 @@ data: unit_quantity: 'm' section_reference: '#/data/referenced' referenced: {} + sub_section: { + string_quantity: 'Hello World' + } + repeated_sub_section: [ + { + string_quantity: 'Hello World' + }, + { + string_quantity: 'Hello World' + } + ] diff --git a/infra/src/nomad_plugin_gui/schema_packages/values_test_schema.py b/infra/src/nomad_plugin_gui/schema_packages/values_test_schema.py index 8897d637..8bcd161f 100644 --- a/infra/src/nomad_plugin_gui/schema_packages/values_test_schema.py +++ b/infra/src/nomad_plugin_gui/schema_packages/values_test_schema.py @@ -53,9 +53,15 @@ class QuantityTypes(MSection): section_reference = Quantity(type=Reference(Referenced)) +class ASection(MSection): + string_quantity = Quantity(type=str) class Main(Schema): quantity_types = SubSection(sub_section=QuantityTypes) + sub_section = SubSection(sub_section=ASection) + repeated_sub_section = SubSection(sub_section=ASection, repeats=True) + empty_sub_section = SubSection(sub_section=ASection) + empty_repeated_sub_section = SubSection(sub_section=ASection, repeats=True) referenced = SubSection(sub_section=Referenced) diff --git a/src/components/archive/Section.tsx b/src/components/archive/Section.tsx new file mode 100644 index 00000000..a433e0fa --- /dev/null +++ b/src/components/archive/Section.tsx @@ -0,0 +1,85 @@ +import {Card, CardContent, CardHeader} from '@mui/material' +import {useCallback} from 'react' + +import {MSectionResponse} from '../../models/graphResponseModels' +import { + Quantity as QuantityDefinition, + Section as SectionDefinition, +} from '../../utils/metainfo' +import {JSONValue} from '../../utils/types' +import {assert} from '../../utils/utils' +import ErrorBoundary from '../ErrorBoundary' +import {getPropertyLabel} from '../editor/PropertyEditor' +import DynamicValue from '../values/utils/DynamicValue' +import {createDynamicComponentSpec} from '../values/utils/dynamicComponents' +import SectionProvider from './SectionProvider' +import {useArchiveProperty} from './useArchive' +import useSubSectionPath from './useSubSectionPath' + +type QantityProps = { + definition: QuantityDefinition + editable?: boolean +} + +function Quantity({definition, editable = false}: QantityProps) { + const path = useSubSectionPath(definition.name) + + const {value, loading, change} = useArchiveProperty< + JSONValue | undefined, + QuantityDefinition + >(path) + + assert(!loading, 'Loading should already be handled') + + const handleChange = useCallback( + (newValue: JSONValue | undefined) => { + change('upsert', newValue) + }, + [change], + ) + + const component = createDynamicComponentSpec(definition) + + return ( + <DynamicValue + component={component} + label={getPropertyLabel(definition.name)} + placeholder='no value' + fullWidth + editable={editable} + value={value} + onChange={handleChange} + /> + ) +} + +export type SectionProps = { + editable?: boolean + path: string +} + +export default function Section({path, editable = false}: SectionProps) { + const {value} = useArchiveProperty<MSectionResponse>(path) + const definition = value?.m_def as SectionDefinition + + assert(value !== undefined, 'The section should have been loaded by now') + assert( + definition !== undefined, + 'The section definition should have been loaded by now', + ) + + return ( + <Card> + <CardHeader title='Quantities' /> + <CardContent sx={{display: 'flex', flexDirection: 'column', gap: 2}}> + <SectionProvider path={path}> + {Object.values(definition.all_quantities).map((quantity) => ( + <ErrorBoundary key={quantity.name}> + <Quantity definition={quantity} editable={editable} /> + </ErrorBoundary> + ))} + </SectionProvider> + </CardContent> + </Card> + ) +} diff --git a/src/components/archive/SectionProvider.tsx b/src/components/archive/SectionProvider.tsx new file mode 100644 index 00000000..851e9390 --- /dev/null +++ b/src/components/archive/SectionProvider.tsx @@ -0,0 +1,14 @@ +import {PropsWithChildren} from 'react' + +import {subSectionContext} from './useSubSectionPath' + +export default function SectionProvider({ + path: path, + children, +}: {path: string} & PropsWithChildren) { + return ( + <subSectionContext.Provider value={{path}}> + {children} + </subSectionContext.Provider> + ) +} diff --git a/src/components/archive/useArchive.test.tsx b/src/components/archive/useArchive.test.tsx index 286c36c2..3badc1de 100644 --- a/src/components/archive/useArchive.test.tsx +++ b/src/components/archive/useArchive.test.tsx @@ -167,14 +167,14 @@ describe('useArchive', () => { addDefinitions(response, {q: 1, s: {}, r: []}) archive.archive = initialArchive - const updates = archive.updateArchive(request, response, {}) + const {changes} = archive.updateArchive(request, response, {}) expect(archive.archive.m_def).not.toBeUndefined() // remove definitions to make the comparison simpler removeDefinitions(archive.archive) expect(archive.archive).toEqual(expectedArchive) - expect(updates).toEqual(expectedUpdates) + expect(changes).toEqual(expectedUpdates) }, ) }) @@ -292,6 +292,7 @@ describe('useArchiveProperty', () => { 'quantity', 'sub_section/quantity', 'repeating_sub_section/0/quantity', + 'repeating_sub_section[0]/quantity', 'sub_section/nested_sub_section/quantity', 'sub_section', 'sub_section/nested_sub_section', diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 1848762f..e0ec92f0 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -17,6 +17,11 @@ import {assert} from '../../utils/utils' import {getIndexedKey} from '../routing/loader' import useRouteData from '../routing/useRouteData' +export type ArchiveChangeConflict = { + type: 'parent' | 'child' | 'exact' + change: ArchiveChange +} + class MultiMap<K, V> { map = new Map<K, V[]>() @@ -72,10 +77,7 @@ export class Archive { this.archive = {} this.changeStack = [] this.responsesWithReferencedArchives = new MultiMap() - - // TODO loading needs to be integrated with routing and the use of - // useDataForRoute - this.loading = true + this.loading = false } registerElement(path: string, dispatch: ArchivePropertyDispatch) { @@ -118,7 +120,7 @@ export class Archive { ) { // apply the value to the archive let current = this.archive - const parts = path.split('/') + const parts = normalizeArchivePath(path) const subSections = parts.slice(0, -1) for (const key of subSections) { current = current[key] as MSectionResponse @@ -173,7 +175,7 @@ export class Archive { if (path === '') { return current as T } - for (const key of path.split('/')) { + for (const key of normalizeArchivePath(path)) { if (current[key] === undefined) { return undefined } @@ -215,10 +217,48 @@ export class Archive { return resolved } + /** + * Detects conflicts between changing the given path and the changes in the + * current chanage stack. The function returns 'parent' if the path is a + * prefix to a path in the change stack, 'child' if a path in the change stack + * is a prefix to the given path, 'none' if there is no conflict, and 'exact' + * of the path is exactly the same as a path in the change stack. + * If multiple conflicting changes exist, the function returns the change + * with the shortest path. + */ + conflictsWithChangeStack(path: string): ArchiveChangeConflict | undefined { + // TODO if this ever causes performance issues (On), we need to use some more + // efficient data structure here, e.g. represent the change stack as a + // tree (Ologn). + const conflicts: ArchiveChangeConflict[] = [] + for (const change of this.changeStack) { + if (change.path === path) { + conflicts.push({type: 'exact', change}) + } + if (change.path.startsWith(path)) { + conflicts.push({type: 'parent', change}) + } + if (path.startsWith(change.path)) { + conflicts.push({type: 'child', change}) + } + } + + if (conflicts.length === 0) { + return undefined + } + + return conflicts.sort( + (a, b) => a.change.path.length - b.change.path.length, + )[0] + } + /** * Merges new archive data represented as a request/response pair into the * archive. It detects a collection of changes and dispatches them to the * registered elements. + * + * TODO what happens when the update effects something that we already + * have local user changes for? */ updateArchive( request: MSectionRequest, @@ -227,6 +267,7 @@ export class Archive { path?: string, ) { const changes: [string, unknown][] = [] + const conflicts: ArchiveChangeConflict[] = [] // collect referenced archives (this might include the current one for // intra-entry references) @@ -251,6 +292,16 @@ export class Archive { }) }) + const checkForConflicts = (path: string) => { + const conflict = this.conflictsWithChangeStack(path) + if (conflict) { + conflicts.push(conflict) + return true + } else { + return false + } + } + // traverse const visit = ( path: string, @@ -311,6 +362,10 @@ export class Archive { if (response[key] === undefined) { // delete + if (checkForConflicts(pathForCurrentKey)) { + continue + } + if (!archive[key]) { continue } @@ -331,6 +386,10 @@ export class Archive { changes.push([pathForCurrentKey, response[key]]) } else if (archive[key] === undefined) { // add + if (checkForConflicts(pathForCurrentKey)) { + continue + } + if (indexed) { const archiveList = (archive[key] as unknown as MSectionResponse[]) || [] @@ -345,9 +404,17 @@ export class Archive { } else { // update if (!childIsSubSection) { + if (checkForConflicts(pathForCurrentKey)) { + continue + } + archive[key] = response[key] changes.push([pathForCurrentKey, response[key]]) } else if (childIsRepeatingSubSection) { + if (checkForConflicts(pathForCurrentKey)) { + continue + } + // The semantics is that the sections with the same index are // the same. Even if a section is removed from the middle of the // list. @@ -419,29 +486,40 @@ export class Archive { visit('', this.archive, request, response) } - // TODO the loading state needs to be properly handled. - if (this.loading) { - this.loading = false - for (const [path, elements] of this.elements.map.entries()) { - for (const dispatch of elements) { + if (!this.loading) { + for (const [path, value] of changes) { + for (const dispatch of this.elements.get(path)) { dispatch?.({ - value: this.getValue(path), + value, loading: false, }) } } - } else { - for (const [path, value] of changes) { - for (const dispatch of this.elements.get(path)) { + } + + return { + changes, + conflicts, + } + } + + setLoading(loading: boolean) { + if (this.loading === loading) { + return + } + + this.loading = loading + + if (!this.loading) { + for (const [path, elements] of this.elements.map.entries()) { + for (const dispatch of elements) { dispatch?.({ - value, + value: this.getValue(path), loading: false, }) } } } - - return changes } /** @@ -458,11 +536,13 @@ export class Archive { assert(pathWithoutIndex, `Invalid property path ${path}`) let section = this.archive + assert(section, 'No archive loaded') const parts = path.split('/') const subSections = parts.slice(0, -1) const property = parts[parts.length - 1] for (const key of subSections) { section = section[key] as MSectionResponse + assert(section, `No section for path ${path}, ${key} is missing`) } const sectionDef = section.m_def as Section const propertyDef = sectionDef?.all_properties?.[property] @@ -570,6 +650,19 @@ export type ArchiveProperty<Type, DefinitionType extends Property> = { resolveRef: (ref: string) => MSectionResponse | undefined } +function normalizeArchivePath(path: string) { + const result: string[] = [] + path.split('/').forEach((segment) => { + const match = segment.match(/([^[]+)\[(\d+)\]$/) + if (match) { + result.push(match[1], match[2]) + } else { + result.push(segment) + } + }) + return result +} + /** * A hook that interacts with the archive of the current entry. It allows * to get, set, and react to changes of individual properties in the archive. diff --git a/src/components/editor/useSubSectionPath.tsx b/src/components/archive/useSubSectionPath.tsx similarity index 100% rename from src/components/editor/useSubSectionPath.tsx rename to src/components/archive/useSubSectionPath.tsx diff --git a/src/components/editor/ImagePreviewItem.tsx b/src/components/editor/ImagePreviewItem.tsx index 96d89a3d..7ca4b4f6 100644 --- a/src/components/editor/ImagePreviewItem.tsx +++ b/src/components/editor/ImagePreviewItem.tsx @@ -1,9 +1,9 @@ import {Skeleton} from '@mui/material' import {useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {LayoutItem} from '../layout/Layout' import {AbstractProperty} from './PropertyEditor' -import useSubSectionPath from './useSubSectionPath' export type ImagePreview = AbstractProperty & { type: 'imagePreview' diff --git a/src/components/editor/PlotEditor.tsx b/src/components/editor/PlotEditor.tsx index 0f007647..4e55e97e 100644 --- a/src/components/editor/PlotEditor.tsx +++ b/src/components/editor/PlotEditor.tsx @@ -3,9 +3,9 @@ import {useLayoutEffect, useRef} from 'react' import {useAsync} from 'react-use' import {useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {LayoutItem} from '../layout/Layout' import {AbstractProperty} from './PropertyEditor' -import useSubSectionPath from './useSubSectionPath' export type Plot = AbstractProperty & { type: 'plot' diff --git a/src/components/editor/QuantityEditor.tsx b/src/components/editor/QuantityEditor.tsx index 987bf2de..6eec46fa 100644 --- a/src/components/editor/QuantityEditor.tsx +++ b/src/components/editor/QuantityEditor.tsx @@ -3,6 +3,7 @@ import {useCallback} from 'react' import {Quantity as QuantityDefinition} from '../../utils/metainfo' import {useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {LayoutItem} from '../layout/Layout' import ContainerProps, { DynamicContainerProps, @@ -13,7 +14,6 @@ import { createDynamicComponentSpec, } from '../values/utils/dynamicComponents' import {AbstractProperty, getLabel} from './PropertyEditor' -import useSubSectionPath from './useSubSectionPath' export type Quantity = AbstractProperty & { type: 'quantity' diff --git a/src/components/editor/RichTextEditor.tsx b/src/components/editor/RichTextEditor.tsx index 2c443b36..7f48ed0f 100644 --- a/src/components/editor/RichTextEditor.tsx +++ b/src/components/editor/RichTextEditor.tsx @@ -2,10 +2,10 @@ import {Skeleton} from '@mui/material' import {useCallback, useRef} from 'react' import {useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {LayoutItem} from '../layout/Layout' import Editor, {RichTextEditorProps} from '../richText/RichTextEditor' import {AbstractProperty, getLabel} from './PropertyEditor' -import useSubSectionPath from './useSubSectionPath' export type RichText = AbstractProperty & { type: 'richText' diff --git a/src/components/editor/SubSectionEditor.tsx b/src/components/editor/SubSectionEditor.tsx index 8b67c02b..d4951fd6 100644 --- a/src/components/editor/SubSectionEditor.tsx +++ b/src/components/editor/SubSectionEditor.tsx @@ -1,27 +1,17 @@ import {Box, Button, IconButton} from '@mui/material' import Grid from '@mui/material/Grid2' -import {PropsWithChildren, useCallback} from 'react' +import {useCallback} from 'react' import {MSectionResponse} from '../../models/graphResponseModels' import {SubSection as SubSectionDefinition} from '../../utils/metainfo' import {assert, splice} from '../../utils/utils' +import SectionProvider from '../archive/SectionProvider' import {ArchiveProperty, useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {Remove} from '../icons' import {Item, Layout, LayoutItem} from '../layout/Layout' import {layoutPropsContext} from '../layout/useLayoutProps' import {AbstractProperty, getLabel} from './PropertyEditor' -import useSubSectionPath, {subSectionContext} from './useSubSectionPath' - -function SectionProvider({ - path: path, - children, -}: {path: string} & PropsWithChildren) { - return ( - <subSectionContext.Provider value={{path}}> - {children} - </subSectionContext.Provider> - ) -} function SubSectionComponent({ label, diff --git a/src/components/editor/SubSectionTableEditor.tsx b/src/components/editor/SubSectionTableEditor.tsx index 28daf9b2..3258426d 100644 --- a/src/components/editor/SubSectionTableEditor.tsx +++ b/src/components/editor/SubSectionTableEditor.tsx @@ -8,6 +8,7 @@ import { } from '../../utils/metainfo' import {assert, splice} from '../../utils/utils' import useArchive, {useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' import {LayoutItem} from '../layout/Layout' import RichTableEditor, { RichTableEditorColumn, @@ -23,7 +24,6 @@ import { } from '../values/utils/dynamicComponents' import {AbstractProperty, getLabel} from './PropertyEditor' import {Quantity} from './QuantityEditor' -import useSubSectionPath from './useSubSectionPath' export type SubSectionTable = AbstractProperty & { type: 'table' diff --git a/src/components/page/Page.tsx b/src/components/page/Page.tsx index 32c6f985..9c13f570 100644 --- a/src/components/page/Page.tsx +++ b/src/components/page/Page.tsx @@ -233,7 +233,7 @@ const PageRoot = styled('div', { height: '100%', })) -interface PageTitleProps { +export interface PageTitleProps { /** The subtitle of the page or part of the page, shown below the title. */ subtitle?: string | ReactNode /** The title of the page or part of the page. */ diff --git a/src/components/routing/Routes.tsx b/src/components/routing/Routes.tsx index 5f85b543..7b984133 100644 --- a/src/components/routing/Routes.tsx +++ b/src/components/routing/Routes.tsx @@ -258,9 +258,16 @@ export default function Routes({ value: RouteContextValue, component: ElementType, ) => { + let key: string | undefined = undefined + if (value.fullMatch[value.index].route.renderWithPathAsKey) { + key = value.fullMatch + .slice(0, value.index + 1) + .map((match) => match.path) + .join('/') + } return React.createElement( routeContext.Provider, - {value}, + {value, key}, React.createElement(component), ) } diff --git a/src/components/routing/loader.ts b/src/components/routing/loader.ts index 1a7fd583..0eeb1847 100644 --- a/src/components/routing/loader.ts +++ b/src/components/routing/loader.ts @@ -125,6 +125,9 @@ export function createRequestForRoute( if (request) { assert(path !== undefined, 'Request for empty paths are not supported.') requests[path] = request + if (routeRequests) { + routeRequests[index] = request + } } parentPath = path }) @@ -142,7 +145,6 @@ export function createRequestForRoute( Object.assign(currentRequest, request) }) - routeRequests?.push(rootRequest) return rootRequest } diff --git a/src/components/routing/types.ts b/src/components/routing/types.ts index db027b48..a5cf8845 100644 --- a/src/components/routing/types.ts +++ b/src/components/routing/types.ts @@ -204,6 +204,18 @@ export type Route< */ lazyComponent?: () => Promise<{default: ElementType}> + /** + * React determines component identities and subsequent updattes, mounts, and + * unmounts based on a components position in the tree. If the path of + * variable routes changes, the component will still be treated as the same + * and it will only be updated. This is not always desired, e.g. if entryId + * changes, you usually want to start from scratch. If `renderWithPathAsKey` + * is set to true, the component will be rendered with a key that is set + * to the path. So if the path changes, it is guaranteed that the old component + * gets unmounted and a new component is created. + */ + renderWithPathAsKey?: boolean + /** * If defined, a given component will only be rendered if the current location * matches on of the given routes. Routes can be given as `Route` objects diff --git a/src/components/routing/useDataForRoute.tsx b/src/components/routing/useDataForRoute.tsx index bd541669..c1e22963 100644 --- a/src/components/routing/useDataForRoute.tsx +++ b/src/components/routing/useDataForRoute.tsx @@ -2,6 +2,7 @@ import {useMemo} from 'react' import {useLatest} from 'react-use' import useData, {UseDataParams, UseDataResult} from '../../hooks/useData' +import {GraphRequest} from '../../models/graphRequestModels' import {GraphResponse} from '../../models/graphResponseModels' import {DefaultToObject, JSONObject} from '../../utils/types' import {createRequestForRoute, getResponsesForRoute} from './loader' @@ -29,6 +30,7 @@ export default function useDataForRoute< >({ request, route, + onBeforeFetch: onBeforeFetchForRoute, onFetch: onFetchForRoute, onError, reloadCount, @@ -51,6 +53,15 @@ export default function useDataForRoute< // eslint-disable-next-line react-hooks/exhaustive-deps }, [request]) + const onBeforeFetch = useMemo(() => { + if (!onBeforeFetchForRoute) { + return undefined + } + return (_: JSONObject, fullRequest: GraphRequest) => { + onBeforeFetchForRoute(request as Request, fullRequest) + } + }, [onBeforeFetchForRoute, request]) + const onFetch = useMemo(() => { if (!onFetchForRoute) { return undefined @@ -65,6 +76,7 @@ export default function useDataForRoute< const {data, ...otherUseDataResults} = useData({ request: fullRequest, + onBeforeFetch, onFetch, onError, ...useDataParams, diff --git a/src/hooks/useData.tsx b/src/hooks/useData.tsx index 31e9cd82..86470bed 100644 --- a/src/hooks/useData.tsx +++ b/src/hooks/useData.tsx @@ -10,6 +10,7 @@ import {graphApi} from '../utils/api' import {resolveAllMDefs} from '../utils/metainfo' import {DefaultToObject, JSONObject, JSONValue} from '../utils/types' import useAuth from './useAuth' +import useImmediateEffect from './useImmediateEffect' import useRender from './useRender' /** @@ -63,6 +64,11 @@ export type UseDataParams<Request, Response> = { */ noImplicitFetch?: boolean + /** + * An optional callback that is called before the fetch is started. + */ + onBeforeFetch?: (request: Request, fullRequest: GraphRequest) => void + /** * An optional callback that is called when the data was successfully * received. This allows to trigger state changes now, instead of needing @@ -100,6 +106,7 @@ export default function useData< >({ request, noImplicitFetch = false, + onBeforeFetch, onFetch, onError, }: UseDataParams<Request, Response>): UseDataResult<Response> { @@ -120,6 +127,7 @@ export default function useData< requestRef.current = request const fetch = async () => { normalizedRequestRef.current = normalizeGraph(request as JSONObject) + onBeforeFetch?.(request, normalizedRequestRef.current) try { const result = await graphApi( normalizedRequestRef.current, @@ -151,12 +159,13 @@ export default function useData< setData, normalizedRequestRef, request, + onBeforeFetch, onFetch, onError, user, ]) - useEffect(() => { + useImmediateEffect(() => { if (!noImplicitFetch) { return fetch() } diff --git a/src/hooks/useImmediateEffect.test.tsx b/src/hooks/useImmediateEffect.test.tsx new file mode 100644 index 00000000..60bc7e1f --- /dev/null +++ b/src/hooks/useImmediateEffect.test.tsx @@ -0,0 +1,81 @@ +import {renderHook} from '@testing-library/react' +import {vi} from 'vitest' + +import {JSONObject} from '../utils/types' +import useImmediateEffect from './useImmediateEffect' + +it('calls effect on render without deps', () => { + const effect = vi.fn() + renderHook(() => { + return useImmediateEffect(effect, []) + }) + expect(effect).toHaveBeenCalledTimes(1) +}) + +it('calls effect on render with deps', () => { + const effect = vi.fn() + renderHook(() => { + return useImmediateEffect(effect, [true]) + }) + expect(effect).toHaveBeenCalledTimes(1) +}) + +it('does not call effect on re-render with unchanged deps', () => { + const effect = vi.fn() + const {rerender} = renderHook( + ({dep}: {dep: boolean}) => { + useImmediateEffect(effect, [dep]) + }, + {initialProps: {dep: false}}, + ) + expect(effect).toHaveBeenCalledTimes(1) + + rerender({dep: false}) + expect(effect).toHaveBeenCalledTimes(1) +}) + +it('calls effect on re-render with changed deps', () => { + const effect = vi.fn() + const {rerender} = renderHook( + ({dep}: {dep: boolean}) => { + useImmediateEffect(effect, [dep]) + }, + {initialProps: {dep: false}}, + ) + expect(effect).toHaveBeenCalledTimes(1) + + rerender({dep: true}) + expect(effect).toHaveBeenCalledTimes(2) +}) + +it('calls effect on re-render with changed object deps', () => { + const effect = vi.fn() + const obj = {a: 1} + const {rerender} = renderHook( + ({dep}: {dep: JSONObject}) => { + useImmediateEffect(effect, [dep]) + }, + {initialProps: {dep: obj}}, + ) + expect(effect).toHaveBeenCalledTimes(1) + + rerender({dep: {...obj}}) + expect(effect).toHaveBeenCalledTimes(2) +}) + +it('calls destructuor', () => { + const destructor = vi.fn() + const {rerender} = renderHook( + ({dep}: {dep: boolean}) => { + useImmediateEffect(() => destructor, [dep]) + }, + {initialProps: {dep: false}}, + ) + expect(destructor).toHaveBeenCalledTimes(0) + + rerender({dep: false}) + expect(destructor).toHaveBeenCalledTimes(0) + + rerender({dep: true}) + expect(destructor).toHaveBeenCalledTimes(1) +}) diff --git a/src/hooks/useImmediateEffect.tsx b/src/hooks/useImmediateEffect.tsx new file mode 100644 index 00000000..311f4bfe --- /dev/null +++ b/src/hooks/useImmediateEffect.tsx @@ -0,0 +1,39 @@ +import {DependencyList, useCallback, useEffect, useRef} from 'react' +import {usePrevious} from 'react-use' + +/** + * A hook that has the same interface and behavior as `useEffect` but runs the + * effect "immediately" upon beeing called and within the current render. + */ +export default function useImmediateEffect( + effect: React.EffectCallback, + deps: DependencyList, +) { + let hasChanged = true !== usePrevious(true) + for (let i = 0; i < deps.length; i++) { + // eslint-disable-next-line react-hooks/rules-of-hooks + if (deps[i] !== usePrevious(deps[i])) { + hasChanged = true + } + } + + const effectDestructor = useRef<ReturnType<React.EffectCallback>>() + + if (hasChanged) { + if (effectDestructor.current) { + effectDestructor.current() + } + effectDestructor.current = effect() + } + + const unmount = useCallback(() => { + if (effectDestructor.current) { + effectDestructor.current() + effectDestructor.current = undefined + } + }, []) + + useEffect(() => { + return unmount + }, [unmount]) +} diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index 71b42405..0ccde221 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -1,18 +1,12 @@ -import GoToIcon from '@mui/icons-material/ChevronRight' -import {Box, Button} from '@mui/material' +import {Box} from '@mui/material' import {useCallback, useMemo, useState} from 'react' import ErrorMessage from '../../components/app/ErrorMessage' -import EditStatus from '../../components/archive/EditStatus' import useArchive from '../../components/archive/useArchive' import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' import useSelect from '../../components/navigation/useSelect' -import {PageTitle} from '../../components/page/Page' -import usePage from '../../components/page/usePage' -import Link from '../../components/routing/Link' import useDataForRoute from '../../components/routing/useDataForRoute' -import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' import { @@ -25,16 +19,17 @@ import {assert} from '../../utils/utils' import UploadMetadataEditor from '../upload/UploadMetadataEditor' import EntryDataEditor from './EntryDataEditor' import EntryMetadataEditor from './EntryMetadataEditor' +import EntryPageTitle from './EntryPageTitle' import EntrySubSectionTable from './EntrySubSectionTable' import entryRoute, {archiveRequest} from './entryRoute' function EntryOverviewEditor() { const {archive: archiveData} = useRouteData(entryRoute) const [error, setError] = useState<Error | undefined>() - const schema = (archiveData?.data as MSectionResponse)?.m_def as Section const {isPage} = useSelect() const layout = useMemo(() => { + const schema = (archiveData?.data as MSectionResponse)?.m_def as Section let layout: LayoutItem const layouts = schema?.m_annotations?.layout as unknown as | LayoutItem @@ -53,7 +48,7 @@ function EntryOverviewEditor() { property: 'data', layout, } satisfies LayoutItem - }, [schema]) + }, [archiveData]) const request = useMemo(() => { const request = { @@ -73,6 +68,10 @@ function EntryOverviewEditor() { const archive = useArchive() + const onBeforeFetch = useCallback(() => { + archive.setLoading(true) + }, [archive]) + const onFetch = useCallback( (data: EntryResponse, fullResponse: GraphResponse) => { archive.updateArchive( @@ -80,14 +79,24 @@ function EntryOverviewEditor() { data?.archive as MSectionResponse, fullResponse, ) + archive.setLoading(false) }, [archive, request], ) + const onError = useCallback( + (error: Error) => { + archive.setLoading(false) + setError(error) + }, + [archive], + ) + useDataForRoute<EntryRequest, EntryResponse>({ request, + onBeforeFetch, onFetch, - onError: setError, + onError, }) let content: React.ReactNode = '' @@ -119,35 +128,13 @@ function EntryOverviewEditor() { } export default function EntryOverview() { - const {url} = useRoute() - const { - upload_id, - mainfile_path, - archive: rootSectionData, - } = useRouteData(entryRoute) + const {archive: rootSectionData} = useRouteData(entryRoute) assert( rootSectionData !== undefined, 'An entry should always have a root section', ) const {isPage, isSelect} = useSelect() - const {isScrolled} = usePage() - - const actions = ( - <Box display='flex' alignItems='center' flexDirection='row' gap={1}> - <EditStatus /> - <Button - variant='contained' - component={Link} - to={url({ - path: `/uploads/${upload_id}/files/${mainfile_path}`, - })} - endIcon={<GoToIcon />} - > - Go to File - </Button> - </Box> - ) // This memo is an optimization to avoid re-rendering the entire // EntryOverviewEditor after usePage causes a srcoll event triggered @@ -170,27 +157,7 @@ export default function EntryOverview() { return ( <> - {isPage && ( - <PageTitle - sx={{ - position: 'sticky', - top: 0, - marginTop: -1, - marginX: -2, - paddingX: 2, - paddingY: 1, - background: (theme) => theme.palette.background.default, - zIndex: (theme) => theme.zIndex.appBar, - ...(isScrolled - ? { - borderBottom: '1px solid', - borderColor: (theme) => theme.palette.divider, - } - : {}), - }} - actions={actions} - /> - )} + {isPage && <EntryPageTitle />} {pageContent} </> ) diff --git a/src/pages/entry/EntryPageTitle.tsx b/src/pages/entry/EntryPageTitle.tsx new file mode 100644 index 00000000..9057baab --- /dev/null +++ b/src/pages/entry/EntryPageTitle.tsx @@ -0,0 +1,65 @@ +import GoToIcon from '@mui/icons-material/ChevronRight' +import {Box, Button} from '@mui/material' + +import EditStatus from '../../components/archive/EditStatus' +import {PageTitle, PageTitleProps} from '../../components/page/Page' +import usePage from '../../components/page/usePage' +import Link from '../../components/routing/Link' +import useRoute from '../../components/routing/useRoute' +import useRouteData from '../../components/routing/useRouteData' +import {assert} from '../../utils/utils' +import entryRoute from './entryRoute' + +export default function EntryPageTitle(props: PageTitleProps) { + const {url} = useRoute() + const { + upload_id, + mainfile_path, + archive: rootSectionData, + } = useRouteData(entryRoute) + assert( + rootSectionData !== undefined, + 'An entry should always have a root section', + ) + + const {isScrolled} = usePage() + + const actions = ( + <Box display='flex' alignItems='center' flexDirection='row' gap={1}> + <EditStatus /> + <Button + variant='contained' + component={Link} + to={url({ + path: `/uploads/${upload_id}/files/${mainfile_path}`, + })} + endIcon={<GoToIcon />} + > + Go to File + </Button> + </Box> + ) + + return ( + <PageTitle + sx={{ + position: 'sticky', + top: 0, + marginTop: -1, + marginX: -2, + paddingX: 2, + paddingY: 1, + background: (theme) => theme.palette.background.default, + zIndex: (theme) => theme.zIndex.appBar, + ...(isScrolled + ? { + borderBottom: '1px solid', + borderColor: (theme) => theme.palette.divider, + } + : {}), + }} + actions={actions} + {...props} + /> + ) +} diff --git a/src/pages/entry/EntrySection.tsx b/src/pages/entry/EntrySection.tsx index 0332a176..5c7818fb 100644 --- a/src/pages/entry/EntrySection.tsx +++ b/src/pages/entry/EntrySection.tsx @@ -2,16 +2,15 @@ import {Box, Card, CardContent, CardHeader} from '@mui/material' import {useMemo} from 'react' import ErrorBoundary from '../../components/ErrorBoundary' -import QuantityValue from '../../components/archive/QuantityValue' +import Section from '../../components/archive/Section' import JsonViewer from '../../components/fileviewer/JsonViewer' -import {PageTitle} from '../../components/page/Page' import Outlet from '../../components/routing/Outlet' import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' -import {Section as SectionDefinition} from '../../utils/metainfo' -import {JSONObject, JSONValue} from '../../utils/types' +import {JSONObject} from '../../utils/types' +import EntryPageTitle from './EntryPageTitle' import EntrySubSectionTable from './EntrySubSectionTable' const sortRawDataKeys = (a: string, b: string) => { @@ -24,48 +23,38 @@ const sortRawDataKeys = (a: string, b: string) => { return 1 } -function Section() { - const data = useRouteData<MSectionRequest, MSectionResponse>() - - const definition = data.m_def as SectionDefinition - const quantities = useMemo(() => { - return Object.keys(definition.all_quantities) - .filter((key) => data[key] !== undefined) - .map((key) => definition.all_quantities[key]) - //.toSorted((a, b) => a.name.localeCompare(b.name)) - }, [data, definition]) - - return ( - <> - <EntrySubSectionTable data={data} /> - <Card> - <CardHeader title='Quantities' /> - <CardContent sx={{display: 'flex', flexDirection: 'column', gap: 2}}> - {quantities.map((quantity) => ( - <ErrorBoundary key={quantity.name}> - <QuantityValue - quantityDef={quantity} - value={data[quantity.name] as JSONValue | undefined} - /> - </ErrorBoundary> - ))} - </CardContent> - </Card> - </> - ) -} - export default function EntrySection() { const {isLeaf, fullMatch} = useRoute() const data = useRouteData<MSectionRequest, MSectionResponse>() + const sectionPath = useMemo(() => { + if (!isLeaf) { + return '' + } + const startIndex = fullMatch.findIndex( + (match) => match.route.path === 'archive', + ) + return fullMatch + .slice(startIndex + 1) + .map((match) => match.path) + .join('/') + }, [fullMatch, isLeaf]) + if (!isLeaf) { return <Outlet /> } + return ( <> - <PageTitle title={fullMatch[fullMatch.length - 1].path} /> + <EntryPageTitle title={fullMatch[fullMatch.length - 1].path} /> <Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}> - {data.m_def && <Section />} + <ErrorBoundary> + <EntrySubSectionTable data={data} /> + </ErrorBoundary> + <ErrorBoundary> + {data.m_def && data.m_def.name !== 'EntryArchive' && ( + <Section path={sectionPath} editable /> + )} + </ErrorBoundary> <Card> <CardHeader title='Raw data' /> <CardContent> diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index 424efebd..725e9b3b 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -31,6 +31,7 @@ export const archiveRoute: Route<MSectionRequest, MSectionResponse> = { path: ':name', request: {...archiveRequest}, lazyComponent: async () => import('./EntrySection'), + renderWithPathAsKey: true, } archiveRoute.children = [archiveRoute] @@ -52,6 +53,7 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { archive: {...archiveRequest}, }, lazyComponent: async () => import('./EntryPage'), + renderWithPathAsKey: true, breadcrumb: ({response}) => path.basename(response?.mainfile_path as string), children: [ { @@ -71,12 +73,13 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { } const entryId = match.path const archive = getArchive(entryId) - + archive.setLoading(true) archive.updateArchive( request.archive, response.archive, fullResponse as GraphResponse, ) + archive.setLoading(false) }, } -- GitLab From 32d22275971d71a0f5e1750f66c9157fed488221 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 29 Jan 2025 17:54:41 +0100 Subject: [PATCH 02/17] Reconciling archive updates with current changes. --- docs/Archive.mdx | 12 +- src/components/archive/archive.helper.tsx | 10 +- src/components/archive/useArchive.test.tsx | 91 ++++++++++++--- src/components/archive/useArchive.tsx | 123 +++++++++++++++------ src/components/routing/Routes.tsx | 52 ++++----- src/components/routing/useDataForRoute.tsx | 2 +- src/hooks/useData.tsx | 25 ++++- src/hooks/useImmediateEffect.test.tsx | 81 -------------- src/hooks/useImmediateEffect.tsx | 39 ------- src/index.tsx | 35 +++--- src/pages/entry/EntryArchiveNav.tsx | 11 +- src/pages/entry/EntryOverview.tsx | 35 ++++-- src/pages/entry/entryRoute.tsx | 15 +-- 13 files changed, 277 insertions(+), 254 deletions(-) delete mode 100644 src/hooks/useImmediateEffect.test.tsx delete mode 100644 src/hooks/useImmediateEffect.tsx diff --git a/docs/Archive.mdx b/docs/Archive.mdx index bd32a9b9..e0a08939 100644 --- a/docs/Archive.mdx +++ b/docs/Archive.mdx @@ -4,7 +4,6 @@ To show and edit processed data and data from `archive.json` raw-files (e.g. "ar we provide some functionality to manage archive data in the UI. This functionality can be use via the `useArchive` hook. - ## Goals - load and maintain partial archives, successively update them with subsequent @@ -13,14 +12,12 @@ functionality can be use via the `useArchive` hook. - tracking changes to the archive data and allowing to save changes back to the API - ## Basic usage There are several hooks to interact with the archive. In any case, the archive is always tight to the current entry. Where the current entry is determined based on the current route. - ### useArchive This hook gives you access to the archive object. See also the doc strings @@ -32,10 +29,8 @@ const archive = useArchive() const onFetch = useCallback( (data: EntryResponse) => { - archive.updateArchive( - request.archive as MSectionRequest, - data?.archive as MSectionResponse, - ) + archive.startUpdate(request.archive as MSectionRequest) + archive.commitUpdate(data?.archive as MSectionResponse) }, [archive, request], ) @@ -66,9 +61,8 @@ propery within the archive. The path segments are names of sub-sections and quantities; numbers are used for indexing repeating sub-sections. As such paths should be mostly compatible with the graph API and Metainfo references. - ### useArchiveChanges This hooks provides access to the changes made to the archive. When a property is changed via `useArchiveProperty(path).change(...)`, the change is -tracked and stored in a stack of changes. \ No newline at end of file +tracked and stored in a stack of changes. diff --git a/src/components/archive/archive.helper.tsx b/src/components/archive/archive.helper.tsx index 2caa1f89..98c4b3ca 100644 --- a/src/components/archive/archive.helper.tsx +++ b/src/components/archive/archive.helper.tsx @@ -31,6 +31,8 @@ export function mockArchive({ if (data) { archive.archive = data archive.loading = false + } else { + archive.loading = true } allArchives.set(entryId, archive) archive.changeStack.push(...changes) @@ -116,9 +118,9 @@ export function addDefinitions( if (Array.isArray(value)) { if (value.length === 0 || typeof value[0] === 'object') { // repeating sub section - const sectionDefs = value.map((item, index) => - visit(item, `${childPath}/${index}`), - ) + const sectionDefs = value + .filter((item) => !!item) + .map((item, index) => visit(item, `${childPath}/${index}`)) all_sub_sections[key] = { m_def: 'SubSection', name: key, @@ -196,7 +198,7 @@ export function removeDefinitions(data: MSectionResponse) { delete data.m_def for (const value of Object.values(data)) { if (Array.isArray(value)) { - value.forEach((item) => removeDefinitions(item)) + value.filter((item) => !!item).forEach((item) => removeDefinitions(item)) } else if (typeof value === 'object') { removeDefinitions(value as MSectionResponse) } diff --git a/src/components/archive/useArchive.test.tsx b/src/components/archive/useArchive.test.tsx index 3badc1de..34a1a99b 100644 --- a/src/components/archive/useArchive.test.tsx +++ b/src/components/archive/useArchive.test.tsx @@ -74,11 +74,19 @@ describe('useArchive', () => { ], [ 'updating an existing repeating SubSection', - {r: [{}]}, + {r: [{q: 1}]}, {r: '*'}, - {r: [{}]}, - {r: [{}]}, - [], + {r: [{q: 2}]}, + {r: [{q: 2}]}, + [['r', [{q: 2}]]], + ], + [ + 'updating an existing repeating SubSection recursively', + {r: [{q: 1}]}, + {r: {q: '*'}}, + {r: [{q: 2}]}, + {r: [{q: 2}]}, + [['r/0/q', 2]], ], ['ignoring a repeating SubSection', {r: [{}]}, {}, {}, {r: [{}]}, []], // a sub section in a repeating sub section @@ -90,6 +98,22 @@ describe('useArchive', () => { {r: [{}]}, [['r', [{}]]], ], + [ + 'index adding to a repeating SubSection with a whole', + {}, + {'r[1]': '*'}, + {r: [null, {}]}, + {r: [undefined, {}]}, + [['r', [undefined, {}]]], + ], + [ + 'index adding to an existing repeating SubSection', + {r: [{q: 1}]}, + {'r[1]': '*'}, + {r: [null, {q: 2}]}, + {r: [{q: 1}, {q: 2}]}, + [['r/1', {q: 2}]], + ], [ 'index removing from a repeating SubSection', {r: [{}]}, @@ -116,11 +140,38 @@ describe('useArchive', () => { ], [ 'index updating in a repeating SubSection', - {r: [{}]}, + {r: [{q: 1}]}, {'r[0]': '*'}, - {r: [{}]}, - {r: [{}]}, - [], + {r: [{q: 2}]}, + {r: [{q: 2}]}, + [['r/0', {q: 2}]], + ], + [ + 'index updating a missing section in a repeating SubSection', + {r: [undefined, undefined, {q: 2}]}, + {'r[1]': '*'}, + {r: [null, {q: 1}]}, + {r: [undefined, {q: 1}, {q: 2}]}, + [['r/1', {q: 1}]], + ], + [ + 'index updating a missing section in a repeating SubSection recursively', + {r: [undefined, undefined, {q: 2}]}, + {'r[1]': {q: '*'}}, + {r: [null, {q: 1}]}, + {r: [undefined, {q: 1}, {q: 2}]}, + [ + ['r/1', {q: 1}], + ['r/1/q', 1], + ], + ], + [ + 'index updating in a repeating SubSection recursively', + {r: [{q: 1}]}, + {'r[0]': {q: '*'}}, + {r: [{q: 2}]}, + {r: [{q: 2}]}, + [['r/0/q', 2]], ], [ 'ignoring a SubSection in a repeating SubSection', @@ -167,7 +218,8 @@ describe('useArchive', () => { addDefinitions(response, {q: 1, s: {}, r: []}) archive.archive = initialArchive - const {changes} = archive.updateArchive(request, response, {}) + archive.startUpdate(request) + const {changes} = archive.commitUpdate(response, {}) expect(archive.archive.m_def).not.toBeUndefined() // remove definitions to make the comparison simpler @@ -211,7 +263,10 @@ describe('useArchive', () => { it('re-renders on update', () => { const {result} = renderHook(() => useArchiveWithRenderCounter('q')) expect(result.current.value).toEqual(undefined) - act(() => archive.updateArchive({q: '*'}, addDefinitions({q: 1}), {})) + act(() => { + archive.startUpdate({q: '*'}) + archive.commitUpdate(addDefinitions({q: 1}), {}) + }) expect(result.current.value).toEqual(1) expect(renderCounter).toHaveBeenCalledTimes(2) }) @@ -219,7 +274,10 @@ describe('useArchive', () => { it('does not re-render if something else updates', () => { const {result} = renderHook(() => useArchiveWithRenderCounter('q')) expect(result.current.value).toEqual(undefined) - act(() => archive.updateArchive({s: '*'}, addDefinitions({s: {}}), {})) + act(() => { + archive.startUpdate({s: '*'}) + archive.commitUpdate(addDefinitions({s: {}}), {}) + }) expect(result.current.value).toEqual(undefined) expect(renderCounter).toHaveBeenCalledTimes(1) }) @@ -256,8 +314,9 @@ describe('useArchive', () => { entry_id: 'otherEntryId', }) const {result, rerender} = renderHook(() => useArchive()) - result.current.updateArchive( - {data: {ref: '*'}}, + const archive = result.current + archive.startUpdate({data: {ref: '*'}}) + archive.commitUpdate( addDefinitions({data: {ref: '/uploads/1/entries/1/archive/data'}}), { uploads: { @@ -387,8 +446,10 @@ describe('useArchiveProperty', () => { entry_id: 'otherEntryId', }) const {result} = renderHook(() => useArchive()) - result.current.updateArchive( - {data: {ref: '*'}}, + const archive = result.current + + archive.startUpdate({data: {ref: '*'}}) + archive.commitUpdate( addDefinitions({data: {ref: '/uploads/1/entries/1/archive/data'}}), { uploads: { diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index e0ec92f0..14700a6e 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -70,6 +70,7 @@ export class Archive { changeStack: ArchiveChange[] loading: boolean responsesWithReferencedArchives: MultiMap<string, GraphResponse> + currentUpdateRequests: MSectionRequest | undefined constructor() { this.elements = new MultiMap<string, ArchivePropertyDispatch>() @@ -78,6 +79,7 @@ export class Archive { this.changeStack = [] this.responsesWithReferencedArchives = new MultiMap() this.loading = false + this.currentUpdateRequests = undefined } registerElement(path: string, dispatch: ArchivePropertyDispatch) { @@ -260,7 +262,7 @@ export class Archive { * TODO what happens when the update effects something that we already * have local user changes for? */ - updateArchive( + private updateArchive( request: MSectionRequest, response: MSectionResponse, apiResponse: GraphResponse, @@ -396,11 +398,14 @@ export class Archive { archive[key] = archiveList const responseList = response[key] as MSectionResponse[] archiveList[index] = responseList[index] + changes.push([ + pathForCurrentKey, + responseList.map((item) => item || undefined), + ]) } else { archive[key] = response[key] + changes.push([pathForCurrentKey, response[key]]) } - - changes.push([pathForCurrentKey, response[key]]) } else { // update if (!childIsSubSection) { @@ -411,37 +416,62 @@ export class Archive { archive[key] = response[key] changes.push([pathForCurrentKey, response[key]]) } else if (childIsRepeatingSubSection) { - if (checkForConflicts(pathForCurrentKey)) { - continue - } + if (indexed) { + const indexedPathForCurrentKey = `${pathForCurrentKey}/${index}` + if (checkForConflicts(indexedPathForCurrentKey)) { + continue + } - // The semantics is that the sections with the same index are - // the same. Even if a section is removed from the middle of the - // list. - // The recursion will update the sections themsleves. - // Here, we only remove/add at the end of the list. - const archiveList = archive[key] as MSectionResponse[] - archive[key] = archiveList - const responseList = response[key] as MSectionResponse[] - if (responseList.length !== archiveList.length) { - if (responseList.length > archiveList.length) { - for ( - let index = archiveList.length; - index < responseList.length; - index++ - ) { - archiveList.push(responseList[index]) - } + const archiveList = archive[key] as MSectionResponse[] + const responseList = response[key] as MSectionResponse[] + if (archiveList[index]) { + // update + if (requestValue === '*') { + archiveList[index] = responseList[index] + changes.push([indexedPathForCurrentKey, responseList[index]]) + } // else { nothing to do, the recursion will update the section } + } else { + // add + archiveList[index] = responseList[index] + changes.push([indexedPathForCurrentKey, responseList[index]]) } - if (responseList.length < archiveList.length) { - archiveList.splice( - responseList.length, - archiveList.length - responseList.length, - ) + } else { + if (checkForConflicts(pathForCurrentKey)) { + continue } - changes.push([pathForCurrentKey, response[key]]) + + // The semantics is that the sections with the same index are + // the same. Even if a section is removed from the middle of the + // list. + // We remove/add at the end of the list, if the response is shorter/longer. + // We replace the section, if the request was about the whole sections. + // We leave it to recursion if the request goes deeper into the sections. + const archiveList = archive[key] as MSectionResponse[] + archive[key] = archiveList + const responseList = response[key] as MSectionResponse[] + if (responseList.length !== archiveList.length) { + if (responseList.length > archiveList.length) { + for ( + let index = archiveList.length; + index < responseList.length; + index++ + ) { + archiveList.push(responseList[index]) + } + } + if (responseList.length < archiveList.length) { + archiveList.splice( + responseList.length, + archiveList.length - responseList.length, + ) + } + changes.push([pathForCurrentKey, response[key]]) + } else if (requestValue === '*') { + archive[key] = response[key] + changes.push([pathForCurrentKey, response[key]]) + } // else { nothing to do, the recursion will update the section } } - } // else { nothing to do, the recursion will update the section } + } } // recurse @@ -503,7 +533,38 @@ export class Archive { } } - setLoading(loading: boolean) { + startUpdate(request: MSectionRequest, setLoading: boolean = false) { + assert( + this.currentUpdateRequests === undefined, + 'Update already in progress', + ) + this.currentUpdateRequests = request + if (setLoading) { + this.setLoading(true) + } + } + + commitUpdate( + response: MSectionResponse, + apiResponse: GraphResponse, + ): {changes: [string, unknown][]; conflicts: ArchiveChangeConflict[]} { + assert(this.currentUpdateRequests, 'No update in progress') + const result = this.updateArchive( + this.currentUpdateRequests, + response, + apiResponse, + ) + this.currentUpdateRequests = undefined + this.setLoading(false) + return result + } + + abortUpdate() { + this.currentUpdateRequests = undefined + this.setLoading(false) + } + + private setLoading(loading: boolean) { if (this.loading === loading) { return } diff --git a/src/components/routing/Routes.tsx b/src/components/routing/Routes.tsx index 7b984133..ae737786 100644 --- a/src/components/routing/Routes.tsx +++ b/src/components/routing/Routes.tsx @@ -131,32 +131,32 @@ function useLoadRouteData( reloadCount?: number, ): AsyncState<LoaderResult | undefined> { const {user} = useAuth() - const fetch = useMemo( - () => loader(fullMatch, user || undefined), - [fullMatch, loader, user], - ) - const state = useAsyncConditional(fetch, [fetch, reloadCount]) - if (state.value) { - state.value.routeResponses.forEach((response, index) => { - const match = fullMatch[index] - if (response && state.value?.response) { - match.response = response - try { - match.route.onFetch?.({ - fullMatch, - match, - response, - request: state.value.routeRequests[index], - fullRequest: state.value?.request, - fullResponse: state.value?.response, - }) - } catch (e) { - state.error = e as Error - } - } - }) - } - return state + const fetch = useMemo(() => { + const result = loader(fullMatch, user || undefined) + if (!result) { + return + } + return () => { + return result().then((result) => { + result.routeResponses.forEach((response, index) => { + const match = fullMatch[index] + if (response && result?.response) { + match.response = response + match.route.onFetch?.({ + fullMatch, + match, + response, + request: result.routeRequests[index], + fullRequest: result?.request, + fullResponse: result?.response, + }) + } + }) + return result + }) + } + }, [fullMatch, loader, user]) + return useAsyncConditional(fetch, [fetch, reloadCount]) } function useRouteMatch(route: Route, {path, rawSearch}: Location) { diff --git a/src/components/routing/useDataForRoute.tsx b/src/components/routing/useDataForRoute.tsx index c1e22963..28f1e5ca 100644 --- a/src/components/routing/useDataForRoute.tsx +++ b/src/components/routing/useDataForRoute.tsx @@ -58,7 +58,7 @@ export default function useDataForRoute< return undefined } return (_: JSONObject, fullRequest: GraphRequest) => { - onBeforeFetchForRoute(request as Request, fullRequest) + return onBeforeFetchForRoute(request as Request, fullRequest) } }, [onBeforeFetchForRoute, request]) diff --git a/src/hooks/useData.tsx b/src/hooks/useData.tsx index 86470bed..21f27cee 100644 --- a/src/hooks/useData.tsx +++ b/src/hooks/useData.tsx @@ -9,8 +9,8 @@ import {GraphResponse} from '../models/graphResponseModels' import {graphApi} from '../utils/api' import {resolveAllMDefs} from '../utils/metainfo' import {DefaultToObject, JSONObject, JSONValue} from '../utils/types' +import {assert} from '../utils/utils' import useAuth from './useAuth' -import useImmediateEffect from './useImmediateEffect' import useRender from './useRender' /** @@ -65,9 +65,14 @@ export type UseDataParams<Request, Response> = { noImplicitFetch?: boolean /** - * An optional callback that is called before the fetch is started. + * An optional callback that is called before the fetch is started. If this + * returns a function, this function will be called when the request effect + * is canceled. */ - onBeforeFetch?: (request: Request, fullRequest: GraphRequest) => void + onBeforeFetch?: ( + request: Request, + fullRequest: GraphRequest, + ) => void | (() => void) /** * An optional callback that is called when the data was successfully @@ -125,10 +130,17 @@ export default function useData< return } requestRef.current = request + normalizedRequestRef.current = normalizeGraph(request as JSONObject) + const effectDestructor = onBeforeFetch?.( + request, + normalizedRequestRef.current, + ) const fetch = async () => { - normalizedRequestRef.current = normalizeGraph(request as JSONObject) - onBeforeFetch?.(request, normalizedRequestRef.current) try { + assert( + normalizedRequestRef.current !== undefined, + 'request is undefined', + ) const result = await graphApi( normalizedRequestRef.current, user || undefined, @@ -153,6 +165,7 @@ export default function useData< fetch() return () => { requestRef.current = undefined + effectDestructor?.() } }, [ requestRef, @@ -165,7 +178,7 @@ export default function useData< user, ]) - useImmediateEffect(() => { + useEffect(() => { if (!noImplicitFetch) { return fetch() } diff --git a/src/hooks/useImmediateEffect.test.tsx b/src/hooks/useImmediateEffect.test.tsx deleted file mode 100644 index 60bc7e1f..00000000 --- a/src/hooks/useImmediateEffect.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import {renderHook} from '@testing-library/react' -import {vi} from 'vitest' - -import {JSONObject} from '../utils/types' -import useImmediateEffect from './useImmediateEffect' - -it('calls effect on render without deps', () => { - const effect = vi.fn() - renderHook(() => { - return useImmediateEffect(effect, []) - }) - expect(effect).toHaveBeenCalledTimes(1) -}) - -it('calls effect on render with deps', () => { - const effect = vi.fn() - renderHook(() => { - return useImmediateEffect(effect, [true]) - }) - expect(effect).toHaveBeenCalledTimes(1) -}) - -it('does not call effect on re-render with unchanged deps', () => { - const effect = vi.fn() - const {rerender} = renderHook( - ({dep}: {dep: boolean}) => { - useImmediateEffect(effect, [dep]) - }, - {initialProps: {dep: false}}, - ) - expect(effect).toHaveBeenCalledTimes(1) - - rerender({dep: false}) - expect(effect).toHaveBeenCalledTimes(1) -}) - -it('calls effect on re-render with changed deps', () => { - const effect = vi.fn() - const {rerender} = renderHook( - ({dep}: {dep: boolean}) => { - useImmediateEffect(effect, [dep]) - }, - {initialProps: {dep: false}}, - ) - expect(effect).toHaveBeenCalledTimes(1) - - rerender({dep: true}) - expect(effect).toHaveBeenCalledTimes(2) -}) - -it('calls effect on re-render with changed object deps', () => { - const effect = vi.fn() - const obj = {a: 1} - const {rerender} = renderHook( - ({dep}: {dep: JSONObject}) => { - useImmediateEffect(effect, [dep]) - }, - {initialProps: {dep: obj}}, - ) - expect(effect).toHaveBeenCalledTimes(1) - - rerender({dep: {...obj}}) - expect(effect).toHaveBeenCalledTimes(2) -}) - -it('calls destructuor', () => { - const destructor = vi.fn() - const {rerender} = renderHook( - ({dep}: {dep: boolean}) => { - useImmediateEffect(() => destructor, [dep]) - }, - {initialProps: {dep: false}}, - ) - expect(destructor).toHaveBeenCalledTimes(0) - - rerender({dep: false}) - expect(destructor).toHaveBeenCalledTimes(0) - - rerender({dep: true}) - expect(destructor).toHaveBeenCalledTimes(1) -}) diff --git a/src/hooks/useImmediateEffect.tsx b/src/hooks/useImmediateEffect.tsx deleted file mode 100644 index 311f4bfe..00000000 --- a/src/hooks/useImmediateEffect.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import {DependencyList, useCallback, useEffect, useRef} from 'react' -import {usePrevious} from 'react-use' - -/** - * A hook that has the same interface and behavior as `useEffect` but runs the - * effect "immediately" upon beeing called and within the current render. - */ -export default function useImmediateEffect( - effect: React.EffectCallback, - deps: DependencyList, -) { - let hasChanged = true !== usePrevious(true) - for (let i = 0; i < deps.length; i++) { - // eslint-disable-next-line react-hooks/rules-of-hooks - if (deps[i] !== usePrevious(deps[i])) { - hasChanged = true - } - } - - const effectDestructor = useRef<ReturnType<React.EffectCallback>>() - - if (hasChanged) { - if (effectDestructor.current) { - effectDestructor.current() - } - effectDestructor.current = effect() - } - - const unmount = useCallback(() => { - if (effectDestructor.current) { - effectDestructor.current() - effectDestructor.current = undefined - } - }, []) - - useEffect(() => { - return unmount - }, [unmount]) -} diff --git a/src/index.tsx b/src/index.tsx index 37115408..58bde89a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,6 @@ import '@fontsource/titillium-web/400.css' import '@fontsource/titillium-web/700.css' import {LocalizationProvider} from '@mui/x-date-pickers' import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFnsV3' -import React from 'react' import ReactDOM from 'react-dom/client' import {RecoilRoot} from 'recoil' @@ -45,23 +44,23 @@ enableMocking().then(() => { document.getElementById('root') as HTMLElement, ) root.render( - <React.StrictMode> - <LocalizationProvider dateAdapter={AdapterDateFns}> - <RecoilRoot> - <Theme> - <AuthProvider> - <ApiProvider> - <UnitProvider> - <DevTools enabled> - <Routes /> - </DevTools> - </UnitProvider> - </ApiProvider> - </AuthProvider> - </Theme> - </RecoilRoot> - </LocalizationProvider> - </React.StrictMode>, + // <React.StrictMode> + <LocalizationProvider dateAdapter={AdapterDateFns}> + <RecoilRoot> + <Theme> + <AuthProvider> + <ApiProvider> + <UnitProvider> + <DevTools enabled> + <Routes /> + </DevTools> + </UnitProvider> + </ApiProvider> + </AuthProvider> + </Theme> + </RecoilRoot> + </LocalizationProvider>, + // </React.StrictMode>, ) }) diff --git a/src/pages/entry/EntryArchiveNav.tsx b/src/pages/entry/EntryArchiveNav.tsx index 612f5c93..5967b9cb 100644 --- a/src/pages/entry/EntryArchiveNav.tsx +++ b/src/pages/entry/EntryArchiveNav.tsx @@ -113,10 +113,11 @@ export default function EntryArchiveNav({ fullMatch.length === index + 2 && fullMatch[index + 1]?.path === 'archive' const request = useMemo( - () => - ({ - ...archiveRequest, - } as JSONObject), + () => ({ + ...archiveRequest, + // TODO this does not seem to have the desired effect + '*': '*', + }), [], ) @@ -126,7 +127,7 @@ export default function EntryArchiveNav({ path='archive' variant='menuItem' icon={undefined} - request={request} + request={request as unknown as JSONObject} expandable={expandable} getChildProps={getChildProps} filterChildKeys={filterChildKeys} diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index 0ccde221..7c5f6101 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -1,5 +1,6 @@ import {Box} from '@mui/material' import {useCallback, useMemo, useState} from 'react' +import {usePrevious} from 'react-use' import ErrorMessage from '../../components/app/ErrorMessage' import useArchive from '../../components/archive/useArchive' @@ -7,6 +8,7 @@ import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' import useSelect from '../../components/navigation/useSelect' import useDataForRoute from '../../components/routing/useDataForRoute' +import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' import { @@ -25,6 +27,7 @@ import entryRoute, {archiveRequest} from './entryRoute' function EntryOverviewEditor() { const {archive: archiveData} = useRouteData(entryRoute) + const {reloadCount} = useRoute() const [error, setError] = useState<Error | undefined>() const {isPage} = useSelect() @@ -64,29 +67,41 @@ function EntryOverviewEditor() { calculateRequestFromLayout(layout, request.archive) } return request as EntryRequest - }, [layout]) + // Depending on the reloadCount is important to for a new request and + // therefore a new fetch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layout, reloadCount]) const archive = useArchive() + // Ideally we would call this in the onBeforeFetch callback, but + // this will be too late because the effect is called after the render. + // When rendering the EntryOverviewEditor and the + // archive was loaded before, the render will think the archive + // is already loaded and updated, even though the update has not even started. + // While this works for now, it will be a problem if the startUpdate function + // will cause state updates in the future as those are not allowed during + // render. + if (request !== usePrevious(request)) { + archive.startUpdate(request.archive as MSectionRequest, true) + } + const onBeforeFetch = useCallback(() => { - archive.setLoading(true) + return () => { + archive.abortUpdate() + } }, [archive]) const onFetch = useCallback( (data: EntryResponse, fullResponse: GraphResponse) => { - archive.updateArchive( - request.archive as MSectionRequest, - data?.archive as MSectionResponse, - fullResponse, - ) - archive.setLoading(false) + archive.commitUpdate(data?.archive as MSectionResponse, fullResponse) }, - [archive, request], + [archive], ) const onError = useCallback( (error: Error) => { - archive.setLoading(false) + archive.abortUpdate() setError(error) }, [archive], diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index 725e9b3b..0d30e586 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -37,7 +37,9 @@ archiveRoute.children = [archiveRoute] const entryRoute: Route<EntryRequest, EntryResponse> = { path: ':entryId', - request: { + // TODO The function should not be necessary, but some buggy thing is modifying the + // request. With the function we force to recreate the request object every time. + request: () => ({ mainfile_path: '*', entry_id: '*', upload_id: '*', @@ -51,7 +53,7 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { datasets: '*', } as EntryMetadataRequest, archive: {...archiveRequest}, - }, + }), lazyComponent: async () => import('./EntryPage'), renderWithPathAsKey: true, breadcrumb: ({response}) => path.basename(response?.mainfile_path as string), @@ -73,13 +75,8 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { } const entryId = match.path const archive = getArchive(entryId) - archive.setLoading(true) - archive.updateArchive( - request.archive, - response.archive, - fullResponse as GraphResponse, - ) - archive.setLoading(false) + archive.startUpdate(request.archive, true) + archive.commitUpdate(response.archive, fullResponse as GraphResponse) }, } -- GitLab From a09176d18f78afb6164c216b3938a2a2771f5908 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 10 Feb 2025 13:47:26 +0100 Subject: [PATCH 03/17] Undo/Redo --- src/components/archive/EditStatus.tsx | 45 +++++-- src/components/archive/Section.tsx | 4 +- src/components/archive/archive.helper.tsx | 5 +- src/components/archive/useArchive.test.tsx | 103 ++++++++++++++++ src/components/archive/useArchive.tsx | 135 +++++++++++++++++---- src/components/icons.tsx | 4 + src/pages/entry/EntryOverview.tsx | 8 +- 7 files changed, 263 insertions(+), 41 deletions(-) diff --git a/src/components/archive/EditStatus.tsx b/src/components/archive/EditStatus.tsx index 3cac3796..73eaaf5a 100644 --- a/src/components/archive/EditStatus.tsx +++ b/src/components/archive/EditStatus.tsx @@ -1,6 +1,7 @@ -import {Button} from '@mui/material' +import {Button, IconButton} from '@mui/material' import {useAsyncFn} from 'react-use' +import {Redo, Undo} from '../../components/icons' import useAuth from '../../hooks/useAuth' import {MSectionResponse} from '../../models/graphResponseModels' import entryRoute from '../../pages/entry/entryRoute' @@ -8,13 +9,14 @@ import {archiveEditApi} from '../../utils/api' import {assert} from '../../utils/utils' import useRoute from '../routing/useRoute' import useRouteData from '../routing/useRouteData' -import {useArchiveChanges} from './useArchive' +import useArchive, {useArchiveChanges} from './useArchive' export default function EditStatus() { const {user} = useAuth() const {reload} = useRoute() const {entry_id} = useRouteData(entryRoute) const {changeStack, clearChangeStack} = useArchiveChanges() + const archive = useArchive() const [saving, save] = useAsyncFn(async () => { const apiChanges = changeStack.map((change) => { @@ -29,6 +31,9 @@ export default function EditStatus() { } delete (apiChange.new_value as MSectionResponse)['m_def'] } + if (apiChange.oldValue) { + delete apiChange['oldValue'] + } return apiChange }) @@ -40,16 +45,30 @@ export default function EditStatus() { }, [changeStack, clearChangeStack, reload]) return ( - <Button - onClick={save} - variant='contained' - disabled={changeStack.length === 0 || saving.loading} - > - {saving.loading - ? 'saving ...' - : changeStack.length === 0 - ? 'no changes to save' - : `save changes`} - </Button> + <> + <IconButton + disabled={!archive.hasChanges()} + onClick={() => archive.undo()} + > + <Undo /> + </IconButton> + <IconButton + disabled={!archive.hasUnDoneChanges()} + onClick={() => archive.redo()} + > + <Redo /> + </IconButton> + <Button + onClick={save} + variant='contained' + disabled={changeStack.length === 0 || saving.loading} + > + {saving.loading + ? 'saving ...' + : changeStack.length === 0 + ? 'no changes to save' + : `save changes`} + </Button> + </> ) } diff --git a/src/components/archive/Section.tsx b/src/components/archive/Section.tsx index a433e0fa..a1028313 100644 --- a/src/components/archive/Section.tsx +++ b/src/components/archive/Section.tsx @@ -62,10 +62,10 @@ export default function Section({path, editable = false}: SectionProps) { const {value} = useArchiveProperty<MSectionResponse>(path) const definition = value?.m_def as SectionDefinition - assert(value !== undefined, 'The section should have been loaded by now') + assert(value !== undefined, 'The section should have been loaded by now.') assert( definition !== undefined, - 'The section definition should have been loaded by now', + 'The section definition should have been loaded by now.', ) return ( diff --git a/src/components/archive/archive.helper.tsx b/src/components/archive/archive.helper.tsx index 98c4b3ca..39014f30 100644 --- a/src/components/archive/archive.helper.tsx +++ b/src/components/archive/archive.helper.tsx @@ -2,18 +2,17 @@ import {useEffect, useState} from 'react' import {vi} from 'vitest' import {UseDataResult} from '../../hooks/useData' -import {ArchiveChange} from '../../models/entryEditRequestModels' import {MDefResponse, MSectionResponse} from '../../models/graphResponseModels' import {Quantity, Section, SubSection} from '../../utils/metainfo' import {DefaultToObject} from '../../utils/types' import * as useDataForRoute from '../routing/useDataForRoute' import * as useRouteData from '../routing/useRouteData' -import {Archive, allArchives} from './useArchive' +import {Archive, ArchiveChangeWithOldValue, allArchives} from './useArchive' export type MockArchiveArgs = { entryId?: string data?: MSectionResponse - changes?: ArchiveChange[] + changes?: ArchiveChangeWithOldValue[] } /** diff --git a/src/components/archive/useArchive.test.tsx b/src/components/archive/useArchive.test.tsx index 34a1a99b..3683a5e9 100644 --- a/src/components/archive/useArchive.test.tsx +++ b/src/components/archive/useArchive.test.tsx @@ -331,6 +331,109 @@ describe('useArchive', () => { q: 'test', }) }) + + it.each([ + ['quantity set', {q: '*'}, {q: 1}, 'q', 'upsert', 2], + ['nested quantity set', {s: {q: '*'}}, {s: {q: 1}}, 's/q', 'upsert', 2], + [ + 'repeated nested quantity set', + {s: [{q: '*'}]}, + {s: [{q: 1}]}, + 's/0/q', + 'upsert', + 2, + ], + [ + 'repeated nested quantity set unnormalized', + {s: [{q: '*'}]}, + {s: [{q: 1}]}, + 's[0]/q', + 'upsert', + 2, + ], + ['sub-section replace', {s: '*'}, {s: {q: 1}}, 's', 'upsert', {q: 2}], + [ + 'repeated sub-section replace implicit', + {s: '*'}, + {s: [{q: 1}]}, + 's', + 'upsert', + [{q: 2}], + ], + [ + 'repeated sub-section implicit add', + {s: '*'}, + {s: [{q: 1}]}, + 's', + 'upsert', + [{q: 1}, {q: 2}], + ], + [ + 'repeated sub-section implicit remove', + {s: '*'}, + {s: [{q: 1}, {q: 2}]}, + 's', + 'upsert', + [{q: 1}], + ], + [ + 'repeated sub-section explicit replace', + {s: '*'}, + {s: [{q: 1}]}, + 's/0', + 'upsert', + {q: 2}, + ], + [ + 'repeated sub-section explicit add', + {s: '*'}, + {s: [{q: 1}]}, + 's/1', + 'upsert', + {q: 2}, + ], + [ + 'repeated sub-section explicit remove', + {s: '*'}, + {s: [{q: 1}, {q: 2}]}, + 's/1', + 'remove', + undefined, + ], + ])( + 'does undo/redo %s', + (description, request, response, path, action, value) => { + const dispatch = vi.fn() + const changeListener = vi.fn() + const {result} = renderHook(() => useArchive()) + act(() => { + archive.startUpdate(request) + archive.commitUpdate(addDefinitions(response), {}) + }) + const oldValue = result.current.getValue(path) + act(() => { + archive.registerElement(path, dispatch) + archive.registerChangeListener(changeListener) + archive.submitElementChange(path, action as 'upsert' | 'remove', value) + }) + expect(result.current.getValue(path)).toBe(value) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(changeListener).toHaveBeenCalledTimes(1) + expect(result.current.changeStack).toHaveLength(1) + + act(() => result.current.undo()) + expect(result.current.changeStack).toHaveLength(0) + expect(result.current.getValue(path)).toBe(oldValue) + expect(dispatch).toHaveBeenCalledTimes(2) + expect(changeListener).toHaveBeenCalledTimes(2) + + act(() => result.current.redo()) + expect(result.current.changeStack).toHaveLength(1) + expect(result.current.getValue(path)).toBe(value) + expect(dispatch).toHaveBeenCalledTimes(3) + expect(changeListener).toHaveBeenCalledTimes(3) + }, + ) }) describe('useArchiveProperty', () => { diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 14700a6e..82038f5d 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -21,6 +21,7 @@ export type ArchiveChangeConflict = { type: 'parent' | 'child' | 'exact' change: ArchiveChange } +export type ArchiveChangeWithOldValue = ArchiveChange & {oldValue?: unknown} class MultiMap<K, V> { map = new Map<K, V[]>() @@ -67,7 +68,8 @@ export class Archive { elements: MultiMap<string, ArchivePropertyDispatch> changeListener: (() => void)[] archive: MSectionResponse - changeStack: ArchiveChange[] + changeStack: ArchiveChangeWithOldValue[] + redoStack: ArchiveChangeWithOldValue[] loading: boolean responsesWithReferencedArchives: MultiMap<string, GraphResponse> currentUpdateRequests: MSectionRequest | undefined @@ -77,6 +79,7 @@ export class Archive { this.changeListener = [] this.archive = {} this.changeStack = [] + this.redoStack = [] this.responsesWithReferencedArchives = new MultiMap() this.loading = false this.currentUpdateRequests = undefined @@ -103,6 +106,96 @@ export class Archive { this.changeListener.forEach((listener) => listener()) } + hasUnDoneChanges() { + return this.redoStack.length > 0 + } + + hasChanges() { + return this.changeStack.length > 0 + } + + /** + * Removes the last change from the change stack and + * - pushes it to the redo stack + * - applies the change to the archive + * - notifies all components that have registered to the particular path + * via `useArchiveProperty` (and `registerElement`). + * - notifies all components that have registered to via `registerChangeListener`. + */ + undo() { + const change = this.changeStack.pop() + assert(change, 'No changes to undo.') + this.applyAndDispatchValue(change.path, change.oldValue) + this.redoStack.push(change) + if (change) { + this.changeListener.forEach((listener) => listener()) + } + } + + /** + * Removes the last change from the redo stack and + * - pushes it back to the change stack + * - applies the change (old value) to the archive + * - notifies all components that have registered to the particular path + * via `useArchiveProperty` (and `registerElement`). + * - notifies all components that have registered to via `registerChangeListener`. + */ + redo() { + const change = this.redoStack.pop() + assert(change, 'No changes to redo.') + this.applyAndDispatchValue(change.path, change.new_value) + this.changeStack.push(change) + if (change) { + this.changeListener.forEach((listener) => listener()) + } + } + + /** + * Perform a change on the archive data and notify all components that have + * registered to the particular path via `useArchiveProperty` (and + * `registerElement`). + * + * @param path The path to the property. + * @param value The new value to set. + * @returns The old value of the property. + */ + private applyAndDispatchValue(path: string, value: unknown) { + // apply + let current = this.archive + const parts = normalizeArchivePath(path) + const subSections = parts.slice(0, -1) + for (const key of subSections) { + current = current[key] as MSectionResponse + } + const quantityOrIndex = parts[parts.length - 1] + const oldValue = current[quantityOrIndex] + current[quantityOrIndex] = value + + // additionally remove deleted sub sections at the end of repeated + // sub sections + if ( + value === undefined && + Array.isArray(current) && + current.length === parseInt(quantityOrIndex) + 1 + ) { + current.pop() + } + + // additionally dispatch the repeated sub section if a section was + // added or removed + if (Array.isArray(current)) { + for (const dispatch of this.elements.get(subSections.join('/'))) { + dispatch({value: current, loading: false}) + } + } + // dispatch change + for (const dispatch of this.elements.get(path)) { + dispatch({value, loading: false}) + } + + return oldValue + } + /** * Submits a change to the archive. This will update the archive, * dispatch the change to all registered elements, and keep track @@ -120,37 +213,28 @@ export class Archive { value: unknown, index?: number, ) { - // apply the value to the archive - let current = this.archive - const parts = normalizeArchivePath(path) - const subSections = parts.slice(0, -1) - for (const key of subSections) { - current = current[key] as MSectionResponse - } - const valueIndex = parts[parts.length - 1] - current[valueIndex] = value - - // dispatch change - for (const dispatch of this.elements.get(path)) { - dispatch({value, loading: false}) - } + assert(!this.loading) + const oldValue = this.applyAndDispatchValue(path, value) // translate the change into an ArchiveChange and keep the change in // this.changes and this.changeStack let change if (index !== undefined) { assert(Array.isArray(value), 'Value must be an array for indexed changes') + assert(Array.isArray(oldValue) || oldValue === undefined) change = { path: `${path}/${index}`, new_value: action === 'remove' ? undefined : {...value[index]}, + oldValue: action === 'remove' ? oldValue?.[index] : undefined, action, - } satisfies ArchiveChange + } satisfies ArchiveChangeWithOldValue } else { change = { path, new_value: action === 'remove' ? undefined : value, + oldValue: oldValue, action, - } satisfies ArchiveChange + } satisfies ArchiveChangeWithOldValue if (this.changeStack.length > 0) { const lastChange = this.changeStack[this.changeStack.length - 1] @@ -221,7 +305,7 @@ export class Archive { /** * Detects conflicts between changing the given path and the changes in the - * current chanage stack. The function returns 'parent' if the path is a + * current change stack. The function returns 'parent' if the path is a * prefix to a path in the change stack, 'child' if a path in the change stack * is a prefix to the given path, 'none' if there is no conflict, and 'exact' * of the path is exactly the same as a path in the change stack. @@ -640,7 +724,7 @@ export default function useArchive() { } export type ArchiveChanges = { - changeStack: ArchiveChange[] + changeStack: ArchiveChangeWithOldValue[] clearChangeStack: () => void // undo: () => void, // redo: () => void, @@ -652,7 +736,7 @@ export type ArchiveChanges = { */ export function useArchiveChanges() { const archive = useArchive() - const [changeStack, setChangeStack] = useState<ArchiveChange[]>([ + const [changeStack, setChangeStack] = useState<ArchiveChangeWithOldValue[]>([ ...archive.changeStack, ]) @@ -711,6 +795,14 @@ export type ArchiveProperty<Type, DefinitionType extends Property> = { resolveRef: (ref: string) => MSectionResponse | undefined } +/** + * Normalizes the use of repeated sub section syntax in paths. The + * normalized syntax uses index segments (e.g. `section/0`) instead + * of having the index in the sub section segment (e.g. `section[0]`). + * + * @param path The path to normalize. + * @returns The normalized path. + */ function normalizeArchivePath(path: string) { const result: string[] = [] path.split('/').forEach((segment) => { @@ -733,7 +825,7 @@ function normalizeArchivePath(path: string) { * root to the property. The paths use `/` as separator and align with the syntax * of the graph API or archive references. * - * What it means that an propery changes depends on the + * What it means that an property changes depends on the * kind of property. A quantity value changes if the value changes, a (repeating) * sub-section changes if a/the section is added, removed, or replaced. * @@ -746,6 +838,7 @@ function normalizeArchivePath(path: string) { export function useArchiveProperty<T = unknown, D extends Property = Property>( path: string, ): ArchiveProperty<T, D> { + path = normalizeArchivePath(path).join('/') const archive = useArchive() const previousPath = usePrevious(path) diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 97bc8b78..c74127c2 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -28,7 +28,9 @@ import PieChartIcon from '@mui/icons-material/PieChart' import PublicIcon from '@mui/icons-material/Public' import PushPinIcon from '@mui/icons-material/PushPin' import QuestionMarkIcon from '@mui/icons-material/QuestionMark' +import RedoIcon from '@mui/icons-material/Redo' import SearchIcon from '@mui/icons-material/Search' +import UndoIcon from '@mui/icons-material/Undo' import UploadIcon from '@mui/icons-material/Upload' import VisibilityIcon from '@mui/icons-material/Visibility' import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' @@ -68,6 +70,8 @@ export const Help = HelpIcon export const Dataset = DatasetIcon export const Pin = PushPinIcon export const Group = GroupIcon +export const Undo = UndoIcon +export const Redo = RedoIcon export function ToggleColorMode() { const theme = useTheme() diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index 7c5f6101..e48971a1 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -82,15 +82,19 @@ function EntryOverviewEditor() { // While this works for now, it will be a problem if the startUpdate function // will cause state updates in the future as those are not allowed during // render. - if (request !== usePrevious(request)) { + const requestHasChanged = request !== usePrevious(request) + if (requestHasChanged) { archive.startUpdate(request.archive as MSectionRequest, true) } const onBeforeFetch = useCallback(() => { + if (!archive.loading) { + archive.startUpdate(request.archive as MSectionRequest, true) + } return () => { archive.abortUpdate() } - }, [archive]) + }, [archive, request]) const onFetch = useCallback( (data: EntryResponse, fullResponse: GraphResponse) => { -- GitLab From a21e2f77c02972f3e9a2e821353aab564266ea10 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Tue, 18 Feb 2025 12:58:33 +0100 Subject: [PATCH 04/17] Fixed archive requests depth to minimum necessary. --- README.md | 7 ++-- src/components/archive/useArchive.tsx | 4 ++ src/components/routing/loader.ts | 3 +- src/pages/entry/entryRoute.tsx | 1 + src/pages/upload/uploadRoute.tsx | 33 +++++++++------- src/utils/metainfo.test.ts | 49 +++++++++++++++++++++++- src/utils/metainfo.ts | 54 +++++++++++++++++++++++++++ 7 files changed, 131 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index fe94dccc..b45dc92d 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,8 @@ in development. From the gui project root folder: ```sh - python3.11 -m venv .pyenv - source .pyenv/bin/activate + python3.11 -m venv .venv + source .venv/bin/activate pip install uv uv pip install --upgrade pip uv pip install -e infra \ @@ -79,13 +79,12 @@ in development. 3. Run the required infrastructure services. Typically you will run them through a `docker-compose.yaml` file that is part of your development setup. - 4. Run NOMAD. Run from the `infra` directory it has the necessary `nomad.yaml` file: ```sh cd infra nomad admin run appworker - ```` + ``` 5. Upload some data. Again, mind the directory. Run from `infra`. diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 82038f5d..5762fe13 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -531,6 +531,10 @@ export class Archive { // We replace the section, if the request was about the whole sections. // We leave it to recursion if the request goes deeper into the sections. const archiveList = archive[key] as MSectionResponse[] + assert( + Array.isArray(archiveList), + `Expected array for key "${key}", but got "${archiveList}"`, + ) archive[key] = archiveList const responseList = response[key] as MSectionResponse[] if (responseList.length !== archiveList.length) { diff --git a/src/components/routing/loader.ts b/src/components/routing/loader.ts index 0eeb1847..788c3a0d 100644 --- a/src/components/routing/loader.ts +++ b/src/components/routing/loader.ts @@ -1,7 +1,7 @@ import {User} from 'oidc-client-ts' import {graphApi} from '../../utils/api' -import {resolveAllMDefs} from '../../utils/metainfo' +import {removeInternalRefs, resolveAllMDefs} from '../../utils/metainfo' import {JSONObject} from '../../utils/types' import {assert} from '../../utils/utils' import {DefaultSearch, LoaderResult, RouteMatch} from './types' @@ -181,6 +181,7 @@ export default function loader( // entries, files, etc. Currently we apply this to all and the complete // responses no matter what they are. resolveAllMDefs(response) + removeInternalRefs(response) return { routeResponses: getResponsesForRoute(match, response as JSONObject), routeRequests: routeRequests, diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index 0d30e586..e54922f8 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -23,6 +23,7 @@ export const archiveRequest = { m_request: { directive: 'plain', include_definition: 'both', + depth: 2, }, m_def: mDefRquest, } as MSectionRequest diff --git a/src/pages/upload/uploadRoute.tsx b/src/pages/upload/uploadRoute.tsx index ed18029c..f1624cd0 100644 --- a/src/pages/upload/uploadRoute.tsx +++ b/src/pages/upload/uploadRoute.tsx @@ -58,21 +58,26 @@ const entriesRoute: Route< > = { path: 'entries', breadcrumb: <b>entries</b>, - request: ({search}) => ({ - m_request: { - pagination: createPaginationRequest(search), - }, - '*': { - entry_id: '*', - mainfile_path: '*', - entry_create_time: '*', - complete_time: '*', - process_status: '*', - metadata: { - entry_type: '*', + request: ({search, isLeaf}) => { + if (!isLeaf) { + return {} as EntriesRequest + } + return { + m_request: { + pagination: createPaginationRequest(search), }, - }, - }), + '*': { + entry_id: '*', + mainfile_path: '*', + entry_create_time: '*', + complete_time: '*', + process_status: '*', + metadata: { + entry_type: '*', + }, + }, + } + }, lazyComponent: async () => import('./Entries'), validateSearch: validatePaginationSearch, onlyRender: '', diff --git a/src/utils/metainfo.test.ts b/src/utils/metainfo.test.ts index 4d5d146a..66dbe5fc 100644 --- a/src/utils/metainfo.test.ts +++ b/src/utils/metainfo.test.ts @@ -1,6 +1,6 @@ import {vi} from 'vitest' -import {Metainfo, resolveAllMDefs} from './metainfo' +import {Metainfo, removeInternalRefs, resolveAllMDefs} from './metainfo' import {JSONObject} from './types' describe('resolveSectionDef', () => { @@ -173,6 +173,53 @@ describe('resolveSectionDef', () => { }) }) +describe('removeInternalRefs', () => { + it('removes internal refs in a JSON object', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const data: any = { + m_def: { + all_sub_sections: { + child_with_m_def: { + sub_section: 'm_def', + }, + child_repeats_with_m_def: { + sub_section: 'm_def', + repeats: true, + }, + child_list_with_m_def: { + sub_section: 'm_def', + repeats: true, + }, + }, + }, + value: '__INTERNAL__:', + child: { + value: '__INTERNAL__:', + }, + child_list: ['__INTERNAL__:', 'not internal'], + recursive: { + child: { + child: '__INTERNAL__:', + }, + }, + child_full_list: ['__INTERNAL__:', '__INTERNAL__:'], + child_with_m_def: '__INTERNAL__:', + child_repeats_with_m_def: '__INTERNAL__:', + child_list_with_m_def: ['__INTERNAL__:'], + } + removeInternalRefs(data) + + expect(data.value).toBeUndefined() + expect(data.child.value).toBeUndefined() + expect(data.child_list).toEqual([null, 'not internal']) + expect(data.recursive.child.child).toBeUndefined() + expect(data.child_full_list).toEqual([]) + expect(data.child_with_m_def).toEqual({m_def: 'm_def'}) + expect(data.child_repeats_with_m_def).toEqual([]) + expect(data.child_list_with_m_def).toEqual([{m_def: 'm_def'}]) + }) +}) + describe('resolveAllMDefs', () => { it('resolves m_defs in a JSON object', () => { function createMDef(m_def: string) { diff --git a/src/utils/metainfo.ts b/src/utils/metainfo.ts index ddcbef72..3bcf45e8 100644 --- a/src/utils/metainfo.ts +++ b/src/utils/metainfo.ts @@ -252,6 +252,60 @@ export class Metainfo { } } +/** + * Recursively traverses through the given data object and + * removes all internal references that where added by the API. + * If there is not m_def available the internal reference are simply removed. + * If there are m_defs and we can figure out the type of the internal + * reference, they are replaces with empty sections or arrays. + */ +export function removeInternalRefs(data: JSONObject) { + const isInternalRef = (value: JSONValue) => + typeof value === 'string' && value.startsWith('__INTERNAL__:') + + const m_def = data?.m_def as Section | undefined + + for (const key in data) { + if (key.startsWith('m_')) { + continue + } + const value = data[key] + const subSection = m_def?.all_sub_sections?.[key] + if (isInternalRef(value)) { + if (subSection) { + if (subSection.repeats) { + data[key] = [] + } else { + data[key] = { + m_def: subSection.sub_section, + } as JSONObject + } + } else { + delete data[key] + } + } else if (Array.isArray(value)) { + value.forEach((item, index) => { + if (isInternalRef(item)) { + if (subSection) { + value[index] = { + m_def: subSection.sub_section, + } as JSONObject + } else { + value[index] = null + } + } else if (typeof item === 'object') { + removeInternalRefs(item as JSONObject) + } + }) + if (value.every((item) => item === null)) { + data[key] = [] + } + } else if (typeof value === 'object') { + removeInternalRefs(value as JSONObject) + } + } +} + /** * Recursively traverses through the given data object and * resolves all m_def references it can find. -- GitLab From be092f54a4a5eaf88feb7aa43f07115d1e0d1ba5 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 19 Feb 2025 13:26:53 +0100 Subject: [PATCH 05/17] Fixed indexed repeated sub section request related loader and archive issues. --- src/components/archive/archive.helper.tsx | 10 ++++--- src/components/archive/useArchive.test.tsx | 24 ++++++++++++++- src/components/archive/useArchive.tsx | 29 +++++++++++++----- src/components/routing/loader.test.ts | 2 +- src/components/routing/loader.ts | 14 +++++++-- src/components/routing/types.ts | 10 +++++++ src/components/routing/useRouteData.test.tsx | 31 ++++++++++++++++++++ src/pages/entry/entryRoute.tsx | 22 +++++++++++++- 8 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/components/archive/archive.helper.tsx b/src/components/archive/archive.helper.tsx index 39014f30..97a56e08 100644 --- a/src/components/archive/archive.helper.tsx +++ b/src/components/archive/archive.helper.tsx @@ -4,7 +4,7 @@ import {vi} from 'vitest' import {UseDataResult} from '../../hooks/useData' import {MDefResponse, MSectionResponse} from '../../models/graphResponseModels' import {Quantity, Section, SubSection} from '../../utils/metainfo' -import {DefaultToObject} from '../../utils/types' +import {DefaultToObject, JSONObject} from '../../utils/types' import * as useDataForRoute from '../routing/useDataForRoute' import * as useRouteData from '../routing/useRouteData' import {Archive, ArchiveChangeWithOldValue, allArchives} from './useArchive' @@ -193,13 +193,15 @@ export function addDefinitions( * Recursively removes `m_def`s from the given response. This is useful * if you want to compare responses with manually written expected responses. */ -export function removeDefinitions(data: MSectionResponse) { +export function removeDefinitions(data: JSONObject) { delete data.m_def for (const value of Object.values(data)) { if (Array.isArray(value)) { - value.filter((item) => !!item).forEach((item) => removeDefinitions(item)) + value + .filter((item) => !!item) + .forEach((item) => removeDefinitions(item as JSONObject)) } else if (typeof value === 'object') { - removeDefinitions(value as MSectionResponse) + removeDefinitions(value as JSONObject) } } diff --git a/src/components/archive/useArchive.test.tsx b/src/components/archive/useArchive.test.tsx index 3683a5e9..5284ea9b 100644 --- a/src/components/archive/useArchive.test.tsx +++ b/src/components/archive/useArchive.test.tsx @@ -5,6 +5,7 @@ import {ArchiveChangeAction} from '../../models/entryEditRequestModels' import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {archiveRequest} from '../../pages/entry/entryRoute' +import {JSONObject} from '../../utils/types' import {assert} from '../../utils/utils' import * as useRouteData from '../routing/useRouteData' import {addDefinitions, mockArchive, removeDefinitions} from './archive.helper' @@ -80,6 +81,26 @@ describe('useArchive', () => { {r: [{q: 2}]}, [['r', [{q: 2}]]], ], + [ + 'updating and closing hole in a repeating SubSection', + {r: [undefined, {q: 2}]}, + {r: '*'}, + {r: [{q: 1}, {q: 2}]}, + {r: [{q: 1}, {q: 2}]}, + [['r', [{q: 1}, {q: 2}]]], + ], + [ + 'indirectly updating and closing hole in a repeating SubSection', + {r: [undefined, {q: 2}]}, + {r: {q: '*'}}, + {r: [{q: 1}, {q: 2}]}, + {r: [{q: 1}, {q: 2}]}, + [ + ['r', [{q: 1}, {q: 2}]], + ['r/0/q', 1], + ['r/1/q', 2], + ], + ], [ 'updating an existing repeating SubSection recursively', {r: [{q: 1}]}, @@ -223,7 +244,8 @@ describe('useArchive', () => { expect(archive.archive.m_def).not.toBeUndefined() // remove definitions to make the comparison simpler - removeDefinitions(archive.archive) + removeDefinitions(archive.archive as JSONObject) + removeDefinitions(changes as unknown as JSONObject) expect(archive.archive).toEqual(expectedArchive) expect(changes).toEqual(expectedUpdates) diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 5762fe13..63404670 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -395,6 +395,7 @@ export class Archive { originalRequest: MSectionRequest, response: MSectionResponse, ) => { + assert(archive && response) const m_def = response.m_def as Section assert(m_def, `MSectionResponse for ${path} must come with a definition`) // TODO Is this good? Do we need to merge something here? What if @@ -477,11 +478,10 @@ export class Archive { } if (indexed) { - const archiveList = - (archive[key] as unknown as MSectionResponse[]) || [] + const archiveList: MSectionResponse[] = [] archive[key] = archiveList const responseList = response[key] as MSectionResponse[] - archiveList[index] = responseList[index] + archiveList[index] = responseList[index] || undefined changes.push([ pathForCurrentKey, responseList.map((item) => item || undefined), @@ -511,12 +511,12 @@ export class Archive { if (archiveList[index]) { // update if (requestValue === '*') { - archiveList[index] = responseList[index] + archiveList[index] = responseList[index] || undefined changes.push([indexedPathForCurrentKey, responseList[index]]) } // else { nothing to do, the recursion will update the section } } else { // add - archiveList[index] = responseList[index] + archiveList[index] = responseList[index] || undefined changes.push([indexedPathForCurrentKey, responseList[index]]) } } else { @@ -535,7 +535,6 @@ export class Archive { Array.isArray(archiveList), `Expected array for key "${key}", but got "${archiveList}"`, ) - archive[key] = archiveList const responseList = response[key] as MSectionResponse[] if (responseList.length !== archiveList.length) { if (responseList.length > archiveList.length) { @@ -544,7 +543,7 @@ export class Archive { index < responseList.length; index++ ) { - archiveList.push(responseList[index]) + archiveList.push(responseList[index] || undefined) } } if (responseList.length < archiveList.length) { @@ -577,8 +576,22 @@ export class Archive { const archiveList = archive[key] as MSectionResponse[] const responseList = response[key] as MSectionResponse[] for (let index = 0; index < responseList.length; index++) { + const archiveListItem = archiveList[index] || undefined + archiveList[index] = archiveListItem + const indexedPathForCurrentKey = `${pathForCurrentKey}/${index}` + if (archiveListItem === undefined) { + // patching a hole in the list + if (checkForConflicts(indexedPathForCurrentKey)) { + continue + } + archiveList[index] = responseList[index] || undefined + changes.push([ + pathForCurrentKey, + responseList.map((item) => item || undefined), + ]) + } visit( - `${pathForCurrentKey}/${index}`, + indexedPathForCurrentKey, archiveList[index], requestValue as MSectionRequest, responseList[index], diff --git a/src/components/routing/loader.test.ts b/src/components/routing/loader.test.ts index 82e55c5a..72d56774 100644 --- a/src/components/routing/loader.test.ts +++ b/src/components/routing/loader.test.ts @@ -40,7 +40,7 @@ describe('getResponseForPath', () => { }) }) -describe('getResonseForRoute', () => { +describe('getResponseForRoute', () => { it('works', () => { const result = getResponsesForRoute( [{route: {requestKey: 'key', request: '*'}} as unknown as RouteMatch], diff --git a/src/components/routing/loader.ts b/src/components/routing/loader.ts index 788c3a0d..054b9b33 100644 --- a/src/components/routing/loader.ts +++ b/src/components/routing/loader.ts @@ -15,7 +15,7 @@ export class DoesNotExistError extends Error { /** * Parses the given key and returns the base key and the index if the key is - * if the key is indexed (e.g. 'key[0]'). Otherwise, returns the key itself ans + * if the key is indexed (e.g. 'key[0]'). Otherwise, returns the key itself and * `undefined`. */ export function getIndexedKey(key: string): [string, number | undefined] { @@ -70,7 +70,17 @@ export function getResponsesForRoute( return undefined } const hasRequest = match[index].route.request !== undefined - return getResponseForPath(path, response, hasRequest) + const responseForPath = getResponseForPath(path, response, hasRequest) + if (responseForPath === undefined) { + return undefined + } + + const transformResponse = + match[index].route.response || ((response: unknown) => response) + return transformResponse(responseForPath, { + ...match[index], + search: match[index].search as DefaultSearch, + }) as JSONObject }) } diff --git a/src/components/routing/types.ts b/src/components/routing/types.ts index a5cf8845..5034f404 100644 --- a/src/components/routing/types.ts +++ b/src/components/routing/types.ts @@ -301,6 +301,16 @@ export type Route< locationData: RouteMatch<Request, Response, Search>, ) => DefaultToObject<Request>) + /** + * An optional function that transforms the response data. The transformed + * data will be available in the `response` property of the respective + * `RouteData` object, e.g. obtained via `useRoute`. + */ + response?: ( + response: DefaultToObject<unknown>, + locationData: RouteMatch<Request, Response, Search>, + ) => DefaultToObject<Response> + /** * An optional function that is called after the data for the route has * been fetched. The function receives the route segment data and the full response. diff --git a/src/components/routing/useRouteData.test.tsx b/src/components/routing/useRouteData.test.tsx index 37ff942b..c3d0b780 100644 --- a/src/components/routing/useRouteData.test.tsx +++ b/src/components/routing/useRouteData.test.tsx @@ -209,6 +209,37 @@ describe('useRouteData', () => { expect(mockedApi).toHaveBeenCalledWith({parent: {'*': '*'}}, undefined) }) + it('calls response', async () => { + const response = vi + .fn() + .mockImplementation(({dataKey: data}) => ({dataKey: data + 'changed'})) + const route: Route = { + path: '', + component: Outlet, + children: [ + { + path: 'data', + request: {dataKey: '*'}, + response, + component: function Component() { + const {dataKey: data} = useRouteData<unknown, {dataKey: string}>() + return <div>{data}</div> + }, + }, + ], + } + mockedApi.mockResolvedValue({ + data: { + dataKey: 'value', + }, + } as GraphResponse) + window.history.replaceState(null, '', '/data') + await act(() => render(<Router route={route} loader={loader} />)) + expect(mockedApi).toBeCalledTimes(1) + expect(response).toBeCalledTimes(1) + expect(screen.getByText('valuechanged')).toBeInTheDocument() + }) + it('calls onFetch', async () => { const onFetch = vi.fn() const route: Route = { diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index e54922f8..efaf736d 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -1,6 +1,7 @@ import path from 'path-browserify' import {getArchive} from '../../components/archive/useArchive' +import {getIndexedKey} from '../../components/routing/loader' import {Route} from '../../components/routing/types' import { EntryMetadataRequest, @@ -30,7 +31,26 @@ export const archiveRequest = { export const archiveRoute: Route<MSectionRequest, MSectionResponse> = { path: ':name', - request: {...archiveRequest}, + requestKey: ({path}) => getIndexedKey(path)[0], + request: ({path}) => { + const index = getIndexedKey(path)[1] + if (index !== undefined) { + return { + ...archiveRequest, + [index]: {...archiveRequest}, + } + } else { + return {...archiveRequest} + } + }, + response: (response, {path}) => { + const index = getIndexedKey(path)[1] + if (index !== undefined) { + return response[index] as MSectionResponse + } else { + return response + } + }, lazyComponent: async () => import('./EntrySection'), renderWithPathAsKey: true, } -- GitLab From aa51856b613681594e960f2134e7d144065c9e66 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 19 Feb 2025 22:40:28 +0100 Subject: [PATCH 06/17] The entry nav syncs with the archive now. --- src/components/archive/useArchive.tsx | 2 - src/components/editor/utils.ts | 4 +- src/components/navigation/TreeNav.tsx | 51 ++++++-- src/pages/entry/EntryArchiveNav.tsx | 181 +++++++++++++++----------- src/pages/entry/EntryOverview.tsx | 45 +------ src/pages/entry/entryRoute.tsx | 69 +++++++++- 6 files changed, 220 insertions(+), 132 deletions(-) diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 63404670..d0216d06 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -864,8 +864,6 @@ export function useArchiveProperty<T = unknown, D extends Property = Property>( 'Archive path must not change', ) - // TODO the value in the archive might have changed for the same path, - // but we keep relying on the old and wrong localState. const propertyState = archive.getState(path) as ArchivePropertyState<T> const render = useRender() diff --git a/src/components/editor/utils.ts b/src/components/editor/utils.ts index 40844b1e..0d99d1cd 100644 --- a/src/components/editor/utils.ts +++ b/src/components/editor/utils.ts @@ -1,7 +1,7 @@ import {has, isEqual, isObject, merge} from 'lodash' import {MSectionRequest} from '../../models/graphRequestModels' -import {mDefRquest} from '../../pages/entry/entryRoute' +import {mDefRequest} from '../../pages/entry/entryRoute' import {assert} from '../../utils/utils' import {LayoutItem} from '../layout/Layout' @@ -11,7 +11,7 @@ export const defaultSubSectionRequest = { include_definition: 'both', exclude: ['*'], }, - m_def: mDefRquest, + m_def: mDefRequest, } as MSectionRequest /** diff --git a/src/components/navigation/TreeNav.tsx b/src/components/navigation/TreeNav.tsx index e8f15134..6885abbf 100644 --- a/src/components/navigation/TreeNav.tsx +++ b/src/components/navigation/TreeNav.tsx @@ -4,16 +4,30 @@ import {useFirstMountState, usePrevious} from 'react-use' import {JSONObject} from '../../utils/types' import {getResponseForPath} from '../routing/loader' -import useDataForRoute from '../routing/useDataForRoute' +import useDataForRoute, { + UseDataForRouteParams, +} from '../routing/useDataForRoute' import useRoute from '../routing/useRoute' import {useAvailableRouteData} from '../routing/useRouteData' import NavItem, {NavItemProps} from './NavItem' export type TreeNavProps = { /** - * The request object that is used to fetch children. + * The request object that is used to fetch children. Either the object + * itself or a function that creates the object. The function is called with + * the path of the child. */ - request: JSONObject + request: JSONObject | ((path: string) => JSONObject) + /** + * An optional function that transforms the response from fetching children + * before it is used. + */ + response?: (response: JSONObject, path: string) => JSONObject + /** + * Optional parameters passed to the useDataForRoute hook, when fetching + * more data with the given `request`. + */ + useDataForRouteParams?: Partial<UseDataForRouteParams> /** * Determines if available route data is sufficient to render the tree item * or if the data has to be fetched. By default route data will always be @@ -76,6 +90,8 @@ export type TreeNavProps = { export default function TreeNav({ path, request, + response = (response) => response, + useDataForRouteParams: dataForRouteParams = {}, dataIsSufficient = () => true, filterChildKeys = () => true, getChildProps = () => ({}), @@ -101,14 +117,21 @@ export default function TreeNav({ const splitPath = useMemo(() => path.split('/'), [path]) const key = useMemo(() => splitPath.at(-1) || 'no path', [splitPath]) - const useDataForRouteParams = useMemo( - () => ({request: {[path]: request}, noImplicitFetch: true}), - [request, path], - ) - const dataForRouteResult = useDataForRoute(useDataForRouteParams) - const {fetch, loading} = dataForRouteResult - const fetchedData = - dataForRouteResult.data && getResponseForPath(path, dataForRouteResult.data) + const useDataForRouteParams = useMemo(() => { + return { + request: + typeof request === 'function' ? request(path) : {[path]: {...request}}, + noImplicitFetch: true, + ...dataForRouteParams, + } + }, [request, path, dataForRouteParams]) + + const { + fetch, + loading, + data: dataForRoute, + } = useDataForRoute(useDataForRouteParams) + const fetchedData = dataForRoute && getResponseForPath(path, dataForRoute) const hasFetchedData = !!fetchedData const {index, fullMatch, navigate} = useRoute() @@ -224,7 +247,7 @@ export default function TreeNav({ isFirstMountState, ]) - const renderData = currentData.current + const renderData = currentData.current && response(currentData.current, path) const renderExpanded = expanded || (justGotInRoute && expandable) const renderSelected = selected || (inRoute && (!renderExpanded || loading)) @@ -279,6 +302,8 @@ export default function TreeNav({ path={`${path}/${key}`} expandable={expandable} request={request} + response={response} + useDataForRouteParams={dataForRouteParams} dataIsSufficient={dataIsSufficient} getChildProps={getChildProps} orderCompareFn={orderCompareFn} @@ -301,6 +326,8 @@ export default function TreeNav({ path={`${path}/${leastChildKey}`} expandable={expandable} request={request} + response={response} + useDataForRouteParams={dataForRouteParams} dataIsSufficient={dataIsSufficient} getChildProps={getChildProps} orderCompareFn={orderCompareFn} diff --git a/src/pages/entry/EntryArchiveNav.tsx b/src/pages/entry/EntryArchiveNav.tsx index 5967b9cb..db99a9de 100644 --- a/src/pages/entry/EntryArchiveNav.tsx +++ b/src/pages/entry/EntryArchiveNav.tsx @@ -2,14 +2,21 @@ import RepeatedSubSectionIcon from '@mui/icons-material/DataArray' import SectionIcon from '@mui/icons-material/DataObjectOutlined' import QuantityIcon from '@mui/icons-material/LooksOneOutlined' import UnknownIcon from '@mui/icons-material/QuestionMark' -import {useMemo} from 'react' +import {useCallback, useEffect} from 'react' +import useArchive from '../../components/archive/useArchive' import TreeNav, {TreeNavProps} from '../../components/navigation/TreeNav' +import {UseDataForRouteParams} from '../../components/routing/useDataForRoute' import useRoute from '../../components/routing/useRoute' +import useRender from '../../hooks/useRender' import {Section} from '../../utils/metainfo' import {JSONObject} from '../../utils/types' import {assert} from '../../utils/utils' -import {archiveRequest} from './entryRoute' +import { + archiveRequest, + mDefRequest, + useEntryDataForRouteParams, +} from './entryRoute' export type EntryArchiveNavProps = Pick< TreeNavProps, @@ -38,86 +45,112 @@ function expandable(data: JSONObject) { ) } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function getChildProps(data: JSONObject, key: string, path: string) { - const sectionDef = data.m_def as Section - const property = sectionDef.all_properties[key] || {} - - const isQuantity = property.m_def === 'Quantity' - const isSubSection = property.m_def === 'SubSection' && !property.repeats - const isUnknown = property.m_def === undefined - const isRepeatingSubSection = - property.m_def === 'SubSection' && property.repeats - - let icon - if (isQuantity) { - icon = <QuantityIcon /> - } else if (isSubSection) { - icon = <SectionIcon /> - } else if (isRepeatingSubSection) { - icon = <RepeatedSubSectionIcon /> - } else if (isUnknown) { - icon = <UnknownIcon /> - } else { - throw new Error('Impossible state.') - } +export default function EntryArchiveNav({ + ...treeNavProps +}: EntryArchiveNavProps) { + const {fullMatch, index} = useRoute() + const highlighted = + fullMatch.length === index + 2 && fullMatch[index + 1]?.path === 'archive' - const sharedProps = { - variant: 'treeNode', - icon, - alignWithExpandIcons: true, - } + const archive = useArchive() + const rerender = useRender() - if (isQuantity || isUnknown) { - return { - ...sharedProps, - expandable: false, - disabled: isUnknown, - } as TreeNavProps - } + useEffect(() => { + return archive.registerChangeListener(rerender) + }, [archive, rerender]) - if (isSubSection) { - return { - ...sharedProps, - getChildProps, - filterChildKeys, - expandable, - } as TreeNavProps - } + const request = useCallback((path: string) => { + const request: JSONObject = {m_def: mDefRequest} + let current = request + for (const key of path.split('/').slice(1)) { + current[key] = {m_def: mDefRequest} + current = current[key] + } + Object.assign(current, archiveRequest) + const result = {archive: request} + return result + }, []) + + const response = useCallback( + (response: JSONObject, path: string) => { + path = path.replace(/^archive\/?/, '') + return archive.getValue(path) as JSONObject + }, + [archive], + ) + + const useDataForRouteParams = useEntryDataForRouteParams((error) => { + // eslint-disable-next-line no-console + console.error('Error while fetching nav data :', error) + }) as Partial<UseDataForRouteParams> + + const getChildProps = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (data: JSONObject, key: string, path: string) => { + const sectionDef = data.m_def as Section + const property = sectionDef.all_properties[key] || {} - if (isRepeatingSubSection) { - return { - ...sharedProps, - expandable: true, - getChildProps: (data: JSONObject, key: string, path: string) => - ({ + const isQuantity = property.m_def === 'Quantity' + const isSubSection = property.m_def === 'SubSection' && !property.repeats + const isUnknown = property.m_def === undefined + const isRepeatingSubSection = + property.m_def === 'SubSection' && property.repeats + + let icon + if (isQuantity) { + icon = <QuantityIcon /> + } else if (isSubSection) { + icon = <SectionIcon /> + } else if (isRepeatingSubSection) { + icon = <RepeatedSubSectionIcon /> + } else if (isUnknown) { + icon = <UnknownIcon /> + } else { + throw new Error('Impossible state.') + } + + const sharedProps = { + variant: 'treeNode', + icon, + alignWithExpandIcons: true, + } + + if (isQuantity || isUnknown) { + return { + ...sharedProps, + expandable: false, + disabled: isUnknown, + } as TreeNavProps + } + + if (isSubSection) { + return { ...sharedProps, getChildProps, filterChildKeys, expandable, - icon: <SectionIcon />, - label: key, - path: `${path}[${key}]`, - } as TreeNavProps), - } as TreeNavProps - } - - throw new Error('Impossible state.') -} + } as TreeNavProps + } -export default function EntryArchiveNav({ - ...treeNavProps -}: EntryArchiveNavProps) { - const {fullMatch, index} = useRoute() - const highlighted = - fullMatch.length === index + 2 && fullMatch[index + 1]?.path === 'archive' + if (isRepeatingSubSection) { + return { + ...sharedProps, + expandable: true, + getChildProps: (data: JSONObject, key: string, path: string) => + ({ + ...sharedProps, + getChildProps, + filterChildKeys, + expandable, + icon: <SectionIcon />, + label: key, + path: `${path}[${key}]`, + } as TreeNavProps), + } as TreeNavProps + } - const request = useMemo( - () => ({ - ...archiveRequest, - // TODO this does not seem to have the desired effect - '*': '*', - }), + throw new Error('Impossible state.') + }, [], ) @@ -127,7 +160,9 @@ export default function EntryArchiveNav({ path='archive' variant='menuItem' icon={undefined} - request={request as unknown as JSONObject} + request={request} + response={response} + useDataForRouteParams={useDataForRouteParams} expandable={expandable} getChildProps={getChildProps} filterChildKeys={filterChildKeys} diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index e48971a1..12161ea5 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -1,5 +1,5 @@ import {Box} from '@mui/material' -import {useCallback, useMemo, useState} from 'react' +import {useMemo, useState} from 'react' import {usePrevious} from 'react-use' import ErrorMessage from '../../components/app/ErrorMessage' @@ -7,15 +7,10 @@ import useArchive from '../../components/archive/useArchive' import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' import useSelect from '../../components/navigation/useSelect' -import useDataForRoute from '../../components/routing/useDataForRoute' import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' -import { - EntryResponse, - GraphResponse, - MSectionResponse, -} from '../../models/graphResponseModels' +import {MSectionResponse} from '../../models/graphResponseModels' import {Section} from '../../utils/metainfo' import {assert} from '../../utils/utils' import UploadMetadataEditor from '../upload/UploadMetadataEditor' @@ -23,7 +18,7 @@ import EntryDataEditor from './EntryDataEditor' import EntryMetadataEditor from './EntryMetadataEditor' import EntryPageTitle from './EntryPageTitle' import EntrySubSectionTable from './EntrySubSectionTable' -import entryRoute, {archiveRequest} from './entryRoute' +import entryRoute, {archiveRequest, useEntryDataForRoute} from './entryRoute' function EntryOverviewEditor() { const {archive: archiveData} = useRouteData(entryRoute) @@ -72,8 +67,6 @@ function EntryOverviewEditor() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [layout, reloadCount]) - const archive = useArchive() - // Ideally we would call this in the onBeforeFetch callback, but // this will be too late because the effect is called after the render. // When rendering the EntryOverviewEditor and the @@ -83,40 +76,12 @@ function EntryOverviewEditor() { // will cause state updates in the future as those are not allowed during // render. const requestHasChanged = request !== usePrevious(request) + const archive = useArchive() if (requestHasChanged) { archive.startUpdate(request.archive as MSectionRequest, true) } - const onBeforeFetch = useCallback(() => { - if (!archive.loading) { - archive.startUpdate(request.archive as MSectionRequest, true) - } - return () => { - archive.abortUpdate() - } - }, [archive, request]) - - const onFetch = useCallback( - (data: EntryResponse, fullResponse: GraphResponse) => { - archive.commitUpdate(data?.archive as MSectionResponse, fullResponse) - }, - [archive], - ) - - const onError = useCallback( - (error: Error) => { - archive.abortUpdate() - setError(error) - }, - [archive], - ) - - useDataForRoute<EntryRequest, EntryResponse>({ - request, - onBeforeFetch, - onFetch, - onError, - }) + useEntryDataForRoute(request, setError) let content: React.ReactNode = '' if (error) { diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index efaf736d..b910f90e 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -1,8 +1,12 @@ import path from 'path-browserify' +import {useCallback, useMemo} from 'react' -import {getArchive} from '../../components/archive/useArchive' +import useArchive, {getArchive} from '../../components/archive/useArchive' import {getIndexedKey} from '../../components/routing/loader' import {Route} from '../../components/routing/types' +import useDataForRoute, { + UseDataForRouteParams, +} from '../../components/routing/useDataForRoute' import { EntryMetadataRequest, EntryRequest, @@ -14,7 +18,7 @@ import { MSectionResponse, } from '../../models/graphResponseModels' -export const mDefRquest = { +export const mDefRequest = { m_request: { directive: 'plain', }, @@ -26,7 +30,7 @@ export const archiveRequest = { include_definition: 'both', depth: 2, }, - m_def: mDefRquest, + m_def: mDefRequest, } as MSectionRequest export const archiveRoute: Route<MSectionRequest, MSectionResponse> = { @@ -56,6 +60,65 @@ export const archiveRoute: Route<MSectionRequest, MSectionResponse> = { } archiveRoute.children = [archiveRoute] +/** + * Provides parameters for the `useDataForRoute` hook that will make the + * hook also update the archive data. + */ +export function useEntryDataForRouteParams( + setError?: (error: Error) => void, +): Pick< + UseDataForRouteParams<EntryRequest, EntryResponse>, + 'onBeforeFetch' | 'onFetch' | 'onError' +> { + const archive = useArchive() + + const onBeforeFetch = useCallback( + (request: EntryRequest) => { + if (!archive.loading) { + archive.startUpdate(request.archive as MSectionRequest, true) + } + return () => { + archive.abortUpdate() + } + }, + [archive], + ) + + const onFetch = useCallback( + (data: EntryResponse, fullResponse: GraphResponse) => { + archive.commitUpdate(data?.archive as MSectionResponse, fullResponse) + }, + [archive], + ) + + const onError = useCallback( + (error: Error) => { + archive.abortUpdate() + setError?.(error) + }, + [archive, setError], + ) + + return useMemo( + () => ({onBeforeFetch, onFetch, onError}), + [onBeforeFetch, onFetch, onError], + ) +} + +/** + * A specific variant of `useEntryDataForRoute` that updates the + * archive data for the entry. + */ +export function useEntryDataForRoute( + request: EntryRequest, + setError?: (error: Error) => void, +) { + return useDataForRoute<EntryRequest, EntryResponse>({ + request, + ...useEntryDataForRouteParams(setError), + }) +} + const entryRoute: Route<EntryRequest, EntryResponse> = { path: ':entryId', // TODO The function should not be necessary, but some buggy thing is modifying the -- GitLab From 562e6adfba9dbadaf3ea342171e954eeb85d9880 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 3 Mar 2025 12:54:41 +0100 Subject: [PATCH 07/17] Added unsaved changes badges. --- src/components/app/MainMenu.tsx | 57 +++++++------ src/components/archive/archive.helper.tsx | 11 ++- src/components/archive/useArchive.test.tsx | 78 +++++++++++++++++ src/components/archive/useArchive.tsx | 98 ++++++++++++++++++++-- src/components/navigation/NavItem.tsx | 17 +++- src/index.tsx | 2 +- src/pages/entry/UnsavedChangesNav.tsx | 62 ++++++++++++++ src/pages/upload/UploadPage.tsx | 3 + src/pages/uploads/UploadsPage.tsx | 31 ++++++- 9 files changed, 314 insertions(+), 45 deletions(-) create mode 100644 src/pages/entry/UnsavedChangesNav.tsx diff --git a/src/components/app/MainMenu.tsx b/src/components/app/MainMenu.tsx index 17f66bdf..d1e3f0a7 100644 --- a/src/components/app/MainMenu.tsx +++ b/src/components/app/MainMenu.tsx @@ -1,5 +1,5 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' -import {List} from '@mui/material' +import {Badge, BadgeProps, List} from '@mui/material' import Box from '@mui/material/Box' import Divider from '@mui/material/Divider' import Drawer from '@mui/material/Drawer' @@ -10,17 +10,10 @@ import ListItemText from '@mui/material/ListItemText' import {CSSObject, Theme, styled} from '@mui/material/styles' import * as React from 'react' +import {UploadsMainMenuItem} from '../../pages/uploads/UploadsPage' import {SxProps} from '../../utils/types' import useDevTools from '../devTools/useDevTools' -import { - Analyze, - Dev, - Explore, - Group, - Manage, - Menu, - ToggleColorMode, -} from '../icons' +import {Analyze, Dev, Explore, Group, Menu, ToggleColorMode} from '../icons' import Link from '../routing/Link' import useRoute from '../routing/useRoute' import {Logo} from '../theme/Theme' @@ -81,6 +74,10 @@ export type MainMenuItemProps = React.PropsWithChildren<{ * Whether the item is active and should be highlighted */ active?: boolean + /** + * Props for a MUI badge to show on icon of the item + */ + badge?: BadgeProps }> & ListItemButtonProps & SxProps @@ -93,11 +90,30 @@ export const MainMenuItem = React.forwardRef<HTMLElement, MainMenuItemProps>( icon, active = false, children, + badge, sx = {}, ...listItemButtonProps }, ref, ) { + const iconElement = icon && ( + <Badge + {...badge} + sx={{ + '& .MuiBadge-badge': {left: 0, right: 'initial'}, + }} + > + <ListItemIcon + sx={{ + minWidth: 0, + mr: open ? 3 : 'auto', + justifyContent: 'center', + }} + > + {icon} + </ListItemIcon> + </Badge> + ) return ( <ListItem ref={ref as React.Ref<HTMLLIElement>} @@ -140,17 +156,7 @@ export const MainMenuItem = React.forwardRef<HTMLElement, MainMenuItemProps>( {children} </Box> )} - {icon && ( - <ListItemIcon - sx={{ - minWidth: 0, - mr: open ? 3 : 'auto', - justifyContent: 'center', - }} - > - {icon} - </ListItemIcon> - )} + {iconElement} <ListItemText primary={label} sx={{opacity: open ? 1 : 0}} /> </ListItemButton> </ListItem> @@ -193,14 +199,7 @@ export default function MainMenu() { </List> <Divider /> <List sx={{flexGrow: 1}}> - <MainMenuItem - label='Manage' - open={open} - icon={<Manage />} - component={Link} - to='/uploads' - active={matches('/uploads')} - /> + <UploadsMainMenuItem open={open} component={Link} /> <MainMenuItem label='Explore' open={open} diff --git a/src/components/archive/archive.helper.tsx b/src/components/archive/archive.helper.tsx index 97a56e08..e6df20be 100644 --- a/src/components/archive/archive.helper.tsx +++ b/src/components/archive/archive.helper.tsx @@ -7,12 +7,13 @@ import {Quantity, Section, SubSection} from '../../utils/metainfo' import {DefaultToObject, JSONObject} from '../../utils/types' import * as useDataForRoute from '../routing/useDataForRoute' import * as useRouteData from '../routing/useRouteData' -import {Archive, ArchiveChangeWithOldValue, allArchives} from './useArchive' +import {ArchiveChangeWithOldValue, allArchives, getArchive} from './useArchive' export type MockArchiveArgs = { entryId?: string data?: MSectionResponse changes?: ArchiveChangeWithOldValue[] + clearArchives?: boolean } /** @@ -24,16 +25,18 @@ export function mockArchive({ entryId = 'entryId', data, changes = [], + clearArchives = true, }: MockArchiveArgs = {}) { - allArchives.clear() - const archive = new Archive() + if (clearArchives) { + allArchives.clear() + } + const archive = getArchive(entryId) if (data) { archive.archive = data archive.loading = false } else { archive.loading = true } - allArchives.set(entryId, archive) archive.changeStack.push(...changes) vi.spyOn(useRouteData, 'default').mockReturnValue({ entry_id: entryId, diff --git a/src/components/archive/useArchive.test.tsx b/src/components/archive/useArchive.test.tsx index 5284ea9b..ed3890d6 100644 --- a/src/components/archive/useArchive.test.tsx +++ b/src/components/archive/useArchive.test.tsx @@ -14,8 +14,13 @@ import useArchive, { allArchives, useArchiveChanges, useArchiveProperty, + useChangedArchives, } from './useArchive' +afterEach(() => { + allArchives.clear() +}) + describe('useArchive', () => { describe('Archive', () => { let archive: Archive @@ -617,3 +622,76 @@ describe('useArchiveChanges', () => { expect(result.current.changeStack.length).toBe(0) }) }) + +describe('useChangedArchives', () => { + it('returns empty list with no archives', () => { + const {result} = renderHook(() => useChangedArchives()) + expect(result.current).toHaveLength(0) + }) + + it('returns empty list with no changes', () => { + vi.spyOn(useRouteData, 'default').mockReturnValue({ + entry_id: 'entryId', + }) + renderHook(() => useArchive()) + const {result} = renderHook(() => useChangedArchives()) + expect(result.current).toHaveLength(0) + }) + + it('returns the list of changed archives', () => { + mockArchive({ + entryId: 'entryId0', + data: addDefinitions({ + quantity: 'test_value', + }), + changes: [{path: 'quantity', new_value: 'new_value', action: 'upsert'}], + }) + mockArchive({ + clearArchives: false, + entryId: 'entryId1', + data: addDefinitions({ + quantity: 'test_value', + }), + changes: [ + {path: 'quantity', new_value: 'new_value', action: 'upsert'}, + {path: 'quantity', new_value: 'new_value', action: 'upsert'}, + ], + }) + mockArchive({ + clearArchives: false, + entryId: 'entryId2', + data: addDefinitions({ + quantity: 'test_value', + }), + changes: [], + }) + const {result} = renderHook(() => useChangedArchives()) + expect(result.current).toHaveLength(2) + }) + + it('renders on change', () => { + const archive = mockArchive({ + data: addDefinitions({ + quantity: 'test_value', + }), + }) + const {result} = renderHook(() => useChangedArchives()) + expect(result.current).toHaveLength(0) + act(() => archive.submitElementChange('quantity', 'upsert', 'new_value')) + expect(result.current).toHaveLength(1) + }) + + it('renders on new changed archive', () => { + const {result} = renderHook(() => useChangedArchives()) + expect(result.current).toHaveLength(0) + act(() => + mockArchive({ + data: addDefinitions({ + quantity: 'test_value', + }), + changes: [{path: 'quantity', new_value: 'new_value', action: 'upsert'}], + }), + ) + expect(result.current).toHaveLength(1) + }) +}) diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index d0216d06..9cb63302 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -60,11 +60,17 @@ type ArchivePropertyState<T> = { type ArchivePropertyDispatch = React.Dispatch<ArchivePropertyState<unknown>> +type ArchiveEntry = Pick< + EntryResponse, + 'entry_id' | 'upload_id' | 'mainfile_path' +> + /** * A class to manage the client-side state of an entry's archive. See * `useArchive` for the hook that provides access it. */ export class Archive { + entry: ArchiveEntry elements: MultiMap<string, ArchivePropertyDispatch> changeListener: (() => void)[] archive: MSectionResponse @@ -74,7 +80,8 @@ export class Archive { responsesWithReferencedArchives: MultiMap<string, GraphResponse> currentUpdateRequests: MSectionRequest | undefined - constructor() { + constructor(entry?: ArchiveEntry) { + this.entry = entry || {} this.elements = new MultiMap<string, ArchivePropertyDispatch>() this.changeListener = [] this.archive = {} @@ -92,6 +99,12 @@ export class Archive { } } + /** + * Registers a callback that is called when the archive changes. + * + * @param listener The callback that is called on each change. + * @returns A function to unregister the listener. + */ registerChangeListener(listener: () => void) { this.changeListener.push(listener) return () => { @@ -719,11 +732,30 @@ export class Archive { export const allArchives = new Map<string, Archive>() -export function getArchive(entry_id: string) { +export function getArchive(entry_id: string, entry?: ArchiveEntry) { if (!allArchives.has(entry_id)) { - allArchives.set(entry_id, new Archive()) + const archive = new Archive(entry) + allArchives.set(entry_id, archive) + allArchivesChangeListeners.forEach((listener) => listener()) + } + const archive = allArchives.get(entry_id) as Archive + archive.entry = {...archive.entry, ...entry} + return archive +} + +const allArchivesChangeListeners = new Set<() => void>() + +/** + * Registers a callback that is called when a new archive is created. + * + * @param listener The callback that is called when a new archive is created. + * @returns A function to unregister the listener. + */ +function registerArchivesChangeListener(listener: () => void) { + allArchivesChangeListeners.add(listener) + return () => { + allArchivesChangeListeners.delete(listener) } - return allArchives.get(entry_id) as Archive } /** @@ -732,12 +764,64 @@ export function getArchive(entry_id: string) { * the client-side state of the archive. This includes the archive data * updated from the latest API requests and user changes, the stack of * unsaved changes, and a registry for components listening to properties - * of the archive via `useArhiveProperty`. + * of the archive via `useArchiveProperty`. + * + * The archive is constant for the current entry and this hook will not + * re-render on archive changes. Use `useArchiveChanges` or `useArchiveProperty` + * or `useChangedArchives` to react to changes. */ export default function useArchive() { - const {entry_id} = useRouteData(entryRoute) + const {entry_id, mainfile_path, upload_id} = useRouteData(entryRoute) assert(entry_id, 'UseArchive only works within an entry route.') - return getArchive(entry_id) + return getArchive(entry_id, {entry_id, upload_id, mainfile_path}) +} + +/** + * A hook that returns a list of all archives that have been loaded and + * re-renders when a new archive is loaded. + */ +function useArchives() { + const [archives, setArchives] = useState<Archive[]>( + Array.from(allArchives.values()), + ) + + useEffect(() => { + return registerArchivesChangeListener(() => { + setArchives(Array.from(allArchives.values())) + }) + }, []) + + return archives +} + +/** + * A hook that returns a list of archives that have unsaved changes. + * The hook causes a re-render, if there are new changes. + */ +export function useChangedArchives() { + const archives = useArchives() + + const [changedArchives, setChangedArchives] = useState<Archive[]>( + archives.filter((archive) => archive.hasChanges()), + ) + + useEffect(() => { + const handleChange = () => { + setChangedArchives(archives.filter((archive) => archive.hasChanges())) + } + + const listeners = Array.from(allArchives.values()).map((archive) => + archive.registerChangeListener(handleChange), + ) + + handleChange() + + return () => { + listeners.forEach((unregister) => unregister()) + } + }, [archives]) + + return changedArchives } export type ArchiveChanges = { diff --git a/src/components/navigation/NavItem.tsx b/src/components/navigation/NavItem.tsx index 8b88d315..35a9b045 100644 --- a/src/components/navigation/NavItem.tsx +++ b/src/components/navigation/NavItem.tsx @@ -1,5 +1,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { + Badge, + BadgeProps, ButtonBase, Collapse, IconButton, @@ -194,6 +196,10 @@ export type NavItemProps = { * disabled. */ disabled?: boolean + /** + * Optional props to create a MUI badge on the item label. + */ + badge?: BadgeProps } /** @@ -214,6 +220,7 @@ export default function NavItem(props: NavItemProps & SxProps) { label, alignWithExpandIcons = false, children, + badge, sx = [], ...buttonBaseProps } = props @@ -227,6 +234,12 @@ export default function NavItem(props: NavItemProps & SxProps) { [onToggle], ) + const labelElement = ( + <NavItemLabel className={classes.label} ownerState={props}> + {label} + </NavItemLabel> + ) + return ( <NavItemRoot className={classes.root} sx={sx}> <NavItemButton @@ -250,9 +263,7 @@ export default function NavItem(props: NavItemProps & SxProps) { </NavItemToggleButton> )} {icon && <NavItemIcon className={classes.icon}>{icon}</NavItemIcon>} - <NavItemLabel className={classes.label} ownerState={props}> - {label} - </NavItemLabel> + <Badge {...badge}>{labelElement}</Badge> </NavItemButton> {expandable && children && ( <NavItemContent diff --git a/src/index.tsx b/src/index.tsx index 58bde89a..39fe09c6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -60,7 +60,7 @@ enableMocking().then(() => { </Theme> </RecoilRoot> </LocalizationProvider>, - // </React.StrictMode>, + // </React.StrictMode> ) }) diff --git a/src/pages/entry/UnsavedChangesNav.tsx b/src/pages/entry/UnsavedChangesNav.tsx new file mode 100644 index 00000000..4af527cf --- /dev/null +++ b/src/pages/entry/UnsavedChangesNav.tsx @@ -0,0 +1,62 @@ +import {useMemo} from 'react' + +import {useChangedArchives} from '../../components/archive/useArchive' +import NavItem from '../../components/navigation/NavItem' +import useSelect from '../../components/navigation/useSelect' +import Link from '../../components/routing/Link' +import useRoute from '../../components/routing/useRoute' + +type UnsavedChangesNavProps = { + uploadId?: string +} + +export default function UnsavedChangesNav({uploadId}: UnsavedChangesNavProps) { + const {isPage} = useSelect() + const changedArchives = useChangedArchives() + const {url} = useRoute() + const relevantChangedArchives = useMemo(() => { + if (!uploadId) { + return changedArchives + } + return changedArchives.filter((archive) => { + return archive.entry.upload_id === uploadId + }) + }, [uploadId, changedArchives]) + + if (!isPage) { + return '' + } + + if (relevantChangedArchives.length === 0) { + return '' + } + + return ( + <NavItem + expandable + expanded + label={'Entries with unsaved changes'} + badge={{badgeContent: relevantChangedArchives.length, color: 'error'}} + > + {relevantChangedArchives + .filter((archive) => { + return archive.entry.entry_id && archive.entry.upload_id + }) + .map((archive, index) => ( + <NavItem + key={archive.entry.entry_id || index} + variant='treeNode' + label={ + archive.entry.mainfile_path || + archive.entry.entry_id || + 'Unknown name' + } + component={Link} + to={url({ + path: `/uploads/${archive.entry.upload_id}/entries/${archive.entry.entry_id}`, + })} + /> + ))} + </NavItem> + ) +} diff --git a/src/pages/upload/UploadPage.tsx b/src/pages/upload/UploadPage.tsx index 862ebdda..a5625c65 100644 --- a/src/pages/upload/UploadPage.tsx +++ b/src/pages/upload/UploadPage.tsx @@ -12,6 +12,7 @@ import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' import useRecent from '../../hooks/useRecent' import {JSONObject} from '../../utils/types' +import UnsavedChangesNav from '../entry/UnsavedChangesNav' import UploadFilesNav from './UploadFilesNav' import UploadNavAbout from './UploadNavAbout' import uploadRoute from './uploadRoute' @@ -99,6 +100,8 @@ export default function UploadPage() { /> </> )} + <Divider /> + <UnsavedChangesNav uploadId={upload_id} /> </Nav> } > diff --git a/src/pages/uploads/UploadsPage.tsx b/src/pages/uploads/UploadsPage.tsx index 69d79424..01545cc7 100644 --- a/src/pages/uploads/UploadsPage.tsx +++ b/src/pages/uploads/UploadsPage.tsx @@ -1,14 +1,41 @@ -import {Divider, TextField} from '@mui/material' +import {BadgeProps, Divider, TextField} from '@mui/material' +import {MainMenuItem, MainMenuItemProps} from '../../components/app/MainMenu' +import {useChangedArchives} from '../../components/archive/useArchive' +import {Manage} from '../../components/icons' import Nav from '../../components/navigation/Nav' import NavItem from '../../components/navigation/NavItem' import RecentNav from '../../components/navigation/RecentNav' import useSelect from '../../components/navigation/useSelect' import Page, {PageActions, PageTitle} from '../../components/page/Page' +import useRoute from '../../components/routing/useRoute' +import UnsavedChangesNav from '../entry/UnsavedChangesNav' import UploadsActions from './UploadsActions' import UploadsTable from './UploadsTable' import UploadsTableFilterMenu from './UploadsTableFilterMenu' +export function UploadsMainMenuItem( + mainMenuProps: Omit<MainMenuItemProps, 'label'>, +) { + const {matches} = useRoute() + const changedArchives = useChangedArchives() + const badge = { + badgeContent: changedArchives.length, + color: 'error', + } satisfies BadgeProps + + return ( + <MainMenuItem + {...mainMenuProps} + label='Manage' + to='/uploads' + icon={<Manage />} + active={matches('/uploads')} + badge={badge} + /> + ) +} + export default function UploadsPage() { const {isPage, pageVariant} = useSelect() @@ -24,6 +51,8 @@ export default function UploadsPage() { scope='global' storageKey='uploads' /> + <Divider /> + <UnsavedChangesNav /> </Nav> } > -- GitLab From 3419010b80c23817a73dcac102248708c13c9ed5 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 3 Mar 2025 13:31:59 +0100 Subject: [PATCH 08/17] Fixed archive updates in react strict mode. --- src/index.tsx | 35 ++++++++++++++++--------------- src/pages/entry/EntryOverview.tsx | 2 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 39fe09c6..37115408 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import '@fontsource/titillium-web/400.css' import '@fontsource/titillium-web/700.css' import {LocalizationProvider} from '@mui/x-date-pickers' import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFnsV3' +import React from 'react' import ReactDOM from 'react-dom/client' import {RecoilRoot} from 'recoil' @@ -44,23 +45,23 @@ enableMocking().then(() => { document.getElementById('root') as HTMLElement, ) root.render( - // <React.StrictMode> - <LocalizationProvider dateAdapter={AdapterDateFns}> - <RecoilRoot> - <Theme> - <AuthProvider> - <ApiProvider> - <UnitProvider> - <DevTools enabled> - <Routes /> - </DevTools> - </UnitProvider> - </ApiProvider> - </AuthProvider> - </Theme> - </RecoilRoot> - </LocalizationProvider>, - // </React.StrictMode> + <React.StrictMode> + <LocalizationProvider dateAdapter={AdapterDateFns}> + <RecoilRoot> + <Theme> + <AuthProvider> + <ApiProvider> + <UnitProvider> + <DevTools enabled> + <Routes /> + </DevTools> + </UnitProvider> + </ApiProvider> + </AuthProvider> + </Theme> + </RecoilRoot> + </LocalizationProvider> + </React.StrictMode>, ) }) diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index 12161ea5..be4f5dae 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -77,7 +77,7 @@ function EntryOverviewEditor() { // render. const requestHasChanged = request !== usePrevious(request) const archive = useArchive() - if (requestHasChanged) { + if (requestHasChanged && archive.currentUpdateRequests === undefined) { archive.startUpdate(request.archive as MSectionRequest, true) } -- GitLab From 69655eacfb70bc395d7f1766c8637f4e8e217b17 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 5 Mar 2025 10:16:40 +0100 Subject: [PATCH 09/17] Fixed NavItem issues. --- src/components/navigation/NavItem.tsx | 29 ++++++++++++++++++--------- src/pages/entry/EntryArchiveNav.tsx | 2 +- src/pages/entry/EntryPage.tsx | 2 +- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/components/navigation/NavItem.tsx b/src/components/navigation/NavItem.tsx index 35a9b045..bcc0d44a 100644 --- a/src/components/navigation/NavItem.tsx +++ b/src/components/navigation/NavItem.tsx @@ -2,6 +2,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { Badge, BadgeProps, + Box, ButtonBase, Collapse, IconButton, @@ -22,6 +23,7 @@ const classes = generateUtilityClasses('NavItem', [ 'button', 'icon', 'label', + 'badge', 'toggleButton', 'content', ]) @@ -106,14 +108,17 @@ const NavItemIcon = styled('span', {name: 'NavItem', slot: 'icon'})( }), ) +const NavItemBadge = styled(Badge, {name: 'NavItem', slot: 'badge'})({ + maxWidth: '100%', +}) + const NavItemLabel = styled(Typography, {name: 'NavItem', slot: 'label'})<{ ownerState: NavItemOwnerState }>(({ownerState: {variant = 'menuItem'}}) => ({ - flexGrow: 1, + width: '100%', textAlign: 'left', overflow: 'clip', textOverflow: 'ellipsis', - width: '100%', whiteSpace: 'nowrap', ...(variant === 'menuItem' && { textTransform: 'uppercase', @@ -234,12 +239,6 @@ export default function NavItem(props: NavItemProps & SxProps) { [onToggle], ) - const labelElement = ( - <NavItemLabel className={classes.label} ownerState={props}> - {label} - </NavItemLabel> - ) - return ( <NavItemRoot className={classes.root} sx={sx}> <NavItemButton @@ -263,7 +262,19 @@ export default function NavItem(props: NavItemProps & SxProps) { </NavItemToggleButton> )} {icon && <NavItemIcon className={classes.icon}>{icon}</NavItemIcon>} - <Badge {...badge}>{labelElement}</Badge> + <Box + sx={{ + flexGrow: 1, + textAlign: 'left', + width: '100%', + }} + > + <NavItemBadge className={classes.badge} {...badge}> + <NavItemLabel className={classes.label} ownerState={props}> + {label} + </NavItemLabel> + </NavItemBadge> + </Box> </NavItemButton> {expandable && children && ( <NavItemContent diff --git a/src/pages/entry/EntryArchiveNav.tsx b/src/pages/entry/EntryArchiveNav.tsx index db99a9de..9daa3e3f 100644 --- a/src/pages/entry/EntryArchiveNav.tsx +++ b/src/pages/entry/EntryArchiveNav.tsx @@ -20,7 +20,7 @@ import { export type EntryArchiveNavProps = Pick< TreeNavProps, - 'getChildProps' | 'label' | 'expanded' | 'selected' + 'getChildProps' | 'label' | 'expanded' | 'selected' | 'defaultExpanded' > function filterChildKeys(data: JSONObject, key: string) { diff --git a/src/pages/entry/EntryPage.tsx b/src/pages/entry/EntryPage.tsx index 56ea8ee9..f1597de2 100644 --- a/src/pages/entry/EntryPage.tsx +++ b/src/pages/entry/EntryPage.tsx @@ -80,7 +80,7 @@ export default function EntryPage() { )} <EntryArchiveNav label='data' - expanded={tab !== 'archive' ? false : undefined} + defaultExpanded={tab !== 'archive' ? false : undefined} selected={tab === 'archive'} /> </Nav> -- GitLab From ba93e5aa0436c57bc1f958ca0ce8b7b8a8836aba Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 5 Mar 2025 14:42:28 +0100 Subject: [PATCH 10/17] Added SubSectionNavEditor and used it for EntrySection. --- src/components/archive/Section.tsx | 88 +++++- src/components/editor/PropertyEditor.tsx | 1 + .../editor/SubSectionNavEditor.test.tsx | 226 +++++++++++++++ src/components/editor/SubSectionNavEditor.tsx | 269 ++++++++++++++++++ src/components/layout/Layout.tsx | 3 + src/components/navigation/NavItem.tsx | 89 ++++-- src/components/navigation/TreeNav.tsx | 9 +- src/components/routing/normalizeRoute.ts | 5 + src/pages/entry/EntryArchiveNav.tsx | 1 + src/pages/entry/EntryOverview.tsx | 8 +- src/pages/entry/EntrySection.tsx | 29 +- src/pages/entry/EntrySubSectionTable.tsx | 52 ---- 12 files changed, 665 insertions(+), 115 deletions(-) create mode 100644 src/components/editor/SubSectionNavEditor.test.tsx create mode 100644 src/components/editor/SubSectionNavEditor.tsx delete mode 100644 src/pages/entry/EntrySubSectionTable.tsx diff --git a/src/components/archive/Section.tsx b/src/components/archive/Section.tsx index a1028313..d0da427b 100644 --- a/src/components/archive/Section.tsx +++ b/src/components/archive/Section.tsx @@ -1,27 +1,47 @@ import {Card, CardContent, CardHeader} from '@mui/material' -import {useCallback} from 'react' +import {useCallback, useMemo} from 'react' import {MSectionResponse} from '../../models/graphResponseModels' import { Quantity as QuantityDefinition, Section as SectionDefinition, + SubSection as SubSectionDefinition, } from '../../utils/metainfo' import {JSONValue} from '../../utils/types' import {assert} from '../../utils/utils' import ErrorBoundary from '../ErrorBoundary' import {getPropertyLabel} from '../editor/PropertyEditor' +import SubSectionNavEditor from '../editor/SubSectionNavEditor' import DynamicValue from '../values/utils/DynamicValue' import {createDynamicComponentSpec} from '../values/utils/dynamicComponents' import SectionProvider from './SectionProvider' import {useArchiveProperty} from './useArchive' import useSubSectionPath from './useSubSectionPath' -type QantityProps = { +type SubSectionProps = { + definition: SubSectionDefinition + editable?: boolean +} + +function SubSection({definition, editable = false}: SubSectionProps) { + return ( + <SubSectionNavEditor + layout={{ + label: getPropertyLabel(definition.name), + type: 'subSectionNav', + property: definition.name, + editable, + }} + /> + ) +} + +type QuantityProps = { definition: QuantityDefinition editable?: boolean } -function Quantity({definition, editable = false}: QantityProps) { +function Quantity({definition, editable = false}: QuantityProps) { const path = useSubSectionPath(definition.name) const {value, loading, change} = useArchiveProperty< @@ -68,18 +88,56 @@ export default function Section({path, editable = false}: SectionProps) { 'The section definition should have been loaded by now.', ) + const subSections = useMemo(() => { + return Object.keys(definition.all_sub_sections) + .map((key) => definition.all_sub_sections[key]) + .toSorted((a, b) => a.name.localeCompare(b.name)) + }, [definition]) + + const quantities = useMemo(() => { + return Object.keys(definition.all_quantities).map( + (key) => definition.all_quantities[key], + ) + }, [definition.all_quantities]) + return ( - <Card> - <CardHeader title='Quantities' /> - <CardContent sx={{display: 'flex', flexDirection: 'column', gap: 2}}> - <SectionProvider path={path}> - {Object.values(definition.all_quantities).map((quantity) => ( - <ErrorBoundary key={quantity.name}> - <Quantity definition={quantity} editable={editable} /> - </ErrorBoundary> - ))} - </SectionProvider> - </CardContent> - </Card> + <> + {subSections.length > 0 && ( + <ErrorBoundary> + <Card> + <CardHeader title='Sub Sections' /> + <CardContent + sx={{display: 'flex', flexDirection: 'column', gap: 2}} + > + <SectionProvider path={path}> + {subSections.map((subSection) => ( + <ErrorBoundary key={subSection.name}> + <SubSection definition={subSection} editable={editable} /> + </ErrorBoundary> + ))} + </SectionProvider> + </CardContent> + </Card> + </ErrorBoundary> + )} + {definition.name !== 'EntryArchive' && quantities.length > 0 && ( + <ErrorBoundary> + <Card> + <CardHeader title='Quantities' /> + <CardContent + sx={{display: 'flex', flexDirection: 'column', gap: 2}} + > + <SectionProvider path={path}> + {quantities.map((quantity) => ( + <ErrorBoundary key={quantity.name}> + <Quantity definition={quantity} editable={editable} /> + </ErrorBoundary> + ))} + </SectionProvider> + </CardContent> + </Card> + </ErrorBoundary> + )} + </> ) } diff --git a/src/components/editor/PropertyEditor.tsx b/src/components/editor/PropertyEditor.tsx index f049bcec..c1823e96 100644 --- a/src/components/editor/PropertyEditor.tsx +++ b/src/components/editor/PropertyEditor.tsx @@ -3,6 +3,7 @@ import {AbstractItem} from '../layout/Layout' export type AbstractProperty = AbstractItem & { property: string label?: string + editable?: boolean } export function getLabel<T extends AbstractProperty>(layout: T) { diff --git a/src/components/editor/SubSectionNavEditor.test.tsx b/src/components/editor/SubSectionNavEditor.test.tsx new file mode 100644 index 00000000..f64dd1d8 --- /dev/null +++ b/src/components/editor/SubSectionNavEditor.test.tsx @@ -0,0 +1,226 @@ +import {Link} from '@mui/material' +import {act, render, screen} from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import {PropsWithChildren} from 'react' +import {vi} from 'vitest' + +import entryRoute from '../../pages/entry/entryRoute' +import {addDefinitions, mockArchive} from '../archive/archive.helper' +import {Layout} from '../layout/Layout' +import {RouteData} from '../routing/types' +import * as useRoute from '../routing/useRoute' +import * as useRouter from '../routing/useRouter' +import {SubSectionNav} from './SubSectionNavEditor' + +describe('SubSectionNavEditor', async () => { + const layout = { + type: 'subSectionNav', + property: 'test_subsection', + editable: true, + } as SubSectionNav + + function mockData(args?: { + repeats?: boolean + withLoading?: boolean + empty?: boolean + definitionsExtra?: Record<string, unknown> + }) { + const { + repeats = false, + withLoading = false, + empty = false, + definitionsExtra, + } = args || {} + const defaultSubSectionData = { + test_quantity: 'test_value', + } + let subSectionData + if (repeats) { + if (empty) { + subSectionData = [] + } else { + subSectionData = [defaultSubSectionData] + } + } else { + if (empty) { + subSectionData = undefined + } else { + subSectionData = defaultSubSectionData + } + } + const data = addDefinitions( + { + ...(subSectionData + ? { + test_subsection: subSectionData, + } + : {}), + }, + definitionsExtra, + ) + return mockArchive({ + data: withLoading ? undefined : data, + }) + } + + const url = vi.fn() + vi.spyOn(useRoute, 'default').mockReturnValue({ + url, + fullMatch: [ + { + path: '/test', + route: {...entryRoute, path: '/test'}, + }, + ], + } as unknown as RouteData) + + const createLink = vi + .fn() + .mockImplementation(({to, ...props}: {to: string} & PropsWithChildren) => ( + <Link href={to} {...props} /> + )) + vi.spyOn(useRouter, 'default').mockReturnValue({ + createLink, + } as unknown as useRouter.RouterValue) + + beforeEach(() => { + url.mockReset() + }) + + it('initially renders with loading indicator', async () => { + mockData({withLoading: true}) + render(<Layout layout={layout} />) + expect(screen.queryByText('test subsection')).not.toBeInTheDocument() + }) + + it('renders sub section', async () => { + mockData() + render(<Layout layout={layout} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + expect(url).toHaveBeenCalledWith({path: '/test/archive/test_subsection'}) + }) + + it('allows to remove non repeating sub section', async () => { + const archive = mockData() + render(<Layout layout={layout} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', {name: 'remove'})) + + expect(archive.changeStack.length).toBe(1) + expect(archive.changeStack[0]).toMatchObject({ + action: 'remove', + new_value: undefined, + path: 'test_subsection', + }) + }) + + it('hides remove button for non repeating sub section if not editable', async () => { + mockData() + render(<Layout layout={{...layout, editable: false}} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + expect( + screen.queryByRole('button', {name: 'remove'}), + ).not.toBeInTheDocument() + }) + + it('allows to add non repeating sub section', async () => { + const archive = mockData({ + empty: true, + definitionsExtra: {test_subsection: {test_quantity: 'test_value'}}, + }) + render(<Layout layout={layout} />) + const addButton = screen.getByRole('button', {name: 'add'}) + expect(addButton).toBeInTheDocument() + await userEvent.click(addButton) + + expect(archive.changeStack.length).toBe(1) + expect(archive.changeStack[0]).toMatchObject({ + action: 'upsert', + new_value: expect.objectContaining({m_def: expect.objectContaining({})}), + path: 'test_subsection', + }) + }) + + it('hides add button for non repeating sub section if not editable', async () => { + mockData({ + empty: true, + definitionsExtra: {test_subsection: {test_quantity: 'test_value'}}, + }) + render(<Layout layout={{...layout, editable: false}} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + expect(screen.queryByRole('button', {name: 'add'})).not.toBeInTheDocument() + }) + + it('renders repeating sub section', async () => { + mockData({repeats: true}) + render(<Layout layout={layout} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + expect(url).not.toHaveBeenCalled() + }) + + it('expands repeating sub section', async () => { + mockData({repeats: true}) + render(<Layout layout={layout} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + await act(async () => { + await userEvent.click(screen.getByText('test subsection')) + }) + expect(screen.getByText('0')).toBeInTheDocument() + expect(url).toHaveBeenCalledWith({path: '/test/archive/test_subsection[0]'}) + }) + + it('allows to remove repeating sub section', async () => { + url.mockReturnValue('/test/archive/test_subsection[0]') + const archive = mockData({repeats: true}) + render(<Layout layout={layout} />) + await act(async () => { + await userEvent.click(screen.getByText('test subsection')) + }) + const removeButton = screen.getByRole('button', {name: 'remove'}) + await userEvent.click(removeButton) + + expect(archive.changeStack.length).toBe(1) + expect(archive.changeStack[0]).toMatchObject({ + action: 'remove', + new_value: {}, + path: 'test_subsection/0', + }) + }) + + it('hides remove button for repeating sub section if not editable', async () => { + mockData({repeats: true}) + render(<Layout layout={{...layout, editable: false}} />) + await act(async () => { + await userEvent.click(screen.getByText('test subsection')) + }) + expect(screen.getByText('0')).toBeInTheDocument() + expect( + screen.queryByRole('button', {name: 'remove'}), + ).not.toBeInTheDocument() + }) + + it('allows to add repeating sub section', async () => { + const archive = mockData({ + empty: true, + repeats: true, + definitionsExtra: {test_subsection: [{test_quantity: 'test_value'}]}, + }) + render(<Layout layout={layout} />) + const addButton = screen.getByRole('button', {name: 'add'}) + await userEvent.click(addButton) + + expect(archive.changeStack.length).toBe(1) + expect(archive.changeStack[0]).toMatchObject({ + action: 'upsert', + new_value: expect.objectContaining({}), + path: 'test_subsection/0', + }) + }) + + it('hides add button for repeating sub section if not editable', async () => { + mockData({empty: true, repeats: true}) + render(<Layout layout={{...layout, editable: false}} />) + expect(screen.getByText('test subsection')).toBeInTheDocument() + expect(screen.queryByRole('button', {name: 'add'})).not.toBeInTheDocument() + }) +}) diff --git a/src/components/editor/SubSectionNavEditor.tsx b/src/components/editor/SubSectionNavEditor.tsx new file mode 100644 index 00000000..cd2a02a8 --- /dev/null +++ b/src/components/editor/SubSectionNavEditor.tsx @@ -0,0 +1,269 @@ +import {Box, IconButton, Skeleton, Typography} from '@mui/material' +import {MouseEventHandler, useCallback, useMemo, useState} from 'react' + +import Link from '../../components/routing/Link' +import {MSectionResponse} from '../../models/graphResponseModels' +import entryRoute from '../../pages/entry/entryRoute' +import {SubSection as SubSectionDefinition} from '../../utils/metainfo' +import {assert, splice} from '../../utils/utils' +import {ArchiveProperty, useArchiveProperty} from '../archive/useArchive' +import useSubSectionPath from '../archive/useSubSectionPath' +import {Add, Remove} from '../icons' +import {LayoutItem} from '../layout/Layout' +import NavItem from '../navigation/NavItem' +import useRoute from '../routing/useRoute' +import {AbstractProperty, getLabel} from './PropertyEditor' + +function SubSectionNavComponent({ + label, + property, + editable, +}: { + label: string + property: ArchiveProperty<SubSectionData, SubSectionDefinition> + editable?: boolean +}) { + const {url, fullMatch} = useRoute() + const {value, getDefinition, change, path} = property + const definition = useMemo(() => getDefinition(), [getDefinition]) + const repeats = definition?.repeats + const entryRouteIndex = fullMatch.findIndex( + (part) => part.route.routeId === entryRoute.routeId, + ) + const archivePath = + fullMatch + .filter((part, index) => index <= entryRouteIndex) + .map((part) => part.path) + .join('/') + '/archive' + + // TODO implement remove and add + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleRemove = useCallback( + (event: React.MouseEvent<HTMLSpanElement>, index?: number) => { + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + event.preventDefault() + if (repeats) { + assert( + index !== undefined, + 'Update operation on repeating sub section without index', + ) + change( + 'remove', + splice((value || []) as MSectionResponse[], index, 1), + index, + ) + } else { + change('remove', undefined) + } + }, + [repeats, change, value], + ) + + // TODO implement remove and add + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const handleAdd = useCallback<MouseEventHandler<HTMLSpanElement>>( + (event) => { + // TODO the definition might not be available. With an improved + // useArchive implementation, it might still be just a references and + // we might still need to explicitly fetch to actual definition. + // I.e., getDefinition() might need to be async. + event.stopPropagation() + event.nativeEvent.stopImmediatePropagation() + event.preventDefault() + const newSubSectionData = { + m_def: getDefinition()?.sub_section, + } as MSectionResponse + if (repeats) { + assert(value === undefined || Array.isArray(value)) + change( + 'upsert', + [...((value || []) as MSectionResponse[]), newSubSectionData], + (value || []).length, + ) + } else { + change('upsert', newSubSectionData) + } + }, + [repeats, change, value, getDefinition], + ) + + const [expanded, setExpanded] = useState(false) + const handleExpand = useCallback(() => { + setExpanded((expanded) => !expanded) + }, []) + + if (repeats) { + assert(value === undefined || Array.isArray(value)) + const subSections = value || [] + return ( + <NavItem + label={label} + variant='wide' + expandable + expanded={expanded} + onToggle={handleExpand} + onSelect={handleExpand} + actions={ + editable && ( + <IconButton + component='span' + size='small' + onClick={handleAdd} + aria-label='add' + title='add' + > + <Add /> + </IconButton> + ) + } + sx={{ + margin: -1, + '& .NavItem-button': { + paddingX: 1.5, + }, + }} + > + {expanded && ( + <Box + sx={{ + display: 'flex', + flexDirection: 'column', + gap: 2, + marginBottom: 1, + }} + > + {subSections.length === 0 ? ( + <Typography + sx={(theme) => ({ + marginLeft: 6, + fontStyle: 'italic', + color: theme.palette.action.disabled, + })} + > + empty + </Typography> + ) : ( + subSections.map((subSection, index) => ( + <NavItem + key={index} + variant='wide' + label={`${index}`} + expandable={false} + expanded={false} + component={Link} + to={url({path: `${archivePath}/${path}[${index}]`})} + actions={ + editable && ( + <IconButton + component='span' + size='small' + onClick={(event) => handleRemove(event, index)} + aria-label='remove' + title='remove' + > + <Remove /> + </IconButton> + ) + } + sx={{ + margin: -1, + '& .NavItem-button': { + paddingLeft: 6, + }, + }} + /> + )) + )} + </Box> + )} + </NavItem> + ) + } + + return ( + <NavItem + disabled={value === undefined} + label={label} + expandable={false} + expanded={false} + component={Link} + to={url({path: `${archivePath}/${path}`})} + variant='wide' + sx={{ + margin: -1, + '& .NavItem-button': { + paddingX: 1.5, + }, + }} + actions={ + editable && + (value !== undefined ? ( + <IconButton + component='span' + size='small' + onClick={handleRemove} + aria-label='remove' + title='remove' + > + <Remove /> + </IconButton> + ) : ( + <IconButton + component='span' + size='small' + onClick={handleAdd} + aria-label='add' + title='add' + sx={{pointerEvents: 'auto'}} + > + <Add /> + </IconButton> + )) + } + /> + ) +} +export type SubSectionNav = AbstractProperty & { + type: 'subSectionNav' +} + +type SubSectionData = MSectionResponse | MSectionResponse[] | undefined + +/** + * Renders all sections in a sub section including actions to modify + * the sub section. Uses SectionEditor to render each sub section. + */ +export default function SubSectionNavEditor({layout}: {layout: LayoutItem}) { + const subSection = layout as SubSectionNav + const {property: propertyName, editable} = subSection + const label = getLabel(subSection) + const path = useSubSectionPath(propertyName) + + const property = useArchiveProperty<SubSectionData, SubSectionDefinition>( + path, + ) + const {loading} = property + + if (loading) { + return ( + <Skeleton> + <NavItem + label='loading' + variant='treeNode' + sx={{ + margin: -1, + }} + /> + </Skeleton> + ) + } + + return ( + <SubSectionNavComponent + label={label} + property={property} + editable={editable} + /> + ) +} diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 5e652928..a6c26317 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -8,6 +8,7 @@ import PlotEditor, {Plot} from '../editor/PlotEditor' import QuantityEditor, {Quantity} from '../editor/QuantityEditor' import RichTextEditor, {RichText} from '../editor/RichTextEditor' import SubSectionEditor, {SubSection} from '../editor/SubSectionEditor' +import SubSectionNavEditor, {SubSectionNav} from '../editor/SubSectionNavEditor' import SubSectionTableEditor, { SubSectionTable, } from '../editor/SubSectionTableEditor' @@ -39,6 +40,7 @@ const components = { element: ElementItem, richText: RichTextEditor, subSection: SubSectionEditor, + subSectionNav: SubSectionNavEditor, table: SubSectionTableEditor, imagePreview: ImagePreviewItem, plot: PlotEditor, @@ -56,6 +58,7 @@ export type LayoutItem = | Element | RichText | SubSection + | SubSectionNav | SubSectionTable | ImagePreview | Plot diff --git a/src/components/navigation/NavItem.tsx b/src/components/navigation/NavItem.tsx index bcc0d44a..9fa70e81 100644 --- a/src/components/navigation/NavItem.tsx +++ b/src/components/navigation/NavItem.tsx @@ -26,19 +26,19 @@ const classes = generateUtilityClasses('NavItem', [ 'badge', 'toggleButton', 'content', + 'actions', ]) const NavItemToggleButton = styled(IconButton, { name: 'NavItem', slot: 'toggleButton', })<{ownerState: NavItemOwnerState}>(({theme, ownerState}) => { - const {expandable, expanded, variant = 'menuItem'} = ownerState + const {expandable, expanded} = ownerState return { - marginY: -theme.spacing(1), - marginLeft: theme.spacing(2), - marginRight: theme.spacing(variant === 'treeNode' ? 1 : 0), - order: variant === 'menuItem' ? 1 : 0, - padding: 0, + // marginY: -theme.spacing(1), + // marginRight: theme.spacing(1), + // marginLeft: theme.spacing(0.5), + // padding: 0, color: theme.palette.action.active, '&.Mui-disabled': { color: expandable ? theme.palette.action.active : 'rgba(0, 0, 0, 0)', @@ -56,7 +56,11 @@ const NavItemToggleButton = styled(IconButton, { } }) as OverridableComponent<IconButtonTypeMap<{ownerState: NavItemOwnerState}>> -const NavItemRoot = styled('div', {name: 'NavItem', slot: 'root'})(() => ({})) +const NavItemRoot = styled('div', {name: 'NavItem', slot: 'root'})(() => ({ + '&:not(:hover) .NavItem-actions': { + display: 'none', + }, +})) const NavItemButton = styled(ButtonBase, { name: 'NavItem', @@ -103,6 +107,7 @@ const NavItemButton = styled(ButtonBase, { const NavItemIcon = styled('span', {name: 'NavItem', slot: 'icon'})( ({theme}) => ({ marginRight: theme.spacing(1), + marginLeft: theme.spacing(2), paddingTop: 2, color: theme.palette.action.active, }), @@ -115,11 +120,10 @@ const NavItemBadge = styled(Badge, {name: 'NavItem', slot: 'badge'})({ const NavItemLabel = styled(Typography, {name: 'NavItem', slot: 'label'})<{ ownerState: NavItemOwnerState }>(({ownerState: {variant = 'menuItem'}}) => ({ - width: '100%', textAlign: 'left', - overflow: 'clip', - textOverflow: 'ellipsis', whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', ...(variant === 'menuItem' && { textTransform: 'uppercase', fontSize: 14, @@ -127,6 +131,12 @@ const NavItemLabel = styled(Typography, {name: 'NavItem', slot: 'label'})<{ }), })) +const NavItemActions = styled('div', {name: 'NavItem', slot: 'actions'})( + () => ({ + whiteSpace: 'nowrap', + }), +) + const NavItemContent = styled(Collapse, {name: 'NavItem', slot: 'content'})( ({theme}) => ({ '& .MuiCollapse-wrapperInner': { @@ -192,10 +202,10 @@ export type NavItemProps = { /** * The variant of the nav item. Default is `menuItem` and is used to produce * a top-level nav item menu. The `treeNode` variant is used to create trees. - * The `treeNode` variant has the expand icon button on the left, the `menuItem` - * on the right. + * The `wide` variant is used to create a navigation items outside the + * navigation panel. */ - variant?: 'menuItem' | 'treeNode' + variant?: 'menuItem' | 'treeNode' | 'wide' /** * The item is disabled. The appreance is different and all actions are * disabled. @@ -205,6 +215,10 @@ export type NavItemProps = { * Optional props to create a MUI badge on the item label. */ badge?: BadgeProps + /** + * Optional actions to display on the right side of the nav item. + */ + actions?: ReactNode } /** @@ -227,6 +241,8 @@ export default function NavItem(props: NavItemProps & SxProps) { children, badge, sx = [], + variant = 'menuItem', + actions, ...buttonBaseProps } = props const handleItemClick = useCallback(() => onSelect?.(), [onSelect]) @@ -248,25 +264,12 @@ export default function NavItem(props: NavItemProps & SxProps) { disabled={!onSelect && !buttonBaseProps.to} {...buttonBaseProps} > - {((expandable && onToggle) || alignWithExpandIcons) && ( - <NavItemToggleButton - data-testid='toggle' - className={classes.toggleButton} - ownerState={props} - size='small' - component='span' - disabled={!expandable} - onClick={handleExpandClick} - > - <ExpandMoreIcon /> - </NavItemToggleButton> - )} {icon && <NavItemIcon className={classes.icon}>{icon}</NavItemIcon>} <Box sx={{ - flexGrow: 1, + flexGrow: variant === 'wide' ? 0 : 1, textAlign: 'left', - width: '100%', + minWidth: 0, }} > <NavItemBadge className={classes.badge} {...badge}> @@ -275,6 +278,36 @@ export default function NavItem(props: NavItemProps & SxProps) { </NavItemLabel> </NavItemBadge> </Box> + {((expandable && onToggle) || alignWithExpandIcons || actions) && ( + <Box + sx={(theme) => ({ + marginY: -theme.spacing(1), + marginRight: theme.spacing(1), + marginLeft: theme.spacing(0.5), + padding: 0, + display: 'flex', + flexDirection: 'row', + whiteSpace: 'nowrap', + })} + > + {((expandable && onToggle) || alignWithExpandIcons) && ( + <NavItemToggleButton + data-testid='toggle' + className={classes.toggleButton} + ownerState={props} + size='small' + component='span' + disabled={!expandable} + onClick={handleExpandClick} + > + <ExpandMoreIcon /> + </NavItemToggleButton> + )} + <NavItemActions className={classes.actions}> + {actions} + </NavItemActions> + </Box> + )} </NavItemButton> {expandable && children && ( <NavItemContent diff --git a/src/components/navigation/TreeNav.tsx b/src/components/navigation/TreeNav.tsx index 6885abbf..3423b5cc 100644 --- a/src/components/navigation/TreeNav.tsx +++ b/src/components/navigation/TreeNav.tsx @@ -76,6 +76,12 @@ export type TreeNavProps = { depth?: number expandable?: boolean | ((data: JSONObject) => boolean) + /** + * Selectable tree items will cause a navigation to the path of the item. + * None selectable tree items will only expand and collapse. + * Default is true. + */ + selectable?: boolean } & Partial<Omit<NavItemProps, 'expandable'>> /** @@ -102,6 +108,7 @@ export default function TreeNav({ onToggle, defaultExpanded, expandable: isExpandable = false, + selectable = true, ...navItemProps }: TreeNavProps) { const expandedIsControlled = controlledExpanded !== undefined @@ -257,7 +264,7 @@ export default function TreeNav({ selected={renderSelected} highlighted={selected} onToggle={handleToggle} - onSelect={handleSelect} + onSelect={selectable ? handleSelect : handleToggle} expandable={expandable} label={key} sx={{ diff --git a/src/components/routing/normalizeRoute.ts b/src/components/routing/normalizeRoute.ts index ac9e7d7c..4fe9d550 100644 --- a/src/components/routing/normalizeRoute.ts +++ b/src/components/routing/normalizeRoute.ts @@ -1,6 +1,7 @@ import {DefaultSearch, Route} from './types' const normalizedRoutes = new Map<Route, Route>() +const routeIds = {currentId: 1} export function routesEqual<Request, Response, Search extends DefaultSearch>( a: Route<Request, Response, Search>, @@ -56,6 +57,10 @@ export function routesEqual<Request, Response, Search extends DefaultSearch>( } export default function normalizeRoute(route: Route): Route { + if (route.routeId === undefined) { + route.routeId = routeIds.currentId++ + } + if (normalizedRoutes.has(route)) { return normalizedRoutes.get(route) as Route } diff --git a/src/pages/entry/EntryArchiveNav.tsx b/src/pages/entry/EntryArchiveNav.tsx index 9daa3e3f..35e4c635 100644 --- a/src/pages/entry/EntryArchiveNav.tsx +++ b/src/pages/entry/EntryArchiveNav.tsx @@ -136,6 +136,7 @@ export default function EntryArchiveNav({ return { ...sharedProps, expandable: true, + selectable: false, getChildProps: (data: JSONObject, key: string, path: string) => ({ ...sharedProps, diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index be4f5dae..9def0a76 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -17,7 +17,7 @@ import UploadMetadataEditor from '../upload/UploadMetadataEditor' import EntryDataEditor from './EntryDataEditor' import EntryMetadataEditor from './EntryMetadataEditor' import EntryPageTitle from './EntryPageTitle' -import EntrySubSectionTable from './EntrySubSectionTable' +import EntrySection from './EntrySection' import entryRoute, {archiveRequest, useEntryDataForRoute} from './entryRoute' function EntryOverviewEditor() { @@ -121,7 +121,7 @@ export default function EntryOverview() { const {isPage, isSelect} = useSelect() // This memo is an optimization to avoid re-rendering the entire - // EntryOverviewEditor after usePage causes a srcoll event triggered + // EntryOverviewEditor after usePage causes a scroll event triggered // rerender that only effects the page title. const pageContent = useMemo( () => ( @@ -133,10 +133,10 @@ export default function EntryOverview() { > <EntryOverviewEditor /> </Box> - {isSelect && <EntrySubSectionTable data={rootSectionData} />} + {isSelect && <EntrySection />} </> ), - [isSelect, rootSectionData], + [isSelect], ) return ( diff --git a/src/pages/entry/EntrySection.tsx b/src/pages/entry/EntrySection.tsx index 5c7818fb..e551076b 100644 --- a/src/pages/entry/EntrySection.tsx +++ b/src/pages/entry/EntrySection.tsx @@ -1,7 +1,6 @@ import {Box, Card, CardContent, CardHeader} from '@mui/material' import {useMemo} from 'react' -import ErrorBoundary from '../../components/ErrorBoundary' import Section from '../../components/archive/Section' import JsonViewer from '../../components/fileviewer/JsonViewer' import Outlet from '../../components/routing/Outlet' @@ -11,7 +10,6 @@ import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {JSONObject} from '../../utils/types' import EntryPageTitle from './EntryPageTitle' -import EntrySubSectionTable from './EntrySubSectionTable' const sortRawDataKeys = (a: string, b: string) => { if (a.startsWith('m_') === b.startsWith('m_')) { @@ -26,35 +24,36 @@ const sortRawDataKeys = (a: string, b: string) => { export default function EntrySection() { const {isLeaf, fullMatch} = useRoute() const data = useRouteData<MSectionRequest, MSectionResponse>() + const startIndex = useMemo( + () => fullMatch.findIndex((match) => match.route.path === 'archive'), + [fullMatch], + ) const sectionPath = useMemo(() => { if (!isLeaf) { return '' } - const startIndex = fullMatch.findIndex( - (match) => match.route.path === 'archive', - ) return fullMatch .slice(startIndex + 1) .map((match) => match.path) .join('/') - }, [fullMatch, isLeaf]) + }, [fullMatch, isLeaf, startIndex]) if (!isLeaf) { return <Outlet /> } + const editable = true // TODO This needs to be set based on the user's permissions + + const title = + fullMatch.length === startIndex + 1 + ? 'Entry Data' + : `Section ${fullMatch[fullMatch.length - 1].path}` + return ( <> - <EntryPageTitle title={fullMatch[fullMatch.length - 1].path} /> + <EntryPageTitle title={title} /> <Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}> - <ErrorBoundary> - <EntrySubSectionTable data={data} /> - </ErrorBoundary> - <ErrorBoundary> - {data.m_def && data.m_def.name !== 'EntryArchive' && ( - <Section path={sectionPath} editable /> - )} - </ErrorBoundary> + <Section path={sectionPath} editable={editable} /> <Card> <CardHeader title='Raw data' /> <CardContent> diff --git a/src/pages/entry/EntrySubSectionTable.tsx b/src/pages/entry/EntrySubSectionTable.tsx deleted file mode 100644 index 3f183afd..00000000 --- a/src/pages/entry/EntrySubSectionTable.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import {useMemo} from 'react' - -import useRoute from '../../components/routing/useRoute' -import PlainDataTable from '../../components/table/PlainDataTable' -import {MSectionResponse} from '../../models/graphResponseModels' -import { - Section as SectionDefinition, - SubSection as SubSectionDefinition, -} from '../../utils/metainfo' - -const subSectionColumns = [ - { - enableSorting: true, - accessorKey: 'name', - header: 'Name', - grow: 1, - }, -] - -export type EntrySubSectionTableProps = { - data: MSectionResponse -} - -export default function EntrySubSectionTable({ - data, -}: EntrySubSectionTableProps) { - const {navigate} = useRoute() - - const definition = data.m_def as SectionDefinition - const subSections = useMemo(() => { - return Object.keys(definition.all_sub_sections) - .filter((key) => data[key] !== undefined) - .map((key) => definition.all_sub_sections[key]) - .toSorted((a, b) => a.name.localeCompare(b.name)) - }, [data, definition]) - - if (subSections.length === 0) { - return null - } - - return ( - <PlainDataTable - onRowClick={(data: SubSectionDefinition) => { - navigate({ - path: data.name, - }) - }} - columns={subSectionColumns} - data={subSections} - /> - ) -} -- GitLab From 1d818a1fd9ba2bb411430510ae4ecc7bc3d74096 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 12 Mar 2025 12:22:10 +0100 Subject: [PATCH 11/17] Fixed that EntrySection reacts to editability. Fixed issues related to appEntryRoute. --- infra/README.md | 4 +- infra/pyproject.toml | 6 +- src/components/archive/EditStatus.tsx | 5 +- src/components/archive/useArchive.tsx | 5 +- src/components/editor/SubSectionNavEditor.tsx | 15 ++++- src/components/routing/useRoute.tsx | 14 ++--- src/components/routing/useRouteData.tsx | 18 ++++-- .../values/primitives/FileReference.tsx | 5 +- .../values/primitives/SectionReference.tsx | 5 +- src/hooks/useViewWriteAbilities.tsx | 61 +++++++++++++++++++ src/index.tsx | 35 ++++++----- src/pages/apps/appsRoute.tsx | 7 +++ src/pages/entry/EntryMetadataEditor.tsx | 5 +- src/pages/entry/EntryNavAbout.tsx | 6 +- src/pages/entry/EntryOverview.tsx | 8 +-- src/pages/entry/EntryPage.test.tsx | 43 +++++++------ src/pages/entry/EntryPage.tsx | 4 +- src/pages/entry/EntryPageTitle.tsx | 5 +- src/pages/entry/EntrySection.tsx | 16 ++++- src/pages/entry/entryRoute.tsx | 1 + src/pages/entry/useEntryRouteData.tsx | 7 +++ src/pages/groups/GroupsTable.test.tsx | 2 + .../upload/UploadMetadataEditor.test.tsx | 24 +++++--- src/pages/upload/UploadMetadataEditor.tsx | 9 ++- src/pages/upload/UploadOverview.tsx | 10 ++- src/pages/upload/uploadRoute.tsx | 25 +++++--- src/pages/uploads/UploadsTable.test.tsx | 2 + 27 files changed, 240 insertions(+), 107 deletions(-) create mode 100644 src/hooks/useViewWriteAbilities.tsx create mode 100644 src/pages/entry/useEntryRouteData.tsx diff --git a/infra/README.md b/infra/README.md index 547c72d1..ad24c8e2 100644 --- a/infra/README.md +++ b/infra/README.md @@ -10,8 +10,8 @@ This is how you run NOMAD, upload some data, and start using the GUI with it: From the gui project root folder: ```sh -python3.11 -m venv .pyenv -source .pyenv/bin/activate +python3.11 -m venv .venv +source .venv/bin/activate pip install uv uv pip install --upgrade pip uv pip install -e infra \ diff --git a/infra/pyproject.toml b/infra/pyproject.toml index 15b2168d..3508134f 100644 --- a/infra/pyproject.toml +++ b/infra/pyproject.toml @@ -6,11 +6,11 @@ build-backend = "setuptools.build_meta" name = "nomad-plugin-gui" description = "The NOMAD plugin for the new NOMAD GUI." authors = [ - { name = "NOMAD Laboratory", email = 'markus.scheidgen@physik.hu-berlin.de' }, + { name = "NOMAD Laboratory", email = 'markus.scheidgen@physik.hu-berlin.de' }, ] dynamic = ["version"] requires-python = ">=3.9" -dependencies = ["nomad-lab[parsing,infrastructure]>=1.3.4"] +dependencies = ["nomad-lab[parsing,infrastructure]==1.3.13"] [project.optional-dependencies] dev = ["pytest"] @@ -69,4 +69,4 @@ gui_api = "nomad_plugin_gui.apis:gui_api" demo_schema = "nomad_plugin_gui.schema_packages:demo_schema" values_test_schema = "nomad_plugin_gui.schema_packages:values_test_schema" excercise_schema = "nomad_plugin_gui.schema_packages:exercise_schema" -ui_demonstration_example_upload = "nomad_plugin_gui.example_uploads:ui_demonstration_example_upload" \ No newline at end of file +ui_demonstration_example_upload = "nomad_plugin_gui.example_uploads:ui_demonstration_example_upload" diff --git a/src/components/archive/EditStatus.tsx b/src/components/archive/EditStatus.tsx index 73eaaf5a..fee1de21 100644 --- a/src/components/archive/EditStatus.tsx +++ b/src/components/archive/EditStatus.tsx @@ -4,17 +4,16 @@ import {useAsyncFn} from 'react-use' import {Redo, Undo} from '../../components/icons' import useAuth from '../../hooks/useAuth' import {MSectionResponse} from '../../models/graphResponseModels' -import entryRoute from '../../pages/entry/entryRoute' +import useEntryRouteData from '../../pages/entry/useEntryRouteData' import {archiveEditApi} from '../../utils/api' import {assert} from '../../utils/utils' import useRoute from '../routing/useRoute' -import useRouteData from '../routing/useRouteData' import useArchive, {useArchiveChanges} from './useArchive' export default function EditStatus() { const {user} = useAuth() const {reload} = useRoute() - const {entry_id} = useRouteData(entryRoute) + const {entry_id} = useEntryRouteData() const {changeStack, clearChangeStack} = useArchiveChanges() const archive = useArchive() diff --git a/src/components/archive/useArchive.tsx b/src/components/archive/useArchive.tsx index 9cb63302..09bab42a 100644 --- a/src/components/archive/useArchive.tsx +++ b/src/components/archive/useArchive.tsx @@ -11,11 +11,10 @@ import { MSectionResponse, UploadResponse, } from '../../models/graphResponseModels' -import entryRoute from '../../pages/entry/entryRoute' +import useEntryRouteData from '../../pages/entry/useEntryRouteData' import {Property, Section, resolveRef} from '../../utils/metainfo' import {assert} from '../../utils/utils' import {getIndexedKey} from '../routing/loader' -import useRouteData from '../routing/useRouteData' export type ArchiveChangeConflict = { type: 'parent' | 'child' | 'exact' @@ -771,7 +770,7 @@ function registerArchivesChangeListener(listener: () => void) { * or `useChangedArchives` to react to changes. */ export default function useArchive() { - const {entry_id, mainfile_path, upload_id} = useRouteData(entryRoute) + const {entry_id, mainfile_path, upload_id} = useEntryRouteData() assert(entry_id, 'UseArchive only works within an entry route.') return getArchive(entry_id, {entry_id, upload_id, mainfile_path}) } diff --git a/src/components/editor/SubSectionNavEditor.tsx b/src/components/editor/SubSectionNavEditor.tsx index cd2a02a8..40709f82 100644 --- a/src/components/editor/SubSectionNavEditor.tsx +++ b/src/components/editor/SubSectionNavEditor.tsx @@ -3,6 +3,7 @@ import {MouseEventHandler, useCallback, useMemo, useState} from 'react' import Link from '../../components/routing/Link' import {MSectionResponse} from '../../models/graphResponseModels' +import {appEntryRoute} from '../../pages/apps/appsRoute' import entryRoute from '../../pages/entry/entryRoute' import {SubSection as SubSectionDefinition} from '../../utils/metainfo' import {assert, splice} from '../../utils/utils' @@ -27,9 +28,17 @@ function SubSectionNavComponent({ const {value, getDefinition, change, path} = property const definition = useMemo(() => getDefinition(), [getDefinition]) const repeats = definition?.repeats - const entryRouteIndex = fullMatch.findIndex( - (part) => part.route.routeId === entryRoute.routeId, - ) + + const entryRouteIndex = useMemo(() => { + const entryRouteIndex = fullMatch.findIndex( + (part) => part.route.routeId === entryRoute.routeId, + ) + const appEntryRouteIndex = fullMatch.findIndex( + (part) => part.route.routeId === appEntryRoute.routeId, + ) + return entryRouteIndex === -1 ? appEntryRouteIndex : entryRouteIndex + }, [fullMatch]) + const archivePath = fullMatch .filter((part, index) => index <= entryRouteIndex) diff --git a/src/components/routing/useRoute.tsx b/src/components/routing/useRoute.tsx index 1e8c92ad..d9f29782 100644 --- a/src/components/routing/useRoute.tsx +++ b/src/components/routing/useRoute.tsx @@ -1,6 +1,5 @@ import {useMemo} from 'react' -import {routesEqual} from './normalizeRoute' import {DefaultSearch, Route, RouteData} from './types' import useRouteContext from './useRouteContext' @@ -9,7 +8,7 @@ export default function useRoute< Response = unknown, Search extends DefaultSearch = DefaultSearch, >( - route?: Route<Request, Response, Search>, + ...route: Route<Request, Response, Search>[] ): RouteData<Request, Response, Search> { const routeContext = useRouteContext() return useMemo(() => { @@ -17,12 +16,8 @@ export default function useRoute< if (route) { const {fullMatch} = globalRouteData for (let i = index; i >= 0; i--) { - if ( - routesEqual( - route, - fullMatch[i].route as unknown as Route<Request, Response, Search>, - ) - ) { + const matchRouteId = fullMatch[i].route.routeId + if (route.find((r) => r.routeId === matchRouteId)) { return createRouteData(globalRouteData, i) as unknown as RouteData< Request, Response, @@ -36,5 +31,6 @@ export default function useRoute< Response, Search > - }, [route, routeContext]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [...route, routeContext]) } diff --git a/src/components/routing/useRouteData.tsx b/src/components/routing/useRouteData.tsx index f2059cf3..a4f9300a 100644 --- a/src/components/routing/useRouteData.tsx +++ b/src/components/routing/useRouteData.tsx @@ -6,9 +6,17 @@ export function useAvailableRouteData< Response = unknown, Search extends DefaultSearch = DefaultSearch, // eslint-disable-next-line @typescript-eslint/no-unused-vars ->(route?: Route<Request, Response, Search>) { - const {response} = useRoute<Request, Response, Search>() - return response as Response | undefined +>(...route: Route<Request, Response, Search>[]) { + const {route: usedRoute, response} = useRoute<Request, Response, Search>( + ...route, + ) + if ( + (route.length > 0 && !route.find((r) => r.routeId === usedRoute.routeId)) || + !response + ) { + return undefined + } + return response as Response } export default function useRouteData< @@ -16,8 +24,8 @@ export default function useRouteData< Response = unknown, Search extends DefaultSearch = DefaultSearch, // eslint-disable-next-line @typescript-eslint/no-unused-vars ->(route?: Route<Request, Response, Search>) { - const {response} = useRoute<Request, Response, Search>(route) +>(...route: Route<Request, Response, Search>[]) { + const response = useAvailableRouteData(...route) if (!response) { throw new Error( 'Route data should be available. This indicates a bug in <Routes/>.', diff --git a/src/components/values/primitives/FileReference.tsx b/src/components/values/primitives/FileReference.tsx index d17b859a..efae8bdd 100644 --- a/src/components/values/primitives/FileReference.tsx +++ b/src/components/values/primitives/FileReference.tsx @@ -1,6 +1,5 @@ -import entryRoute from '../../../pages/entry/entryRoute' +import useEntryRouteData from '../../../pages/entry/useEntryRouteData' import {FileSelectOptions} from '../../navigation/useSelect' -import useRouteData from '../../routing/useRouteData' import Reference, {DynamicReferenceProps, ReferenceProps} from './Reference' export type FileReferenceProps = ReferenceProps & @@ -16,7 +15,7 @@ export default function FileReference({ filePattern, ...referenceProps }: FileReferenceProps) { - const {upload_id, mainfile_path} = useRouteData(entryRoute) + const {upload_id, mainfile_path} = useEntryRouteData() const selectOptions = { entity: 'file', diff --git a/src/components/values/primitives/SectionReference.tsx b/src/components/values/primitives/SectionReference.tsx index 2a49b8f7..b552a7d6 100644 --- a/src/components/values/primitives/SectionReference.tsx +++ b/src/components/values/primitives/SectionReference.tsx @@ -1,6 +1,5 @@ -import entryRoute from '../../../pages/entry/entryRoute' +import useEntryRouteData from '../../../pages/entry/useEntryRouteData' import {SectionSelectOptions} from '../../navigation/useSelect' -import useRouteData from '../../routing/useRouteData' import Reference, {DynamicReferenceProps, ReferenceProps} from './Reference' export type SectionReferenceProps = ReferenceProps & @@ -16,7 +15,7 @@ export default function SectionReference({ sectionDefinition, ...referenceProps }: SectionReferenceProps) { - const {upload_id, entry_id} = useRouteData(entryRoute) + const {upload_id, entry_id} = useEntryRouteData() const selectOptions = { entity: 'section', diff --git a/src/hooks/useViewWriteAbilities.tsx b/src/hooks/useViewWriteAbilities.tsx new file mode 100644 index 00000000..6af7cfde --- /dev/null +++ b/src/hooks/useViewWriteAbilities.tsx @@ -0,0 +1,61 @@ +import {useAvailableRouteData} from '../components/routing/useRouteData' +import {appEntryRoute} from '../pages/apps/appsRoute' +import entryRoute from '../pages/entry/entryRoute' +import uploadRoute from '../pages/upload/uploadRoute' +import {assert} from '../utils/utils' +import useAuth from './useAuth' + +/** + * A hook that provides information about the current user's abilities + * to read and write data. + * + * Considers upload authors, reviewers, and respective groups, publish, and + * processing status. Works in the context of upload page, entry page ( + * within upload and search routes). + */ +export default function useViewWriteAbilities() { + const {user} = useAuth() + + const uploadResponse = useAvailableRouteData(uploadRoute) || {} + const entryResponse = useAvailableRouteData(entryRoute) || {} + const appEntryResponse = useAvailableRouteData(appEntryRoute) || { + metadata: {}, + } + const data = { + ...uploadResponse, + ...entryResponse, + ...appEntryResponse.metadata, + } + const { + viewers, + viewer_groups, + writers, + writer_groups, + process_running, + published, + with_embargo, + } = data + + if (!(writers && writer_groups && viewers && viewer_groups)) { + return { + canRead: false, + canWrite: false, + } + } + assert( + published !== undefined && with_embargo !== undefined, + 'published data missing in route data', + ) + const userId = user?.profile?.sub + return { + canRead: + (published && !with_embargo) || + viewers.map((u) => u.user_id).includes(userId), + // TODO viewer groups + canWrite: + !process_running && + !published && + writers.map((u) => u.user_id).includes(userId), + // TODO writer groups + } +} diff --git a/src/index.tsx b/src/index.tsx index 37115408..58bde89a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,7 +2,6 @@ import '@fontsource/titillium-web/400.css' import '@fontsource/titillium-web/700.css' import {LocalizationProvider} from '@mui/x-date-pickers' import {AdapterDateFns} from '@mui/x-date-pickers/AdapterDateFnsV3' -import React from 'react' import ReactDOM from 'react-dom/client' import {RecoilRoot} from 'recoil' @@ -45,23 +44,23 @@ enableMocking().then(() => { document.getElementById('root') as HTMLElement, ) root.render( - <React.StrictMode> - <LocalizationProvider dateAdapter={AdapterDateFns}> - <RecoilRoot> - <Theme> - <AuthProvider> - <ApiProvider> - <UnitProvider> - <DevTools enabled> - <Routes /> - </DevTools> - </UnitProvider> - </ApiProvider> - </AuthProvider> - </Theme> - </RecoilRoot> - </LocalizationProvider> - </React.StrictMode>, + // <React.StrictMode> + <LocalizationProvider dateAdapter={AdapterDateFns}> + <RecoilRoot> + <Theme> + <AuthProvider> + <ApiProvider> + <UnitProvider> + <DevTools enabled> + <Routes /> + </DevTools> + </UnitProvider> + </ApiProvider> + </AuthProvider> + </Theme> + </RecoilRoot> + </LocalizationProvider>, + // </React.StrictMode>, ) }) diff --git a/src/pages/apps/appsRoute.tsx b/src/pages/apps/appsRoute.tsx index a7e9243e..9bacaac2 100644 --- a/src/pages/apps/appsRoute.tsx +++ b/src/pages/apps/appsRoute.tsx @@ -17,11 +17,18 @@ export const appEntryRoute: Route<EntryRequest, EntryResponse> = { process_status: '*', entry_create_time: '*', complete_time: '*', + parser_name: '*', metadata: { entry_name: '*', references: '*', authors: '*', datasets: '*', + writers: '*', + writer_groups: '*', + viewers: '*', + viewer_groups: '*', + published: '*', + with_embargo: '*', } as EntryMetadataRequest, archive: {...archiveRequest}, }, diff --git a/src/pages/entry/EntryMetadataEditor.tsx b/src/pages/entry/EntryMetadataEditor.tsx index 376d5d53..676b41e1 100644 --- a/src/pages/entry/EntryMetadataEditor.tsx +++ b/src/pages/entry/EntryMetadataEditor.tsx @@ -2,12 +2,11 @@ import {useMemo} from 'react' import ProcessStatus from '../../components/ProcessStatus' import {Layout, LayoutItem} from '../../components/layout/Layout' -import useRouteData from '../../components/routing/useRouteData' import List from '../../components/values/containers/List' import Text from '../../components/values/primitives/Text' import {EntryResponse} from '../../models/graphResponseModels' import {assert} from '../../utils/utils' -import entryRoute from './entryRoute' +import useEntryRouteData from './useEntryRouteData' function DatasetList({data}: {data: EntryResponse}) { // TODO We should remove the "|| []" at some point. This is only there, @@ -39,7 +38,7 @@ export default function EntryMetadataEditor({ editable = false, expandedByDefault = false, }: EntryMetadataEditorProps) { - const data = useRouteData(entryRoute) as EntryResponse + const data = useEntryRouteData() const layout = useMemo( () => ({ diff --git a/src/pages/entry/EntryNavAbout.tsx b/src/pages/entry/EntryNavAbout.tsx index 9eb9bb31..f807be2f 100644 --- a/src/pages/entry/EntryNavAbout.tsx +++ b/src/pages/entry/EntryNavAbout.tsx @@ -1,14 +1,12 @@ import ProcessStatus from '../../components/ProcessStatus' import NavAbout from '../../components/navigation/NavAbout' -import useRouteData from '../../components/routing/useRouteData' import CopyToClipboard from '../../components/values/actions/CopyToClipboard' import Value from '../../components/values/containers/Value' import Id from '../../components/values/primitives/Id' -import {EntryRequest} from '../../models/graphRequestModels' -import {EntryResponse} from '../../models/graphResponseModels' +import useEntryRouteData from './useEntryRouteData' export default function EntryNavAbout() { - const entry = useRouteData<EntryRequest, EntryResponse>() + const entry = useEntryRouteData() const {mainfile_path, entry_id} = entry return ( <NavAbout label='entry' title={mainfile_path as string}> diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index 9def0a76..ef80ae65 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -8,7 +8,6 @@ import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' import useSelect from '../../components/navigation/useSelect' import useRoute from '../../components/routing/useRoute' -import useRouteData from '../../components/routing/useRouteData' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {Section} from '../../utils/metainfo' @@ -18,10 +17,11 @@ import EntryDataEditor from './EntryDataEditor' import EntryMetadataEditor from './EntryMetadataEditor' import EntryPageTitle from './EntryPageTitle' import EntrySection from './EntrySection' -import entryRoute, {archiveRequest, useEntryDataForRoute} from './entryRoute' +import {archiveRequest, useEntryDataForRoute} from './entryRoute' +import useEntryRouteData from './useEntryRouteData' function EntryOverviewEditor() { - const {archive: archiveData} = useRouteData(entryRoute) + const {archive: archiveData} = useEntryRouteData() const {reloadCount} = useRoute() const [error, setError] = useState<Error | undefined>() const {isPage} = useSelect() @@ -112,7 +112,7 @@ function EntryOverviewEditor() { } export default function EntryOverview() { - const {archive: rootSectionData} = useRouteData(entryRoute) + const {archive: rootSectionData} = useEntryRouteData() assert( rootSectionData !== undefined, 'An entry should always have a root section', diff --git a/src/pages/entry/EntryPage.test.tsx b/src/pages/entry/EntryPage.test.tsx index 9201f47b..4143a490 100644 --- a/src/pages/entry/EntryPage.test.tsx +++ b/src/pages/entry/EntryPage.test.tsx @@ -3,20 +3,20 @@ import {expect, it, vi} from 'vitest' import {addDefinitions} from '../../components/archive/archive.helper' import {getArchive} from '../../components/archive/useArchive' -import {GraphResponse} from '../../models/graphResponseModels' +import {Route} from '../../components/routing/types' import * as api from '../../utils/api' import { importLazyComponents, renderWithRouteData, } from '../../utils/test.helper' -import entryRoute from './entryRoute' +import uploadsRoute from '../uploads/uploadsRoute' -await importLazyComponents(entryRoute) +await importLazyComponents(uploadsRoute) describe('EntryPage', () => { it('loads and initially renders', async () => { const mockedApi = vi.spyOn(api, 'graphApi') - window.history.replaceState(null, '', '/entryId') + window.history.replaceState(null, '', 'uploads/uploadId/entries/entryId') const metadata = { entry_name: 'entry-name', @@ -24,22 +24,29 @@ describe('EntryPage', () => { } mockedApi.mockResolvedValue({ - entryId: { - entry_id: 'entryId', - mainfile_path: 'entry.archive.json', - metadata: { - ...metadata, - datasets: [], - }, - archive: addDefinitions({ - metadata, - material: { - name: 'gold', + uploads: { + uploadId: { + upload_id: 'uploadId', + entries: { + entryId: { + entry_id: 'entryId', + mainfile_path: 'entry.archive.json', + metadata: { + ...metadata, + datasets: [], + }, + archive: addDefinitions({ + metadata, + material: { + name: 'gold', + }, + }), + }, }, - }), + }, }, - } as GraphResponse) - await renderWithRouteData(entryRoute) + }) + await renderWithRouteData(uploadsRoute as unknown as Route) await waitFor(() => expect(screen.getAllByText(/entry.archive.json/)).toHaveLength(3), ) diff --git a/src/pages/entry/EntryPage.tsx b/src/pages/entry/EntryPage.tsx index f1597de2..cb25ed95 100644 --- a/src/pages/entry/EntryPage.tsx +++ b/src/pages/entry/EntryPage.tsx @@ -18,13 +18,13 @@ import UploadNavAbout from '../upload/UploadNavAbout' import uploadRoute from '../upload/uploadRoute' import EntryArchiveNav from './EntryArchiveNav' import EntryNavItem from './EntryNavAbout' -import entryRoute from './entryRoute' +import useEntryRouteData from './useEntryRouteData' export default function EntryPage() { const {url, fullMatch, index} = useRoute() const nextPath = fullMatch[index + 1]?.path const tab = nextPath === '' ? 'overview' : nextPath - const {entry_id, mainfile_path} = useRouteData(entryRoute) + const {entry_id, mainfile_path} = useEntryRouteData() const {upload_id} = useRouteData(uploadRoute) const {isPage, pageVariant} = useSelect() diff --git a/src/pages/entry/EntryPageTitle.tsx b/src/pages/entry/EntryPageTitle.tsx index 9057baab..f6da367f 100644 --- a/src/pages/entry/EntryPageTitle.tsx +++ b/src/pages/entry/EntryPageTitle.tsx @@ -6,9 +6,8 @@ import {PageTitle, PageTitleProps} from '../../components/page/Page' import usePage from '../../components/page/usePage' import Link from '../../components/routing/Link' import useRoute from '../../components/routing/useRoute' -import useRouteData from '../../components/routing/useRouteData' import {assert} from '../../utils/utils' -import entryRoute from './entryRoute' +import useEntryRouteData from './useEntryRouteData' export default function EntryPageTitle(props: PageTitleProps) { const {url} = useRoute() @@ -16,7 +15,7 @@ export default function EntryPageTitle(props: PageTitleProps) { upload_id, mainfile_path, archive: rootSectionData, - } = useRouteData(entryRoute) + } = useEntryRouteData() assert( rootSectionData !== undefined, 'An entry should always have a root section', diff --git a/src/pages/entry/EntrySection.tsx b/src/pages/entry/EntrySection.tsx index e551076b..1d4ebd61 100644 --- a/src/pages/entry/EntrySection.tsx +++ b/src/pages/entry/EntrySection.tsx @@ -3,13 +3,16 @@ import {useMemo} from 'react' import Section from '../../components/archive/Section' import JsonViewer from '../../components/fileviewer/JsonViewer' +import useSelect from '../../components/navigation/useSelect' import Outlet from '../../components/routing/Outlet' import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' +import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {JSONObject} from '../../utils/types' import EntryPageTitle from './EntryPageTitle' +import useEntryRouteData from './useEntryRouteData' const sortRawDataKeys = (a: string, b: string) => { if (a.startsWith('m_') === b.startsWith('m_')) { @@ -21,9 +24,15 @@ const sortRawDataKeys = (a: string, b: string) => { return 1 } +const nonEditableSections = ['results', 'metadata'] + export default function EntrySection() { const {isLeaf, fullMatch} = useRoute() const data = useRouteData<MSectionRequest, MSectionResponse>() + const {parser_name} = useEntryRouteData() + const {canWrite} = useViewWriteAbilities() + const {isPage} = useSelect() + const startIndex = useMemo( () => fullMatch.findIndex((match) => match.route.path === 'archive'), [fullMatch], @@ -42,7 +51,12 @@ export default function EntrySection() { return <Outlet /> } - const editable = true // TODO This needs to be set based on the user's permissions + const editable = + canWrite && + isPage && + sectionPath !== '' && + !nonEditableSections.find((s) => sectionPath.startsWith(s)) && + parser_name === 'parsers/archive' const title = fullMatch.length === startIndex + 1 diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index b910f90e..2a09ceca 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -130,6 +130,7 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { process_status: '*', entry_create_time: '*', complete_time: '*', + parser_name: '*', metadata: { entry_name: '*', references: '*', diff --git a/src/pages/entry/useEntryRouteData.tsx b/src/pages/entry/useEntryRouteData.tsx new file mode 100644 index 00000000..73e76423 --- /dev/null +++ b/src/pages/entry/useEntryRouteData.tsx @@ -0,0 +1,7 @@ +import useRouteData from '../../components/routing/useRouteData' +import {appEntryRoute} from '../apps/appsRoute' +import entryRoute from './entryRoute' + +export default function useEntryRouteData() { + return useRouteData(entryRoute, appEntryRoute) +} diff --git a/src/pages/groups/GroupsTable.test.tsx b/src/pages/groups/GroupsTable.test.tsx index e1432ef6..799dbbd6 100644 --- a/src/pages/groups/GroupsTable.test.tsx +++ b/src/pages/groups/GroupsTable.test.tsx @@ -2,6 +2,7 @@ import {render, screen, within} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' import GroupsTable from './GroupsTable' +import groupsRoute from './groupsRoute' const setPaginate = vi.fn() const navigate = vi.fn() @@ -22,6 +23,7 @@ vi.mock('../../components/routing/usePagination', () => ({ vi.mock('../../components/routing/useRoute', () => ({ default: () => ({ navigate, + route: groupsRoute, search: {}, url: () => '/', response: Array.from({length: 12}, (_, index) => ({ diff --git a/src/pages/upload/UploadMetadataEditor.test.tsx b/src/pages/upload/UploadMetadataEditor.test.tsx index 50d99e15..8ba93cd2 100644 --- a/src/pages/upload/UploadMetadataEditor.test.tsx +++ b/src/pages/upload/UploadMetadataEditor.test.tsx @@ -2,19 +2,20 @@ import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {vi} from 'vitest' +import * as useRouteData from '../../components/routing/useRouteData' import {UploadResponse} from '../../models/graphResponseModels' import UploadMetadataEditor from './UploadMetadataEditor' describe('UploadMetadataEditor', () => { + const useAvailableRouteData = vi.spyOn(useRouteData, 'useAvailableRouteData') + it('renders with name and id', async () => { - vi.mock('../../components/routing/useRouteData', () => ({ - default: vi.fn().mockReturnValue({ - upload_id: 'test-id', - upload_name: 'test-name', - main_author: {name: 'test-main-author'}, - coauthors: [{name: 'test-coauthor'}], - } as UploadResponse), - })) + useAvailableRouteData.mockReturnValue({ + upload_id: 'test-id', + upload_name: 'test-name', + main_author: {name: 'test-main-author'}, + coauthors: [{name: 'test-coauthor'}], + } as UploadResponse) render(<UploadMetadataEditor />) @@ -24,4 +25,11 @@ describe('UploadMetadataEditor', () => { expect(screen.getByText(/test-main-author/)).toBeInTheDocument() expect(screen.getByText(/test-coauthor/)).toBeInTheDocument() }) + + it('renders nothing if no upload data is available', () => { + useAvailableRouteData.mockReturnValue(undefined) + render(<UploadMetadataEditor />) + + expect(screen.queryByText('test-name')).not.toBeInTheDocument() + }) }) diff --git a/src/pages/upload/UploadMetadataEditor.tsx b/src/pages/upload/UploadMetadataEditor.tsx index 67e72700..b260fd36 100644 --- a/src/pages/upload/UploadMetadataEditor.tsx +++ b/src/pages/upload/UploadMetadataEditor.tsx @@ -3,7 +3,7 @@ import {useMemo} from 'react' import ProcessStatus from '../../components/ProcessStatus' import Visibility from '../../components/Visibility' import {Layout, LayoutItem} from '../../components/layout/Layout' -import useRouteData from '../../components/routing/useRouteData' +import {useAvailableRouteData} from '../../components/routing/useRouteData' import List from '../../components/values/containers/List' import Text from '../../components/values/primitives/Text' import {UploadResponse} from '../../models/graphResponseModels' @@ -44,7 +44,8 @@ export default function UploadMetadataEditor({ main = false, }: UploadMetadataEditorProps) { expandedByDefault = main ? true : expandedByDefault - const data = useRouteData(uploadRoute) as UploadResponse + const availableData = useAvailableRouteData(uploadRoute) + const data = useMemo(() => availableData || {}, [availableData]) const layout = useMemo( () => @@ -132,5 +133,9 @@ export default function UploadMetadataEditor({ [editable, expandedByDefault, main, data], ) + if (availableData === undefined) { + return '' + } + return <Layout layout={layout} /> } diff --git a/src/pages/upload/UploadOverview.tsx b/src/pages/upload/UploadOverview.tsx index d579c453..aafbeced 100644 --- a/src/pages/upload/UploadOverview.tsx +++ b/src/pages/upload/UploadOverview.tsx @@ -2,15 +2,16 @@ import {Box} from '@mui/material' import {PageTitle} from '../../components/page/Page' import useRouteData from '../../components/routing/useRouteData' +import {DirectoryResponse} from '../../models/graphResponseModels' import UploadActions from './UploadActions' import {UploadFilePreview} from './UploadFiles' import UploadFilesDownload from './UploadFilesDownload' import UploadFilesTable from './UploadFilesTable' import UploadMetadataEditor from './UploadMetadataEditor' -import {filesRoute} from './uploadRoute' +import {uploadOverviewRoute} from './uploadRoute' export default function UploadOverview() { - const data = useRouteData(filesRoute) + const data = useRouteData(uploadOverviewRoute) const hasReadme = Object.keys(data).includes('README.md') return ( @@ -20,7 +21,10 @@ export default function UploadOverview() { <UploadMetadataEditor main editable /> </Box> <UploadActions /> - <UploadFilesTable data={data} navigatePath={(path) => ['files', path]} /> + <UploadFilesTable + data={data as DirectoryResponse} + navigatePath={(path) => ['files', path]} + /> {hasReadme && <UploadFilePreview file='README.md' sx={{mt: 4}} />} </> ) diff --git a/src/pages/upload/uploadRoute.tsx b/src/pages/upload/uploadRoute.tsx index f1624cd0..fb15bbda 100644 --- a/src/pages/upload/uploadRoute.tsx +++ b/src/pages/upload/uploadRoute.tsx @@ -84,6 +84,18 @@ const entriesRoute: Route< children: [entryRoute], } +export const uploadOverviewRoute: Route< + DirectoryRequest, + DirectoryResponse, + Required<PageBasedPagination> +> = { + ...pathRoute, + path: '', + breadcrumb: <b>overview</b>, + lazyComponent: async () => import('./UploadOverview'), + requestKey: 'files', +} + const uploadRoute: Route<UploadRequest, UploadResponse> = { path: ':uploadId', request: { @@ -96,6 +108,11 @@ const uploadRoute: Route<UploadRequest, UploadResponse> = { complete_time: '*', main_author: {name: '*'}, coauthors: '*', + writers: '*', + writer_groups: '*', + viewers: '*', + viewer_groups: '*', + process_running: '*', }, lazyComponent: async () => import('./UploadPage'), breadcrumb: ({response}) => @@ -108,13 +125,7 @@ const uploadRoute: Route<UploadRequest, UploadResponse> = { ), onlyRender: ['', 'files/*', 'entries', 'settings'], children: [ - { - ...pathRoute, - path: '', - breadcrumb: <b>overview</b>, - lazyComponent: async () => import('./UploadOverview'), - requestKey: 'files', - }, + uploadOverviewRoute, filesRoute, entriesRoute, { diff --git a/src/pages/uploads/UploadsTable.test.tsx b/src/pages/uploads/UploadsTable.test.tsx index 97f1c651..bdb71e35 100644 --- a/src/pages/uploads/UploadsTable.test.tsx +++ b/src/pages/uploads/UploadsTable.test.tsx @@ -2,6 +2,7 @@ import {act, fireEvent, render, screen} from '@testing-library/react' import {describe, expect, it, vi} from 'vitest' import UploadsTable from './UploadsTable' +import uploadsRoute from './uploadsRoute' const setPaginate = vi.fn() const navigate = vi.fn() @@ -24,6 +25,7 @@ vi.mock('../../components/routing/useRoute', () => ({ navigate, search: {}, url: () => '/', + route: uploadsRoute, response: Array.from({length: 12}, (_, index) => ({ upload_id: `${index + 1}`, upload_name: `Upload ${index + 1}`, -- GitLab From 1a207c995229e3929b66f43e447b9d21405e9ff5 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Tue, 18 Mar 2025 12:22:03 +0100 Subject: [PATCH 12/17] Added ELN preview and expanded editability control. --- src/components/editor/PropertyEditor.tsx | 1 - src/components/editor/SubSectionEditor.tsx | 10 ++++-- .../editor/SubSectionTableEditor.test.tsx | 3 +- .../editor/SubSectionTableEditor.tsx | 3 +- src/components/layout/CardItem.tsx | 7 ++-- src/components/layout/ContainerItem.tsx | 8 ++--- src/components/layout/Layout.tsx | 1 + src/components/layout/useLayoutProps.tsx | 16 ++++++++++ src/components/richTable/RichTableEditor.tsx | 18 ++++++++--- src/components/richText/RichTextEditor.tsx | 11 ++++--- src/components/routing/useSearch.tsx | 8 +++-- src/hooks/useViewWriteAbilities.tsx | 1 + src/pages/apps/AppEntryPage.tsx | 4 +-- src/pages/apps/appsRoute.tsx | 5 +-- src/pages/entry/EntryDataEditor.test.tsx | 3 +- src/pages/entry/EntryMetadataEditor.tsx | 11 ++++--- src/pages/entry/EntryOverview.tsx | 12 +++++-- src/pages/entry/EntryPageTitle.tsx | 32 ++++++++++++++++--- src/pages/entry/EntrySection.tsx | 6 +++- src/pages/entry/entryRoute.tsx | 7 +++- src/pages/upload/UploadMetadataEditor.tsx | 9 ++++-- src/pages/upload/UploadOverview.tsx | 5 ++- src/pages/upload/UploadPage.test.tsx | 5 +++ 23 files changed, 139 insertions(+), 47 deletions(-) diff --git a/src/components/editor/PropertyEditor.tsx b/src/components/editor/PropertyEditor.tsx index c1823e96..f049bcec 100644 --- a/src/components/editor/PropertyEditor.tsx +++ b/src/components/editor/PropertyEditor.tsx @@ -3,7 +3,6 @@ import {AbstractItem} from '../layout/Layout' export type AbstractProperty = AbstractItem & { property: string label?: string - editable?: boolean } export function getLabel<T extends AbstractProperty>(layout: T) { diff --git a/src/components/editor/SubSectionEditor.tsx b/src/components/editor/SubSectionEditor.tsx index d4951fd6..53cf63f2 100644 --- a/src/components/editor/SubSectionEditor.tsx +++ b/src/components/editor/SubSectionEditor.tsx @@ -10,7 +10,7 @@ import {ArchiveProperty, useArchiveProperty} from '../archive/useArchive' import useSubSectionPath from '../archive/useSubSectionPath' import {Remove} from '../icons' import {Item, Layout, LayoutItem} from '../layout/Layout' -import {layoutPropsContext} from '../layout/useLayoutProps' +import {editableLayout, layoutPropsContext} from '../layout/useLayoutProps' import {AbstractProperty, getLabel} from './PropertyEditor' function SubSectionComponent({ @@ -153,7 +153,11 @@ type SubSectionData = MSectionResponse | MSectionResponse[] | undefined */ export default function SubSectionEditor({layout}: {layout: LayoutItem}) { const subSection = layout as SubSection - const {property: propertyName, layout: subSectionLayout} = subSection + const { + property: propertyName, + layout: subSectionLayout, + editable, + } = subSection const label = getLabel(subSection) const path = useSubSectionPath(propertyName) @@ -173,7 +177,7 @@ export default function SubSectionEditor({layout}: {layout: LayoutItem}) { return ( <SubSectionComponent label={label} - layout={subSectionLayout} + layout={editableLayout(subSectionLayout, editable)} property={property} /> ) diff --git a/src/components/editor/SubSectionTableEditor.test.tsx b/src/components/editor/SubSectionTableEditor.test.tsx index de242bbc..6003c896 100644 --- a/src/components/editor/SubSectionTableEditor.test.tsx +++ b/src/components/editor/SubSectionTableEditor.test.tsx @@ -8,15 +8,16 @@ import {SubSectionTable} from './SubSectionTableEditor' describe('SubSectionTableEditor', async () => { const layout = { + editable: true, type: 'table', property: 'test_subsection', columns: [ { property: 'quantity', + editable: false, }, { property: 'editable_quantity', - editable: true, }, ], } as SubSectionTable diff --git a/src/components/editor/SubSectionTableEditor.tsx b/src/components/editor/SubSectionTableEditor.tsx index 3258426d..ceea79ed 100644 --- a/src/components/editor/SubSectionTableEditor.tsx +++ b/src/components/editor/SubSectionTableEditor.tsx @@ -64,7 +64,7 @@ function createCellEditor( export default function SubSectionTableEditor({layout}: {layout: LayoutItem}) { const table = layout as SubSectionTable - const {property, title, columns} = table + const {property, title, columns, editable} = table const label = getLabel(table) const path = useSubSectionPath(property) @@ -185,6 +185,7 @@ export default function SubSectionTableEditor({layout}: {layout: LayoutItem}) { onAdd={handleAdd} onDelete={handleDelete} itemLabel={label} + editable={editable} /> </> ) diff --git a/src/components/layout/CardItem.tsx b/src/components/layout/CardItem.tsx index 8018ecee..ce037dc2 100644 --- a/src/components/layout/CardItem.tsx +++ b/src/components/layout/CardItem.tsx @@ -10,7 +10,7 @@ import { import React, {useCallback, useMemo, useState} from 'react' import ExpandMore from '../ExpandMore' -import {layoutPropsContext} from '../layout/useLayoutProps' +import {editableLayout, layoutPropsContext} from '../layout/useLayoutProps' import ContainerItem, {Container} from './ContainerItem' import {AbstractContainer, LayoutItem} from './Layout' import useLayoutProps from './useLayoutProps' @@ -70,6 +70,7 @@ export default function CardItem({layout}: CardItemProps) { expandedByDefault, expandTransitionTimeout, variant = 'standard', + editable, ...cardProps } = card const [expanded, setExpanded] = useState( @@ -120,7 +121,9 @@ export default function CardItem({layout}: CardItemProps) { }} > <Box sx={{flexGrow: 1, width: '100%'}}> - <ContainerItem layout={collapsedLayout} /> + <ContainerItem + layout={editableLayout(collapsedLayout, editable)} + /> </Box> {!title && actions.length > 0 && ( <Box sx={{marginLeft: 1, marginRight: -1, marginTop: -1}}> diff --git a/src/components/layout/ContainerItem.tsx b/src/components/layout/ContainerItem.tsx index d74d2de6..8683803e 100644 --- a/src/components/layout/ContainerItem.tsx +++ b/src/components/layout/ContainerItem.tsx @@ -2,7 +2,7 @@ import {Box, BoxProps} from '@mui/material' import Grid from '@mui/material/Grid2' import {AbstractContainer, Item, Layout, LayoutItem} from './Layout' -import useLayoutProps from './useLayoutProps' +import useLayoutProps, {editableLayout} from './useLayoutProps' export type Container = AbstractContainer & { type: 'container' @@ -11,7 +11,7 @@ export type Container = AbstractContainer & { export default function ContainerItem({layout}: {layout: LayoutItem}) { const container = layout as Container - const {type, children, variant = 'grid', ...boxProps} = container + const {type, children, variant = 'grid', editable, ...boxProps} = container const {action} = useLayoutProps() let contents @@ -29,7 +29,7 @@ export default function ContainerItem({layout}: {layout: LayoutItem}) { > {children.map((child, index) => ( <Box key={index} sx={{...(child.grow && {flexGrow: 1})}}> - <Layout layout={child} /> + <Layout layout={editableLayout(child, editable)} /> </Box> ))} </Box> @@ -39,7 +39,7 @@ export default function ContainerItem({layout}: {layout: LayoutItem}) { <Grid container rowSpacing={2} columnSpacing={2} sx={{flexGrow: 1}}> {children.map((child, index) => ( <Item layout={child} key={index}> - <Layout layout={child} /> + <Layout layout={editableLayout(child, editable)} /> </Item> ))} </Grid> diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index a6c26317..c74a244b 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -66,6 +66,7 @@ export type LayoutItem = export type AbstractItem = { type: string grow?: boolean + editable?: boolean } & RegularBreakpoints export type AbstractContainer = AbstractItem & { diff --git a/src/components/layout/useLayoutProps.tsx b/src/components/layout/useLayoutProps.tsx index fd8b7ea2..b2f1cb7a 100644 --- a/src/components/layout/useLayoutProps.tsx +++ b/src/components/layout/useLayoutProps.tsx @@ -1,5 +1,21 @@ import React from 'react' +import {LayoutItem} from './Layout' + +export function editableLayout<Layout extends LayoutItem = LayoutItem>( + layout: Layout, + editable?: boolean, +): Layout { + if (layout.editable === undefined) { + return {...layout, editable} + } else { + return { + ...layout, + editable: editable && layout.editable, + } + } +} + type LayoutPropsContextValue = { action?: React.ReactNode } diff --git a/src/components/richTable/RichTableEditor.tsx b/src/components/richTable/RichTableEditor.tsx index fbf46b59..e521fb03 100644 --- a/src/components/richTable/RichTableEditor.tsx +++ b/src/components/richTable/RichTableEditor.tsx @@ -54,6 +54,7 @@ export type RichtTableEditorProps = { // pressed escape to "abort" the edit. Parent components need to manage // the state and restore the old value. onAbort?: (columnKey: string, rowIndex: number) => void + editable?: boolean } const TableCellWithPrimitive = React.forwardRef(function TabelCellInput( @@ -152,7 +153,7 @@ const TableCellWithPrimitive = React.forwardRef(function TabelCellInput( {error} </Typography> </Popover> - <Box ref={anchorRef}> + <Box ref={anchorRef} sx={!props.editable ? {padding: 2} : {}}> <Component valueIndex={valueIndex} {...componentProps} @@ -210,6 +211,7 @@ export default function RichTableEditor({ onAdd, onDelete, itemLabel, + editable = true, }: RichtTableEditorProps) { const cellRefs = useRef<HTMLElement[]>([]) const editorRefs = useRef<HTMLInputElement[]>([]) @@ -372,7 +374,7 @@ export default function RichTableEditor({ </TableCell> ) })} - {onDelete && <TableCell />} + {onDelete && editable && <TableCell />} </TableRow> </TableHead> <TableBody sx={{backgroundColor: 'background.default'}}> @@ -393,7 +395,13 @@ export default function RichTableEditor({ element as HTMLElement }} component={column.component} - componentProps={column.props} + componentProps={{ + ...column.props, + editable: + column.props?.editable === undefined + ? editable + : column.props.editable && editable, + }} value={row[column.key]} onChange={(value) => onChange?.(column.key, rowIndex, value)} inputRef={(element: HTMLInputElement | null) => { @@ -402,7 +410,7 @@ export default function RichTableEditor({ }} /> ))} - {onDelete && ( + {onDelete && editable && ( <TableCell sx={{width: '64px', paddingY: 0}}> <IconButton onClick={() => onDelete(rowIndex)} size='small'> <Delete /> @@ -413,7 +421,7 @@ export default function RichTableEditor({ ))} </TableBody> </Table> - {onAdd && ( + {onAdd && editable && ( <> <Divider /> <Box sx={{margin: 1, display: 'flex', flexDirection: 'row-reverse'}}> diff --git a/src/components/richText/RichTextEditor.tsx b/src/components/richText/RichTextEditor.tsx index 383bd0b7..0d566930 100644 --- a/src/components/richText/RichTextEditor.tsx +++ b/src/components/richText/RichTextEditor.tsx @@ -98,8 +98,9 @@ function Editor({ onChange, placeholder, initialValue, - readonly, + editable, }: RichTextEditorProps) { + const readonly = !!editable const [editor] = useLexicalComposerContext() const [showDev, setShowDev] = useState(false) @@ -288,15 +289,15 @@ export type RichTextEditorProps = { onChange?: (html: string) => void placeholder?: string initialValue?: string - readonly?: boolean + editable?: boolean } export default function RichTextEditor(props: RichTextEditorProps) { - const {readonly} = props + const {editable = true} = props return ( <LexicalComposer - initialConfig={{...editorConfig, editable: !readonly}} - key={`${!readonly}`} + initialConfig={{...editorConfig, editable: !!editable}} + key={`${!!editable}`} > <Editor {...props} /> </LexicalComposer> diff --git a/src/components/routing/useSearch.tsx b/src/components/routing/useSearch.tsx index 66f1561e..c7d3be3f 100644 --- a/src/components/routing/useSearch.tsx +++ b/src/components/routing/useSearch.tsx @@ -1,9 +1,13 @@ import {DefaultSearch, Route} from './types' import useRoute from './useRoute' -export default function useSearch<Search extends DefaultSearch = DefaultSearch>( +export default function useSearch< + Request = unknown, + Response = unknown, + Search extends DefaultSearch = DefaultSearch, +>( // eslint-disable-next-line @typescript-eslint/no-unused-vars - route?: Route<unknown, unknown, Search>, + route?: Route<Request, Response, Search>, ) { const {search} = useRoute() return search diff --git a/src/hooks/useViewWriteAbilities.tsx b/src/hooks/useViewWriteAbilities.tsx index 6af7cfde..4e08cd65 100644 --- a/src/hooks/useViewWriteAbilities.tsx +++ b/src/hooks/useViewWriteAbilities.tsx @@ -47,6 +47,7 @@ export default function useViewWriteAbilities() { 'published data missing in route data', ) const userId = user?.profile?.sub + return { canRead: (published && !with_embargo) || diff --git a/src/pages/apps/AppEntryPage.tsx b/src/pages/apps/AppEntryPage.tsx index a350cf14..36129615 100644 --- a/src/pages/apps/AppEntryPage.tsx +++ b/src/pages/apps/AppEntryPage.tsx @@ -23,9 +23,9 @@ export default function EntryPage() { navigation={ <Nav> <NavItem - label='projects' + label='search apps' component={Link} - to={url({path: '../../../../uploads'})} + to={url({path: '../../../../explore'})} /> <Divider /> <EntryNavItem /> diff --git a/src/pages/apps/appsRoute.tsx b/src/pages/apps/appsRoute.tsx index 9bacaac2..260631fc 100644 --- a/src/pages/apps/appsRoute.tsx +++ b/src/pages/apps/appsRoute.tsx @@ -4,10 +4,10 @@ import { EntryRequest, } from '../../models/graphRequestModels' import {EntryResponse} from '../../models/graphResponseModels' -import {archiveRequest, archiveRoute} from '../entry/entryRoute' +import {EntrySearch, archiveRequest, archiveRoute} from '../entry/entryRoute' import AppsPage from './AppsPage' -export const appEntryRoute: Route<EntryRequest, EntryResponse> = { +export const appEntryRoute: Route<EntryRequest, EntryResponse, EntrySearch> = { path: 'explore/:appName/:entryId', requestPath: ({path}) => `entries/${path}`, request: { @@ -34,6 +34,7 @@ export const appEntryRoute: Route<EntryRequest, EntryResponse> = { }, lazyComponent: async () => import('./AppEntryPage'), breadcrumb: ({path}) => path, + validateSearch: ({rawSearch}) => ({preview: rawSearch.preview === 'true'}), children: [ { path: '', diff --git a/src/pages/entry/EntryDataEditor.test.tsx b/src/pages/entry/EntryDataEditor.test.tsx index e54c05be..3d13e51f 100644 --- a/src/pages/entry/EntryDataEditor.test.tsx +++ b/src/pages/entry/EntryDataEditor.test.tsx @@ -11,11 +11,11 @@ import EntryDataEditor from './EntryDataEditor' describe('EntryDataEditor', async () => { const layout: LayoutItem = { type: 'container', + editable: true, children: [ { type: 'quantity', property: 'test_quantity1', - editable: true, }, { type: 'subSection', @@ -26,7 +26,6 @@ describe('EntryDataEditor', async () => { { type: 'quantity', property: 'test_quantity2', - editable: true, }, ], }, diff --git a/src/pages/entry/EntryMetadataEditor.tsx b/src/pages/entry/EntryMetadataEditor.tsx index 676b41e1..a1d43144 100644 --- a/src/pages/entry/EntryMetadataEditor.tsx +++ b/src/pages/entry/EntryMetadataEditor.tsx @@ -2,6 +2,7 @@ import {useMemo} from 'react' import ProcessStatus from '../../components/ProcessStatus' import {Layout, LayoutItem} from '../../components/layout/Layout' +import {editableLayout} from '../../components/layout/useLayoutProps' import List from '../../components/values/containers/List' import Text from '../../components/values/primitives/Text' import {EntryResponse} from '../../models/graphResponseModels' @@ -57,6 +58,7 @@ export default function EntryMetadataEditor({ { type: 'value', label: 'mainfile', + editable: false, grow: true, value: data.mainfile_path, component: { @@ -67,6 +69,7 @@ export default function EntryMetadataEditor({ }, { type: 'value', + editable: false, component: { Id: { abbreviated: true, @@ -90,7 +93,6 @@ export default function EntryMetadataEditor({ children: [ { type: 'quantity', - editable, grow: true, label: 'entry name', property: 'metadata/entry_name', @@ -105,6 +107,7 @@ export default function EntryMetadataEditor({ type: 'value', label: 'created at', value: data.entry_create_time, + editable: false, component: { Datetime: { variant: 'datetime', @@ -115,6 +118,7 @@ export default function EntryMetadataEditor({ type: 'value', label: 'last change', value: data.complete_time, + editable: false, component: { Datetime: { variant: 'datetime', @@ -125,7 +129,6 @@ export default function EntryMetadataEditor({ }, { md: 6, - editable: true, type: 'quantity', label: 'references', placeholder: 'no references', @@ -148,8 +151,8 @@ export default function EntryMetadataEditor({ }, ], } as LayoutItem), - [editable, expandedByDefault, data], + [expandedByDefault, data], ) - return <Layout layout={layout} /> + return <Layout layout={editableLayout(layout, editable)} /> } diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index ef80ae65..aefef973 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -6,8 +6,11 @@ import ErrorMessage from '../../components/app/ErrorMessage' import useArchive from '../../components/archive/useArchive' import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' +import {editableLayout} from '../../components/layout/useLayoutProps' import useSelect from '../../components/navigation/useSelect' import useRoute from '../../components/routing/useRoute' +import useSearch from '../../components/routing/useSearch' +import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {Section} from '../../utils/metainfo' @@ -17,7 +20,7 @@ import EntryDataEditor from './EntryDataEditor' import EntryMetadataEditor from './EntryMetadataEditor' import EntryPageTitle from './EntryPageTitle' import EntrySection from './EntrySection' -import {archiveRequest, useEntryDataForRoute} from './entryRoute' +import entryRoute, {archiveRequest, useEntryDataForRoute} from './entryRoute' import useEntryRouteData from './useEntryRouteData' function EntryOverviewEditor() { @@ -25,6 +28,8 @@ function EntryOverviewEditor() { const {reloadCount} = useRoute() const [error, setError] = useState<Error | undefined>() const {isPage} = useSelect() + const {canWrite} = useViewWriteAbilities() + const {preview} = useSearch(entryRoute) const layout = useMemo(() => { const schema = (archiveData?.data as MSectionResponse)?.m_def as Section @@ -82,6 +87,7 @@ function EntryOverviewEditor() { } useEntryDataForRoute(request, setError) + const editable = canWrite && isPage && !preview let content: React.ReactNode = '' if (error) { @@ -91,7 +97,7 @@ function EntryOverviewEditor() { } else { if (isPage) { if (layout) { - content = <EntryDataEditor layout={layout} /> + content = <EntryDataEditor layout={editableLayout(layout, editable)} /> } else { content = <ErrorMessage error={new Error('No layout defined')} /> } @@ -104,7 +110,7 @@ function EntryOverviewEditor() { <UploadMetadataEditor /> </Box> <Box sx={{marginBottom: 4}}> - <EntryMetadataEditor editable={isPage} expandedByDefault={isPage} /> + <EntryMetadataEditor editable={editable} expandedByDefault={isPage} /> </Box> {content} </> diff --git a/src/pages/entry/EntryPageTitle.tsx b/src/pages/entry/EntryPageTitle.tsx index f6da367f..5dd2512f 100644 --- a/src/pages/entry/EntryPageTitle.tsx +++ b/src/pages/entry/EntryPageTitle.tsx @@ -1,31 +1,55 @@ import GoToIcon from '@mui/icons-material/ChevronRight' -import {Box, Button} from '@mui/material' +import {Box, Button, Checkbox, FormControlLabel, FormGroup} from '@mui/material' +import {useCallback} from 'react' import EditStatus from '../../components/archive/EditStatus' +import useSelect from '../../components/navigation/useSelect' import {PageTitle, PageTitleProps} from '../../components/page/Page' import usePage from '../../components/page/usePage' import Link from '../../components/routing/Link' import useRoute from '../../components/routing/useRoute' +import useSearch from '../../components/routing/useSearch' +import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {assert} from '../../utils/utils' +import entryRoute from './entryRoute' import useEntryRouteData from './useEntryRouteData' export default function EntryPageTitle(props: PageTitleProps) { - const {url} = useRoute() + const {isScrolled} = usePage() + const {url, navigate} = useRoute() + const {isPage} = useSelect() + const {canWrite} = useViewWriteAbilities() + const { upload_id, mainfile_path, archive: rootSectionData, } = useEntryRouteData() + const {preview} = useSearch(entryRoute) assert( rootSectionData !== undefined, 'An entry should always have a root section', ) - const {isScrolled} = usePage() + const handlePreviewChange = useCallback(() => { + navigate({searchUpdates: {preview: preview ? undefined : true}}) + }, [navigate, preview]) + + const editable = canWrite && isPage const actions = ( <Box display='flex' alignItems='center' flexDirection='row' gap={1}> - <EditStatus /> + {editable && ( + <FormGroup> + <FormControlLabel + control={ + <Checkbox checked={!!preview} onChange={handlePreviewChange} /> + } + label='Preview' + /> + </FormGroup> + )} + {editable && <EditStatus />} <Button variant='contained' component={Link} diff --git a/src/pages/entry/EntrySection.tsx b/src/pages/entry/EntrySection.tsx index 1d4ebd61..bde06daf 100644 --- a/src/pages/entry/EntrySection.tsx +++ b/src/pages/entry/EntrySection.tsx @@ -7,11 +7,13 @@ import useSelect from '../../components/navigation/useSelect' import Outlet from '../../components/routing/Outlet' import useRoute from '../../components/routing/useRoute' import useRouteData from '../../components/routing/useRouteData' +import useSearch from '../../components/routing/useSearch' import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import {JSONObject} from '../../utils/types' import EntryPageTitle from './EntryPageTitle' +import entryRoute from './entryRoute' import useEntryRouteData from './useEntryRouteData' const sortRawDataKeys = (a: string, b: string) => { @@ -32,6 +34,7 @@ export default function EntrySection() { const {parser_name} = useEntryRouteData() const {canWrite} = useViewWriteAbilities() const {isPage} = useSelect() + const {preview} = useSearch(entryRoute) const startIndex = useMemo( () => fullMatch.findIndex((match) => match.route.path === 'archive'), @@ -56,7 +59,8 @@ export default function EntrySection() { isPage && sectionPath !== '' && !nonEditableSections.find((s) => sectionPath.startsWith(s)) && - parser_name === 'parsers/archive' + parser_name === 'parsers/archive' && + !preview const title = fullMatch.length === startIndex + 1 diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index 2a09ceca..9bc78f78 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -119,7 +119,11 @@ export function useEntryDataForRoute( }) } -const entryRoute: Route<EntryRequest, EntryResponse> = { +export type EntrySearch = { + preview?: boolean +} + +const entryRoute: Route<EntryRequest, EntryResponse, EntrySearch> = { path: ':entryId', // TODO The function should not be necessary, but some buggy thing is modifying the // request. With the function we force to recreate the request object every time. @@ -142,6 +146,7 @@ const entryRoute: Route<EntryRequest, EntryResponse> = { lazyComponent: async () => import('./EntryPage'), renderWithPathAsKey: true, breadcrumb: ({response}) => path.basename(response?.mainfile_path as string), + validateSearch: ({rawSearch}) => ({preview: rawSearch.preview === 'true'}), children: [ { path: '', diff --git a/src/pages/upload/UploadMetadataEditor.tsx b/src/pages/upload/UploadMetadataEditor.tsx index b260fd36..7aa80cc2 100644 --- a/src/pages/upload/UploadMetadataEditor.tsx +++ b/src/pages/upload/UploadMetadataEditor.tsx @@ -3,6 +3,7 @@ import {useMemo} from 'react' import ProcessStatus from '../../components/ProcessStatus' import Visibility from '../../components/Visibility' import {Layout, LayoutItem} from '../../components/layout/Layout' +import {editableLayout} from '../../components/layout/useLayoutProps' import {useAvailableRouteData} from '../../components/routing/useRouteData' import List from '../../components/values/containers/List' import Text from '../../components/values/primitives/Text' @@ -66,7 +67,6 @@ export default function UploadMetadataEditor({ grow: true, type: 'value', label: 'project name', - editable: editable, value: data.upload_name, component: { Text: { @@ -83,6 +83,7 @@ export default function UploadMetadataEditor({ { xs: 5, type: 'value', + editable: false, component: { Id: { abbreviated: true, @@ -109,6 +110,7 @@ export default function UploadMetadataEditor({ { type: 'value', label: 'created at', + editable: false, component: { Datetime: { variant: 'datetime', @@ -119,6 +121,7 @@ export default function UploadMetadataEditor({ { type: 'value', label: 'last change', + editable: false, component: { Datetime: { variant: 'datetime', @@ -130,12 +133,12 @@ export default function UploadMetadataEditor({ }, ], } as LayoutItem), - [editable, expandedByDefault, main, data], + [expandedByDefault, main, data], ) if (availableData === undefined) { return '' } - return <Layout layout={layout} /> + return <Layout layout={editableLayout(layout, editable)} /> } diff --git a/src/pages/upload/UploadOverview.tsx b/src/pages/upload/UploadOverview.tsx index aafbeced..8b35baec 100644 --- a/src/pages/upload/UploadOverview.tsx +++ b/src/pages/upload/UploadOverview.tsx @@ -2,6 +2,7 @@ import {Box} from '@mui/material' import {PageTitle} from '../../components/page/Page' import useRouteData from '../../components/routing/useRouteData' +import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {DirectoryResponse} from '../../models/graphResponseModels' import UploadActions from './UploadActions' import {UploadFilePreview} from './UploadFiles' @@ -13,12 +14,14 @@ import {uploadOverviewRoute} from './uploadRoute' export default function UploadOverview() { const data = useRouteData(uploadOverviewRoute) const hasReadme = Object.keys(data).includes('README.md') + const {canWrite} = useViewWriteAbilities() + const editable = canWrite return ( <> <PageTitle actions={<UploadFilesDownload />} /> <Box sx={{mb: 4}}> - <UploadMetadataEditor main editable /> + <UploadMetadataEditor main editable={editable} /> </Box> <UploadActions /> <UploadFilesTable diff --git a/src/pages/upload/UploadPage.test.tsx b/src/pages/upload/UploadPage.test.tsx index 4c3af358..0249e4e5 100644 --- a/src/pages/upload/UploadPage.test.tsx +++ b/src/pages/upload/UploadPage.test.tsx @@ -1,6 +1,7 @@ import {screen, waitFor} from '@testing-library/react' import {expect, it, vi} from 'vitest' +import * as useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {GraphResponse} from '../../models/graphResponseModels' import * as api from '../../utils/api' import { @@ -14,6 +15,10 @@ await importLazyComponents(uploadRoute) describe('UploadPage', () => { it('loads and initially renders the overview', async () => { const mockedApi = vi.spyOn(api, 'graphApi') + vi.spyOn(useViewWriteAbilities, 'default').mockReturnValue({ + canWrite: true, + canRead: true, + }) window.history.replaceState(null, '', '/project') mockedApi.mockResolvedValue({ project: { -- GitLab From 029d3561bece43f567abadb9bb988e7240818d2a Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Wed, 19 Mar 2025 13:04:23 +0100 Subject: [PATCH 13/17] Added default layout and layout tabs. --- src/components/archive/Section.tsx | 37 +++++- src/components/richTable/RichTableEditor.tsx | 5 +- src/components/routing/Routes.tsx | 4 +- src/components/routing/loader.ts | 16 ++- src/components/routing/types.ts | 4 + src/pages/apps/appsRoute.tsx | 5 +- src/pages/entry/EntryOverview.tsx | 123 +++++++++++-------- src/pages/entry/EntryPage.test.tsx | 4 +- src/pages/entry/EntrySection.tsx | 41 ++----- src/pages/entry/entryRoute.tsx | 5 +- 10 files changed, 151 insertions(+), 93 deletions(-) diff --git a/src/components/archive/Section.tsx b/src/components/archive/Section.tsx index d0da427b..a852d515 100644 --- a/src/components/archive/Section.tsx +++ b/src/components/archive/Section.tsx @@ -1,17 +1,20 @@ -import {Card, CardContent, CardHeader} from '@mui/material' +import {Box, Card, CardContent, CardHeader} from '@mui/material' import {useCallback, useMemo} from 'react' +import {MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' import { Quantity as QuantityDefinition, Section as SectionDefinition, SubSection as SubSectionDefinition, } from '../../utils/metainfo' -import {JSONValue} from '../../utils/types' +import {JSONObject, JSONValue} from '../../utils/types' import {assert} from '../../utils/utils' import ErrorBoundary from '../ErrorBoundary' import {getPropertyLabel} from '../editor/PropertyEditor' import SubSectionNavEditor from '../editor/SubSectionNavEditor' +import JsonViewer from '../fileviewer/JsonViewer' +import useRouteData from '../routing/useRouteData' import DynamicValue from '../values/utils/DynamicValue' import {createDynamicComponentSpec} from '../values/utils/dynamicComponents' import SectionProvider from './SectionProvider' @@ -81,8 +84,12 @@ export type SectionProps = { export default function Section({path, editable = false}: SectionProps) { const {value} = useArchiveProperty<MSectionResponse>(path) const definition = value?.m_def as SectionDefinition + const data = useRouteData<MSectionRequest, MSectionResponse>() - assert(value !== undefined, 'The section should have been loaded by now.') + assert( + value !== undefined, + `The section ${path} should have been loaded by now.`, + ) assert( definition !== undefined, 'The section definition should have been loaded by now.', @@ -100,8 +107,18 @@ export default function Section({path, editable = false}: SectionProps) { ) }, [definition.all_quantities]) + const sortRawDataKeys = (a: string, b: string) => { + if (a.startsWith('m_') === b.startsWith('m_')) { + return a.localeCompare(b) + } + if (a.startsWith('m_')) { + return -1 + } + return 1 + } + return ( - <> + <Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}> {subSections.length > 0 && ( <ErrorBoundary> <Card> @@ -138,6 +155,16 @@ export default function Section({path, editable = false}: SectionProps) { </Card> </ErrorBoundary> )} - </> + <Card> + <CardHeader title='Raw data' /> + <CardContent> + <JsonViewer + value={data as JSONObject} + defaultInspectDepth={1} + objectSortKeys={sortRawDataKeys} + /> + </CardContent> + </Card> + </Box> ) } diff --git a/src/components/richTable/RichTableEditor.tsx b/src/components/richTable/RichTableEditor.tsx index e521fb03..b72ed983 100644 --- a/src/components/richTable/RichTableEditor.tsx +++ b/src/components/richTable/RichTableEditor.tsx @@ -153,7 +153,10 @@ const TableCellWithPrimitive = React.forwardRef(function TabelCellInput( {error} </Typography> </Popover> - <Box ref={anchorRef} sx={!props.editable ? {padding: 2} : {}}> + <Box + ref={anchorRef} + sx={!props.editable ? {padding: componentProps?.editable ? 0 : 2} : {}} + > <Component valueIndex={valueIndex} {...componentProps} diff --git a/src/components/routing/Routes.tsx b/src/components/routing/Routes.tsx index ae737786..84409f91 100644 --- a/src/components/routing/Routes.tsx +++ b/src/components/routing/Routes.tsx @@ -12,6 +12,7 @@ import {AsyncState} from 'react-use/lib/useAsyncFn' import useAsyncConditional from '../../hooks/useAsyncConditional' import useAuth from '../../hooks/useAuth' +import {JSONObject} from '../../utils/types' import {assert} from '../../utils/utils' import DevToolsJsonViewer from '../devTools/DevToolsJsonViewer' import useDevTools from '../devTools/useDevTools' @@ -131,8 +132,9 @@ function useLoadRouteData( reloadCount?: number, ): AsyncState<LoaderResult | undefined> { const {user} = useAuth() + const cache = useRef<{request?: JSONObject; response?: JSONObject}>({}) const fetch = useMemo(() => { - const result = loader(fullMatch, user || undefined) + const result = loader(fullMatch, user || undefined, cache.current) if (!result) { return } diff --git a/src/components/routing/loader.ts b/src/components/routing/loader.ts index 054b9b33..bf335ae5 100644 --- a/src/components/routing/loader.ts +++ b/src/components/routing/loader.ts @@ -1,3 +1,4 @@ +import _ from 'lodash' import {User} from 'oidc-client-ts' import {graphApi} from '../../utils/api' @@ -161,6 +162,10 @@ export function createRequestForRoute( export default function loader( match: RouteMatch[], user?: User, + cache: { + request?: JSONObject + response?: JSONObject + } = {}, ): (() => Promise<LoaderResult>) | undefined { let isEmpty = true function getRequest(routeMatch: RouteMatch) { @@ -186,7 +191,16 @@ export default function loader( } return async () => { - const response = (await graphApi(request, user)) as JSONObject + // Some route changes do not change the request, so we can reuse the last + // response. TODO How to implement reload buttons? Pass some reload flag. + let response: JSONObject + if (_.isEqual(request, cache.request) && cache.response) { + response = cache.response + } else { + response = (await graphApi(request, user)) as JSONObject + } + cache.request = request + cache.response = response // TODO We should only resolve m_defs in archive data and not in uploads, // entries, files, etc. Currently we apply this to all and the complete // responses no matter what they are. diff --git a/src/components/routing/types.ts b/src/components/routing/types.ts index 5034f404..66566d16 100644 --- a/src/components/routing/types.ts +++ b/src/components/routing/types.ts @@ -21,6 +21,10 @@ export type LoaderResult = { export type Loader = ( fullMatch: RouteMatch[], user?: User, + cache?: { + request?: JSONObject + response?: JSONObject + }, ) => (() => Promise<LoaderResult>) | undefined export type RouteMatch< diff --git a/src/pages/apps/appsRoute.tsx b/src/pages/apps/appsRoute.tsx index 260631fc..3aee3808 100644 --- a/src/pages/apps/appsRoute.tsx +++ b/src/pages/apps/appsRoute.tsx @@ -34,7 +34,10 @@ export const appEntryRoute: Route<EntryRequest, EntryResponse, EntrySearch> = { }, lazyComponent: async () => import('./AppEntryPage'), breadcrumb: ({path}) => path, - validateSearch: ({rawSearch}) => ({preview: rawSearch.preview === 'true'}), + validateSearch: ({rawSearch}) => ({ + preview: rawSearch.preview === 'true', + layout: rawSearch.layout, + }), children: [ { path: '', diff --git a/src/pages/entry/EntryOverview.tsx b/src/pages/entry/EntryOverview.tsx index aefef973..10c0474f 100644 --- a/src/pages/entry/EntryOverview.tsx +++ b/src/pages/entry/EntryOverview.tsx @@ -1,8 +1,10 @@ -import {Box} from '@mui/material' -import {useMemo, useState} from 'react' +import {Box, Tab, Tabs, Typography} from '@mui/material' +import {useCallback, useMemo, useState} from 'react' import {usePrevious} from 'react-use' +import ErrorBoundary from '../../components/ErrorBoundary' import ErrorMessage from '../../components/app/ErrorMessage' +import Section from '../../components/archive/Section' import useArchive from '../../components/archive/useArchive' import {calculateRequestFromLayout} from '../../components/editor/utils' import {LayoutItem} from '../../components/layout/Layout' @@ -13,7 +15,7 @@ import useSearch from '../../components/routing/useSearch' import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' import {EntryRequest, MSectionRequest} from '../../models/graphRequestModels' import {MSectionResponse} from '../../models/graphResponseModels' -import {Section} from '../../utils/metainfo' +import {Section as SectionDefinition} from '../../utils/metainfo' import {assert} from '../../utils/utils' import UploadMetadataEditor from '../upload/UploadMetadataEditor' import EntryDataEditor from './EntryDataEditor' @@ -23,35 +25,16 @@ import EntrySection from './EntrySection' import entryRoute, {archiveRequest, useEntryDataForRoute} from './entryRoute' import useEntryRouteData from './useEntryRouteData' -function EntryOverviewEditor() { - const {archive: archiveData} = useEntryRouteData() +function EntryOverviewEditor({ + layout, + editable, +}: { + layout: LayoutItem + editable?: boolean +}) { const {reloadCount} = useRoute() const [error, setError] = useState<Error | undefined>() const {isPage} = useSelect() - const {canWrite} = useViewWriteAbilities() - const {preview} = useSearch(entryRoute) - - const layout = useMemo(() => { - const schema = (archiveData?.data as MSectionResponse)?.m_def as Section - let layout: LayoutItem - const layouts = schema?.m_annotations?.layout as unknown as - | LayoutItem - | LayoutItem[] - | undefined - if (Array.isArray(layouts)) { - layout = layouts[0] - } else if (layouts !== undefined) { - layout = layouts - } else { - return undefined - } - - return { - type: 'subSection', - property: 'data', - layout, - } satisfies LayoutItem - }, [archiveData]) const request = useMemo(() => { const request = { @@ -87,7 +70,6 @@ function EntryOverviewEditor() { } useEntryDataForRoute(request, setError) - const editable = canWrite && isPage && !preview let content: React.ReactNode = '' if (error) { @@ -96,35 +78,57 @@ function EntryOverviewEditor() { content = <ErrorMessage error={error} /> } else { if (isPage) { - if (layout) { - content = <EntryDataEditor layout={editableLayout(layout, editable)} /> - } else { - content = <ErrorMessage error={new Error('No layout defined')} /> - } + content = <EntryDataEditor layout={editableLayout(layout, editable)} /> } } - return ( - <> - <Box sx={{marginBottom: 2}}> - <UploadMetadataEditor /> - </Box> - <Box sx={{marginBottom: 4}}> - <EntryMetadataEditor editable={editable} expandedByDefault={isPage} /> - </Box> - {content} - </> - ) + return <ErrorBoundary>{content}</ErrorBoundary> } export default function EntryOverview() { const {archive: rootSectionData} = useEntryRouteData() + const {canWrite} = useViewWriteAbilities() + const {preview} = useSearch(entryRoute) + const {navigate} = useRoute() + assert( rootSectionData !== undefined, 'An entry should always have a root section', ) const {isPage, isSelect} = useSelect() + const layout = useMemo(() => { + const schema = (rootSectionData?.data as MSectionResponse) + ?.m_def as SectionDefinition + let layout: LayoutItem + const layouts = schema?.m_annotations?.layout as unknown as + | LayoutItem + | LayoutItem[] + | undefined + if (Array.isArray(layouts)) { + layout = layouts[0] + } else if (layouts !== undefined) { + layout = layouts + } else { + return undefined + } + + return { + type: 'subSection', + property: 'data', + layout, + } satisfies LayoutItem + }, [rootSectionData]) + + const {layout: layoutTab = layout ? 'layout' : 'data'} = useSearch(entryRoute) + const handleLayoutTabChange = useCallback( + (_: React.SyntheticEvent, newValue: 'layout' | 'data') => { + navigate({searchUpdates: {layout: newValue}}) + }, + [navigate], + ) + + const editable = canWrite && isPage && !preview // This memo is an optimization to avoid re-rendering the entire // EntryOverviewEditor after usePage causes a scroll event triggered @@ -137,12 +141,35 @@ export default function EntryOverview() { marginTop: -1, }} > - <EntryOverviewEditor /> + <Box sx={{marginBottom: 2}}> + <UploadMetadataEditor /> + </Box> + <Box sx={{marginBottom: 4}}> + <EntryMetadataEditor + editable={editable} + expandedByDefault={isPage} + /> + </Box> + <Tabs value={layoutTab} onChange={handleLayoutTabChange} sx={{mb: 2}}> + <Tab label='Layout' value='layout' /> + <Tab label='Data' value='data' /> + </Tabs> + {layoutTab === 'layout' && + (layout ? ( + <EntryOverviewEditor layout={layout} editable={editable} /> + ) : ( + <Typography> + This entry has no custom layout defined in it's schema. + </Typography> + ))} + {layoutTab === 'data' && ( + <Section path={'data'} editable={editable} /> + )} </Box> {isSelect && <EntrySection />} </> ), - [isSelect], + [editable, handleLayoutTabChange, isPage, isSelect, layout, layoutTab], ) return ( diff --git a/src/pages/entry/EntryPage.test.tsx b/src/pages/entry/EntryPage.test.tsx index 4143a490..094e494f 100644 --- a/src/pages/entry/EntryPage.test.tsx +++ b/src/pages/entry/EntryPage.test.tsx @@ -6,6 +6,7 @@ import {getArchive} from '../../components/archive/useArchive' import {Route} from '../../components/routing/types' import * as api from '../../utils/api' import { + createMatchMedia, importLazyComponents, renderWithRouteData, } from '../../utils/test.helper' @@ -16,6 +17,7 @@ await importLazyComponents(uploadsRoute) describe('EntryPage', () => { it('loads and initially renders', async () => { const mockedApi = vi.spyOn(api, 'graphApi') + vi.spyOn(window, 'matchMedia').mockImplementation(createMatchMedia(500)) window.history.replaceState(null, '', 'uploads/uploadId/entries/entryId') const metadata = { @@ -37,7 +39,7 @@ describe('EntryPage', () => { }, archive: addDefinitions({ metadata, - material: { + data: { name: 'gold', }, }), diff --git a/src/pages/entry/EntrySection.tsx b/src/pages/entry/EntrySection.tsx index bde06daf..af54b782 100644 --- a/src/pages/entry/EntrySection.tsx +++ b/src/pages/entry/EntrySection.tsx @@ -1,45 +1,30 @@ -import {Box, Card, CardContent, CardHeader} from '@mui/material' import {useMemo} from 'react' import Section from '../../components/archive/Section' -import JsonViewer from '../../components/fileviewer/JsonViewer' import useSelect from '../../components/navigation/useSelect' import Outlet from '../../components/routing/Outlet' import useRoute from '../../components/routing/useRoute' -import useRouteData from '../../components/routing/useRouteData' import useSearch from '../../components/routing/useSearch' import useViewWriteAbilities from '../../hooks/useViewWriteAbilities' -import {MSectionRequest} from '../../models/graphRequestModels' -import {MSectionResponse} from '../../models/graphResponseModels' -import {JSONObject} from '../../utils/types' +import {assert} from '../../utils/utils' import EntryPageTitle from './EntryPageTitle' import entryRoute from './entryRoute' import useEntryRouteData from './useEntryRouteData' -const sortRawDataKeys = (a: string, b: string) => { - if (a.startsWith('m_') === b.startsWith('m_')) { - return a.localeCompare(b) - } - if (a.startsWith('m_')) { - return -1 - } - return 1 -} - const nonEditableSections = ['results', 'metadata'] export default function EntrySection() { const {isLeaf, fullMatch} = useRoute() - const data = useRouteData<MSectionRequest, MSectionResponse>() const {parser_name} = useEntryRouteData() const {canWrite} = useViewWriteAbilities() const {isPage} = useSelect() const {preview} = useSearch(entryRoute) - const startIndex = useMemo( - () => fullMatch.findIndex((match) => match.route.path === 'archive'), - [fullMatch], - ) + const startIndex = useMemo(() => { + const index = fullMatch.findIndex((match) => match.route.path === 'archive') + assert(index !== -1, 'not an archive route') + return index + }, [fullMatch]) const sectionPath = useMemo(() => { if (!isLeaf) { return '' @@ -70,19 +55,7 @@ export default function EntrySection() { return ( <> <EntryPageTitle title={title} /> - <Box sx={{display: 'flex', flexDirection: 'column', gap: 2}}> - <Section path={sectionPath} editable={editable} /> - <Card> - <CardHeader title='Raw data' /> - <CardContent> - <JsonViewer - value={data as JSONObject} - defaultInspectDepth={1} - objectSortKeys={sortRawDataKeys} - /> - </CardContent> - </Card> - </Box> + <Section path={sectionPath} editable={editable} /> </> ) } diff --git a/src/pages/entry/entryRoute.tsx b/src/pages/entry/entryRoute.tsx index 9bc78f78..35db6f60 100644 --- a/src/pages/entry/entryRoute.tsx +++ b/src/pages/entry/entryRoute.tsx @@ -146,7 +146,10 @@ const entryRoute: Route<EntryRequest, EntryResponse, EntrySearch> = { lazyComponent: async () => import('./EntryPage'), renderWithPathAsKey: true, breadcrumb: ({response}) => path.basename(response?.mainfile_path as string), - validateSearch: ({rawSearch}) => ({preview: rawSearch.preview === 'true'}), + validateSearch: ({rawSearch}) => ({ + preview: rawSearch.preview === 'true', + layout: rawSearch.layout, + }), children: [ { path: '', -- GitLab From b78a12677241a2af857e800fbb25d54cc7ec692d Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 24 Mar 2025 12:36:42 +0100 Subject: [PATCH 14/17] Fixed Reference and Select issues. --- README.md | 3 +-- src/components/navigation/Select.tsx | 7 ++++--- src/components/values/primitives/FileReference.tsx | 9 ++++++--- src/components/values/primitives/Reference.tsx | 7 ++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index b45dc92d..edbcea40 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,7 @@ in development. ```sh cd infra - nomad client upload --upload-name demo-schema --ignore-path-prefix data/demo-schema - nomad client upload --upload-name entry-data --ignore-path-prefix data/entry-data + nomad client upload --upload-name demo-schema --ignore-path-prefix src/nomad_plugin_gui/example_uploads/ui_demonstration nomad client upload --upload-name upload-navigation data/upload-navigation.zip ``` diff --git a/src/components/navigation/Select.tsx b/src/components/navigation/Select.tsx index f8e09d5d..61b5aac3 100644 --- a/src/components/navigation/Select.tsx +++ b/src/components/navigation/Select.tsx @@ -11,7 +11,7 @@ import { import {PropsWithChildren, useCallback, useState} from 'react' import {archiveRoute} from '../../pages/entry/entryRoute' -import {filesRoute} from '../../pages/upload/uploadRoute' +import {filesRoute, pathRoute} from '../../pages/upload/uploadRoute' import uploadsRoute from '../../pages/uploads/uploadsRoute' import {Section} from '../../utils/metainfo' import {assert} from '../../utils/utils' @@ -237,7 +237,7 @@ export default function Select<S extends SelectOptions = SelectOptions>({ setPath(location) let matchesEntity = false if (entity === 'file') { - matchesEntity = leafMatch.route.routeId === filesRoute.routeId + matchesEntity = leafMatch.route.routeId === pathRoute.routeId matchesEntity &&= leafMatch.response?.m_is === 'File' const {filePattern} = selectOptions as unknown as FileSelectOptions if (filePattern) { @@ -260,7 +260,8 @@ export default function Select<S extends SelectOptions = SelectOptions>({ } } - onChange?.(matchesEntity ? location : undefined) + const decodedLocation = decodeURIComponent(location) + onChange?.(matchesEntity ? decodedLocation : undefined) }, [setPath, onChange, entity, selectOptions], ) diff --git a/src/components/values/primitives/FileReference.tsx b/src/components/values/primitives/FileReference.tsx index efae8bdd..94305f4d 100644 --- a/src/components/values/primitives/FileReference.tsx +++ b/src/components/values/primitives/FileReference.tsx @@ -1,4 +1,5 @@ import useEntryRouteData from '../../../pages/entry/useEntryRouteData' +import {assert} from '../../../utils/utils' import {FileSelectOptions} from '../../navigation/useSelect' import Reference, {DynamicReferenceProps, ReferenceProps} from './Reference' @@ -16,16 +17,18 @@ export default function FileReference({ ...referenceProps }: FileReferenceProps) { const {upload_id, mainfile_path} = useEntryRouteData() + assert(mainfile_path, 'mainfile_path is required') const selectOptions = { entity: 'file', filePattern, } satisfies FileSelectOptions - const initialPath = `/uploads/${upload_id}/files${mainfile_path?.replace( - /\/[^/]+$/, + const directoryPath = mainfile_path.replace(/[^/]+$/, '').replace(/^\/?/, '') + const initialPath = `/uploads/${upload_id}/files/${directoryPath}`.replace( + /\/$/, '', - )}` + ) return ( <Reference diff --git a/src/components/values/primitives/Reference.tsx b/src/components/values/primitives/Reference.tsx index 544a03c1..4f9866a3 100644 --- a/src/components/values/primitives/Reference.tsx +++ b/src/components/values/primitives/Reference.tsx @@ -78,10 +78,11 @@ export default function Reference<S extends SelectOptions = SelectOptions>({ const handleOpen = useCallback(() => { assert(value !== undefined, 'Open should only be possible with value.') - if (value.startsWith('/')) { - navigate({path: value}) + const normalizedValue = value.replace(/^((\.|#)\/)?/, '') + if (normalizedValue.startsWith('/')) { + navigate({path: normalizedValue}) } else { - navigate({path: `${initialPath}/${value}`}) + navigate({path: `${initialPath}/${normalizedValue}`}) } }, [value, navigate, initialPath]) -- GitLab From 5ee42fbb749d08d362b8d356f4c2e551aa17cde3 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 24 Mar 2025 12:51:02 +0100 Subject: [PATCH 15/17] Revert using local keycloak to central keycloak. --- infra/nomad.yaml | 3 +-- public/config.js | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/infra/nomad.yaml b/infra/nomad.yaml index 48639927..d333579c 100644 --- a/infra/nomad.yaml +++ b/infra/nomad.yaml @@ -1,7 +1,6 @@ keycloak: - server_url: 'http://keycloak:8080/' + server_url: 'https://nomad-lab.eu/fairdi/keycloak/auth/' realm_name: 'fairdi_nomad_test' - public_server_url: 'http://localhost:8080/' services: admin_user_id: 'c97facc2-92ec-4fa6-80cf-a08ed957255b' client: diff --git a/public/config.js b/public/config.js index 9d8981b0..6ed1474f 100644 --- a/public/config.js +++ b/public/config.js @@ -1 +1 @@ -window.nomadConfig = {"keycloak": {"realm_name": "fairdi_nomad_test", "client_id": "nomad_public", "public_server_url": "http://localhost:8080"}, "services": {"console_log_level": 30, "api_host": "localhost", "api_port": 8000, "api_base_path": "/fairdi/nomad/latest", "api_secret": "defaultApiSecret", "api_timeout": 600, "https": false, "https_upload": false, "admin_user_id": "c97facc2-92ec-4fa6-80cf-a08ed957255b", "encyclopedia_base": "https://nomad-lab.eu/prod/rae/encyclopedia/#", "optimade_enabled": true, "dcat_enabled": true, "h5grove_enabled": true, "upload_limit": 10, "force_raw_file_decoding": false, "max_entry_download": 50000, "unavailable_value": "unavailable", "app_token_max_expires_in": 2592000, "html_resource_http_max_age": 60, "image_resource_http_max_age": 2592000, "upload_members_group_search_enabled": false, "log_api_queries": true}, "ui": {"unit_systems": {"items": [{"label": "Custom", "units": {"angle": {"definition": "\u00b0", "locked": false}, "energy": {"definition": "eV", "locked": false}, "length": {"definition": "\u00c5", "locked": false}, "pressure": {"definition": "GPa", "locked": false}, "time": {"definition": "fs", "locked": false}, "dimensionless": {"definition": "dimensionless", "locked": false}, "mass": {"definition": "kg", "locked": false}, "current": {"definition": "A", "locked": false}, "temperature": {"definition": "K", "locked": false}, "luminosity": {"definition": "cd", "locked": false}, "luminous_flux": {"definition": "lm", "locked": false}, "substance": {"definition": "mol", "locked": false}, "information": {"definition": "bit", "locked": false}, "force": {"definition": "N", "locked": false}, "power": {"definition": "W", "locked": false}, "charge": {"definition": "C", "locked": false}, "resistance": {"definition": "\u03a9", "locked": false}, "conductance": {"definition": "S", "locked": false}, "inductance": {"definition": "H", "locked": false}, "magnetic_flux": {"definition": "Wb", "locked": false}, "magnetic_field": {"definition": "T", "locked": false}, "frequency": {"definition": "Hz", "locked": false}, "luminance": {"definition": "nit", "locked": false}, "illuminance": {"definition": "lx", "locked": false}, "electric_potential": {"definition": "V", "locked": false}, "capacitance": {"definition": "F", "locked": false}, "activity": {"definition": "kat", "locked": false}}, "id": "Custom"}, {"label": "International System of Units (SI)", "units": {"activity": {"definition": "kat", "locked": true}, "angle": {"definition": "rad", "locked": true}, "capacitance": {"definition": "F", "locked": true}, "charge": {"definition": "C", "locked": true}, "conductance": {"definition": "S", "locked": true}, "current": {"definition": "A", "locked": true}, "dimensionless": {"definition": "dimensionless", "locked": true}, "electric_potential": {"definition": "V", "locked": true}, "energy": {"definition": "J", "locked": true}, "force": {"definition": "N", "locked": true}, "frequency": {"definition": "Hz", "locked": true}, "illuminance": {"definition": "lx", "locked": true}, "inductance": {"definition": "H", "locked": true}, "information": {"definition": "bit", "locked": true}, "length": {"definition": "m", "locked": true}, "luminance": {"definition": "nit", "locked": true}, "luminosity": {"definition": "cd", "locked": true}, "luminous_flux": {"definition": "lm", "locked": true}, "magnetic_field": {"definition": "T", "locked": true}, "magnetic_flux": {"definition": "Wb", "locked": true}, "mass": {"definition": "kg", "locked": true}, "power": {"definition": "W", "locked": true}, "pressure": {"definition": "Pa", "locked": true}, "resistance": {"definition": "\u03a9", "locked": true}, "substance": {"definition": "mol", "locked": true}, "temperature": {"definition": "K", "locked": true}, "time": {"definition": "s", "locked": true}}, "id": "SI"}, {"label": "Hartree atomic units (AU)", "units": {"activity": {"definition": "kat", "locked": false}, "angle": {"definition": "rad", "locked": false}, "capacitance": {"definition": "F", "locked": false}, "charge": {"definition": "C", "locked": false}, "conductance": {"definition": "S", "locked": false}, "current": {"definition": "atomic_unit_of_current", "locked": true}, "dimensionless": {"definition": "dimensionless", "locked": true}, "electric_potential": {"definition": "V", "locked": false}, "energy": {"definition": "Ha", "locked": true}, "force": {"definition": "atomic_unit_of_force", "locked": true}, "frequency": {"definition": "Hz", "locked": false}, "illuminance": {"definition": "lx", "locked": false}, "inductance": {"definition": "H", "locked": false}, "information": {"definition": "bit", "locked": false}, "length": {"definition": "bohr", "locked": true}, "luminance": {"definition": "nit", "locked": false}, "luminosity": {"definition": "cd", "locked": false}, "luminous_flux": {"definition": "lm", "locked": false}, "magnetic_field": {"definition": "T", "locked": false}, "magnetic_flux": {"definition": "Wb", "locked": false}, "mass": {"definition": "m_e", "locked": true}, "power": {"definition": "W", "locked": false}, "pressure": {"definition": "atomic_unit_of_pressure", "locked": true}, "resistance": {"definition": "\u03a9", "locked": false}, "substance": {"definition": "mol", "locked": false}, "temperature": {"definition": "atomic_unit_of_temperature", "locked": true}, "time": {"definition": "atomic_unit_of_time", "locked": true}}, "id": "AU"}], "selected": "Custom"}}, "plugins": {"entry_points": {"items": [{"id": "apps/1_all/1_entries", "entry_point_type": "app", "app": {"label": "Entries", "path": "entries", "resource": "entries", "category": "All", "description": "Search entries across all domains", "readme": "This page allows you to search **entries** within NOMAD. Entries represent any individual data items that have been uploaded to NOMAD, no matter whether they come from theoretical calculations, experiments, lab notebooks or any other source of data. This allows you to perform cross-domain queries, but if you are interested in a specific subfield, you should see if a specific application exists for it in the explore menu to get more details.", "pagination": {"order_by": "upload_create_time", "order": "desc", "page_size": 20}, "columns": [{"search_quantity": "entry_name", "selected": true, "title": "Name", "align": "left"}, {"search_quantity": "results.material.chemical_formula_hill", "selected": true, "title": "Formula", "align": "left"}, {"search_quantity": "entry_type", "selected": true, "align": "left"}, {"search_quantity": "upload_create_time", "selected": true, "title": "Upload time", "align": "left"}, {"search_quantity": "authors", "selected": true, "align": "left"}, {"search_quantity": "upload_name", "selected": false, "align": "left"}, {"search_quantity": "upload_id", "selected": false, "align": "left"}, {"search_quantity": "results.method.method_name", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.program_name", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.apw_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.basis_set", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.k_line_density", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.native_tier", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.material.structural_type", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.crystal_system", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_symbol", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_number", "selected": false, "align": "left"}, {"search_quantity": "results.eln.lab_ids", "selected": false, "align": "left"}, {"search_quantity": "results.eln.sections", "selected": false, "align": "left"}, {"search_quantity": "results.eln.methods", "selected": false, "align": "left"}, {"search_quantity": "results.eln.tags", "selected": false, "align": "left"}, {"search_quantity": "results.eln.instruments", "selected": false, "align": "left"}, {"search_quantity": "mainfile", "selected": false, "align": "left"}, {"search_quantity": "comment", "selected": false, "align": "left"}, {"search_quantity": "references", "selected": false, "align": "left"}, {"search_quantity": "datasets", "selected": false, "align": "left"}, {"search_quantity": "published", "selected": false, "title": "Access", "align": "left"}], "rows": {"actions": {"enabled": true}, "details": {"enabled": true}, "selection": {"enabled": true}}, "menu": {"width": 12, "show_header": true, "title": "Filters", "type": "menu", "size": "sm", "indentation": 0, "items": [{"width": 12, "show_header": true, "title": "Material", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Elements / Formula", "type": "menu", "size": "xxl", "indentation": 1, "items": [{"type": "periodic_table", "search_quantity": "results.material.elements", "scale": "linear", "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_hill", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_iupac", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_reduced", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_anonymous", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.material.n_elements", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Structure / Symmetry", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.material.structural_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.bravais_lattice", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.structure_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.strukturbericht_designation", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.point_group", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.hall_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.prototype_aflow_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Method", "type": "menu", "size": "md", "items": [{"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.program_version", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Precision", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.k_line_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.native_tier", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.basis_set", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.apw_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DFT": {"label": "Search DFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.xc_functional_names", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.exact_exchange_mixing_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.hubbard_kanamori_model.u_effective", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.core_electron_treatment", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.relativity_method", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "TB", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"TB": {"label": "Search TB entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.tb.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.tb.localization_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "GW", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"GW": {"label": "Search GW entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.gw.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "BSE", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"BSE": {"label": "Search BSE entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.bse.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.solver", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.gw_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DMFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DMFT": {"label": "Search DMFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dmft.impurity_solver_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.inverse_temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.magnetic_state", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.u", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.jh", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.analytical_continuation", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "EELS", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"EELS": {"label": "Search EELS entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.spectroscopic.spectra.provenance.eels", "items": [{"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.detector_type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.resolution", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.min_energy", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.max_energy", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Workflow", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Molecular dynamics", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.workflow_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"MolecularDynamics": {"label": "Search molecular dynamics entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.thermodynamic.trajectory", "items": [{"search_quantity": "results.properties.thermodynamic.trajectory.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 4, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Geometry Optimization", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"geometry_optimization": {"label": "Search geometry optimization entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.geometry_optimization", "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_energy_difference", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_force_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_displacement_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Properties", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Electronic", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "electronic_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic.band_gap", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.dos_electronic", "items": [{"search_quantity": "results.properties.electronic.dos_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Vibrational", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "vibrational_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Mechanical", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "mechanical_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.bulk_modulus", "items": [{"search_quantity": "results.properties.mechanical.bulk_modulus.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.bulk_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.shear_modulus", "items": [{"search_quantity": "results.properties.mechanical.shear_modulus.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.shear_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.energy_volume_curve", "items": [{"search_quantity": "results.properties.mechanical.energy_volume_curve.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Use Cases", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Solar Cells", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.efficiency", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.fill_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.short_circuit_current_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.device_area", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.device_architecture", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.device_stack", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.absorber", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.absorber_fabrication", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.electron_transport_layer", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.hole_transport_layer", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.substrate", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.back_contact", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Heterogeneous Catalysis", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.catalytic.reaction.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.reaction.reactants", "items": [{"search_quantity": "results.properties.catalytic.reaction.reactants.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.conversion", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.mole_fraction_in", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.mole_fraction_out", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.reaction.products", "items": [{"search_quantity": "results.properties.catalytic.reaction.products.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.products.selectivity", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.products.mole_fraction_out", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.catalyst", "items": [{"search_quantity": "results.properties.catalytic.catalyst.catalyst_type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.support", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.preparation_method", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.catalyst_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.characterization_methods", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.catalyst.surface_area", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Author / Origin / Dataset", "type": "menu", "size": "lg", "items": [{"search_quantity": "authors.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "upload_create_time", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "external_db", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.doi", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Visibility / IDs / Schema", "type": "menu", "size": "md", "items": [{"width": 12, "show_header": true, "type": "visibility"}, {"search_quantity": "entry_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.material_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "definitions"}]}, {"width": 12, "show_header": true, "title": "Optimade", "type": "menu", "size": "lg", "items": [{"width": 12, "show_header": true, "type": "optimade"}]}]}, "search_quantities": {"exclude": ["mainfile", "entry_name", "combine"]}, "search_syntaxes": {"exclude": ["free_text"]}}}, {"id": "apps/2_theory/1_calculations", "entry_point_type": "app", "app": {"label": "Calculations", "path": "calculations", "resource": "entries", "category": "Theory", "description": "Search calculations", "readme": "This page allows you to search **calculations** within NOMAD. Calculations typically come from a specific simulation software that uses an approximate model to investigate and report different physical properties.", "pagination": {"order_by": "upload_create_time", "order": "desc", "page_size": 20}, "columns": [{"search_quantity": "results.material.chemical_formula_hill", "selected": true, "title": "Formula", "align": "left"}, {"search_quantity": "results.method.simulation.program_name", "selected": true, "align": "left"}, {"search_quantity": "results.method.method_name", "selected": true, "align": "left"}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "selected": true, "align": "left"}, {"search_quantity": "upload_create_time", "selected": true, "title": "Upload time", "align": "left"}, {"search_quantity": "authors", "selected": true, "align": "left"}, {"search_quantity": "results.method.simulation.precision.apw_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.basis_set", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.k_line_density", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.native_tier", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.material.structural_type", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.crystal_system", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_symbol", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_number", "selected": false, "align": "left"}, {"search_quantity": "entry_name", "selected": false, "title": "Name", "align": "left"}, {"search_quantity": "mainfile", "selected": false, "align": "left"}, {"search_quantity": "comment", "selected": false, "align": "left"}, {"search_quantity": "references", "selected": false, "align": "left"}, {"search_quantity": "datasets", "selected": false, "align": "left"}, {"search_quantity": "published", "selected": false, "title": "Access", "align": "left"}], "rows": {"actions": {"enabled": true}, "details": {"enabled": true}, "selection": {"enabled": true}}, "menu": {"width": 12, "show_header": true, "title": "Filters", "type": "menu", "size": "sm", "indentation": 0, "items": [{"width": 12, "show_header": true, "title": "Material", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Elements / Formula", "type": "menu", "size": "xxl", "indentation": 1, "items": [{"type": "periodic_table", "search_quantity": "results.material.elements", "scale": "linear", "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_hill", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_iupac", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_reduced", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_anonymous", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.material.n_elements", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Structure / Symmetry", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.material.structural_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.bravais_lattice", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.structure_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.strukturbericht_designation", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.point_group", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.hall_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.prototype_aflow_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Method", "type": "menu", "size": "md", "items": [{"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.program_version", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Precision", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.k_line_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.native_tier", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.basis_set", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.apw_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DFT": {"label": "Search DFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.xc_functional_names", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.exact_exchange_mixing_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.hubbard_kanamori_model.u_effective", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.core_electron_treatment", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.relativity_method", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "TB", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"TB": {"label": "Search TB entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.tb.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.tb.localization_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "GW", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"GW": {"label": "Search GW entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.gw.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "BSE", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"BSE": {"label": "Search BSE entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.bse.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.solver", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.gw_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DMFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DMFT": {"label": "Search DMFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dmft.impurity_solver_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.inverse_temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.magnetic_state", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.u", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.jh", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.analytical_continuation", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Workflow", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Molecular dynamics", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.workflow_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"MolecularDynamics": {"label": "Search molecular dynamics entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.thermodynamic.trajectory", "items": [{"search_quantity": "results.properties.thermodynamic.trajectory.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 4, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Geometry Optimization", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"geometry_optimization": {"label": "Search geometry optimization entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.geometry_optimization", "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_energy_difference", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_force_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_displacement_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Properties", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Electronic", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "electronic_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic.band_gap", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.dos_electronic", "items": [{"search_quantity": "results.properties.electronic.dos_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Vibrational", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "vibrational_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Mechanical", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "mechanical_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.bulk_modulus", "items": [{"search_quantity": "results.properties.mechanical.bulk_modulus.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.bulk_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.shear_modulus", "items": [{"search_quantity": "results.properties.mechanical.shear_modulus.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.shear_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.energy_volume_curve", "items": [{"search_quantity": "results.properties.mechanical.energy_volume_curve.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Author / Origin / Dataset", "type": "menu", "size": "lg", "items": [{"search_quantity": "authors.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "upload_create_time", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "external_db", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.doi", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Visibility / IDs / Schema", "type": "menu", "size": "md", "items": [{"width": 12, "show_header": true, "type": "visibility"}, {"search_quantity": "entry_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.material_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "definitions"}]}, {"width": 12, "show_header": true, "title": "Optimade", "type": "menu", "size": "lg", "items": [{"width": 12, "show_header": true, "type": "optimade"}]}]}, "search_quantities": {"exclude": ["mainfile", "entry_name", "combine"]}, "dashboard": {"widgets": [{"type": "periodictable", "search_quantity": "results.material.elements", "scale": "linear", "layout": {"lg": {"h": 11, "w": 14, "x": 0, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 8, "w": 12, "x": 0, "y": 0, "minH": 3, "minW": 3}, "sm": {"h": 8, "w": 12, "x": 0, "y": 0, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 14, "x": 0, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 13, "x": 0, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "layout": {"lg": {"h": 5, "w": 5, "x": 19, "y": 6, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 12, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 6, "y": 13, "minH": 3, "minW": 3}, "xl": {"h": 6, "w": 6, "x": 24, "y": 5, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 30, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.structural_type", "type": "terms", "scale": "log", "show_input": false, "layout": {"lg": {"h": 6, "w": 5, "x": 19, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 0, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 6, "y": 8, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 5, "x": 19, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 19, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "log", "show_input": true, "layout": {"lg": {"h": 6, "w": 5, "x": 14, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 8, "w": 6, "x": 12, "y": 0, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 0, "y": 8, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 5, "x": 14, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 13, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "layout": {"lg": {"h": 5, "w": 5, "x": 14, "y": 6, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 6, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 0, "y": 13, "minH": 3, "minW": 3}, "xl": {"h": 5, "w": 6, "x": 24, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 5, "x": 25, "y": 0, "minH": 3, "minW": 3}}}]}, "filters_locked": {"quantities": "results.method.simulation.program_name"}, "search_syntaxes": {"exclude": ["free_text"]}}}, {"id": "apps/2_theory/2_materials", "entry_point_type": "app", "app": {"label": "Materials", "path": "materials", "resource": "materials", "category": "Theory", "description": "Search materials that are identified from calculations", "readme": "This page allows you to search **materials** within NOMAD. NOMAD can often automatically detect the material from individual calculations that contain the full atomistic structure and can then group the data by using these detected materials. This allows you to search individual materials which have properties that are aggregated from several entries. Following the link for a specific material will take you to the corresponding [NOMAD Encyclopedia](https://nomad-lab.eu/prod/rae/encyclopedia/#/search) page for that material. NOMAD Encyclopedia is a service that is specifically oriented towards materials property exploration.\nNotice that by default the properties that you search can be combined from several different entries. If instead you wish to search for a material with an individual entry fullfilling your search criteria, uncheck the **combine results from several entries**-checkbox.", "pagination": {"order_by": "chemical_formula_hill", "order": "asc", "page_size": 20}, "columns": [{"search_quantity": "chemical_formula_hill", "selected": true, "title": "Formula", "align": "left"}, {"search_quantity": "structural_type", "selected": true, "align": "left"}, {"search_quantity": "symmetry.structure_name", "selected": true, "align": "left"}, {"search_quantity": "symmetry.space_group_number", "selected": true, "align": "left"}, {"search_quantity": "symmetry.crystal_system", "selected": true, "align": "left"}, {"search_quantity": "symmetry.space_group_symbol", "selected": false, "align": "left"}, {"search_quantity": "material_id", "selected": false, "align": "left"}], "rows": {"actions": {"enabled": true}, "details": {"enabled": false}, "selection": {"enabled": false}}, "menu": {"width": 12, "show_header": true, "title": "Filters", "type": "menu", "size": "sm", "indentation": 0, "items": [{"width": 12, "show_header": true, "title": "Material", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Elements / Formula", "type": "menu", "size": "xxl", "indentation": 1, "items": [{"type": "periodic_table", "search_quantity": "results.material.elements", "scale": "linear", "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_hill", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_iupac", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_reduced", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_anonymous", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.material.n_elements", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Structure / Symmetry", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.material.structural_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.bravais_lattice", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.structure_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.strukturbericht_designation", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.point_group", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.hall_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.prototype_aflow_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Method", "type": "menu", "size": "md", "items": [{"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.program_version", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DFT": {"label": "Search DFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.xc_functional_names", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.exact_exchange_mixing_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.hubbard_kanamori_model.u_effective", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.core_electron_treatment", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.relativity_method", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "TB", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"TB": {"label": "Search TB entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.tb.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.tb.localization_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "GW", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"GW": {"label": "Search GW entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.gw.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "BSE", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"BSE": {"label": "Search BSE entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.bse.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.solver", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.gw_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DMFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DMFT": {"label": "Search DMFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dmft.impurity_solver_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.inverse_temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.magnetic_state", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.u", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.jh", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.analytical_continuation", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Workflow", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Molecular dynamics", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.workflow_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"MolecularDynamics": {"label": "Search molecular dynamics entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.thermodynamic.trajectory", "items": [{"search_quantity": "results.properties.thermodynamic.trajectory.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 4, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Geometry Optimization", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"geometry_optimization": {"label": "Search geometry optimization entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.geometry_optimization", "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_energy_difference", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_force_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_displacement_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Properties", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Electronic", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "electronic_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic.band_gap", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.dos_electronic", "items": [{"search_quantity": "results.properties.electronic.dos_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Vibrational", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "vibrational_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Mechanical", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "mechanical_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.bulk_modulus", "items": [{"search_quantity": "results.properties.mechanical.bulk_modulus.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.bulk_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.shear_modulus", "items": [{"search_quantity": "results.properties.mechanical.shear_modulus.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.shear_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.energy_volume_curve", "items": [{"search_quantity": "results.properties.mechanical.energy_volume_curve.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Author / Origin / Dataset", "type": "menu", "size": "lg", "items": [{"search_quantity": "authors.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "upload_create_time", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "external_db", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.doi", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Visibility / IDs / Schema", "type": "menu", "size": "md", "items": [{"width": 12, "show_header": true, "type": "visibility"}, {"search_quantity": "entry_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.material_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "definitions"}]}, {"width": 12, "show_header": true, "title": "Optimade", "type": "menu", "size": "lg", "items": [{"width": 12, "show_header": true, "type": "optimade"}]}, {"search_quantity": "combine", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"True": {"label": "Combine results from several entries", "description": "If selected, your filters may be matched from several entries that contain the same material. When unchecked, the material has to have a single entry that matches all your filters."}}, "n_columns": 1, "sort_static": true, "show_statistics": false}]}, "search_quantities": {"exclude": ["mainfile", "entry_name"]}, "search_syntaxes": {"exclude": ["free_text"]}}}]}}}; +window.nomadConfig = {"keycloak": {"realm_name": "fairdi_nomad_test", "client_id": "nomad_public", "public_server_url": "https://nomad-lab.eu/fairdi/keycloak/auth/"}, "services": {"api_host": "localhost", "api_port": 8000, "api_base_path": "/fairdi/nomad/latest", "api_secret": "defaultApiSecret", "api_timeout": 600, "https": false, "https_upload": false, "admin_user_id": "c97facc2-92ec-4fa6-80cf-a08ed957255b", "encyclopedia_base": "https://nomad-lab.eu/prod/rae/encyclopedia/#", "optimade_enabled": true, "dcat_enabled": true, "h5grove_enabled": true, "console_log_level": 30, "upload_limit": 10, "force_raw_file_decoding": false, "max_entry_download": 50000, "unavailable_value": "unavailable", "app_token_max_expires_in": 2592000, "html_resource_http_max_age": 60, "image_resource_http_max_age": 2592000, "upload_members_group_search_enabled": false, "log_api_queries": true}, "ui": {"unit_systems": {"items": [{"label": "Custom", "units": {"angle": {"definition": "\u00b0", "locked": false}, "energy": {"definition": "eV", "locked": false}, "length": {"definition": "\u00c5", "locked": false}, "pressure": {"definition": "GPa", "locked": false}, "time": {"definition": "fs", "locked": false}, "dimensionless": {"definition": "dimensionless", "locked": false}, "mass": {"definition": "kg", "locked": false}, "current": {"definition": "A", "locked": false}, "temperature": {"definition": "K", "locked": false}, "luminosity": {"definition": "cd", "locked": false}, "luminous_flux": {"definition": "lm", "locked": false}, "substance": {"definition": "mol", "locked": false}, "information": {"definition": "bit", "locked": false}, "force": {"definition": "N", "locked": false}, "power": {"definition": "W", "locked": false}, "charge": {"definition": "C", "locked": false}, "resistance": {"definition": "\u03a9", "locked": false}, "conductance": {"definition": "S", "locked": false}, "inductance": {"definition": "H", "locked": false}, "magnetic_flux": {"definition": "Wb", "locked": false}, "magnetic_field": {"definition": "T", "locked": false}, "frequency": {"definition": "Hz", "locked": false}, "luminance": {"definition": "nit", "locked": false}, "illuminance": {"definition": "lx", "locked": false}, "electric_potential": {"definition": "V", "locked": false}, "capacitance": {"definition": "F", "locked": false}, "activity": {"definition": "kat", "locked": false}}, "id": "Custom"}, {"label": "International System of Units (SI)", "units": {"activity": {"definition": "kat", "locked": true}, "angle": {"definition": "rad", "locked": true}, "capacitance": {"definition": "F", "locked": true}, "charge": {"definition": "C", "locked": true}, "conductance": {"definition": "S", "locked": true}, "current": {"definition": "A", "locked": true}, "dimensionless": {"definition": "dimensionless", "locked": true}, "electric_potential": {"definition": "V", "locked": true}, "energy": {"definition": "J", "locked": true}, "force": {"definition": "N", "locked": true}, "frequency": {"definition": "Hz", "locked": true}, "illuminance": {"definition": "lx", "locked": true}, "inductance": {"definition": "H", "locked": true}, "information": {"definition": "bit", "locked": true}, "length": {"definition": "m", "locked": true}, "luminance": {"definition": "nit", "locked": true}, "luminosity": {"definition": "cd", "locked": true}, "luminous_flux": {"definition": "lm", "locked": true}, "magnetic_field": {"definition": "T", "locked": true}, "magnetic_flux": {"definition": "Wb", "locked": true}, "mass": {"definition": "kg", "locked": true}, "power": {"definition": "W", "locked": true}, "pressure": {"definition": "Pa", "locked": true}, "resistance": {"definition": "\u03a9", "locked": true}, "substance": {"definition": "mol", "locked": true}, "temperature": {"definition": "K", "locked": true}, "time": {"definition": "s", "locked": true}}, "id": "SI"}, {"label": "Hartree atomic units (AU)", "units": {"activity": {"definition": "kat", "locked": false}, "angle": {"definition": "rad", "locked": false}, "capacitance": {"definition": "F", "locked": false}, "charge": {"definition": "C", "locked": false}, "conductance": {"definition": "S", "locked": false}, "current": {"definition": "atomic_unit_of_current", "locked": true}, "dimensionless": {"definition": "dimensionless", "locked": true}, "electric_potential": {"definition": "V", "locked": false}, "energy": {"definition": "Ha", "locked": true}, "force": {"definition": "atomic_unit_of_force", "locked": true}, "frequency": {"definition": "Hz", "locked": false}, "illuminance": {"definition": "lx", "locked": false}, "inductance": {"definition": "H", "locked": false}, "information": {"definition": "bit", "locked": false}, "length": {"definition": "bohr", "locked": true}, "luminance": {"definition": "nit", "locked": false}, "luminosity": {"definition": "cd", "locked": false}, "luminous_flux": {"definition": "lm", "locked": false}, "magnetic_field": {"definition": "T", "locked": false}, "magnetic_flux": {"definition": "Wb", "locked": false}, "mass": {"definition": "m_e", "locked": true}, "power": {"definition": "W", "locked": false}, "pressure": {"definition": "atomic_unit_of_pressure", "locked": true}, "resistance": {"definition": "\u03a9", "locked": false}, "substance": {"definition": "mol", "locked": false}, "temperature": {"definition": "atomic_unit_of_temperature", "locked": true}, "time": {"definition": "atomic_unit_of_time", "locked": true}}, "id": "AU"}], "selected": "Custom"}}, "plugins": {"entry_points": {"items": [{"id": "apps/1_all/1_entries", "entry_point_type": "app", "app": {"label": "Entries", "path": "entries", "resource": "entries", "category": "All", "description": "Search entries across all domains", "readme": "This page allows you to search **entries** within NOMAD. Entries represent any individual data items that have been uploaded to NOMAD, no matter whether they come from theoretical calculations, experiments, lab notebooks or any other source of data. This allows you to perform cross-domain queries, but if you are interested in a specific subfield, you should see if a specific application exists for it in the explore menu to get more details.", "pagination": {"order_by": "upload_create_time", "order": "desc", "page_size": 20}, "columns": [{"search_quantity": "entry_name", "selected": true, "title": "Name", "align": "left"}, {"search_quantity": "results.material.chemical_formula_hill", "selected": true, "title": "Formula", "align": "left"}, {"search_quantity": "entry_type", "selected": true, "align": "left"}, {"search_quantity": "upload_create_time", "selected": true, "title": "Upload time", "align": "left"}, {"search_quantity": "authors", "selected": true, "align": "left"}, {"search_quantity": "upload_name", "selected": false, "align": "left"}, {"search_quantity": "upload_id", "selected": false, "align": "left"}, {"search_quantity": "results.method.method_name", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.program_name", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.apw_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.basis_set", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.k_line_density", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.native_tier", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.material.structural_type", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.crystal_system", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_symbol", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_number", "selected": false, "align": "left"}, {"search_quantity": "results.eln.lab_ids", "selected": false, "align": "left"}, {"search_quantity": "results.eln.sections", "selected": false, "align": "left"}, {"search_quantity": "results.eln.methods", "selected": false, "align": "left"}, {"search_quantity": "results.eln.tags", "selected": false, "align": "left"}, {"search_quantity": "results.eln.instruments", "selected": false, "align": "left"}, {"search_quantity": "mainfile", "selected": false, "align": "left"}, {"search_quantity": "comment", "selected": false, "align": "left"}, {"search_quantity": "references", "selected": false, "align": "left"}, {"search_quantity": "datasets", "selected": false, "align": "left"}, {"search_quantity": "published", "selected": false, "title": "Access", "align": "left"}], "rows": {"actions": {"enabled": true, "items": []}, "details": {"enabled": true}, "selection": {"enabled": true}}, "menu": {"width": 12, "show_header": true, "title": "Filters", "type": "menu", "size": "sm", "indentation": 0, "items": [{"width": 12, "show_header": true, "title": "Material", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Elements / Formula", "type": "menu", "size": "xxl", "indentation": 1, "items": [{"type": "periodic_table", "search_quantity": "results.material.elements", "scale": "linear", "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_hill", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_iupac", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_reduced", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_anonymous", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.material.n_elements", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Structure / Symmetry", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.material.structural_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.bravais_lattice", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.structure_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.strukturbericht_designation", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.point_group", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.hall_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.prototype_aflow_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Method", "type": "menu", "size": "md", "items": [{"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.program_version", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Precision", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.k_line_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.native_tier", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.basis_set", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.apw_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DFT": {"label": "Search DFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.xc_functional_names", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.exact_exchange_mixing_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.hubbard_kanamori_model.u_effective", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.core_electron_treatment", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.relativity_method", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "TB", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"TB": {"label": "Search TB entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.tb.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.tb.localization_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "GW", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"GW": {"label": "Search GW entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.gw.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "BSE", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"BSE": {"label": "Search BSE entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.bse.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.solver", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.gw_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DMFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DMFT": {"label": "Search DMFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dmft.impurity_solver_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.inverse_temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.magnetic_state", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.u", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.jh", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.analytical_continuation", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "EELS", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"EELS": {"label": "Search EELS entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.spectroscopic.spectra.provenance.eels", "items": [{"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.detector_type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.resolution", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.min_energy", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.spectroscopic.spectra.provenance.eels.max_energy", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Workflow", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Molecular dynamics", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.workflow_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"MolecularDynamics": {"label": "Search molecular dynamics entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.thermodynamic.trajectory", "items": [{"search_quantity": "results.properties.thermodynamic.trajectory.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 4, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Geometry Optimization", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"geometry_optimization": {"label": "Search geometry optimization entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.geometry_optimization", "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_energy_difference", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_force_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_displacement_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Properties", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Electronic", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "electronic_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic.band_gap", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.dos_electronic", "items": [{"search_quantity": "results.properties.electronic.dos_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Vibrational", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "vibrational_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Mechanical", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "mechanical_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.bulk_modulus", "items": [{"search_quantity": "results.properties.mechanical.bulk_modulus.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.bulk_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.shear_modulus", "items": [{"search_quantity": "results.properties.mechanical.shear_modulus.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.shear_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.energy_volume_curve", "items": [{"search_quantity": "results.properties.mechanical.energy_volume_curve.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Use Cases", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Solar Cells", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.efficiency", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.fill_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.open_circuit_voltage", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.short_circuit_current_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.illumination_intensity", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.optoelectronic.solar_cell.device_area", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.device_architecture", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.device_stack", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.absorber", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.absorber_fabrication", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.electron_transport_layer", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.hole_transport_layer", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.substrate", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.optoelectronic.solar_cell.back_contact", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Heterogeneous Catalysis", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.catalytic.reaction.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.reaction.reactants", "items": [{"search_quantity": "results.properties.catalytic.reaction.reactants.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.conversion", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.mole_fraction_in", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reactants.mole_fraction_out", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.reaction.products", "items": [{"search_quantity": "results.properties.catalytic.reaction.products.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.products.selectivity", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.products.mole_fraction_out", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.reaction.reaction_conditions.temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.catalytic.catalyst", "items": [{"search_quantity": "results.properties.catalytic.catalyst.catalyst_type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.support", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.preparation_method", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.catalyst_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.catalytic.catalyst.characterization_methods", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.catalytic.catalyst.surface_area", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Author / Origin / Dataset", "type": "menu", "size": "lg", "items": [{"search_quantity": "authors.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "upload_create_time", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "external_db", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.doi", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Visibility / IDs / Schema", "type": "menu", "size": "md", "items": [{"width": 12, "show_header": true, "type": "visibility"}, {"search_quantity": "entry_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.material_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "definitions"}]}, {"width": 12, "show_header": true, "title": "Optimade", "type": "menu", "size": "lg", "items": [{"width": 12, "show_header": true, "type": "optimade"}]}]}, "search_quantities": {"exclude": ["mainfile", "entry_name", "combine"]}, "search_syntaxes": {"exclude": ["free_text"]}}}, {"id": "apps/2_theory/1_calculations", "entry_point_type": "app", "app": {"label": "Calculations", "path": "calculations", "resource": "entries", "category": "Theory", "description": "Search calculations", "readme": "This page allows you to search **calculations** within NOMAD. Calculations typically come from a specific simulation software that uses an approximate model to investigate and report different physical properties.", "pagination": {"order_by": "upload_create_time", "order": "desc", "page_size": 20}, "columns": [{"search_quantity": "results.material.chemical_formula_hill", "selected": true, "title": "Formula", "align": "left"}, {"search_quantity": "results.method.simulation.program_name", "selected": true, "align": "left"}, {"search_quantity": "results.method.method_name", "selected": true, "align": "left"}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "selected": true, "align": "left"}, {"search_quantity": "upload_create_time", "selected": true, "title": "Upload time", "align": "left"}, {"search_quantity": "authors", "selected": true, "align": "left"}, {"search_quantity": "results.method.simulation.precision.apw_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.basis_set", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.k_line_density", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.native_tier", "selected": false, "align": "left"}, {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "selected": false, "align": "left"}, {"search_quantity": "results.material.structural_type", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.crystal_system", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_symbol", "selected": false, "align": "left"}, {"search_quantity": "results.material.symmetry.space_group_number", "selected": false, "align": "left"}, {"search_quantity": "entry_name", "selected": false, "title": "Name", "align": "left"}, {"search_quantity": "mainfile", "selected": false, "align": "left"}, {"search_quantity": "comment", "selected": false, "align": "left"}, {"search_quantity": "references", "selected": false, "align": "left"}, {"search_quantity": "datasets", "selected": false, "align": "left"}, {"search_quantity": "published", "selected": false, "title": "Access", "align": "left"}], "rows": {"actions": {"enabled": true, "items": []}, "details": {"enabled": true}, "selection": {"enabled": true}}, "menu": {"width": 12, "show_header": true, "title": "Filters", "type": "menu", "size": "sm", "indentation": 0, "items": [{"width": 12, "show_header": true, "title": "Material", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Elements / Formula", "type": "menu", "size": "xxl", "indentation": 1, "items": [{"type": "periodic_table", "search_quantity": "results.material.elements", "scale": "linear", "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_hill", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_iupac", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_reduced", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.chemical_formula_anonymous", "type": "terms", "scale": "linear", "show_input": true, "width": 6, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.material.n_elements", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Structure / Symmetry", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.material.structural_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.bravais_lattice", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.structure_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.strukturbericht_designation", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.point_group", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.hall_symbol", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.symmetry.prototype_aflow_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Method", "type": "menu", "size": "md", "items": [{"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.program_version", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Precision", "type": "menu", "size": "md", "indentation": 1, "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.k_line_density", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.native_tier", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.precision.basis_set", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.planewave_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.precision.apw_cutoff", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DFT": {"label": "Search DFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dft.xc_functional_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.xc_functional_names", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.exact_exchange_mixing_factor", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dft.hubbard_kanamori_model.u_effective", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.core_electron_treatment", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dft.relativity_method", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "TB", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"TB": {"label": "Search TB entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.tb.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.tb.localization_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "GW", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"GW": {"label": "Search GW entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.gw.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.gw.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "BSE", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"BSE": {"label": "Search BSE entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.bse.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.solver", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.starting_point_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.basis_set_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.bse.gw_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "DMFT", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.method_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"DMFT": {"label": "Search DMFT entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"search_quantity": "results.method.simulation.dmft.impurity_solver_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 2, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.inverse_temperature", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.magnetic_state", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.u", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.method.simulation.dmft.jh", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "results.method.simulation.dmft.analytical_continuation", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Workflow", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Molecular dynamics", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.method.workflow_name", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"MolecularDynamics": {"label": "Search molecular dynamics entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.thermodynamic.trajectory", "items": [{"search_quantity": "results.properties.thermodynamic.trajectory.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 4, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.ensemble_type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.thermodynamic.trajectory.provenance.molecular_dynamics.time_step", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Geometry Optimization", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "results.properties.available_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": false, "options": {"geometry_optimization": {"label": "Search geometry optimization entries"}}, "n_columns": 1, "sort_static": true, "show_statistics": false}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.geometry_optimization", "items": [{"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_energy_difference", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_force_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.geometry_optimization.final_displacement_maximum", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Properties", "type": "menu", "size": "md"}, {"width": 12, "show_header": true, "title": "Electronic", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "electronic_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic.band_gap", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 2, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.electronic.band_structure_electronic.band_gap.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.band_structure_electronic", "items": [{"search_quantity": "results.properties.electronic.band_structure_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.electronic.dos_electronic", "items": [{"search_quantity": "results.properties.electronic.dos_electronic.spin_polarized", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Vibrational", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "vibrational_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Mechanical", "type": "menu", "size": "md", "indentation": 1, "items": [{"search_quantity": "mechanical_properties", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.bulk_modulus", "items": [{"search_quantity": "results.properties.mechanical.bulk_modulus.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.bulk_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.shear_modulus", "items": [{"search_quantity": "results.properties.mechanical.shear_modulus.type", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "results.properties.mechanical.shear_modulus.value", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "type": "nested_object", "path": "results.properties.mechanical.energy_volume_curve", "items": [{"search_quantity": "results.properties.mechanical.energy_volume_curve.type", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}]}]}, {"width": 12, "show_header": true, "title": "Author / Origin / Dataset", "type": "menu", "size": "lg", "items": [{"search_quantity": "authors.name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"type": "histogram", "show_input": true, "x": {"search_quantity": "upload_create_time", "scale": "linear"}, "y": {"scale": "linear"}, "autorange": false, "width": 12, "show_header": true, "show_statistics": true}, {"search_quantity": "external_db", "type": "terms", "scale": "linear", "show_input": false, "width": 12, "show_header": true, "options": 5, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.doi", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}]}, {"width": 12, "show_header": true, "title": "Visibility / IDs / Schema", "type": "menu", "size": "md", "items": [{"width": 12, "show_header": true, "type": "visibility"}, {"search_quantity": "entry_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "upload_name", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "results.material.material_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"search_quantity": "datasets.dataset_id", "type": "terms", "scale": "linear", "show_input": true, "width": 12, "show_header": true, "options": 0, "n_columns": 1, "sort_static": true, "show_statistics": true}, {"width": 12, "show_header": true, "type": "definitions"}]}, {"width": 12, "show_header": true, "title": "Optimade", "type": "menu", "size": "lg", "items": [{"width": 12, "show_header": true, "type": "optimade"}]}]}, "search_quantities": {"exclude": ["mainfile", "entry_name", "combine"]}, "dashboard": {"widgets": [{"type": "periodictable", "search_quantity": "results.material.elements", "scale": "linear", "layout": {"lg": {"h": 11, "w": 14, "x": 0, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 8, "w": 12, "x": 0, "y": 0, "minH": 3, "minW": 3}, "sm": {"h": 8, "w": 12, "x": 0, "y": 0, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 14, "x": 0, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 13, "x": 0, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.symmetry.space_group_symbol", "type": "terms", "scale": "linear", "show_input": true, "layout": {"lg": {"h": 5, "w": 5, "x": 19, "y": 6, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 12, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 6, "y": 13, "minH": 3, "minW": 3}, "xl": {"h": 6, "w": 6, "x": 24, "y": 5, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 30, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.structural_type", "type": "terms", "scale": "log", "show_input": false, "layout": {"lg": {"h": 6, "w": 5, "x": 19, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 0, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 6, "y": 8, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 5, "x": 19, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 19, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.method.simulation.program_name", "type": "terms", "scale": "log", "show_input": true, "layout": {"lg": {"h": 6, "w": 5, "x": 14, "y": 0, "minH": 3, "minW": 3}, "md": {"h": 8, "w": 6, "x": 12, "y": 0, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 0, "y": 8, "minH": 3, "minW": 3}, "xl": {"h": 11, "w": 5, "x": 14, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 6, "x": 13, "y": 0, "minH": 3, "minW": 3}}}, {"search_quantity": "results.material.symmetry.crystal_system", "type": "terms", "scale": "linear", "show_input": false, "layout": {"lg": {"h": 5, "w": 5, "x": 14, "y": 6, "minH": 3, "minW": 3}, "md": {"h": 6, "w": 6, "x": 6, "y": 8, "minH": 3, "minW": 3}, "sm": {"h": 5, "w": 6, "x": 0, "y": 13, "minH": 3, "minW": 3}, "xl": {"h": 5, "w": 6, "x": 24, "y": 0, "minH": 3, "minW": 3}, "xxl": {"h": 9, "w": 5, "x": 25, "y": 0, "minH": 3, "minW": 3}}}]}, "filters_locked": {"quantities": "results.method.simulation.program_name"}, "search_syntaxes": {"exclude": ["free_text"]}}}]}}}; -- GitLab From ef17c2e1c7cfd9dec11078c6d07845b771cdb52b Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 24 Mar 2025 12:59:14 +0100 Subject: [PATCH 16/17] Fixed input label padding on none filled inputs. --- src/components/values/containers/ContainerControl.tsx | 8 +++++--- src/components/values/containers/Matrix.tsx | 6 +++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/values/containers/ContainerControl.tsx b/src/components/values/containers/ContainerControl.tsx index 2d690df9..fd177bc2 100644 --- a/src/components/values/containers/ContainerControl.tsx +++ b/src/components/values/containers/ContainerControl.tsx @@ -6,7 +6,6 @@ import { InputLabel, Stack, styled, - useTheme, } from '@mui/material' import {FocusEvent, PropsWithChildren, useCallback} from 'react' @@ -66,7 +65,6 @@ export function ValueFormControl({...props}: ValueFormControlProps) { ...formControlProps } = props const {inputId, editable} = useProps<ContainerProps>() - const theme = useTheme() return ( <FormControl @@ -83,7 +81,11 @@ export function ValueFormControl({...props}: ValueFormControlProps) { shrink id={editable ? undefined : inputId && `${inputId}-label`} htmlFor={editable ? inputId : undefined} - style={{marginLeft: editable ? theme.spacing(1.5) : 0}} + sx={{ + '&.MuiInputLabel-filled': { + marginLeft: 1.5, + }, + }} > {label} </StaticInputLabel> diff --git a/src/components/values/containers/Matrix.tsx b/src/components/values/containers/Matrix.tsx index 267525b1..b68f4802 100644 --- a/src/components/values/containers/Matrix.tsx +++ b/src/components/values/containers/Matrix.tsx @@ -173,7 +173,11 @@ export default function Matrix<Value = unknown>({ <div style={{...style, paddingLeft: 4, paddingRight: 4, textAlign: 'center'}} > - <Value value={matrixValue?.[rowIndex][columnIndex]} hiddenUnit> + <Value + value={matrixValue?.[rowIndex][columnIndex]} + hiddenUnit + editable={false} + > {renderValue?.({})} </Value> </div> -- GitLab From 841c7ed0f085d04c391cf4357f03a30160a98107 Mon Sep 17 00:00:00 2001 From: Markus Scheidgen <markus.scheidgen@gmail.com> Date: Mon, 24 Mar 2025 13:15:13 +0100 Subject: [PATCH 17/17] No matrix full width out dynamic component specs. --- src/components/archive/Section.tsx | 2 +- src/components/values/containers/ContainerControl.tsx | 2 +- src/components/values/containers/Matrix.tsx | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/archive/Section.tsx b/src/components/archive/Section.tsx index a852d515..83dd6dc6 100644 --- a/src/components/archive/Section.tsx +++ b/src/components/archive/Section.tsx @@ -68,9 +68,9 @@ function Quantity({definition, editable = false}: QuantityProps) { component={component} label={getPropertyLabel(definition.name)} placeholder='no value' - fullWidth editable={editable} value={value} + fullWidth={component.Matrix ? false : true} onChange={handleChange} /> ) diff --git a/src/components/values/containers/ContainerControl.tsx b/src/components/values/containers/ContainerControl.tsx index fd177bc2..8c3c09bc 100644 --- a/src/components/values/containers/ContainerControl.tsx +++ b/src/components/values/containers/ContainerControl.tsx @@ -92,7 +92,7 @@ export function ValueFormControl({...props}: ValueFormControlProps) { <LabelActionsContainer>{labelActions}</LabelActionsContainer> </LabelContainer> )} - <Stack direction='row' width='100%'> + <Stack direction='row' sx={{width: fullWidth ? '100%' : 'inherit'}}> {children} </Stack> {(edit || error) && helperText && ( diff --git a/src/components/values/containers/Matrix.tsx b/src/components/values/containers/Matrix.tsx index b68f4802..f651d82f 100644 --- a/src/components/values/containers/Matrix.tsx +++ b/src/components/values/containers/Matrix.tsx @@ -254,7 +254,10 @@ export default function Matrix<Value = unknown>({ return ( <ContainerControl showLabel {...containerProps}> - <Stack width='100%' marginTop={label ? 3 : undefined}> + <Stack + sx={{width: containerProps.fullWidth ? '100%' : 'inherit'}} + marginTop={label ? 3 : undefined} + > {matrixElement} </Stack> </ContainerControl> -- GitLab