Skip to content

Commit b1bfe81

Browse files
authored
Metadata manager (#1931)
1 parent c962040 commit b1bfe81

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

59 files changed

+2553
-65
lines changed

geonode_mapstore_client/apps.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from django.views.generic import TemplateView
1414
from django.utils.translation import gettext_lazy as _
1515
from django.apps import apps, AppConfig as BaseAppConfig
16-
16+
from . import views
1717

1818
def run_setup_hooks(*args, **kwargs):
1919
from geonode.urls import urlpatterns
@@ -83,6 +83,8 @@ def run_setup_hooks(*args, **kwargs):
8383
template_name="geonode-mapstore-client/catalogue.html"
8484
),
8585
),
86+
re_path(r"^metadata/(?P<pk>[^/]*)$", views.metadata, name='metadata'),
87+
re_path(r"^metadata/(?P<pk>[^/]*)/embed$", views.metadata_embed, name='metadata'),
8688
# required, otherwise will raise no-lookup errors to be analysed
8789
re_path(r"^api/v2/", include(router.urls)),
8890
]

geonode_mapstore_client/client/js/api/geonode/v2/constants.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ let endpoints = {
2828
'groups': '/api/v2/groups',
2929
'executionrequest': '/api/v2/executionrequest',
3030
'facets': '/api/v2/facets',
31-
'uploads': '/api/v2/uploads'
31+
'uploads': '/api/v2/uploads',
32+
'metadata': '/api/v2/metadata'
3233
};
3334

3435
export const RESOURCES = 'resources';
@@ -42,6 +43,7 @@ export const GROUPS = 'groups';
4243
export const EXECUTION_REQUEST = 'executionrequest';
4344
export const FACETS = 'facets';
4445
export const UPLOADS = 'uploads';
46+
export const METADATA = 'metadata';
4547

4648
export const setEndpoints = (data) => {
4749
endpoints = { ...endpoints, ...data };
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2024, GeoSolutions Sas.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import axios from '@mapstore/framework/libs/ajax';
10+
import {
11+
METADATA,
12+
RESOURCES,
13+
getEndpointUrl
14+
} from './constants';
15+
import { isObject, isArray, castArray } from 'lodash';
16+
17+
const parseUiSchema = (properties) => {
18+
return Object.keys(properties).reduce((acc, key) => {
19+
const entry = properties[key];
20+
const uiKeys = Object.keys(entry).filter(propertyKey => propertyKey.indexOf('ui:') === 0);
21+
if (uiKeys.length) {
22+
acc[key] = Object.fromEntries(uiKeys.map(uiKey => [uiKey, entry[uiKey]]));
23+
}
24+
if (entry.type === 'object') {
25+
const nestedProperties = parseUiSchema(entry?.properties);
26+
acc[key] = { ...acc[key], ...nestedProperties };
27+
}
28+
return acc;
29+
}, {});
30+
};
31+
32+
let metadataSchemas;
33+
export const getMetadataSchema = () => {
34+
if (metadataSchemas) {
35+
return Promise.resolve(metadataSchemas);
36+
}
37+
return axios.get(getEndpointUrl(METADATA, '/schema/'))
38+
.then(({ data }) => {
39+
const schema = data;
40+
metadataSchemas = {
41+
schema: schema,
42+
uiSchema: parseUiSchema(schema?.properties || {})
43+
};
44+
return metadataSchemas;
45+
});
46+
};
47+
48+
const removeNullValueRecursive = (metadata = {}, schema = {}) => {
49+
return Object.keys(metadata).reduce((acc, key) => {
50+
const schemaTypes = castArray(schema?.[key]?.type || []);
51+
if (metadata[key] === null && !schemaTypes.includes('null')) {
52+
return {
53+
...acc,
54+
[key]: undefined
55+
};
56+
}
57+
return {
58+
...acc,
59+
[key]: !isArray(metadata[key]) && isObject(metadata[key])
60+
? removeNullValueRecursive(metadata[key], schema[key])
61+
: metadata[key]
62+
};
63+
}, {});
64+
};
65+
66+
export const getMetadataByPk = (pk) => {
67+
return getMetadataSchema()
68+
.then(({ schema, uiSchema }) => {
69+
const resourceProperties = ['pk', 'title', 'detail_url', 'perms'];
70+
return Promise.all([
71+
axios.get(getEndpointUrl(METADATA, `/instance/${pk}/`)),
72+
axios.get(getEndpointUrl(RESOURCES, `/${pk}/?exclude[]=*&${resourceProperties.map(value => `include[]=${value}`).join('&')}`))
73+
])
74+
.then((response) => {
75+
const metadataResponse = response?.[0]?.data || {};
76+
const resource = response?.[1]?.data?.resource || {};
77+
const { extraErrors, ...metadata } = metadataResponse;
78+
return {
79+
schema,
80+
uiSchema,
81+
metadata: removeNullValueRecursive(metadata, schema?.properties),
82+
resource,
83+
extraErrors
84+
};
85+
});
86+
});
87+
};
88+
89+
export const updateMetadata = (pk, body) => {
90+
return axios.put(getEndpointUrl(METADATA, `/instance/${pk}/`), body)
91+
.then(({ data }) => data);
92+
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
* Copyright 2024, GeoSolutions Sas.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React from "react";
10+
import { FormControl as FormControlRB } from 'react-bootstrap';
11+
import withDebounceOnCallback from '@mapstore/framework/components/misc/enhancers/withDebounceOnCallback';
12+
import localizedProps from '@mapstore/framework/components/misc/enhancers/localizedProps';
13+
const FormControl = localizedProps('placeholder')(FormControlRB);
14+
function InputControl({ onChange, value, debounceTime, ...props }) {
15+
return <FormControl {...props} value={value} onChange={event => onChange(event.target.value)}/>;
16+
}
17+
const InputControlWithDebounce = withDebounceOnCallback('onChange', 'value')(InputControl);
18+
19+
export default InputControlWithDebounce;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './InputControlWithDebounce';

geonode_mapstore_client/client/js/components/SelectInfiniteScroll/SelectInfiniteScroll.jsx

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import React, { useRef, useState, useEffect } from 'react';
1010
import axios from '@mapstore/framework/libs/ajax';
1111
import debounce from 'lodash/debounce';
12+
import isEmpty from 'lodash/isEmpty';
1213
import ReactSelect from 'react-select';
1314
import localizedProps from '@mapstore/framework/components/misc/enhancers/localizedProps';
1415

@@ -18,6 +19,9 @@ function SelectInfiniteScroll({
1819
loadOptions,
1920
pageSize = 20,
2021
debounceTime = 500,
22+
labelKey = "label",
23+
valueKey = "value",
24+
newOptionPromptText = "Create option",
2125
...props
2226
}) {
2327

@@ -40,6 +44,27 @@ function SelectInfiniteScroll({
4044
source.current = cancelToken.source();
4145
};
4246

47+
const updateNewOption = (newOptions, query) => {
48+
if (props.creatable && !isEmpty(query)) {
49+
const compareValue = (option) =>
50+
option?.[labelKey]?.toLowerCase() === query.toLowerCase();
51+
52+
const isValueExist = props.value?.some(compareValue);
53+
const isOptionExist = newOptions.some(compareValue);
54+
55+
// Add new option if it doesn't exist and `creatable` is enabled
56+
if (!isValueExist && !isOptionExist) {
57+
return [{
58+
[labelKey]: `${newOptionPromptText} "${query}"`,
59+
[valueKey]: query,
60+
result: { [valueKey]: query, [labelKey]: query }
61+
}].concat(newOptions);
62+
}
63+
return newOptions;
64+
}
65+
return newOptions;
66+
};
67+
4368
const handleUpdateOptions = useRef();
4469
handleUpdateOptions.current = (args = {}) => {
4570
createToken();
@@ -56,8 +81,10 @@ function SelectInfiniteScroll({
5681
}
5782
})
5883
.then((response) => {
59-
const newOptions = response.results.map(({ selectOption }) => selectOption);
60-
setOptions(newPage === 1 ? newOptions : [...options, ...newOptions]);
84+
let newOptions = response.results.map(({ selectOption }) => selectOption);
85+
newOptions = newPage === 1 ? newOptions : [...options, ...newOptions];
86+
newOptions = updateNewOption(newOptions, query);
87+
setOptions(newOptions);
6188
setIsNextPageAvailable(response.isNextPageAvailable);
6289
setLoading(false);
6390
source.current = undefined;
@@ -89,7 +116,7 @@ function SelectInfiniteScroll({
89116
handleUpdateOptions.current({ q: value, page: 1 });
90117
}
91118
}, debounceTime);
92-
}, []);
119+
}, [text]);
93120

94121
useEffect(() => {
95122
if (open) {
@@ -106,16 +133,23 @@ function SelectInfiniteScroll({
106133
}
107134
}, [page]);
108135

136+
const filterOptions = (currentOptions) => {
137+
return currentOptions.map(option=> {
138+
const match = /\"(.*?)\"/.exec(text);
139+
return match ? match[1] : option;
140+
});
141+
};
142+
109143
return (
110144
<SelectSync
111145
{...props}
112146
isLoading={loading}
113147
options={options}
148+
labelKey={labelKey}
149+
valueKey={valueKey}
114150
onOpen={() => setOpen(true)}
115151
onClose={() => setOpen(false)}
116-
filterOptions={(currentOptions) => {
117-
return currentOptions;
118-
}}
152+
filterOptions={filterOptions}
119153
onInputChange={(q) => handleInputChange(q)}
120154
onMenuScrollToBottom={() => {
121155
if (!loading && isNextPageAvailable) {
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
* Copyright 2024, GeoSolutions Sas.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
import React from 'react';
9+
import { createPlugin } from '@mapstore/framework/utils/PluginsUtils';
10+
import { connect } from 'react-redux';
11+
import { createSelector } from 'reselect';
12+
import { withRouter } from 'react-router';
13+
import isEqual from 'lodash/isEqual';
14+
import Message from '@mapstore/framework/components/I18N/Message';
15+
import ResizableModal from '@mapstore/framework/components/misc/ResizableModal';
16+
import Portal from '@mapstore/framework/components/misc/Portal';
17+
import Button from '@js/components/Button';
18+
19+
import {
20+
setMetadataPreview
21+
} from './actions/metadata';
22+
23+
import metadataReducer from './reducers/metadata';
24+
25+
const connectMetadataViewer = connect(
26+
createSelector([
27+
state => state?.metadata?.metadata,
28+
state => state?.metadata?.initialMetadata,
29+
state => state?.metadata?.preview
30+
], (metadata, initialMetadata, preview) => ({
31+
preview,
32+
pendingChanges: !isEqual(initialMetadata, metadata)
33+
})),
34+
{
35+
setPreview: setMetadataPreview
36+
}
37+
);
38+
39+
const MetadataViewer = ({
40+
match,
41+
preview,
42+
setPreview,
43+
labelId = 'gnviewer.viewMetadata'
44+
}) => {
45+
const { params } = match || {};
46+
const pk = params?.pk;
47+
return (
48+
<Portal>
49+
<ResizableModal
50+
title={<Message msgId={labelId} />}
51+
show={preview}
52+
size="lg"
53+
clickOutEnabled={false}
54+
modalClassName="gn-simple-dialog"
55+
onClose={() => setPreview(false)}
56+
>
57+
<iframe style={{ border: 'none', position: 'absolute', width: '100%', height: '100%' }} src={`/metadata/${pk}/embed`} />
58+
</ResizableModal>
59+
</Portal>
60+
);
61+
};
62+
63+
const MetadataViewerPlugin = connectMetadataViewer(withRouter(MetadataViewer));
64+
65+
const PreviewButton = ({
66+
size,
67+
variant,
68+
pendingChanges,
69+
setPreview = () => {},
70+
labelId = 'gnviewer.viewMetadata'
71+
}) => {
72+
return (
73+
<Button
74+
size={size}
75+
variant={variant}
76+
disabled={pendingChanges}
77+
onClick={() => setPreview(true)}
78+
>
79+
<Message msgId={labelId} />
80+
</Button>
81+
);
82+
};
83+
84+
const PreviewButtonPlugin = connectMetadataViewer(PreviewButton);
85+
86+
export default createPlugin('MetadataViewer', {
87+
component: MetadataViewerPlugin,
88+
containers: {
89+
ActionNavbar: {
90+
name: 'MetadataViewer',
91+
Component: PreviewButtonPlugin
92+
}
93+
},
94+
epics: {},
95+
reducers: {
96+
metadata: metadataReducer
97+
}
98+
});

0 commit comments

Comments
 (0)