Skip to content

Commit

Permalink
Metadata manager (#1931)
Browse files Browse the repository at this point in the history
  • Loading branch information
allyoucanmap authored Jan 15, 2025
1 parent c962040 commit b1bfe81
Show file tree
Hide file tree
Showing 59 changed files with 2,553 additions and 65 deletions.
4 changes: 3 additions & 1 deletion geonode_mapstore_client/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -83,6 +83,8 @@ def run_setup_hooks(*args, **kwargs):
template_name="geonode-mapstore-client/catalogue.html"
),
),
re_path(r"^metadata/(?P<pk>[^/]*)$", views.metadata, name='metadata'),
re_path(r"^metadata/(?P<pk>[^/]*)/embed$", views.metadata_embed, name='metadata'),
# required, otherwise will raise no-lookup errors to be analysed
re_path(r"^api/v2/", include(router.urls)),
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 };
Expand Down
92 changes: 92 additions & 0 deletions geonode_mapstore_client/client/js/api/geonode/v2/metadata.js
Original file line number Diff line number Diff line change
@@ -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);
};
Original file line number Diff line number Diff line change
@@ -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 <FormControl {...props} value={value} onChange={event => onChange(event.target.value)}/>;
}
const InputControlWithDebounce = withDebounceOnCallback('onChange', 'value')(InputControl);

export default InputControlWithDebounce;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './InputControlWithDebounce';
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -18,6 +19,9 @@ function SelectInfiniteScroll({
loadOptions,
pageSize = 20,
debounceTime = 500,
labelKey = "label",
valueKey = "value",
newOptionPromptText = "Create option",
...props
}) {

Expand All @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -89,7 +116,7 @@ function SelectInfiniteScroll({
handleUpdateOptions.current({ q: value, page: 1 });
}
}, debounceTime);
}, []);
}, [text]);

useEffect(() => {
if (open) {
Expand All @@ -106,16 +133,23 @@ function SelectInfiniteScroll({
}
}, [page]);

const filterOptions = (currentOptions) => {
return currentOptions.map(option=> {
const match = /\"(.*?)\"/.exec(text);
return match ? match[1] : option;
});
};

return (
<SelectSync
{...props}
isLoading={loading}
options={options}
labelKey={labelKey}
valueKey={valueKey}
onOpen={() => setOpen(true)}
onClose={() => setOpen(false)}
filterOptions={(currentOptions) => {
return currentOptions;
}}
filterOptions={filterOptions}
onInputChange={(q) => handleInputChange(q)}
onMenuScrollToBottom={() => {
if (!loading && isNextPageAvailable) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Portal>
<ResizableModal
title={<Message msgId={labelId} />}
show={preview}
size="lg"
clickOutEnabled={false}
modalClassName="gn-simple-dialog"
onClose={() => setPreview(false)}
>
<iframe style={{ border: 'none', position: 'absolute', width: '100%', height: '100%' }} src={`/metadata/${pk}/embed`} />
</ResizableModal>
</Portal>
);
};

const MetadataViewerPlugin = connectMetadataViewer(withRouter(MetadataViewer));

const PreviewButton = ({
size,
variant,
pendingChanges,
setPreview = () => {},
labelId = 'gnviewer.viewMetadata'
}) => {
return (
<Button
size={size}
variant={variant}
disabled={pendingChanges}
onClick={() => setPreview(true)}
>
<Message msgId={labelId} />
</Button>
);
};

const PreviewButtonPlugin = connectMetadataViewer(PreviewButton);

export default createPlugin('MetadataViewer', {
component: MetadataViewerPlugin,
containers: {
ActionNavbar: {
name: 'MetadataViewer',
Component: PreviewButtonPlugin
}
},
epics: {},
reducers: {
metadata: metadataReducer
}
});
Loading

0 comments on commit b1bfe81

Please sign in to comment.