diff --git a/geonode_mapstore_client/apps.py b/geonode_mapstore_client/apps.py index 9ea0005011..d6e927486c 100644 --- a/geonode_mapstore_client/apps.py +++ b/geonode_mapstore_client/apps.py @@ -13,7 +13,7 @@ from django.views.generic import TemplateView from django.utils.translation import gettext_lazy as _ from django.apps import apps, AppConfig as BaseAppConfig - +from . import views def run_setup_hooks(*args, **kwargs): from geonode.urls import urlpatterns @@ -83,6 +83,8 @@ def run_setup_hooks(*args, **kwargs): template_name="geonode-mapstore-client/catalogue.html" ), ), + re_path(r"^metadata/(?P[^/]*)$", views.metadata, name='metadata'), + re_path(r"^metadata/(?P[^/]*)/embed$", views.metadata_embed, name='metadata'), # required, otherwise will raise no-lookup errors to be analysed re_path(r"^api/v2/", include(router.urls)), ] diff --git a/geonode_mapstore_client/client/js/api/geonode/v2/constants.js b/geonode_mapstore_client/client/js/api/geonode/v2/constants.js index 98f81286aa..f1f2129e20 100644 --- a/geonode_mapstore_client/client/js/api/geonode/v2/constants.js +++ b/geonode_mapstore_client/client/js/api/geonode/v2/constants.js @@ -28,7 +28,8 @@ let endpoints = { 'groups': '/api/v2/groups', 'executionrequest': '/api/v2/executionrequest', 'facets': '/api/v2/facets', - 'uploads': '/api/v2/uploads' + 'uploads': '/api/v2/uploads', + 'metadata': '/api/v2/metadata' }; export const RESOURCES = 'resources'; @@ -42,6 +43,7 @@ export const GROUPS = 'groups'; export const EXECUTION_REQUEST = 'executionrequest'; export const FACETS = 'facets'; export const UPLOADS = 'uploads'; +export const METADATA = 'metadata'; export const setEndpoints = (data) => { endpoints = { ...endpoints, ...data }; diff --git a/geonode_mapstore_client/client/js/api/geonode/v2/metadata.js b/geonode_mapstore_client/client/js/api/geonode/v2/metadata.js new file mode 100644 index 0000000000..1dd7afe7e5 --- /dev/null +++ b/geonode_mapstore_client/client/js/api/geonode/v2/metadata.js @@ -0,0 +1,92 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ + +import axios from '@mapstore/framework/libs/ajax'; +import { + METADATA, + RESOURCES, + getEndpointUrl +} from './constants'; +import { isObject, isArray, castArray } from 'lodash'; + +const parseUiSchema = (properties) => { + return Object.keys(properties).reduce((acc, key) => { + const entry = properties[key]; + const uiKeys = Object.keys(entry).filter(propertyKey => propertyKey.indexOf('ui:') === 0); + if (uiKeys.length) { + acc[key] = Object.fromEntries(uiKeys.map(uiKey => [uiKey, entry[uiKey]])); + } + if (entry.type === 'object') { + const nestedProperties = parseUiSchema(entry?.properties); + acc[key] = { ...acc[key], ...nestedProperties }; + } + return acc; + }, {}); +}; + +let metadataSchemas; +export const getMetadataSchema = () => { + if (metadataSchemas) { + return Promise.resolve(metadataSchemas); + } + return axios.get(getEndpointUrl(METADATA, '/schema/')) + .then(({ data }) => { + const schema = data; + metadataSchemas = { + schema: schema, + uiSchema: parseUiSchema(schema?.properties || {}) + }; + return metadataSchemas; + }); +}; + +const removeNullValueRecursive = (metadata = {}, schema = {}) => { + return Object.keys(metadata).reduce((acc, key) => { + const schemaTypes = castArray(schema?.[key]?.type || []); + if (metadata[key] === null && !schemaTypes.includes('null')) { + return { + ...acc, + [key]: undefined + }; + } + return { + ...acc, + [key]: !isArray(metadata[key]) && isObject(metadata[key]) + ? removeNullValueRecursive(metadata[key], schema[key]) + : metadata[key] + }; + }, {}); +}; + +export const getMetadataByPk = (pk) => { + return getMetadataSchema() + .then(({ schema, uiSchema }) => { + const resourceProperties = ['pk', 'title', 'detail_url', 'perms']; + return Promise.all([ + axios.get(getEndpointUrl(METADATA, `/instance/${pk}/`)), + axios.get(getEndpointUrl(RESOURCES, `/${pk}/?exclude[]=*&${resourceProperties.map(value => `include[]=${value}`).join('&')}`)) + ]) + .then((response) => { + const metadataResponse = response?.[0]?.data || {}; + const resource = response?.[1]?.data?.resource || {}; + const { extraErrors, ...metadata } = metadataResponse; + return { + schema, + uiSchema, + metadata: removeNullValueRecursive(metadata, schema?.properties), + resource, + extraErrors + }; + }); + }); +}; + +export const updateMetadata = (pk, body) => { + return axios.put(getEndpointUrl(METADATA, `/instance/${pk}/`), body) + .then(({ data }) => data); +}; diff --git a/geonode_mapstore_client/client/js/components/InputControlWithDebounce/InputControlWithDebounce.jsx b/geonode_mapstore_client/client/js/components/InputControlWithDebounce/InputControlWithDebounce.jsx new file mode 100644 index 0000000000..330ac3f80a --- /dev/null +++ b/geonode_mapstore_client/client/js/components/InputControlWithDebounce/InputControlWithDebounce.jsx @@ -0,0 +1,19 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. +*/ + +import React from "react"; +import { FormControl as FormControlRB } from 'react-bootstrap'; +import withDebounceOnCallback from '@mapstore/framework/components/misc/enhancers/withDebounceOnCallback'; +import localizedProps from '@mapstore/framework/components/misc/enhancers/localizedProps'; +const FormControl = localizedProps('placeholder')(FormControlRB); +function InputControl({ onChange, value, debounceTime, ...props }) { + return onChange(event.target.value)}/>; +} +const InputControlWithDebounce = withDebounceOnCallback('onChange', 'value')(InputControl); + +export default InputControlWithDebounce; diff --git a/geonode_mapstore_client/client/js/components/InputControlWithDebounce/index.js b/geonode_mapstore_client/client/js/components/InputControlWithDebounce/index.js new file mode 100644 index 0000000000..3be5df86a6 --- /dev/null +++ b/geonode_mapstore_client/client/js/components/InputControlWithDebounce/index.js @@ -0,0 +1 @@ +export { default } from './InputControlWithDebounce'; diff --git a/geonode_mapstore_client/client/js/components/SelectInfiniteScroll/SelectInfiniteScroll.jsx b/geonode_mapstore_client/client/js/components/SelectInfiniteScroll/SelectInfiniteScroll.jsx index e05fa7a958..dc33e6f4a0 100644 --- a/geonode_mapstore_client/client/js/components/SelectInfiniteScroll/SelectInfiniteScroll.jsx +++ b/geonode_mapstore_client/client/js/components/SelectInfiniteScroll/SelectInfiniteScroll.jsx @@ -9,6 +9,7 @@ import React, { useRef, useState, useEffect } from 'react'; import axios from '@mapstore/framework/libs/ajax'; import debounce from 'lodash/debounce'; +import isEmpty from 'lodash/isEmpty'; import ReactSelect from 'react-select'; import localizedProps from '@mapstore/framework/components/misc/enhancers/localizedProps'; @@ -18,6 +19,9 @@ function SelectInfiniteScroll({ loadOptions, pageSize = 20, debounceTime = 500, + labelKey = "label", + valueKey = "value", + newOptionPromptText = "Create option", ...props }) { @@ -40,6 +44,27 @@ function SelectInfiniteScroll({ source.current = cancelToken.source(); }; + const updateNewOption = (newOptions, query) => { + if (props.creatable && !isEmpty(query)) { + const compareValue = (option) => + option?.[labelKey]?.toLowerCase() === query.toLowerCase(); + + const isValueExist = props.value?.some(compareValue); + const isOptionExist = newOptions.some(compareValue); + + // Add new option if it doesn't exist and `creatable` is enabled + if (!isValueExist && !isOptionExist) { + return [{ + [labelKey]: `${newOptionPromptText} "${query}"`, + [valueKey]: query, + result: { [valueKey]: query, [labelKey]: query } + }].concat(newOptions); + } + return newOptions; + } + return newOptions; + }; + const handleUpdateOptions = useRef(); handleUpdateOptions.current = (args = {}) => { createToken(); @@ -56,8 +81,10 @@ function SelectInfiniteScroll({ } }) .then((response) => { - const newOptions = response.results.map(({ selectOption }) => selectOption); - setOptions(newPage === 1 ? newOptions : [...options, ...newOptions]); + let newOptions = response.results.map(({ selectOption }) => selectOption); + newOptions = newPage === 1 ? newOptions : [...options, ...newOptions]; + newOptions = updateNewOption(newOptions, query); + setOptions(newOptions); setIsNextPageAvailable(response.isNextPageAvailable); setLoading(false); source.current = undefined; @@ -89,7 +116,7 @@ function SelectInfiniteScroll({ handleUpdateOptions.current({ q: value, page: 1 }); } }, debounceTime); - }, []); + }, [text]); useEffect(() => { if (open) { @@ -106,16 +133,23 @@ function SelectInfiniteScroll({ } }, [page]); + const filterOptions = (currentOptions) => { + return currentOptions.map(option=> { + const match = /\"(.*?)\"/.exec(text); + return match ? match[1] : option; + }); + }; + return ( setOpen(true)} onClose={() => setOpen(false)} - filterOptions={(currentOptions) => { - return currentOptions; - }} + filterOptions={filterOptions} onInputChange={(q) => handleInputChange(q)} onMenuScrollToBottom={() => { if (!loading && isNextPageAvailable) { diff --git a/geonode_mapstore_client/client/js/plugins/MetadataEditor/MetadataViewer.jsx b/geonode_mapstore_client/client/js/plugins/MetadataEditor/MetadataViewer.jsx new file mode 100644 index 0000000000..c3085034a5 --- /dev/null +++ b/geonode_mapstore_client/client/js/plugins/MetadataEditor/MetadataViewer.jsx @@ -0,0 +1,98 @@ +/* + * Copyright 2024, GeoSolutions Sas. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. + */ +import React from 'react'; +import { createPlugin } from '@mapstore/framework/utils/PluginsUtils'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { withRouter } from 'react-router'; +import isEqual from 'lodash/isEqual'; +import Message from '@mapstore/framework/components/I18N/Message'; +import ResizableModal from '@mapstore/framework/components/misc/ResizableModal'; +import Portal from '@mapstore/framework/components/misc/Portal'; +import Button from '@js/components/Button'; + +import { + setMetadataPreview +} from './actions/metadata'; + +import metadataReducer from './reducers/metadata'; + +const connectMetadataViewer = connect( + createSelector([ + state => state?.metadata?.metadata, + state => state?.metadata?.initialMetadata, + state => state?.metadata?.preview + ], (metadata, initialMetadata, preview) => ({ + preview, + pendingChanges: !isEqual(initialMetadata, metadata) + })), + { + setPreview: setMetadataPreview + } +); + +const MetadataViewer = ({ + match, + preview, + setPreview, + labelId = 'gnviewer.viewMetadata' +}) => { + const { params } = match || {}; + const pk = params?.pk; + return ( + + } + show={preview} + size="lg" + clickOutEnabled={false} + modalClassName="gn-simple-dialog" + onClose={() => setPreview(false)} + > +