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

geosolutions-it / MapStore2 / 18371528919

09 Oct 2025 09:16AM UTC coverage: 76.738% (-0.05%) from 76.789%
18371528919

Pull #11572

github

web-flow
Merge 62e9c9670 into 2686c544e
Pull Request #11572: Feat: #11527 Add the tabbed view for the dashboard

31855 of 49574 branches covered (64.26%)

94 of 155 new or added lines in 10 files covered. (60.65%)

3 existing lines in 2 files now uncovered.

39633 of 51647 relevant lines covered (76.74%)

37.71 hits per line

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

81.6
/web/client/reducers/widgets.js
1
/*
2
 * Copyright 2017, 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 uuidv1 from 'uuid/v1';
10
import {
11
    EDIT_NEW,
12
    INSERT,
13
    EDIT,
14
    UPDATE_PROPERTY,
15
    UPDATE_LAYER,
16
    DELETE,
17
    EDITOR_CHANGE,
18
    EDITOR_SETTING_CHANGE,
19
    INIT,
20
    CHANGE_LAYOUT,
21
    CLEAR_WIDGETS,
22
    DEFAULT_TARGET,
23
    ADD_DEPENDENCY,
24
    REMOVE_DEPENDENCY,
25
    LOAD_DEPENDENCIES,
26
    RESET_DEPENDENCIES,
27
    TOGGLE_COLLAPSE,
28
    TOGGLE_MAXIMIZE,
29
    TOGGLE_COLLAPSE_ALL,
30
    TOGGLE_TRAY,
31
    toggleCollapse,
32
    REPLACE,
33
    WIDGETS_REGEX,
34
    REPLACE_LAYOUT_VIEW,
35
    SET_SELECTED_LAYOUT_VIEW_ID
36
} from '../actions/widgets';
37
import { REFRESH_SECURITY_LAYERS, CLEAR_SECURITY } from '../actions/security';
38
import { MAP_CONFIG_LOADED } from '../actions/config';
39
import { DASHBOARD_LOADED, DASHBOARD_RESET } from '../actions/dashboard';
40
import set from 'lodash/fp/set';
41
import { get, find, omit, mapValues, castArray, isEmpty } from 'lodash';
42
import { arrayUpsert, compose, arrayDelete } from '../utils/ImmutableUtils';
43
import {
44
    convertDependenciesMappingForCompatibility as convertToCompatibleWidgets,
45
    editorChange
46
} from "../utils/WidgetsUtils";
47

48
const emptyState = {
1✔
49
    dependencies: {
50
        viewport: "map.bbox",
51
        center: "map.center",
52
        zoom: "map.zoom"
53
    },
54
    containers: {
55
        floating: {
56
            widgets: []
57
        }
58
    },
59
    builder: {
60
        map: null,
61
        settings: {
62
            step: 0
63
        }
64
    }
65
};
66

67

68
/**
69
 * Manages the state of the widgets
70
 * @prop {array} widgets version identifier
71
 *
72
 * @example
73
 *{
74
 *  widgets: {
75
 *    containers: {
76
 *       floating: {
77
 *          widgets: [{
78
 *              //...
79
 *          }]
80
 *       }
81
 *    }
82
 *  }
83
 *}
84
 * @memberof reducers
85
 */
86
function widgetsReducer(state = emptyState, action) {
21✔
87
    switch (action.type) {
51!
88
    case INIT: {
89
        return set(`defaults`, action.cfg, state);
1✔
90
    }
91
    case EDITOR_SETTING_CHANGE: {
92
        return set(`builder.settings.${action.key}`, action.value, state);
1✔
93
    }
94
    case EDIT_NEW: {
95
        return set(`builder.editor`, action.widget,
1✔
96
            set("builder.settings", action.settings || emptyState.settings, state));
1!
97
    }
98
    case EDIT: {
99
        return set(`builder.editor`, {
4✔
100
            ...action.widget,
101
            // for backward compatibility widgets without widgetType are charts
102
            widgetType: action.widget && action.widget.widgetType || 'chart'
10✔
103
        }, set("builder.settings.step", 0, state));
104
    }
105
    case EDITOR_CHANGE: {
106
        return editorChange(action, state);
5✔
107
    }
108
    case INSERT: {
109
        let widget = {...action.widget};
3✔
110
        if (widget.widgetType === 'chart') {
3!
111
            widget = omit(widget, ["layer", "url"]);
×
112
        }
113
        const w = state?.defaults?.initialSize?.w ?? 1;
3✔
114
        const h = state?.defaults?.initialSize?.h ?? 1;
3✔
115
        const selectedLayoutId = get(state, `containers[${DEFAULT_TARGET}].selectedLayoutId`);
3✔
116
        const layouts = get(state, `containers[${DEFAULT_TARGET}].layouts`);
3✔
117
        const layoutId = selectedLayoutId || layouts?.[0]?.id;
3✔
118
        return arrayUpsert(`containers[${action.target}].widgets`, {
3✔
119
            id: action.id,
120
            ...widget,
121
            ...(layoutId ? { layoutId } : {}),
3!
122
            dataGrid: action.id && {
6✔
123
                w,
124
                h,
125
                x: 0,
126
                y: 0
127
            }
128
        }, {
129
            id: action.widget.id || action.id
3!
130
        }, state);
131
    }
132

133
    case REPLACE:
134
        const widgetsPath = `containers[${action.target}].widgets`;
×
135
        const widgets = get(state, widgetsPath);
×
136
        if (widgets) {
×
137
            return set(widgetsPath, action.widgets, state);
×
138
        }
139
        return state;
×
140
    case UPDATE_PROPERTY:
141
        // if "merge" update map by merging a partial map object coming from
142
        // onMapViewChanges handler for MapWidget
143
        // if "replace" update the widget setting the value to the existing object
144
        const oldWidget = find(get(state, `containers[${action.target}].widgets`), {
5✔
145
            id: action.id
146
        });
147
        let uValue = action.value;
5✔
148
        if (action.mode === "merge") {
5✔
149
            uValue = action.key === "maps"
1!
150
                ? oldWidget.maps.map(m => m.mapId === action.value?.mapId ? {...m, ...action?.value} : m)
×
151
                : Object.assign({}, oldWidget[action.key], action.value);
152
        }
153
        return arrayUpsert(`containers[${action.target}].widgets`,
5✔
154
            set(action.key, uValue, oldWidget), { id: action.id },
155
            state
156
        );
157
    case UPDATE_LAYER: {
158
        if (action.layer) {
1!
159
            const _widgets = get(state, `containers[${DEFAULT_TARGET}].widgets`);
1✔
160
            if (_widgets) {
1!
161
                return set(`containers[${DEFAULT_TARGET}].widgets`,
1✔
162
                    _widgets.map(w => {
163
                        if (w.widgetType === "chart" && w?.charts) {
5✔
164
                            // every chart stores the layer object configuration
165
                            // so we need to loop around them to update correctly the layer properties
166
                            // including the layerFilter
167
                            let chartsCopy = w?.charts?.length ? [...w.charts] : [];
1!
168
                            chartsCopy = chartsCopy.map(chart=>{
1✔
169
                                let chartItem = {...chart};
3✔
170
                                chartItem.traces = chartItem?.traces?.map(trace=>
3✔
171
                                    get(trace, "layer.id") === action.layer.id ? set("layer", action.layer, trace) : trace
1!
172
                                );
173
                                return chartItem;
3✔
174
                            });
175
                            return set("charts", chartsCopy, w);
1✔
176
                        }
177
                        return get(w, "layer.id") === action.layer.id ? set("layer", action.layer, w) : w;
4✔
178
                    }), state);
179
            }
180
        }
181
        return state;
×
182
    }
183
    case DELETE:
184
        const path = `containers[${DEFAULT_TARGET}].widgets`;
3✔
185
        const updatedState = arrayDelete(`containers[${action.target}].widgets`, {
3✔
186
            id: action.widget.id
187
        }, state);
188
        const allWidgets = get(updatedState, path, []);
3✔
189
        return set(path, allWidgets.map(m => {
3✔
190
            if (m.dependenciesMap) {
3!
191
                const [, dependentWidgetId] = WIDGETS_REGEX.exec((Object.values(m.dependenciesMap) || [])[0]) || [];
3!
192
                if (dependentWidgetId) {
3✔
193
                    if (action.widget.id === dependentWidgetId) {
2!
194
                        return {...omit(m, "dependenciesMap"), mapSync: false};
2✔
195
                    }
196
                }
197
            }
198
            return m;
1✔
199
        }), state);
200
    case DASHBOARD_LOADED:
201
        const { data } = action;
1✔
202
        return set(`containers[${DEFAULT_TARGET}]`, {
1✔
203
            ...data
204
        }, state);
205
    case REFRESH_SECURITY_LAYERS: {
206
        let newWidgets = state?.containers?.[DEFAULT_TARGET].widgets || [];
1!
207
        newWidgets = newWidgets?.map(w => {
1✔
208
            const newMaps = w.maps?.map(map => {
1✔
209
                return {
1✔
210
                    ...map,
211
                    layers: map.layers?.map(l => {
212
                        return l.security ? {
1!
213
                            ...l,
214
                            security: {
215
                                ...l.security,
216
                                rand: uuidv1()
217
                            }
218
                        } : l;
219
                    })
220
                };
221
            });
222
            return {...w, maps: newMaps};
1✔
223
        });
224
        const newMaps = state.builder?.editor?.maps?.map(map => {
1✔
225
            return {
1✔
226
                ...map,
227
                layers: map.layers.map(l => {
228
                    return l.security ? {
1!
229
                        ...l,
230
                        security: {
231
                            ...l.security,
232
                            rand: uuidv1()
233
                        }
234
                    } : l;
235
                })
236
            };
237
        });
238
        return set(`containers[${DEFAULT_TARGET}].widgets`, newWidgets, set(`builder.editor.maps`, newMaps, state));}
1✔
239
    case CLEAR_SECURITY: {
240
        let newWidgets = state?.containers?.[DEFAULT_TARGET].widgets || [];
×
241
        newWidgets = newWidgets?.map(w => {
×
242
            const maps = w.maps?.map(map => {
×
243
                return {
×
244
                    ...map,
245
                    layers: map.layers.map(l => {
246
                        return l?.security?.sourceId === action.protectedId ? {
×
247
                            ...l,
248
                            security: undefined
249
                        } : l;
250
                    })
251
                };
252
            });
253
            return {...w, maps};
×
254
        });
255
        const maps = state.builder?.editor?.maps?.map(map => {
×
256
            return {
×
257
                ...map,
258
                layers: map.layers.map(l => {
259
                    return l?.security?.sourceId === action.protectedId ? {
×
260
                        ...l,
261
                        security: undefined
262
                    } : l;
263
                })
264
            };
265
        });
266
        return set(`containers[${DEFAULT_TARGET}].widgets`, newWidgets, set(`builder.editor.maps`, maps, state));
×
267
    }
268
    case MAP_CONFIG_LOADED:
269
        let { widgetsConfig } = (action.config || {});
4!
270
        if (!isEmpty(widgetsConfig)) {
4✔
271
            widgetsConfig = convertToCompatibleWidgets(widgetsConfig);
2✔
272
        }
273
        return set(`containers[${DEFAULT_TARGET}]`, {
4✔
274
            ...widgetsConfig
275
        }, state);
276
    case CHANGE_LAYOUT: {
277
        return set(`containers[${action.target}].layout`, action.layout)(set(`containers[${action.target}].layouts`, action.allLayouts, state));
2✔
278
    }
279
    case CLEAR_WIDGETS:
280
    case DASHBOARD_RESET: {
281
        return set(`containers[${DEFAULT_TARGET}]`, emptyState.containers[DEFAULT_TARGET], state);
1✔
282
    }
283
    case ADD_DEPENDENCY: {
284
        const {key, value} = action;
1✔
285
        return set(`dependencies[${key}]`, value, state);
1✔
286
    }
287
    case REMOVE_DEPENDENCY: {
288
        const {key} = action;
1✔
289
        return set(`dependencies[${key}]`, null, state);
1✔
290
    }
291
    case LOAD_DEPENDENCIES:
292
        const {dependencies} = action;
1✔
293
        return set(`dependencies`, dependencies, state);
1✔
294
    case RESET_DEPENDENCIES:
295
        return set('dependencies', emptyState.dependencies, state);
1✔
296
    case TOGGLE_COLLAPSE: {
297
        /*
298
             * Collapse functionality has been implemented keeping the widget unchanged, adding it's layout is added to a map of collapsed objects.
299
             * The widgets plugin filters out the collapsed widget from the widgets list to render
300
             * So the containers triggers a layout change that removes the layout.
301
             * So when we want to expand again the widget, we have to restore the original layout settings.
302
             * This allows to save (and restore) collapsed state of the widgets in one unique separated object.
303
             */
304
        const {widget = {}} = action;
5!
305

306
        // locked widgets can not be collapsed
307
        if (widget.dataGrid && widget.dataGrid.static) {
5✔
308
            return state;
1✔
309
        }
310
        const widgetCollapsedState = get(state, `containers[${action.target}].collapsed[${widget.id}`);
4✔
311
        if (widgetCollapsedState) {
4✔
312
            // EXPAND
313

314
            const newLayoutValue = [
1✔
315
                ...get(state, `containers[${action.target}].layout`, []),
316
                ...castArray(
317
                    get(widgetCollapsedState, `layout`, [])
318
                ) // add stored old layout, if exists
319
            ];
320
            const updatedLayoutsMap = mapValues(
1✔
321
                get(state, `containers[${action.target}].layouts`, {}),
322
                (v = [], k) => ([
1!
323
                    ...v,
324
                    ...castArray(
325
                        get(widgetCollapsedState, `layouts[${k}]`, [])
326
                    )
327
                ])
328
            );
329
            return omit(
1✔
330
                compose(
331
                    // restore original layout for the widget
332
                    set(
333
                        `containers[${action.target}].layout`,
334
                        newLayoutValue
335
                    ),
336
                    // restore original layout for each break point
337
                    set(
338
                        `containers[${action.target}].layouts`,
339
                        updatedLayoutsMap
340
                    )
341
                    // restore original layout for each breakpoint (md, xs, ...) for the widget
342
                )(state),
343
                `containers[${action.target}].collapsed[${widget.id}]`);
344
        }
345

346
        return set(`containers[${action.target}].collapsed[${widget.id}]`, {
3✔
347
            // COLLAPSE
348

349
            // NOTE: when the collapse is toggled, the widget is not visible anymore
350
            // because it is filtered out from the view ( by the selector)
351
            // this causes a second action CHANGE_LAYOUT, automatically triggered
352
            // by react-grid-layout that removes the layout objects from the
353
            // `layout` and `layouts` state parts
354

355
            // get layout object for each k for the widget
356
            layout: find(
357
                get(state, `containers[${action.target}].layout`, []),
358
                { i: widget.id }
359
            ),
360
            // get layout object for each breakpoint (md, xs...) for the widget
361
            layouts: mapValues(
362
                get(state, `containers[${action.target}].layouts`, {}),
363
                v => find(v, {i: widget.id})
3✔
364
            )
365
        }, state);
366
    }
367
    case TOGGLE_MAXIMIZE: {
368
        const widget = action.widget;
3✔
369
        const maximized = state?.containers?.[action.target]?.maximized;
3✔
370

371
        if (!widget || widget.dataGrid?.static) {
3✔
372
            return state;
1✔
373
        }
374

375
        if (maximized?.widget) {
2✔
376
            return compose(
1✔
377
                set(`containers[${action.target}].layout`, maximized.layout),
378
                set(`containers[${action.target}].layouts`, maximized.layouts),
379
                set(`containers[${action.target}].maximized`, {}),
380
                set(`containers[${action.target}].widgets`, state?.containers?.[action.target]?.widgets?.map(w => w.id === maximized.widget.id ?
3✔
381
                    {
382
                        ...w,
383
                        dataGrid: {
384
                            ...w.dataGrid,
385
                            isDraggable: true,
386
                            isResizable: true
387
                        }
388
                    } : w)
389
                )
390
            )(state);
391
        }
392

393
        if (state?.containers?.[action.target]?.collapsed?.[widget.id]) {
1!
394
            return state;
×
395
        }
396

397
        // we assume that react-grid-layout has just one cell with one xxs breakpoint at 0, that is covering
398
        // the area that is supposed to be taken by maximized widget, when maximized state is present
399
        const newLayoutValues = {
1✔
400
            x: 0,
401
            y: 0,
402
            w: 1,
403
            h: 1
404
        };
405
        const oldLayoutValue = find(state?.containers?.[action.target]?.layout, {i: widget.id});
1✔
406
        const newLayoutValue = {
1✔
407
            ...oldLayoutValue,
408
            ...newLayoutValues
409
        };
410

411
        return compose(
1✔
412
            set(`containers[${action.target}].maximized`, {
413
                widget,
414
                layout: state?.containers?.[action.target]?.layout,
415
                layouts: state?.containers?.[action.target]?.layouts
416
            }),
417
            set(`containers[${action.target}].layout`, [newLayoutValue]),
418
            set(`containers[${action.target}].layouts`, {
419
                xxs: [newLayoutValue]
420
            }),
421
            set(`containers[${action.target}].widgets`, state?.containers?.[action.target]?.widgets?.map(w => w.id === widget.id ?
3✔
422
                {
423
                    ...w,
424
                    dataGrid: {
425
                        ...w.dataGrid,
426
                        isDraggable: false,
427
                        isResizable: false
428
                    }
429
                } : w)
430
            )
431
        )(state);
432
    }
433
    case TOGGLE_COLLAPSE_ALL: {
434
        // get widgets excluding static widgets
435
        const widgetsStatic = get(state, `containers[${action.target}].widgets`, [])
1✔
436
            .filter( w => !w.dataGrid || !w.dataGrid.static );
3✔
437
        const collapsedWidgets = widgetsStatic.filter(w => get(state, `containers[${action.target}].collapsed[${w.id}]`));
2✔
438
        const expandedWidgets = widgetsStatic.filter(w => !get(state, `containers[${action.target}].collapsed[${w.id}]`));
2✔
439
        const shouldExpandAll = expandedWidgets.length === 0;
1✔
440
        if (shouldExpandAll) {
1!
441
            return collapsedWidgets.reduce((acc, w) => widgetsReducer(
×
442
                acc,
443
                toggleCollapse(w)
444
            ), state);
445
        } else if (expandedWidgets.length > 0) {
1!
446
            return expandedWidgets.reduce((acc, w) => widgetsReducer(
2✔
447
                acc,
448
                toggleCollapse(w)
449
            ), state);
450
        }
451
        return state;
×
452
    }
453
    case TOGGLE_TRAY: {
454
        return set('tray', action.value, state);
2✔
455
    }
456
    case REPLACE_LAYOUT_VIEW: {
NEW
457
        return set(`containers[${action.target}].layouts`, action.layouts, state);
×
458
    }
459
    case SET_SELECTED_LAYOUT_VIEW_ID: {
NEW
460
        return set(`containers[${action.target}].selectedLayoutId`, action.viewId, state);
×
461
    }
462
    default:
463
        return state;
3✔
464
    }
465
}
466

467
export default widgetsReducer;
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