Skip to content

Commit e1786e6

Browse files
authored
#1945: Implement time series settings inside the Management panel (#1946)
1 parent b4e5dcc commit e1786e6

File tree

22 files changed

+585
-39
lines changed

22 files changed

+585
-39
lines changed

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ export const getDatasetByPk = (pk) => {
300300
.then(({ data }) => data.dataset);
301301
};
302302

303+
export const getDatasetTimeSettingsByPk = (pk) => {
304+
return axios.get(getEndpointUrl(DATASETS, `/${pk}/timeseries`))
305+
.then(({ data }) => data).catch(() => {});
306+
};
307+
303308
export const getDocumentByPk = (pk) => {
304309
return axios.get(getEndpointUrl(DOCUMENTS, `/${pk}`), {
305310
params: {
@@ -388,6 +393,10 @@ export const updateGeoApp = (pk, body) => {
388393
.then(({ data }) => data.geoapp);
389394
};
390395

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

392401
export const updateDataset = (pk, body) => {
393402
return axios.patch(getEndpointUrl(DATASETS, `/${pk}`), body)

geonode_mapstore_client/client/js/components/DetailsPanel/DetailsSettings.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React, { forwardRef } from 'react';
22
import { Checkbox } from 'react-bootstrap';
33
import Message from '@mapstore/framework/components/I18N/Message';
4-
import { RESOURCE_MANAGEMENT_PROPERTIES } from '@js/utils/ResourceUtils';
54
import tooltip from '@mapstore/framework/components/misc/enhancers/tooltip';
5+
import { RESOURCE_MANAGEMENT_PROPERTIES } from '@js/utils/ResourceUtils';
6+
import TimeSeriesSettings from '@js/components/DetailsPanel/DetailsTimeSeries';
67

78
const MessageTooltip = tooltip(forwardRef(({children, msgId, ...props}, ref) => {
89
return (
@@ -35,6 +36,9 @@ function DetailsSettings({ resource, onChange }) {
3536
</div>
3637
);
3738
})}
39+
<div className="gn-details-info-form">
40+
<TimeSeriesSettings resource={resource} onChange={onChange} />
41+
</div>
3842
</div>
3943
</div>
4044
);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2025, 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, { useState } from 'react';
9+
import PropTypes from "prop-types";
10+
import isNil from 'lodash/isNil';
11+
import isEmpty from 'lodash/isEmpty';
12+
import { Checkbox, FormGroup, ControlLabel, FormControl, HelpBlock } from 'react-bootstrap';
13+
import Select from 'react-select';
14+
15+
import Message from '@mapstore/framework/components/I18N/Message';
16+
import HTML from '@mapstore/framework/components/I18N/HTML';
17+
import { getMessageById } from '@mapstore/framework/utils/LocaleUtils';
18+
import InfoPopover from '@mapstore/framework/components/widgets/widget/InfoPopover';
19+
20+
import { TIME_SERIES_PROPERTIES, TIME_ATTRIBUTE_TYPES, TIME_PRECISION_STEPS } from '@js/utils/ResourceUtils';
21+
22+
const TimeSeriesSettings = ({ resource, onChange }, context) => {
23+
const timeAttributes = (resource?.attribute_set ?? [])
24+
.filter((attribute) => TIME_ATTRIBUTE_TYPES.includes(attribute.attribute_type))
25+
.map((attribute)=> ({value: attribute.pk, label: attribute.attribute}));
26+
27+
const [timeseries, setTimeSeries] = useState(resource.timeseries);
28+
const [error, setError] = useState();
29+
30+
if (isEmpty(timeAttributes)) return null;
31+
32+
const onChangeTimeSettings = (key, value) => {
33+
const _timeseries = {
34+
...timeseries,
35+
[key]: value,
36+
...(key === "presentation"
37+
? value === "LIST"
38+
// reset precision field when presentation is LIST
39+
? {precision_value: undefined, precision_step: undefined}
40+
: {precision_value: null, precision_step: "seconds"} : undefined
41+
),
42+
...(key === "has_time"
43+
? !value
44+
// reset all time series properties when has_time is `false`
45+
? TIME_SERIES_PROPERTIES.reduce((obj, prop) => ({...obj, [prop]: undefined}), {})
46+
: { presentation: "LIST"} : undefined
47+
)
48+
};
49+
const isFormInValid = _timeseries.has_time ? isNil(_timeseries.attribute) && isNil(_timeseries.end_attribute) : false;
50+
setTimeSeries(_timeseries);
51+
setError(isFormInValid);
52+
if (!isFormInValid) {
53+
// update resource when timeseries is changed and valid
54+
onChange({timeseries: _timeseries}, "timeseries");
55+
}
56+
};
57+
58+
const attributeFields = ['attribute', 'end_attribute'];
59+
const hasTime = !!timeseries?.has_time;
60+
return (
61+
<>
62+
<div className="gn-details-info-row gn-details-flex-field">
63+
<Message msgId={"gnviewer.timeSeriesSetting.title"} />
64+
<InfoPopover
65+
glyph="info-sign"
66+
placement="right"
67+
title={<Message msgId="gnviewer.timeSeriesSetting.additionalHelp" />}
68+
popoverStyle={{ maxWidth: 500 }}
69+
text={<HTML msgId="gnviewer.timeSeriesSetting.helpText"/>}
70+
/>
71+
</div>
72+
<div className="gn-details-info-row gn-details-flex-field">
73+
<Checkbox
74+
style={{ margin: 0 }}
75+
checked={hasTime}
76+
onChange={(event) => onChangeTimeSettings('has_time', !!event.target.checked)}
77+
>
78+
<Message msgId={"gnviewer.timeSeriesSetting.hasTime"}/>
79+
</Checkbox>
80+
</div>
81+
{hasTime && <div className="gn-time-settings-form">
82+
<div className="gn-details-info-row gn-details-flex-field">
83+
{attributeFields.map((attributeField, index) => (
84+
<FormGroup validationState={error ? "error" : null} >
85+
<ControlLabel><Message msgId={`gnviewer.timeSeriesSetting.${attributeField}`} /></ControlLabel>
86+
<Select
87+
fullWidth={false}
88+
clearable={false}
89+
key={`time-attribute-${index}`}
90+
options={timeAttributes}
91+
value={timeseries[attributeField]}
92+
onChange={({ value } = {}) => onChangeTimeSettings(attributeField, value)}
93+
/>
94+
{error && <HelpBlock><Message msgId="gnviewer.timeSeriesSetting.helpTextAttribute"/></HelpBlock>}
95+
</FormGroup>)
96+
)}
97+
</div>
98+
<div className="gn-details-info-row gn-details-flex-field">
99+
<FormGroup>
100+
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.presentation" /></ControlLabel>
101+
<Select
102+
fullWidth={false}
103+
clearable={false}
104+
key="presentation-dropdown"
105+
options={[
106+
{value: "LIST", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.list") },
107+
{value: "DISCRETE_INTERVAL", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.discreteInterval") },
108+
{value: "CONTINUOUS_INTERVAL", label: getMessageById(context.messages, "gnviewer.timeSeriesSetting.continuousInterval") }
109+
]}
110+
value={timeseries.presentation}
111+
onChange={({ value } = {}) => onChangeTimeSettings("presentation", value)}
112+
/>
113+
</FormGroup>
114+
</div>
115+
{timeseries?.presentation && timeseries?.presentation !== "LIST" && <div className="gn-details-info-row gn-details-flex-field">
116+
<FormGroup>
117+
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.precisionValue" /></ControlLabel>
118+
<FormControl
119+
type="number"
120+
value={timeseries.precision_value}
121+
onChange={(event) => {
122+
let value = event.target.value;
123+
value = value ? Number(value) : null;
124+
onChangeTimeSettings("precision_value", value);
125+
}} />
126+
</FormGroup>
127+
<FormGroup>
128+
<ControlLabel><Message msgId="gnviewer.timeSeriesSetting.precisionStep" /></ControlLabel>
129+
<Select
130+
fullWidth={false}
131+
clearable={false}
132+
key="precision-step-dropdown"
133+
options={TIME_PRECISION_STEPS.map(precisionStep=> (
134+
{value: precisionStep, label: getMessageById(context.messages, `gnviewer.timeSeriesSetting.${precisionStep}`) }
135+
))}
136+
value={timeseries.precision_step}
137+
onChange={({ value } = {}) => onChangeTimeSettings("precision_step", value)}
138+
/>
139+
</FormGroup>
140+
</div>}
141+
</div>}
142+
</>
143+
);
144+
};
145+
146+
TimeSeriesSettings.contextTypes = {
147+
messages: PropTypes.object
148+
};
149+
150+
export default TimeSeriesSettings;

geonode_mapstore_client/client/js/epics/gnresource.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
getCompactPermissionsByPk,
2626
setResourceThumbnail,
2727
setLinkedResourcesByPk,
28-
removeLinkedResourcesByPk
28+
removeLinkedResourcesByPk,
29+
getDatasetTimeSettingsByPk
2930
} from '@js/api/geonode/v2';
3031
import { configureMap } from '@mapstore/framework/actions/config';
3132
import { mapSelector } from '@mapstore/framework/selectors/map';
@@ -133,11 +134,20 @@ const resourceTypes = {
133134
: getResourceByPk(pk)
134135
])
135136
.then((response) => {
136-
const [mapConfig, gnLayer] = response;
137+
const [, gnLayer] = response ?? [];
138+
if (gnLayer?.has_time) {
139+
// fetch timeseries when applicable
140+
return getDatasetTimeSettingsByPk(pk)
141+
.then((timeseries) => response.concat(timeseries));
142+
}
143+
return response;
144+
})
145+
.then((response) => {
146+
const [mapConfig, gnLayer, timeseries] = response;
137147
const newLayer = resourceToLayerConfig(gnLayer);
138148

139149
if (!newLayer?.extendedParams?.defaultStyle || page !== 'dataset_edit_style_viewer') {
140-
return [mapConfig, gnLayer, newLayer];
150+
return [mapConfig, {...gnLayer, timeseries}, newLayer];
141151
}
142152

143153
return getStyleProperties({
@@ -146,7 +156,7 @@ const resourceTypes = {
146156
}).then((updatedStyle) => {
147157
return [
148158
mapConfig,
149-
gnLayer,
159+
{...gnLayer, timeseries},
150160
{
151161
...newLayer,
152162
availableStyles: [{

geonode_mapstore_client/client/js/epics/gnsave.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import axios from '@mapstore/framework/libs/ajax';
1010
import { Observable } from 'rxjs';
1111
import get from 'lodash/get';
12+
import castArray from 'lodash/castArray';
13+
1214
import { mapInfoSelector } from '@mapstore/framework/selectors/map';
1315
import { userSelector } from '@mapstore/framework/selectors/security';
1416
import {
@@ -73,12 +75,16 @@ import {
7375
ResourceTypes,
7476
cleanCompactPermissions,
7577
toGeoNodeMapConfig,
76-
RESOURCE_MANAGEMENT_PROPERTIES
78+
RESOURCE_MANAGEMENT_PROPERTIES,
79+
getDimensions
7780
} from '@js/utils/ResourceUtils';
7881
import {
7982
ProcessTypes,
8083
ProcessStatus
8184
} from '@js/utils/ResourceServiceUtils';
85+
import { updateDatasetTimeSeries } from '@js/api/geonode/v2/index';
86+
import { updateNode } from '@mapstore/framework/actions/layers';
87+
import { layersSelector } from '@mapstore/framework/selectors/layers';
8288

8389
const RESOURCE_MANAGEMENT_PROPERTIES_KEYS = Object.keys(RESOURCE_MANAGEMENT_PROPERTIES);
8490

@@ -122,7 +128,24 @@ const SaveAPI = {
122128
return id ? updateDocument(id, body) : false;
123129
},
124130
[ResourceTypes.DATASET]: (state, id, body) => {
125-
return id ? updateDataset(id, body) : false;
131+
const currentResource = getResourceData(state);
132+
const timeseries = currentResource?.timeseries;
133+
const updatedBody = {
134+
...body,
135+
...(timeseries && { has_time: timeseries?.has_time })
136+
};
137+
return (id
138+
? axios.all([updateDataset(id, updatedBody), updateDatasetTimeSeries(id, timeseries)])
139+
: Promise.resolve([]))
140+
.then(([resource]) => {
141+
if (timeseries) {
142+
const dimensions = resource?.has_time ? getDimensions({...resource, has_time: true}) : [];
143+
const layerId = layersSelector(state)?.find((l) => l.pk === resource?.pk)?.id;
144+
// actions to be dispacted are added to response array
145+
return [resource, updateNode(layerId, 'layers', { dimensions: dimensions?.length > 0 ? dimensions : undefined })];
146+
}
147+
return resource;
148+
});
126149
},
127150
[ResourceTypes.VIEWER]: (state, id, body) => {
128151
const user = userSelector(state);
@@ -159,7 +182,8 @@ export const gnSaveContent = (action$, store) =>
159182
...(extent && { extent })
160183
};
161184
return Observable.defer(() => SaveAPI[contentType](state, action.id, body, action.reload))
162-
.switchMap((resource) => {
185+
.switchMap((response) => {
186+
const [resource, ...actions] = castArray(response);
163187
if (action.reload) {
164188
if (contentType === ResourceTypes.VIEWER) {
165189
const sourcepk = get(state, 'router.location.pathname', '').split('/').pop();
@@ -183,7 +207,8 @@ export const gnSaveContent = (action$, store) =>
183207
? successNotification({title: "saveDialog.saveSuccessTitle", message: "saveDialog.saveSuccessMessage"})
184208
: warningNotification(action.showNotifications)
185209
]
186-
: [])
210+
: []),
211+
...actions // additional actions to be dispatched
187212
);
188213
})
189214
.catch((error) => {

geonode_mapstore_client/client/js/selectors/resource.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,10 @@ export const getResourceDirtyState = (state) => {
285285
return null;
286286
}
287287
const resourceType = state?.gnresource?.type;
288-
const metadataKeys = ['title', 'abstract', 'data', 'extent', ...RESOURCE_MANAGEMENT_PROPERTIES_KEYS];
288+
let metadataKeys = ['title', 'abstract', 'data', 'extent', ...RESOURCE_MANAGEMENT_PROPERTIES_KEYS];
289+
if (resourceType === ResourceTypes.DATASET) {
290+
metadataKeys = metadataKeys.concat('timeseries');
291+
}
289292
const { data: initialData = {}, ...resource } = pick(state?.gnresource?.initialResource || {}, metadataKeys);
290293
const { compactPermissions, geoLimits } = getPermissionsPayload(state);
291294
const currentData = JSON.parse(JSON.stringify(getDataPayload(state) || {})); // JSON stringify is needed to remove undefined values

geonode_mapstore_client/client/js/utils/ResourceUtils.js

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export const RESOURCE_MANAGEMENT_PROPERTIES = {
8080
}
8181
};
8282

83+
export const TIME_SERIES_PROPERTIES = ['attribute', 'end_attribute', 'presentation', 'precision_value', 'precision_step'];
84+
85+
export const TIME_ATTRIBUTE_TYPES = ['xsd:date', 'xsd:dateTime', 'xsd:date-time', 'xsd:time'];
86+
87+
export const TIME_PRECISION_STEPS = ['years', 'months', 'days', 'hours', 'minutes', 'seconds'];
88+
8389
export const isDefaultDatasetSubtype = (subtype) => !subtype || ['vector', 'raster', 'remote', 'vector_time'].includes(subtype);
8490

8591
export const FEATURE_INFO_FORMAT = 'TEMPLATE';
@@ -105,6 +111,21 @@ const datasetAttributeSetToFields = ({ attribute_set: attributeSet = [] }) => {
105111
});
106112
};
107113

114+
export const getDimensions = ({links, has_time: hasTime} = {}) => {
115+
const { url: wmsUrl } = links?.find(({ link_type: linkType }) => linkType === 'OGC:WMS') || {};
116+
const { url: wmtsUrl } = links?.find(({ link_type: linkType }) => linkType === 'OGC:WMTS') || {};
117+
const dimensions = [
118+
...(hasTime ? [{
119+
name: 'time',
120+
source: {
121+
type: 'multidim-extension',
122+
url: wmtsUrl || (wmsUrl || '').split('/geoserver/')[0] + '/geoserver/gwc/service/wmts'
123+
}
124+
}] : [])
125+
];
126+
return dimensions;
127+
};
128+
108129
/**
109130
* convert resource layer configuration to a mapstore layer object
110131
* @param {object} resource geonode layer resource
@@ -119,7 +140,6 @@ export const resourceToLayerConfig = (resource) => {
119140
title,
120141
perms,
121142
pk,
122-
has_time: hasTime,
123143
default_style: defaultStyle,
124144
ptype,
125145
subtype,
@@ -179,17 +199,8 @@ export const resourceToLayerConfig = (resource) => {
179199
default:
180200
const { url: wfsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WFS') || {};
181201
const { url: wmsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMS') || {};
182-
const { url: wmtsUrl } = links.find(({ link_type: linkType }) => linkType === 'OGC:WMTS') || {};
183-
184-
const dimensions = [
185-
...(hasTime ? [{
186-
name: 'time',
187-
source: {
188-
type: 'multidim-extension',
189-
url: wmtsUrl || (wmsUrl || '').split('/geoserver/')[0] + '/geoserver/gwc/service/wmts'
190-
}
191-
}] : [])
192-
];
202+
203+
const dimensions = getDimensions(resource);
193204

194205
const params = wmsUrl && url.parse(wmsUrl, true).query;
195206
const {

0 commit comments

Comments
 (0)