Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Metadata manager #1931

Open
wants to merge 31 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f9f14a2
initial setup of metadata editor
allyoucanmap Oct 17, 2024
1e0c83d
Merge branch 'master' of github.com:GeoNode/geonode-mapstore-client i…
allyoucanmap Oct 21, 2024
fda5b0e
review metadata editor ui
allyoucanmap Oct 24, 2024
1f4d693
add metadata viewer layout
allyoucanmap Oct 25, 2024
fa0a4f4
improve update and filter feedback
allyoucanmap Oct 25, 2024
78f7a50
review metadata view
allyoucanmap Oct 28, 2024
4013071
Autocomplete: Single select entry and pagination support (#1903)
dsuren1 Nov 12, 2024
bedc777
Autocomplete with create entries & metadata title field with help int…
dsuren1 Nov 19, 2024
45a7fd5
Fix: Some titles are not capitalized in metadata form (#1911)
dsuren1 Nov 22, 2024
cbfc8f8
Autocomplete enhancement with additional schema types (#1910)
dsuren1 Nov 22, 2024
9f79aaf
Fix - Autocomplete field not showing validation error (#1912)
dsuren1 Nov 28, 2024
753ed98
Handle options and errors on field (#1915)
dsuren1 Dec 10, 2024
d8e2d42
Plain arrays (non autocomplete) fix render (#1921)
dsuren1 Dec 10, 2024
e2dff1b
Handle extra error when returned in the failure response (#1927)
dsuren1 Dec 11, 2024
c86b34d
Fix root errors rendering (#1928)
dsuren1 Dec 12, 2024
b8b91a3
update metadata editor styles
allyoucanmap Dec 13, 2024
53090ec
trigger validation on form initialization
allyoucanmap Dec 13, 2024
8c20031
fix failing tests
allyoucanmap Dec 13, 2024
cdf49c1
render initial extraErrors
allyoucanmap Dec 13, 2024
491acd8
review error messages
allyoucanmap Dec 13, 2024
03fbf1e
capitalize field titles in the sidebar
allyoucanmap Dec 17, 2024
e498522
disable metadata editor for user without permissions
allyoucanmap Dec 17, 2024
492bb45
fix metadata resource monitored state
allyoucanmap Dec 17, 2024
7970a68
move readonly logic from localConfig to plugin
allyoucanmap Dec 17, 2024
3bbaaf8
improve error messages
allyoucanmap Dec 18, 2024
906c1c1
add parser for null values
allyoucanmap Dec 18, 2024
855872e
improve autocomplete
allyoucanmap Dec 18, 2024
3c0284a
fix form initialization
allyoucanmap Dec 18, 2024
924731d
revert changes to autocomplete
allyoucanmap Dec 18, 2024
567e1d7
fix metadata viewer
allyoucanmap Dec 18, 2024
b697f14
Merge branch 'master' of github.com:GeoNode/geonode-mapstore-client i…
allyoucanmap Dec 19, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading