Skip to content

Commit

Permalink
GeoNode#1945: Implement time series settings inside the Management panel
Browse files Browse the repository at this point in the history
  • Loading branch information
dsuren1 committed Jan 27, 2025
1 parent c0f8177 commit 26cce04
Show file tree
Hide file tree
Showing 21 changed files with 522 additions and 40 deletions.
10 changes: 10 additions & 0 deletions geonode_mapstore_client/client/js/api/geonode/v2/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ export const getDatasetByPk = (pk) => {
.then(({ data }) => data.dataset);
};

export const getDatasetTimeSettingsByPk = (pk) => {
return axios.get(getEndpointUrl(DATASETS, `/${pk}/timeseries`))
.then(({ data }) => data).catch(() => {});
};

export const getDocumentByPk = (pk) => {
return axios.get(getEndpointUrl(DOCUMENTS, `/${pk}`), {
params: {
Expand Down Expand Up @@ -394,6 +399,11 @@ export const updateDataset = (pk, body) => {
.then(({ data }) => (data.dataset));
};

export const updateDatasetTimeSeries = (pk, body) => {
return axios.put(getEndpointUrl(DATASETS, `/${pk}/timeseries`), body)
.then(({ data }) => data);
};

export const updateDocument = (pk, body) => {
return axios.patch(getEndpointUrl(DOCUMENTS, `/${pk}`), body)
.then(({ data }) => data.document);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import React, { forwardRef } from 'react';
import { Checkbox } from 'react-bootstrap';
import Message from '@mapstore/framework/components/I18N/Message';
import { RESOURCE_MANAGEMENT_PROPERTIES } from '@js/utils/ResourceUtils';
import tooltip from '@mapstore/framework/components/misc/enhancers/tooltip';
import { RESOURCE_MANAGEMENT_PROPERTIES } from '@js/utils/ResourceUtils';
import TimeSeriesSettings from '@js/components/DetailsPanel/DetailsTimeSeries';

const MessageTooltip = tooltip(forwardRef(({children, msgId, ...props}, ref) => {
return (
Expand Down Expand Up @@ -35,6 +36,9 @@ function DetailsSettings({ resource, onChange }) {
</div>
);
})}
<div className="gn-details-info-form">
<TimeSeriesSettings resource={resource} onChange={onChange} />
</div>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
* Copyright 2025, 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, { useEffect, useState } from 'react';
import PropTypes from "prop-types";
import isNil from 'lodash/isNil';
import isEmpty from 'lodash/isEmpty';
import { Checkbox, FormGroup, ControlLabel, FormControl, HelpBlock } from 'react-bootstrap';
import Select from 'react-select';
import Message from '@mapstore/framework/components/I18N/Message';
import HTML from '@mapstore/framework/components/I18N/HTML';
import { TIME_SERIES_PROPERTIES, TIME_ATTRIBUTE_TYPES, TIME_PRECISION_STEPS } from '@js/utils/ResourceUtils';
import { getMessageById } from '@mapstore/framework/utils/LocaleUtils';
import InfoPopover from '@mapstore/framework/components/widgets/widget/InfoPopover';

const TimeSeriesSettings = ({ resource, onChange }, context) => {
const timeAttributes = (resource?.attribute_set ?? [])
.filter((attribute) => TIME_ATTRIBUTE_TYPES.includes(attribute.attribute_type))
.map((attribute)=> ({value: attribute.pk, label: attribute.attribute}));

if (isEmpty(timeAttributes)) return null;

const [timeseries, setTimeSeries] = useState();
const [error, setError] = useState();
useEffect(() => {
resource.timeseries && setTimeSeries(resource.timeseries);
}, [resource?.timeseries]);


const onChangeTimeSettings = (key, value) => {
const _timeseries = {
...timeseries,
[key]: value,
...(key === "presentation"
? value === "LIST"
// reset precision field when presentation is LIST
? {precision_value: undefined, precision_step: undefined}
: {precision_value: null, precision_step: "seconds"} : undefined
),
...(key === "has_time"
? !value
// reset all time series properties when has_time is `false`
? TIME_SERIES_PROPERTIES.reduce((obj, prop) => ({...obj, [prop]: undefined}), {})
: { presentation: "LIST"} : undefined
)
};
setTimeSeries(_timeseries);
setError(_timeseries.has_time ? isNil(_timeseries.attribute) && isNil(_timeseries.end_attribute) : false );
};

useEffect(() => {
if (!error && !isEmpty(timeseries)) {
// update resource when timeseries is changed and valid
onChange({timeseries}, "timeseries");
}
}, [error, JSON.stringify(timeseries)]);

const attributeFields = ['attribute', 'end_attribute'];
const hasTime = !!timeseries?.has_time;
return (
<>
<div className="gn-details-info-row gn-details-flex-field">
<Message msgId={"gnviewer.timeSeriesSetting.title"} />
<InfoPopover
glyph="info-sign"
placement="right"
title={<Message msgId="gnviewer.timeSeriesSetting.additionalHelp" />}
popoverStyle={{ maxWidth: 500 }}
text={<HTML msgId="gnviewer.timeSeriesSetting.helpText"/>}
/>
</div>
<div className="gn-details-info-row gn-details-flex-field">
<Checkbox
style={{ margin: 0 }}
checked={hasTime}
onChange={(event) => onChangeTimeSettings('has_time', !!event.target.checked)}
>
<Message msgId={"gnviewer.timeSeriesSetting.hasTime"}/>
</Checkbox>
</div>
{hasTime && <div className="gn-time-settings-form">
<div className="gn-details-info-row gn-details-flex-field">
{attributeFields.map((attributeField, index) => (
<FormGroup validationState={error ? "error" : null} >
<ControlLabel><Message msgId={`gnviewer.timeSeriesSetting.${attributeField}`} /></ControlLabel>
<Select
fullWidth={false}
clearable={false}
key={`time-attribute-${index}`}
options={timeAttributes}
value={timeseries[attributeField]}
onChange={({ value } = {}) => onChangeTimeSettings(attributeField, value)}
/>
{error && <HelpBlock><Message msgId="gnviewer.timeSeriesSetting.helpTextAttribute"/></HelpBlock>}
</FormGroup>)
)}
</div>
<div className="gn-details-info-row gn-details-flex-field">
<FormGroup>
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.presentation" /></ControlLabel>
<Select
fullWidth={false}
clearable={false}
key="presentation-dropdown"
options={[
{value: "LIST", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.list") },
{value: "DISCRETE_INTERVAL", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.discreteInterval") },
{value: "CONTINUOUS_INTERVAL", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.continuousInterval") }
]}
value={timeseries.presentation}
onChange={({ value } = {}) => onChangeTimeSettings("presentation", value)}
/>
</FormGroup>
</div>
{timeseries?.presentation && timeseries?.presentation !== "LIST" && <div className="gn-details-info-row gn-details-flex-field">
<FormGroup>
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.precisionValue" /></ControlLabel>
<FormControl
type="number"
value={timeseries.precision_value}
onChange={(event) => {
let value = event.target.value;
value = value ? Number(value) : null;
onChangeTimeSettings("precision_value", value);
}} />
</FormGroup>
<FormGroup>
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.precisionStep" /></ControlLabel>
<Select
fullWidth={false}
clearable={false}
key="precision-step-dropdown"
options={TIME_PRECISION_STEPS.map(precisionStep=> (
{value: precisionStep, label: getMessageById(context.messages, `gnviewer.timeSeriesSetting.${precisionStep}`) }
))}
value={timeseries.precision_step}
onChange={({ value } = {}) => onChangeTimeSettings("precision_step", value)}
/>
</FormGroup>
</div>}
</div>}
</>
);
};

TimeSeriesSettings.contextTypes = {
messages: PropTypes.object
};

export default TimeSeriesSettings;
12 changes: 7 additions & 5 deletions geonode_mapstore_client/client/js/epics/gnresource.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
getCompactPermissionsByPk,
setResourceThumbnail,
setLinkedResourcesByPk,
removeLinkedResourcesByPk
removeLinkedResourcesByPk,
getDatasetTimeSettingsByPk
} from '@js/api/geonode/v2';
import { configureMap } from '@mapstore/framework/actions/config';
import { mapSelector } from '@mapstore/framework/selectors/map';
Expand Down Expand Up @@ -130,14 +131,15 @@ const resourceTypes = {
? new Promise(resolve => resolve(options.resourceData))
: isDefaultDatasetSubtype(subtype)
? getDatasetByPk(pk)
: getResourceByPk(pk)
: getResourceByPk(pk),
getDatasetTimeSettingsByPk(pk)
])
.then((response) => {
const [mapConfig, gnLayer] = response;
const [mapConfig, gnLayer, timeseries] = response;
const newLayer = resourceToLayerConfig(gnLayer);

if (!newLayer?.extendedParams?.defaultStyle || page !== 'dataset_edit_style_viewer') {
return [mapConfig, gnLayer, newLayer];
return [mapConfig, {...gnLayer, timeseries}, newLayer];
}

return getStyleProperties({
Expand All @@ -146,7 +148,7 @@ const resourceTypes = {
}).then((updatedStyle) => {
return [
mapConfig,
gnLayer,
{...gnLayer, timeseries},
{
...newLayer,
availableStyles: [{
Expand Down
23 changes: 18 additions & 5 deletions geonode_mapstore_client/client/js/epics/gnsave.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
import {
getResourceByPk,
updateDataset,
updateDatasetTimeSeries,
createGeoApp,
updateGeoApp,
createMap,
Expand Down Expand Up @@ -73,12 +74,15 @@ import {
ResourceTypes,
cleanCompactPermissions,
toGeoNodeMapConfig,
RESOURCE_MANAGEMENT_PROPERTIES
RESOURCE_MANAGEMENT_PROPERTIES,
getDimensions
} from '@js/utils/ResourceUtils';
import {
ProcessTypes,
ProcessStatus
} from '@js/utils/ResourceServiceUtils';
import { updateNode } from '@mapstore/framework/actions/layers';
import { layersSelector } from '@mapstore/framework/selectors/layers';

const RESOURCE_MANAGEMENT_PROPERTIES_KEYS = Object.keys(RESOURCE_MANAGEMENT_PROPERTIES);

Expand Down Expand Up @@ -146,6 +150,7 @@ export const gnSaveContent = (action$, store) =>
const data = getDataPayload(state, contentType);
const extent = getExtentPayload(state, contentType);
const currentResource = getResourceData(state);
const timeseries = currentResource?.timeseries;
const body = {
'title': action.metadata.name,
...(RESOURCE_MANAGEMENT_PROPERTIES_KEYS.reduce((acc, key) => {
Expand All @@ -156,10 +161,15 @@ export const gnSaveContent = (action$, store) =>
}, {})),
...(action.metadata.description && { 'abstract': action.metadata.description }),
...(data && { 'data': JSON.parse(JSON.stringify(data)) }),
...(extent && { extent })
...(extent && { extent }),
...(timeseries && { has_time: timeseries?.has_time })
};
return Observable.defer(() => SaveAPI[contentType](state, action.id, body, action.reload))
.switchMap((resource) => {
const layerId = layersSelector(state)?.find((l) => l.pk === currentResource.pk)?.id;
return Observable.defer(() => axios.all([
SaveAPI[contentType](state, action.id, body, action.reload)]
.concat(contentType === ResourceTypes.DATASET && timeseries?.has_time ? [updateDatasetTimeSeries(action.id, timeseries)] : []))
)
.switchMap(([resource]) => {
if (action.reload) {
if (contentType === ResourceTypes.VIEWER) {
const sourcepk = get(state, 'router.location.pathname', '').split('/').pop();
Expand All @@ -169,13 +179,16 @@ export const gnSaveContent = (action$, store) =>
window.location.reload();
return Observable.empty();
}
const dimensions = timeseries?.has_time ? getDimensions({...currentResource, has_time: true}) : [];
return Observable.of(
saveSuccess(resource),
setResource({
...currentResource,
...body,
...resource
...resource,
timeseries
}),
...(timeseries ? [updateNode(layerId, 'layers', { dimensions: dimensions?.length > 0 ? dimensions : undefined })] : []),
updateResource(resource),
...(action.showNotifications
? [
Expand Down
5 changes: 4 additions & 1 deletion geonode_mapstore_client/client/js/selectors/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,10 @@ export const getResourceDirtyState = (state) => {
return null;
}
const resourceType = state?.gnresource?.type;
const metadataKeys = ['title', 'abstract', 'data', 'extent', ...RESOURCE_MANAGEMENT_PROPERTIES_KEYS];
let metadataKeys = ['title', 'abstract', 'data', 'extent', ...RESOURCE_MANAGEMENT_PROPERTIES_KEYS];
if (resourceType === ResourceTypes.DATASET) {
metadataKeys = metadataKeys.concat('timeseries');
}
const { data: initialData = {}, ...resource } = pick(state?.gnresource?.initialResource || {}, metadataKeys);
const { compactPermissions, geoLimits } = getPermissionsPayload(state);
const currentData = JSON.parse(JSON.stringify(getDataPayload(state) || {})); // JSON stringify is needed to remove undefined values
Expand Down
35 changes: 23 additions & 12 deletions geonode_mapstore_client/client/js/utils/ResourceUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export const RESOURCE_MANAGEMENT_PROPERTIES = {
}
};

export const TIME_SERIES_PROPERTIES = ['attribute', 'end_attribute', 'presentation', 'precision_value', 'precision_step'];

export const TIME_ATTRIBUTE_TYPES = ['xsd:date', 'xsd:dateTime', 'xsd:date-time', 'xsd:time'];

export const TIME_PRECISION_STEPS = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];

export const isDefaultDatasetSubtype = (subtype) => !subtype || ['vector', 'raster', 'remote', 'vector_time'].includes(subtype);

export const FEATURE_INFO_FORMAT = 'TEMPLATE';
Expand All @@ -100,6 +106,21 @@ const datasetAttributeSetToFields = ({ attribute_set: attributeSet = [] }) => {
});
};

export const getDimensions = ({links, has_time: hasTime} = {}) => {
const { url: wmsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMS') || {};
const { url: wmtsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMTS') || {};
const dimensions = [
...(hasTime ? [{
name: 'time',
source: {
type: 'multidim-extension',
url: wmtsUrl || (wmsUrl || '').split('/geoserver/')[0] + '/geoserver/gwc/service/wmts'
}
}] : [])
];
return dimensions;
};

/**
* convert resource layer configuration to a mapstore layer object
* @param {object} resource geonode layer resource
Expand All @@ -114,7 +135,6 @@ export const resourceToLayerConfig = (resource) => {
title,
perms,
pk,
has_time: hasTime,
default_style: defaultStyle,
ptype,
subtype,
Expand Down Expand Up @@ -174,17 +194,8 @@ export const resourceToLayerConfig = (resource) => {
default:
const { url: wfsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WFS') || {};
const { url: wmsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMS') || {};
const { url: wmtsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMTS') || {};

const dimensions = [
...(hasTime ? [{
name: 'time',
source: {
type: 'multidim-extension',
url: wmtsUrl || (wmsUrl || '').split('/geoserver/')[0] + '/geoserver/gwc/service/wmts'
}
}] : [])
];

const dimensions = getDimensions(resource);

const params = wmsUrl && url.parse(wmsUrl, true).query;
const {
Expand Down
Loading

0 comments on commit 26cce04

Please sign in to comment.