• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

geosolutions-it / MapStore2 / 18968041869

31 Oct 2025 09:13AM UTC coverage: 76.918% (-0.01%) from 76.932%
18968041869

Pull #11611

github

web-flow
Merge 1b2ac4428 into f5928b825
Pull Request #11611: #11577 Doc build fixed for node 22. Build strategy

31983 of 49693 branches covered (64.36%)

39738 of 51663 relevant lines covered (76.92%)

37.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.58
/web/client/epics/widgets.js
1
/*
2
 * Copyright 2023, 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

10
import Rx from 'rxjs';
11
import { endsWith, has, get, includes, isEqual, omit, omitBy } from 'lodash';
12

13
import {
14
    EXPORT_CSV,
15
    INSERT,
16
    TOGGLE_CONNECTION,
17
    WIDGET_SELECTED,
18
    EDITOR_SETTING_CHANGE,
19
    onEditorChange,
20
    updateWidgetLayer,
21
    clearWidgets,
22
    loadDependencies,
23
    toggleDependencySelector,
24
    DEPENDENCY_SELECTOR_KEY,
25
    WIDGETS_REGEX,
26
    UPDATE_PROPERTY,
27
    replaceWidgets,
28
    WIDGETS_MAPS_REGEX,
29
    EDITOR_CHANGE,
30
    OPEN_FILTER_EDITOR
31
} from '../actions/widgets';
32

33
import { changeMapEditor } from '../actions/queryform';
34
import { MAP_CONFIG_LOADED } from '../actions/config';
35
import { TOGGLE_CONTROL } from '../actions/controls';
36
import { queryPanelSelector } from '../selectors/controls';
37

38
import {
39
    availableDependenciesSelector,
40
    isWidgetSelectionActive,
41
    getDependencySelectorConfig,
42
    getFloatingWidgets,
43
    getWidgetLayer
44
} from '../selectors/widgets';
45
import { CHANGE_LAYER_PROPERTIES, LAYER_LOAD, LAYER_ERROR, UPDATE_NODE } from '../actions/layers';
46

47
import { getLayerFromId } from '../selectors/layers';
48
import { pathnameSelector } from '../selectors/router';
49
import { isDashboardEditing } from '../selectors/dashboard';
50
import { DASHBOARD_LOADED } from '../actions/dashboard';
51
import { LOCATION_CHANGE } from 'connected-react-router';
52
import { saveAs } from 'file-saver';
53
import {reprojectBbox} from '../utils/CoordinatesUtils';
54
import {json2csv} from 'json-2-csv';
55
import { defaultGetZoomForExtent } from '../utils/MapUtils';
56
import { updateDependenciesMapOfMapList, DEFAULT_MAP_SETTINGS } from "../utils/WidgetsUtils";
57

58
const updateDependencyMap = (active, targetId, { dependenciesMap, mappings}) => {
1✔
59
    const tableDependencies = ["layer", "filter", "quickFilters", "options"];
4✔
60
    const mapDependencies = ["layers", "groups", "viewport", "zoom", "center"];
4✔
61
    const dimensionDependencies = ["dimension.currentTime", "dimension.offsetTime"];
4✔
62
    const id = (WIDGETS_REGEX.exec(targetId) || [])[1];
4✔
63
    const cleanDependenciesMap = omitBy(dependenciesMap, i => i.indexOf(id) === -1);
4✔
64

65
    const depToTheWidget = targetId.split(".maps")[0];
4✔
66
    const overrides = Object.keys(mappings).filter(k => mappings[k] !== undefined).reduce( (ov, k) => {
8✔
67
        if (includes(dimensionDependencies, k)) {
8!
68
            return {
×
69
                ...ov,
70
                [k]: targetId === "map" ? `dimension.${mappings[k]}` : `${depToTheWidget}.${mappings[k]}`
×
71
            };
72
        }
73
        if (!endsWith(targetId, "map") && includes(tableDependencies, k)) {
8!
74
            return {
×
75
                ...ov,
76
                [k]: `${targetId}.${mappings[k]}`
77
            };
78
        }
79
        if (endsWith(targetId, "map")) {
8!
80
            if (includes(mapDependencies, k)) {
8!
81
                return {
8✔
82
                    ...ov,
83
                    [k]: targetId === "map" ? mappings[k] : `${targetId.replace(/.map$/, "")}.${mappings[k]}`
8✔
84
                };
85
            }
86
            return {
×
87
                ...ov,
88
                [k]: `${depToTheWidget}.${mappings[k]}`
89
            };
90
        }
91
        return ov;
×
92
    }, {});
93
    return active
4✔
94
        ? { ...cleanDependenciesMap, ...overrides, ["dependenciesMap"]: `${depToTheWidget}.dependenciesMap`, ["mapSync"]: `${depToTheWidget}.mapSync`}
95
        : omit(cleanDependenciesMap, [Object.keys(mappings)]);
96
};
97

98
/**
99
 * Action flow to add/Removes dependencies for a widgets.
100
 * Trigger `mapSync` property of a widget and sets `dependenciesMap` object to map `dependency` prop onto widget props.
101
 * For instance if
102
 *  - `active = true`
103
 *  - `mappings` option is `{a: "b"}
104
 *  - `dependency = "x"`
105
 * then you will have dependencyMap set to : {a: "x.b"}.
106
 * It manages also special dependency "map" where mappings are applied directly (center...) .
107
 * If active = false the dependencies will be removed from dependencyMap.
108
 *
109
 * @param {boolean} active true if the connection must be activated
110
 * @param {string} dependency the dependency element id to add
111
 * @param {object} options dependency mapping options. Must contain `mappings` object
112
 */
113
const configureDependency = (active, dependency, options) =>
1✔
114
    Rx.Observable.of(
4✔
115
        onEditorChange("mapSync", active),
116
        onEditorChange('dependenciesMap',
117
            updateDependencyMap(active, dependency, options)
118
        )
119
    );
120

121

122
export const exportWidgetData = action$ =>
1✔
123
    action$.ofType(EXPORT_CSV)
×
124
        .do( ({data = [], title = "data"}) =>
×
125
            saveAs(new Blob([
×
126
                json2csv(data)
127
            ], {type: "text/csv"}), title + ".csv"))
128
        .filter( () => false);
×
129
/**
130
 * Intercepts changes to widgets to catch widgets that can share some dependencies.
131
 * Then re-configures the dependencies to it.
132
 */
133
export const alignDependenciesToWidgets = (action$, { getState = () => { } } = {}) =>
1!
134
    action$.ofType(MAP_CONFIG_LOADED, DASHBOARD_LOADED, INSERT)
2✔
135
        .map(() => availableDependenciesSelector(getState()))
2✔
136
        .pluck('availableDependencies')
137
        .distinctUntilChanged( (oldMaps = [], newMaps = []) => isEqual([...oldMaps], [...newMaps]))
×
138
    // add dependencies for all map widgets (for the moment the only ones that shares dependencies)
139
    // and for main "map" dependency, the "viewport" and "center"
140
        .map((maps = []) => loadDependencies(maps.reduce( (deps, m) => {
2!
141
            const depToTheWidget = m.split(".maps")[0];
4✔
142
            const depToTheMap = m.replace(/.map$/, "");
4✔
143
            if (!endsWith(m, "map")) {
4!
144
                return {
×
145
                    ...deps,
146
                    [`${m}.filter`]: `${m}.filter`,
147
                    [`${m}.quickFilters`]: `${m}.quickFilters`,
148
                    [`${depToTheWidget}.dependenciesMap`]: `${depToTheWidget}.dependenciesMap`,
149
                    [`${depToTheWidget}.mapSync`]: `${depToTheWidget}.mapSync`,
150
                    [`${m}.layer`]: `${m}.layer`,
151
                    [`${m}.options`]: `${m}.options`,
152
                    [`dimension.currentTime`]: `dimension.currentTime`,
153
                    [`dimension.offsetTime`]: `dimension.offsetTime`
154
                };
155
            }
156
            return {
4✔
157
                ...deps,
158
                [`${depToTheWidget}.dependenciesMap`]: `${depToTheWidget}.dependenciesMap`,
159
                [`${depToTheWidget}.mapSync`]: `${depToTheWidget}.mapSync`,
160
                [m === "map" ? "viewport" : `${depToTheMap}.viewport`]: `${depToTheMap}.bbox`, // {viewport: "map.bbox"} or {"widgets[ID_W].maps[ID_M].viewport": "widgets[ID_W].maps[ID_M].bbox"}
4✔
161
                [m === "map" ? "center" : `${depToTheMap}.center`]: `${depToTheMap}.center`, // {center: "map.center"} or {"widgets[ID_W].maps[ID_M].center": "widgets[ID_W].maps[ID_M].center"}
4✔
162
                [m === "map" ? "zoom" : `${depToTheMap}.zoom`]: `${depToTheMap}.zoom`,
4✔
163
                [m === "map" ? "layers" : `${depToTheMap}.layers`]: m === "map" ? `layers.flat` : `${depToTheMap}.layers`,
8✔
164
                [m === "map" ? "groups" : `${depToTheMap}.groups`]: m === "map" ? `layers.groups` : `${depToTheMap}.groups`,
8✔
165
                [`dimension.currentTime`]: `dimension.currentTime`,
166
                [`dimension.offsetTime`]: `dimension.offsetTime`
167
            };
168
        }, {}))
169
        );
170
/**
171
 * Toggles the dependencies setup and widget selection for dependencies
172
 * (if more than one widget is available for connection)
173
 */
174
export const toggleWidgetConnectFlow = (action$, {getState = () => {}} = {}) =>
1!
175
    action$.ofType(TOGGLE_CONNECTION).switchMap(({ active, availableDependencies = [], options}) =>
4!
176
        (active && availableDependencies.length > 0)
4✔
177
            // activate flow
178
            ? availableDependencies.length === 1
3✔
179
                // case singleMap
180
                // In future may be necessary to pass active prop, if different from mapSync, in options object
181
                // also if connection is triggered for a different target (widget not in editing) we should change actions to trigger (onChange instead of onEditorChange)
182
                ? configureDependency(active, availableDependencies[0], options)
183
                // case of multiple map
184
                : Rx.Observable.of(toggleDependencySelector(active, {
185
                    availableDependencies
186
                })
187
                ).merge(
188
                    action$.ofType(WIDGET_SELECTED)
189
                        .filter(() => isWidgetSelectionActive(getState()))
1✔
190
                        .switchMap(({ widget }) => {
191
                            const ad = get(getDependencySelectorConfig(getState()), 'availableDependencies');
1✔
192
                            let deps = ad.filter(d => (WIDGETS_REGEX.exec(d) || [])[1] === widget.id);
3✔
193
                            if (widget.widgetType === 'map') {
1!
194
                                deps = deps.filter(d => (WIDGETS_MAPS_REGEX.exec(d) || [])[2] === widget.selectedMapId);
1!
195
                            }
196
                            return configureDependency(active, deps[0], options).concat(Rx.Observable.of(toggleDependencySelector(false, {})));
1✔
197
                        }).takeUntil(
198
                            action$.ofType(LOCATION_CHANGE)
199
                                .merge(action$.filter(({ type, key } = {}) => type === EDITOR_SETTING_CHANGE && key === DEPENDENCY_SELECTOR_KEY))
1!
200
                        )
201
                )
202

203
            // deactivate flow
204
            : configureDependency(active, availableDependencies[0], options)
205
    );
206

207
export const clearWidgetsOnLocationChange = (action$, {getState = () => {}} = {}) =>
1!
208
    action$.ofType(MAP_CONFIG_LOADED).switchMap( () => {
2✔
209
        const location = pathnameSelector(getState()).split('/');
2✔
210
        const locationDifference = location[location.length - 1];
2✔
211
        return action$.ofType(LOCATION_CHANGE)
2✔
212
            .filter( ({ payload }) => {
213
                const newLocation = pathnameSelector(getState()).split('/');
2✔
214
                const newLocationDifference = newLocation[newLocation.length - 1];
2✔
215
                return payload.action !== 'REPLACE' && newLocationDifference !== locationDifference;
2✔
216
            }).switchMap( ({payload = {}} = {}) => {
×
217
                if (payload && payload.location && payload.location.pathname) {
1!
218
                    return Rx.Observable.of(clearWidgets());
1✔
219
                }
220
                return Rx.Observable.empty();
×
221
            });
222
    });
223

224
/**
225
 * Triggers updates of the layer property of widgets on layerFilter change
226
 * @memberof epics.widgets
227
 * @param {external:Observable} action$ manages `CHANGE_LAYER_PROPERTIES`
228
 * @return {external:Observable}
229
 */
230
export const updateLayerOnLayerPropertiesChange = (action$, store) =>
1✔
231
    action$.ofType(CHANGE_LAYER_PROPERTIES, UPDATE_NODE)
6✔
232
        .filter(({layer, newProperties, nodeType, options}) => {
233
            return (layer && newProperties) || (nodeType === "layers" && has(options, "layerFilter"));
6✔
234
        })
235
        .switchMap(({layer, newProperties, node, options}) => {
236
            const state = store.getState();
4✔
237
            const flatLayer = getLayerFromId(state, layer ?? node);
4✔
238
            const shouldUpdate = flatLayer && (has(newProperties ?? options, "layerFilter") || has(newProperties, "fields"));
4✔
239
            if (shouldUpdate) {
4✔
240
                return Rx.Observable.of(updateWidgetLayer(flatLayer));
3✔
241
            }
242
            return Rx.Observable.empty();
1✔
243
        });
244

245
/**
246
 * Triggers updates of the layer property of widgets on loading error state change
247
 * @memberof epics.widgets
248
 * @param {external:Observable} action$ manages `LAYER_LOAD, LAYER_ERROR`
249
 * @return {external:Observable}
250
 */
251
export const updateLayerOnLoadingErrorChange = (action$, store) =>
1✔
252
    action$.ofType(LAYER_LOAD, LAYER_ERROR)
3✔
253
        .groupBy(({layerId}) => layerId)
3✔
254
        .map(layerStream$ => layerStream$
3✔
255
            .switchMap(({layerId}) => {
256
                const state = store.getState();
3✔
257
                const flatLayer = getLayerFromId(state, layerId);
3✔
258
                return Rx.Observable.of(
3✔
259
                    ...(flatLayer && flatLayer.previousLoadingError !== flatLayer.loadingError ?
9✔
260
                        [updateWidgetLayer(flatLayer)] :
261
                        [])
262
                );
263
            })
264
        ).mergeAll();
265

266
export const updateDependenciesMapOnMapSwitch = (action$, store) =>
1✔
267
    action$.ofType(UPDATE_PROPERTY)
2✔
268
        .filter(({key}) => includes(["maps", "selectedMapId"], key))
2✔
269
        .switchMap(({id: widgetId, value}) => {
270
            let observable$ = Rx.Observable.empty();
2✔
271
            const selectedMapId = typeof value === "string" ? value : value?.mapId;
2✔
272
            if (selectedMapId) {
2!
273
                const widgets = getFloatingWidgets(store.getState());
2✔
274
                const updatedWidgets = updateDependenciesMapOfMapList(widgets, widgetId, selectedMapId);
2✔
275
                if (!isEqual(widgets, updatedWidgets)) {
2!
276
                    observable$ = Rx.Observable.of(replaceWidgets(updatedWidgets));
2✔
277
                }
278
            }
279
            return observable$;
2✔
280
        });
281

282
export const onWidgetCreationFromMap = (action$, store) =>
1✔
283
    action$.ofType(EDITOR_CHANGE)
1✔
284
        .filter(({key, value}) => key === 'widgetType' && value === 'chart' && !isDashboardEditing(store.getState()))
1✔
285
        .switchMap(() => {
286
            let observable$ = Rx.Observable.empty();
1✔
287
            const state = store.getState();
1✔
288
            const layer = getWidgetLayer(state);
1✔
289
            if (layer) {
1!
290
                observable$ = Rx.Observable.of(
1✔
291
                    onEditorChange('chart-layers', [layer])
292
                );
293
            }
294
            return observable$;
1✔
295
        });
296

297

298
export const onOpenFilterEditorEpic = (action$, store) =>
1✔
299
    action$.ofType(OPEN_FILTER_EDITOR)
×
300
        .switchMap(() => {
301
            const state = store.getState();
×
302
            const layer = getWidgetLayer(state);
×
303
            const zoom = defaultGetZoomForExtent(reprojectBbox(layer.bbox.bounds, "EPSG:4326", "EPSG:3857"), DEFAULT_MAP_SETTINGS.size, 0, 21, 96, DEFAULT_MAP_SETTINGS.resolutions);
×
304
            const map = {
×
305
                ...DEFAULT_MAP_SETTINGS,
306
                zoom,
307
                center: {
308
                    crs: layer.bbox.crs,
309
                    x: (layer.bbox.bounds.maxx + layer.bbox.bounds.minx) / 2,
310
                    y: (layer.bbox.bounds.maxy + layer.bbox.bounds.miny) / 2
311
                }
312
            };
313
            const mapData = layer?.bbox ? map : null;
×
314
            return Rx.Observable.of( changeMapEditor(mapData) );
×
315
        });
316

317

318
export const onResetMapEpic = (action$, store) =>
1✔
319
    action$.ofType(TOGGLE_CONTROL)
×
320
        .filter((type, control) => !queryPanelSelector(store.getState()) && control === "queryPanel" && isDashboardEditing(store.getState()))
×
321
        .switchMap(() => {
322
            return Rx.Observable.of(
×
323
                changeMapEditor(null)
324
            );
325
        });
326

327
export default {
328
    exportWidgetData,
329
    alignDependenciesToWidgets,
330
    toggleWidgetConnectFlow,
331
    clearWidgetsOnLocationChange,
332
    updateLayerOnLayerPropertiesChange,
333
    updateLayerOnLoadingErrorChange,
334
    updateDependenciesMapOnMapSwitch,
335
    onWidgetCreationFromMap,
336
    onOpenFilterEditorEpic,
337
    onResetMapEpic
338
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc