diff --git a/docs/developer-guide/maps-configuration.md b/docs/developer-guide/maps-configuration.md
index 1c8653047c..ac1e9e8c84 100644
--- a/docs/developer-guide/maps-configuration.md
+++ b/docs/developer-guide/maps-configuration.md
@@ -1644,6 +1644,12 @@ Example:
             "clippingPolygonFeatureId": "feature.id.01",
             "clippingPolygonUnion": false
           }
+        ],
+        "groups": [
+          {
+            "id": "group_01",
+            "visibility": true
+          }
         ]
       }
     ],
@@ -1710,6 +1716,7 @@ View configuration object
 | globeTranslucency.nearDistance | number | when `fadeByDistance` is true it indicates the minimum distance to apply translucency |
 | globeTranslucency.farDistance | number |  when `fadeByDistance` is true it indicates the maximum distance to apply translucency |
 | layers | array | array of layer configuration overrides, default properties override `visibility` and `opacity` |
+| groups | array | array of group configuration overrides, default property overrides `visibility` |
 
 Resource object configuration
 
diff --git a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js
index 6e2e06c884..c50c89483c 100644
--- a/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js
+++ b/web/client/components/map/cesium/plugins/ThreeDTilesLayer.js
@@ -95,12 +95,19 @@ function clip3DTiles(tileSet, options, map) {
         });
 }
 
-function ensureReady(tileSet, callback) {
+let pendingCallbacks = {};
+
+function ensureReady(layer, callback, eventKey) {
+    const tileSet = layer.getTileSet();
+    if (!tileSet && eventKey) {
+        pendingCallbacks[eventKey] = callback;
+        return;
+    }
     if (tileSet.ready) {
-        callback();
+        callback(tileSet);
     } else {
         tileSet.readyPromise.then(() => {
-            callback();
+            callback(tileSet);
         });
     }
 }
@@ -152,10 +159,13 @@ const createLayer = (options, map) => {
         map.scene.primitives.remove(tileSet);
         tileSet = undefined;
     };
+    const layer = {
+        getTileSet: () => tileSet,
+        getResource: () => resource
+    };
     return {
         detached: true,
-        getTileSet: () => tileSet,
-        getResource: () => resource,
+        ...layer,
         add: () => {
             resource = new Cesium.Resource({
                 url: options.url,
@@ -175,7 +185,7 @@ const createLayer = (options, map) => {
                 map.scene.primitives.add(tileSet);
                 // assign the original mapstore id of the layer
                 tileSet.msId = options.id;
-                ensureReady(tileSet, () => {
+                ensureReady(layer, () => {
                     updateModelMatrix(tileSet, options);
                     clip3DTiles(tileSet, options, map);
                     updateShading(tileSet, options, map);
@@ -184,6 +194,10 @@ const createLayer = (options, map) => {
                             if (style) {
                                 tileSet.style = new Cesium.Cesium3DTileStyle(style);
                             }
+                            Object.keys(pendingCallbacks).forEach((eventKey) => {
+                                pendingCallbacks[eventKey](tileSet);
+                            });
+                            pendingCallbacks = {};
                         });
                 });
             });
@@ -209,21 +223,20 @@ Layers.registerType('3dtiles', {
         if (newOptions.forceProxy !== oldOptions.forceProxy) {
             return createLayer(newOptions, map);
         }
-        const tileSet = layer?.getTileSet();
         if (
             (!isEqual(newOptions.clippingPolygon, oldOptions.clippingPolygon)
             || newOptions.clippingPolygonUnion !== oldOptions.clippingPolygonUnion
             || newOptions.clipOriginalGeometry !== oldOptions.clipOriginalGeometry)
-         && tileSet) {
-            ensureReady(tileSet, () => {
+        ) {
+            ensureReady(layer, (tileSet) => {
                 clip3DTiles(tileSet, newOptions, map);
-            });
+            }, 'clip');
         }
         if ((
             !isEqual(newOptions.style, oldOptions.style)
             || newOptions?.pointCloudShading?.attenuation !== oldOptions?.pointCloudShading?.attenuation
-        ) && tileSet) {
-            ensureReady(tileSet, () => {
+        )) {
+            ensureReady(layer, (tileSet) => {
                 getStyle(newOptions)
                     .then((style) => {
                         if (style && tileSet) {
@@ -231,17 +244,17 @@ Layers.registerType('3dtiles', {
                             tileSet.style = new Cesium.Cesium3DTileStyle(style);
                         }
                     });
-            });
+            }, 'style');
         }
-        if (!isEqual(newOptions.pointCloudShading, oldOptions.pointCloudShading) && tileSet) {
-            ensureReady(tileSet, () => {
+        if (!isEqual(newOptions.pointCloudShading, oldOptions.pointCloudShading)) {
+            ensureReady(layer, (tileSet) => {
                 updateShading(tileSet, newOptions, map);
-            });
+            }, 'shading');
         }
-        if (tileSet && newOptions.heightOffset !== oldOptions.heightOffset) {
-            ensureReady(tileSet, () => {
+        if (newOptions.heightOffset !== oldOptions.heightOffset) {
+            ensureReady(layer, (tileSet) => {
                 updateModelMatrix(tileSet, newOptions);
-            });
+            }, 'matrix');
         }
         return null;
     }
diff --git a/web/client/components/mapviews/MapViewSettings.jsx b/web/client/components/mapviews/MapViewSettings.jsx
index 2ada681d68..80a29eb697 100644
--- a/web/client/components/mapviews/MapViewSettings.jsx
+++ b/web/client/components/mapviews/MapViewSettings.jsx
@@ -31,6 +31,7 @@ function ViewSettings({
     view,
     api,
     layers = [],
+    groups = [],
     onChange,
     onUpdateResource = () => { },
     onCaptureView,
@@ -77,6 +78,26 @@ function ViewSettings({
         });
     }
 
+    function handleChangeGroup(groupId, options) {
+        const viewGroup = view?.groups?.find(vGroup => vGroup.id === groupId);
+        const viewGroups = viewGroup
+            ? (view?.groups || [])
+                .map((vGroup) => vGroup.id === groupId ? ({ ...viewGroup, ...options }) : vGroup)
+            : [...(view?.groups || []), { id: groupId, ...options }];
+        onChange({
+            ...view,
+            groups: viewGroups
+        });
+    }
+
+    function handleResetGroup(groupId) {
+        const viewGroups = view?.groups?.filter(vGroup => vGroup.id !== groupId);
+        onChange({
+            ...view,
+            groups: viewGroups
+        });
+    }
+
     function updateLayerRequest({ layer, inverse = false, offset = 0 } = {}) {
         return getResourceFromLayer({
             layer,
@@ -108,11 +129,14 @@ function ViewSettings({
                             onChange={handleChange}
                             resources={resources}
                             layers={layers.filter(({ type }) => !(api?.options?.unsupportedLayers || []).includes(type))}
+                            groups={groups}
                             vectorLayers={availableVectorLayers}
                             updateLayerRequest={updateLayerRequest}
                             locale={locale}
                             onChangeLayer={handleChangeLayer}
                             onResetLayer={handleResetLayer}
+                            onChangeGroup={handleChangeGroup}
+                            onResetGroup={handleResetGroup}
                             showClipGeometries={showClipGeometries}
                             onShowClipGeometries={onShowClipGeometries}
                             onCaptureView={onCaptureView}
diff --git a/web/client/components/mapviews/MapViewsSupport.jsx b/web/client/components/mapviews/MapViewsSupport.jsx
index f489588660..0ac177e87f 100644
--- a/web/client/components/mapviews/MapViewsSupport.jsx
+++ b/web/client/components/mapviews/MapViewsSupport.jsx
@@ -161,6 +161,7 @@ function MapViewsSupport({
     selectedId,
     defaultTitle = 'Map View',
     layers,
+    groups,
     locale,
     resources: resourcesProp = [],
     services,
@@ -597,6 +598,7 @@ function MapViewsSupport({
                                             onChange={handleUpdateView}
                                             onCaptureView={handleCaptureView}
                                             layers={layers}
+                                            groups={groups}
                                             locale={locale}
                                             services={services}
                                             selectedService={selectedService}
diff --git a/web/client/components/mapviews/settings/LayerOverridesNode.jsx b/web/client/components/mapviews/settings/LayerOverridesNode.jsx
deleted file mode 100644
index 3b48893efa..0000000000
--- a/web/client/components/mapviews/settings/LayerOverridesNode.jsx
+++ /dev/null
@@ -1,158 +0,0 @@
-/*
- * Copyright 2022, 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, { useState } from 'react';
-import {
-    ButtonGroup,
-    Glyphicon,
-    Checkbox,
-    FormGroup,
-    ControlLabel,
-    Alert
-} from 'react-bootstrap';
-import Select from 'react-select';
-import {clamp} from 'lodash';
-import FormControl from '../../misc/DebouncedFormControl';
-import { formatClippingFeatures } from '../../../utils/MapViewsUtils';
-import Message from '../../I18N/Message';
-import ButtonMS from '../../misc/Button';
-import tooltip from '../../misc/enhancers/tooltip';
-const Button = tooltip(ButtonMS);
-
-function LayerOverridesNode({
-    layer = {},
-    onChange,
-    onReset,
-    title,
-    updateLayerRequest,
-    vectorLayers,
-    clippingFeatures,
-    clippingLayerResource,
-    initialExpanded
-}) {
-
-    const [expanded, setExpanded] = useState(initialExpanded);
-    const [loading, setLoading] = useState(false);
-    const [error, setError] = useState(false);
-    const isVisibilitySupported = !['terrain'].includes(layer.type);
-    const isOpacitySupported = !['3dtiles', 'terrain'].includes(layer.type);
-    const isClippingSupported = !!['3dtiles', 'terrain'].includes(layer.type);
-
-    function handleUpdateLayer({ layer: resourceLayer }) {
-        setError(false);
-        if (!resourceLayer) {
-            return onChange({
-                clippingLayerResourceId: undefined,
-                clippingPolygonFeatureId: undefined,
-                clippingPolygonUnion: undefined
-            });
-        }
-        setLoading(true);
-        return updateLayerRequest({ layer: resourceLayer })
-            .then((clippingLayerResourceId) => {
-                onChange({ clippingLayerResourceId });
-                setLoading(false);
-            })
-            .catch(() => {
-                setError(true);
-                setLoading(false);
-            });
-    }
-
-    const formattedClippingFeatures = formatClippingFeatures(clippingFeatures);
-
-    return (
-        <li className={`ms-map-views-layer-node${layer.changed ? ' changed' : ''}`}>
-            <div className="ms-map-views-layer-node-head">
-                <Button
-                    className="square-button-md no-border"
-                    style={{ borderRadius: '50%', marginRight: 4 }}
-                    onClick={() => setExpanded(!expanded)}
-                >
-                    <Glyphicon glyph={expanded ? "chevron-down" : "chevron-right"} />
-                </Button>
-                <div className="ms-map-views-layer-node-title">{title}</div>
-                <ButtonGroup>
-                    {layer.changed && <Button
-                        className="square-button-md no-border"
-                        tooltipId="mapViews.resetLayerOverrides"
-                        onClick={() => onReset()}
-                    >
-                        <Glyphicon glyph="refresh" />
-                    </Button>}
-                    {isVisibilitySupported && <Button
-                        className="square-button-md no-border"
-                        tooltipId={layer.visibility ? 'mapViews.hideLayer' : 'mapViews.showLayer'}
-                        onClick={() => onChange({ visibility: !layer.visibility })}
-                    >
-                        <Glyphicon glyph={layer.visibility ? 'eye-open' : 'eye-close'} />
-                    </Button>}
-                </ButtonGroup>
-            </div>
-            {expanded && <div className="ms-map-views-layer-node-body">
-                {isOpacitySupported && <FormGroup controlId={`map-views-layer-opacity-${layer.id}`} className="inline">
-                    <ControlLabel><Message msgId="mapViews.layerOpacity"/></ControlLabel>
-                    <FormControl
-                        type="number"
-                        min={0}
-                        max={1}
-                        step={0.1}
-                        className="opacity-field"
-                        fallbackValue={1}
-                        value={layer.opacity}
-                        onChange={(value) => {
-                            const opacity = value && clamp(parseFloat(value), 0, 1);
-                            onChange({ opacity: opacity || 0 });
-                        }}
-                    />
-                </FormGroup>}
-                {isClippingSupported && <div className="ms-map-views-layer-clipping">
-                    <FormGroup
-                        controlId={`map-views-layer-clipping-source-${layer.id}`}
-                    >
-                        <ControlLabel><Message msgId="mapViews.clippingSourceLayer"/></ControlLabel>
-                        <Select
-                            isLoading={loading}
-                            value={clippingLayerResource}
-                            options={vectorLayers}
-                            onChange={(option) => handleUpdateLayer({ layer: option?.layer })}
-                        />
-                        {error && <Alert bsStyle="danger" style={{ marginTop: 8 }}>
-                            <Message msgId="mapViews.resourceLayerRequestError"/>
-                        </Alert>}
-                    </FormGroup>
-                    <FormGroup
-                        controlId={`map-views-layer-clipping-feature-id-${layer.id}`}
-                    >
-                        <ControlLabel><Message msgId="mapViews.clippingFeature"/></ControlLabel>
-                        <Select
-                            value={layer.clippingPolygonFeatureId ? { value: layer.clippingPolygonFeatureId, label: layer.clippingPolygonFeatureId } : undefined}
-                            disabled={!layer.clippingLayerResourceId}
-                            options={formattedClippingFeatures?.map((feature) => ({ value: feature.id, label: feature.id, feature }))}
-                            onChange={(option) => onChange({ clippingPolygonFeatureId: option?.feature?.id })}
-                        />
-                        {(!!layer.clippingLayerResourceId && formattedClippingFeatures?.length === 0) && <Alert bsStyle="danger" style={{ marginTop: 8 }}>
-                            <Message msgId="mapViews.clipPolygonFeaturesNotAvailable"/>
-                        </Alert>}
-                    </FormGroup>
-                    <FormGroup controlId={`map-views-layer-clipping-inverse-${layer.id}`}>
-                        <Checkbox
-                            checked={!!layer.clippingPolygonUnion}
-                            disabled={!layer.clippingPolygonFeatureId || loading}
-                            onChange={() => onChange({ clippingPolygonUnion: !layer.clippingPolygonUnion })}>
-                            <Message msgId="mapViews.clippingInverse"/>
-                        </Checkbox>
-                    </FormGroup>
-                    {loading && <div className="ms-map-views-loading-mask"/>}
-                </div>}
-            </div>}
-        </li>
-    );
-}
-
-export default LayerOverridesNode;
diff --git a/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx b/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx
new file mode 100644
index 0000000000..2b648d27c0
--- /dev/null
+++ b/web/client/components/mapviews/settings/LayerOverridesNodeContent.jsx
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2022, 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, { useState } from 'react';
+import {
+    Checkbox,
+    FormGroup,
+    ControlLabel,
+    Alert
+} from 'react-bootstrap';
+import Select from 'react-select';
+import { formatClippingFeatures } from '../../../utils/MapViewsUtils';
+import Message from '../../I18N/Message';
+import { getTitle } from '../../../utils/LayersUtils';
+
+function LayerOverridesNodeContent({
+    node = {},
+    config,
+    onChange
+}) {
+
+    const [loading, setLoading] = useState(false);
+    const [error, setError] = useState(false);
+
+    const isClippingSupported = !!['3dtiles', 'terrain'].includes(node.type);
+
+    if (!isClippingSupported) {
+        return null;
+    }
+
+    const {
+        updateLayerRequest,
+        vectorLayers,
+        resources,
+        locale
+    } = config?.mapViews || {};
+
+    const vectorLayersOptions = vectorLayers
+        ?.filter((layer) => {
+            if (layer.type === 'wfs') {
+                return true;
+            }
+            if (layer.type === 'vector') {
+                return !!layer?.features?.find(({ geometry }) => ['Polygon'].includes(geometry?.type));
+            }
+            return false;
+        })
+        .map((layer) => ({
+            label: getTitle(layer.title, locale) || layer.name || layer.id,
+            value: layer.id,
+            layer
+        }));
+
+    function handleUpdateLayer({ layer: resourceLayer }) {
+        setError(false);
+        if (!resourceLayer) {
+            return onChange({
+                clippingLayerResourceId: undefined,
+                clippingPolygonFeatureId: undefined,
+                clippingPolygonUnion: undefined
+            });
+        }
+        setLoading(true);
+        return updateLayerRequest({ layer: resourceLayer })
+            .then((clippingLayerResourceId) => {
+                onChange({ clippingLayerResourceId });
+                setLoading(false);
+            })
+            .catch(() => {
+                setError(true);
+                setLoading(false);
+            });
+    }
+
+    const _clippingLayerResource = resources?.find(({ id }) => id === node.clippingLayerResourceId)?.data;
+    const vectorLayer = vectorLayers?.find(({ id }) => id === _clippingLayerResource?.id);
+    const clippingFeatures = _clippingLayerResource?.collection?.features || vectorLayer?.features;
+    const clippingLayerResource = _clippingLayerResource
+        ? {
+            value: _clippingLayerResource?.id,
+            label: getTitle(vectorLayer?.title, locale) || vectorLayer?.name || vectorLayer?.id,
+            resource: _clippingLayerResource
+        } : undefined;
+
+    const formattedClippingFeatures = formatClippingFeatures(clippingFeatures);
+
+    return (
+        <div className="ms-map-views-layer-clipping">
+            <FormGroup
+                controlId={`map-views-layer-clipping-source-${node.id}`}
+            >
+                <ControlLabel><Message msgId="mapViews.clippingSourceLayer"/></ControlLabel>
+                <Select
+                    isLoading={loading}
+                    value={clippingLayerResource}
+                    options={vectorLayersOptions}
+                    onChange={(option) => handleUpdateLayer({ layer: option?.layer })}
+                />
+                {error && <Alert bsStyle="danger" style={{ marginTop: 8 }}>
+                    <Message msgId="mapViews.resourceLayerRequestError"/>
+                </Alert>}
+            </FormGroup>
+            <FormGroup
+                controlId={`map-views-layer-clipping-feature-id-${node.id}`}
+            >
+                <ControlLabel><Message msgId="mapViews.clippingFeature"/></ControlLabel>
+                <Select
+                    value={node.clippingPolygonFeatureId ? { value: node.clippingPolygonFeatureId, label: node.clippingPolygonFeatureId } : undefined}
+                    disabled={!node.clippingLayerResourceId}
+                    options={formattedClippingFeatures?.map((feature) => ({ value: feature.id, label: feature.id, feature }))}
+                    onChange={(option) => onChange({ clippingPolygonFeatureId: option?.feature?.id })}
+                />
+                {(!!node.clippingLayerResourceId && formattedClippingFeatures?.length === 0) && <Alert bsStyle="danger" style={{ marginTop: 8 }}>
+                    <Message msgId="mapViews.clipPolygonFeaturesNotAvailable"/>
+                </Alert>}
+            </FormGroup>
+            <FormGroup controlId={`map-views-layer-clipping-inverse-${node.id}`}>
+                <Checkbox
+                    checked={!!node.clippingPolygonUnion}
+                    disabled={!node.clippingPolygonFeatureId || loading}
+                    onChange={() => onChange({ clippingPolygonUnion: !node.clippingPolygonUnion })}>
+                    <Message msgId="mapViews.clippingInverse"/>
+                </Checkbox>
+            </FormGroup>
+            {loading && <div className="ms-map-views-loading-mask"/>}
+        </div>
+    );
+}
+
+export default LayerOverridesNodeContent;
diff --git a/web/client/components/mapviews/settings/LayersSection.jsx b/web/client/components/mapviews/settings/LayersSection.jsx
index 0f09c05d29..5c75bb63b3 100644
--- a/web/client/components/mapviews/settings/LayersSection.jsx
+++ b/web/client/components/mapviews/settings/LayersSection.jsx
@@ -6,13 +6,52 @@
  * LICENSE file in the root directory of this source tree.
  */
 
-import React from 'react';
-import { FormGroup, Checkbox } from 'react-bootstrap';
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { FormControl as FormControlRB, FormGroup, InputGroup, Glyphicon, Checkbox, Button as ButtonRB, ButtonGroup } from 'react-bootstrap';
 import Section from './Section';
-import { getTitle } from '../../../utils/LayersUtils';
-import { mergeViewLayers } from '../../../utils/MapViewsUtils';
-import LayerOverridesNode from './LayerOverridesNode';
+import { mergeViewLayers, mergeViewGroups, pickViewLayerProperties, pickViewGroupProperties } from '../../../utils/MapViewsUtils';
+import LayerOverridesNodeContent from './LayerOverridesNodeContent';
 import Message from '../../I18N/Message';
+import TOC from '../../../plugins/TOC/components/TOC';
+import tooltip from '../../misc/enhancers/tooltip';
+import localizedProps from '../../misc/enhancers/localizedProps';
+import { NodeTypes } from '../../../utils/LayersUtils';
+import { getMessageById } from '../../../utils/LocaleUtils';
+
+const Button = tooltip(ButtonRB);
+const FormControl = localizedProps('placeholder')(FormControlRB);
+
+function ResetLayerOverrides({
+    itemComponent,
+    node,
+    config,
+    nodeType,
+    nodeTypes,
+    onChange
+}) {
+    const ItemComponent = itemComponent;
+    const { view } = config?.mapViews || {};
+    const changed = nodeType === nodeTypes.LAYER
+        ? !!view?.layers?.find(layer => layer.id === node.id)
+        : !!view?.groups?.find(group => group.id === node.id);
+    function handleClick() {
+        if (changed) {
+            onChange({ resetView: true });
+        } else {
+            onChange(nodeType === nodeTypes.LAYER
+                ? pickViewLayerProperties(node)
+                : pickViewGroupProperties(node));
+        }
+    }
+    return (
+        <ItemComponent
+            tooltipId={changed ? `mapViews.${nodeType}Unlinked` : `mapViews.${nodeType}Linked`}
+            glyph={changed ? 'unplug' : 'plug'}
+            onClick={handleClick}
+        />
+    );
+}
 
 function LayersSection({
     view,
@@ -20,38 +59,42 @@ function LayersSection({
     onExpandSection,
     onChange,
     resources,
-    layers,
+    layers = [],
+    groups = [],
     vectorLayers,
     updateLayerRequest,
     locale,
     onChangeLayer,
     onResetLayer,
+    onChangeGroup,
+    onResetGroup,
     showClipGeometries,
     onShowClipGeometries,
     isTerrainAvailable,
     isClippingAvailable
-}) {
+}, { messages }) {
 
-    const terrainClippingLayerResource = resources?.find(resource => resource.id === view?.terrain?.clippingLayerResourceId)?.data;
-    const terrainVectorLayer = vectorLayers?.find(layer => layer.id === terrainClippingLayerResource?.id);
-    const terrainClippingFeatures = terrainClippingLayerResource?.collection?.features || terrainVectorLayer?.features;
+    const [filterText, setFilterText] = useState('');
+    const [expandedNodes, setExpandedNodes] = useState([
+        ...groups.filter((group) => group.expanded).map(group => group.id),
+        ...layers.filter((layer) => layer.expanded).map(layer => layer.id)
+    ]);
     const mergedLayers = mergeViewLayers(layers, view);
-    const vectorLayersOptions = vectorLayers
-        ?.filter((layer) => {
-            if (layer.type === 'wfs') {
-                return true;
-            }
-            if (layer.type === 'vector') {
-                return !!layer?.features?.find(({ geometry }) => ['Polygon'].includes(geometry?.type));
-            }
-            return false;
-        })
-        .map((layer) => ({
-            label: getTitle(layer.title, locale) || layer.name || layer.id,
-            value: layer.id,
-            layer
-        }));
-
+    const mergedGroups = mergeViewGroups(groups, view);
+    const tocMapViewConfig = {
+        view,
+        updateLayerRequest,
+        vectorLayers,
+        resources,
+        locale
+    };
+    function applyExpandedProperty(nodes) {
+        return nodes.map(node => ({ ...node, expanded: expandedNodes.includes(node.id) }));
+    }
+    function areAllNodesUnlinked() {
+        return layers.every(layer => (view?.layers || []).some(vLayer => vLayer.id === layer.id))
+            && groups.every(group => (view?.groups || []).some(vGroup => vGroup.id === group.id));
+    }
     return (
         <Section
             title={<Message msgId="mapViews.layersOptions"/>}
@@ -69,55 +112,129 @@ function LayersSection({
                     </Checkbox>
                 </FormGroup>
             </div>}
-            <ul className="ms-map-views-layers-options-body">
-                {isTerrainAvailable && <LayerOverridesNode
-                    key="terrain"
-                    title={<Message msgId="mapViews.terrain"/>}
-                    layer={{
+            <FormGroup style={{ marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
+                <InputGroup style={{ flex: 1 }}>
+                    <FormControl
+                        placeholder="toc.filterPlaceholder"
+                        value={filterText}
+                        onChange={(event) => setFilterText(event?.target?.value)}
+                    />
+                    {filterText
+                        ? <InputGroup.Button>
+                            <Button tooltipId="toc.clearFilter" onClick={() => setFilterText('')}><Glyphicon glyph="1-close"/></Button>
+                        </InputGroup.Button>
+                        : <InputGroup.Addon>
+                            <Glyphicon glyph="filter"/>
+                        </InputGroup.Addon>}
+                </InputGroup>
+                <ButtonGroup>
+                    <Button
+                        tooltipId="mapViews.linkAllNodes"
+                        disabled={!view?.layers?.length && !view?.groups?.length}
+                        className="square-button-md"
+                        bsStyle="primary"
+                        onClick={() => {
+                            onChange({
+                                groups: undefined,
+                                layers: undefined
+                            });
+                        }}
+                    >
+                        <Glyphicon glyph="plug"/>
+                    </Button>
+                    <Button
+                        tooltipId="mapViews.unlinkAllNodes"
+                        className="square-button-md"
+                        bsStyle="primary"
+                        disabled={areAllNodesUnlinked()}
+                        onClick={() => {
+                            onChange({
+                                groups: groups.map((group) => {
+                                    const viewGroup = (view?.groups || []).find(vGroup => vGroup.id === group.id);
+                                    return pickViewGroupProperties(viewGroup || group);
+                                }),
+                                layers: layers.map((layer) => {
+                                    const viewLayer = (view?.layers || []).find(vLayer => vLayer.id === layer.id);
+                                    return pickViewLayerProperties(viewLayer || layer);
+                                })
+                            });
+                        }}
+                    >
+                        <Glyphicon glyph="unplug" />
+                    </Button>
+                </ButtonGroup>
+            </FormGroup>
+            {isTerrainAvailable ? <TOC
+                map={{
+                    layers: [{
                         ...view?.terrain,
+                        id: 'terrain',
                         type: 'terrain',
-                        id: 'terrain'
-                    }}
-                    onChange={(newOptions) => onChange({  terrain: { ...view?.terrain, ...newOptions }})}
-                    updateLayerRequest={updateLayerRequest}
-                    vectorLayers={vectorLayersOptions}
-                    clippingFeatures={terrainClippingFeatures}
-                    clippingLayerResource={terrainClippingLayerResource
-                        ? {
-                            value: terrainClippingLayerResource?.id,
-                            label: getTitle(terrainVectorLayer?.title, locale) || terrainVectorLayer?.name || terrainVectorLayer?.id,
-                            resource: terrainClippingLayerResource
-                        }
-                        : undefined}
-                />}
-                {mergedLayers?.length === 0
-                    ? <Message msgId="mapViews.addNewLayerToTheMap"/>
-                    : [ ...mergedLayers ].reverse().map((layer) => {
-                        const clippingLayerResource = resources?.find(({ id }) => id === layer.clippingLayerResourceId)?.data;
-                        const vectorLayer = vectorLayers?.find(({ id }) => id === clippingLayerResource?.id);
-                        const clippingFeatures = clippingLayerResource?.collection?.features || vectorLayer?.features;
-                        return (
-                            <LayerOverridesNode
-                                key={`${view?.id}-${layer.id}`}
-                                layer={layer}
-                                title={getTitle(layer.title, locale) || layer.name || layer.id}
-                                onChange={(newOptions) => onChangeLayer(layer.id, newOptions)}
-                                onReset={() => onResetLayer(layer.id)}
-                                updateLayerRequest={updateLayerRequest}
-                                vectorLayers={vectorLayersOptions}
-                                clippingFeatures={clippingFeatures}
-                                clippingLayerResource={clippingLayerResource
-                                    ? {
-                                        value: clippingLayerResource?.id,
-                                        label: getTitle(vectorLayer?.title, locale) || vectorLayer?.name || vectorLayer?.id,
-                                        resource: clippingLayerResource
-                                    } : undefined}
-                            />
-                        );
-                    })}
-            </ul>
+                        title: getMessageById(messages, 'mapViews.terrain')
+                    }]
+                }}
+                nodeContentItems={[
+                    { name: 'LayerOverridesNodeContent', Component: LayerOverridesNodeContent }
+                ]}
+                config={{
+                    sortable: false,
+                    hideOpacitySlider: true,
+                    hideVisibilityButton: true,
+                    layerOptions: {
+                        hideFilter: true,
+                        hideLegend: true
+                    },
+                    mapViews: tocMapViewConfig
+                }}
+                onChangeNode={(nodeId, nodeType, options) => {
+                    if (nodeId === 'terrain' && nodeType === NodeTypes.LAYER) {
+                        onChange({ terrain: { ...view?.terrain, ...options }});
+                    }
+                }}
+            /> : null}
+            <TOC
+                map={{
+                    layers: applyExpandedProperty(mergedLayers),
+                    groups: applyExpandedProperty(mergedGroups)
+                }}
+                filterText={filterText}
+                config={{
+                    sortable: false,
+                    layerOptions: {
+                        hideFilter: true,
+                        hideLegend: true
+                    },
+                    mapViews: tocMapViewConfig
+                }}
+                nodeContentItems={[
+                    { name: 'LayerOverridesNodeContent', Component: LayerOverridesNodeContent }
+                ]}
+                nodeToolItems={[
+                    { name: 'ResetLayerOverrides', Component: ResetLayerOverrides }
+                ]}
+                onChangeNode={(nodeId, nodeType, options) => {
+                    if (options.expanded !== undefined) {
+                        return setExpandedNodes(
+                            options.expanded
+                                ? [...expandedNodes, nodeId]
+                                : expandedNodes.filter(expandedNodeId => expandedNodeId !== nodeId));
+                    }
+                    if (options.resetView) {
+                        return nodeType === NodeTypes.LAYER
+                            ? onResetLayer(nodeId)
+                            : onResetGroup(nodeId);
+                    }
+                    return nodeType === NodeTypes.LAYER
+                        ? onChangeLayer(nodeId, options)
+                        : onChangeGroup(nodeId, options);
+                }}
+            />
         </Section>
     );
 }
 
+LayersSection.contextTypes = {
+    messages: PropTypes.object
+};
+
 export default LayersSection;
diff --git a/web/client/components/mapviews/settings/Section.jsx b/web/client/components/mapviews/settings/Section.jsx
index f40718dec1..25df460d9b 100644
--- a/web/client/components/mapviews/settings/Section.jsx
+++ b/web/client/components/mapviews/settings/Section.jsx
@@ -29,7 +29,7 @@ function Section({
                     onClick={handleExpand}
                     style={{ borderRadius: '50%', marginRight: 4 }}
                 >
-                    <Glyphicon glyph={expanded ? "chevron-down" : "chevron-right"} />
+                    <Glyphicon glyph={expanded ? "bottom" : "next"} />
                 </Button>
                 <div className="ms-map-views-section-title">
                     {title}
diff --git a/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx b/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx
deleted file mode 100644
index 9138afd124..0000000000
--- a/web/client/components/mapviews/settings/__tests__/LayerOverridesNode-test.jsx
+++ /dev/null
@@ -1,75 +0,0 @@
-/*
- * Copyright 2022, 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 ReactDOM from 'react-dom';
-import LayerOverridesNode from '../LayerOverridesNode';
-import expect from 'expect';
-
-describe('LayerOverridesNode component', () => {
-    beforeEach((done) => {
-        document.body.innerHTML = '<div id="container"></div>';
-        setTimeout(done);
-    });
-
-    afterEach((done) => {
-        ReactDOM.unmountComponentAtNode(document.getElementById("container"));
-        document.body.innerHTML = '';
-        setTimeout(done);
-    });
-
-    it('should render with default', () => {
-        ReactDOM.render(<LayerOverridesNode />, document.getElementById("container"));
-        const layerNode = document.querySelector('.ms-map-views-layer-node');
-        expect(layerNode).toBeTruthy();
-    });
-    it('should display three buttons for changed layers', () => {
-        ReactDOM.render(<LayerOverridesNode
-            layer={{
-                type: 'wms',
-                changed: true
-            }}
-        />, document.getElementById("container"));
-        const layerNode = document.querySelector('.ms-map-views-layer-node.changed');
-        expect(layerNode).toBeTruthy();
-        const buttonNodes = document.querySelectorAll('button');
-        expect(buttonNodes.length).toBe(3);
-    });
-    it('should display clipping opacity option for layers', () => {
-        ReactDOM.render(<LayerOverridesNode
-            layer={{
-                type: 'wms'
-            }}
-            initialExpanded
-        />, document.getElementById("container"));
-        const layerNode = document.querySelector('.ms-map-views-layer-node');
-        expect(layerNode).toBeTruthy();
-        const buttonNodes = document.querySelectorAll('button');
-        expect(buttonNodes.length).toBe(2);
-        const inputNodes = document.querySelectorAll('input');
-        expect(inputNodes.length).toBe(1);
-    });
-    it('should display clipping options for 3D tiles', () => {
-        ReactDOM.render(<LayerOverridesNode
-            layer={{
-                type: '3dtiles'
-            }}
-            initialExpanded
-        />, document.getElementById("container"));
-        const layerNode = document.querySelector('.ms-map-views-layer-node');
-        expect(layerNode).toBeTruthy();
-        const buttonNodes = document.querySelectorAll('button');
-        expect(buttonNodes.length).toBe(2);
-        const selectNodes = document.querySelectorAll('.Select');
-        expect(selectNodes.length).toBe(2);
-        const inputNodes = document.querySelectorAll('input');
-        expect(inputNodes.length).toBe(2);
-        expect([...inputNodes].map(node => node.getAttribute('type')))
-            .toEqual([ null, 'checkbox' ]);
-    });
-});
diff --git a/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx b/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx
new file mode 100644
index 0000000000..ab9e349f6b
--- /dev/null
+++ b/web/client/components/mapviews/settings/__tests__/LayerOverridesNodeContent-test.jsx
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2022, 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 ReactDOM from 'react-dom';
+import LayerOverridesNodeContent from '../LayerOverridesNodeContent';
+import expect from 'expect';
+
+describe('LayerOverridesNodeContent component', () => {
+    beforeEach((done) => {
+        document.body.innerHTML = '<div id="container"></div>';
+        setTimeout(done);
+    });
+
+    afterEach((done) => {
+        ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+        document.body.innerHTML = '';
+        setTimeout(done);
+    });
+
+    it('should not render with default', () => {
+        ReactDOM.render(<LayerOverridesNodeContent />, document.getElementById("container"));
+        expect(document.querySelector('#container').children.length).toBe(0);
+    });
+    it('should display clipping options for 3D tiles', () => {
+        ReactDOM.render(<LayerOverridesNodeContent
+            node={{
+                type: '3dtiles'
+            }}
+        />, document.getElementById("container"));
+        const layerNode = document.querySelector('.ms-map-views-layer-clipping');
+        expect(layerNode).toBeTruthy();
+        const selectNodes = document.querySelectorAll('.Select');
+        expect(selectNodes.length).toBe(2);
+        const inputNodes = document.querySelectorAll('input');
+        expect(inputNodes.length).toBe(2);
+        expect([...inputNodes].map(node => node.getAttribute('type')))
+            .toEqual([ null, 'checkbox' ]);
+    });
+});
diff --git a/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx b/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx
index 2262668745..eda86ea8a8 100644
--- a/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx
+++ b/web/client/components/mapviews/settings/__tests__/LayersSection-test.jsx
@@ -10,6 +10,7 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import LayersSection from '../LayersSection';
 import expect from 'expect';
+import { Simulate } from 'react-dom/test-utils';
 
 describe('LayersSection component', () => {
     beforeEach((done) => {
@@ -44,8 +45,8 @@ describe('LayersSection component', () => {
         const sectionNode = document.querySelector('.ms-map-views-section');
         expect(sectionNode).toBeTruthy();
         const inputNodes = document.querySelectorAll('input');
-        expect(inputNodes.length).toBe(1);
-        expect([...inputNodes].map(node => node.getAttribute('type'))).toEqual(['checkbox']);
+        expect(inputNodes.length).toBe(2);
+        expect(inputNodes[0].getAttribute('type')).toBe('checkbox');
     });
     it('should display terrain node', () => {
         ReactDOM.render(<LayersSection
@@ -54,7 +55,7 @@ describe('LayersSection component', () => {
         />, document.getElementById("container"));
         const sectionNode = document.querySelector('.ms-map-views-section');
         expect(sectionNode).toBeTruthy();
-        const layerNodes = [...document.querySelectorAll('.ms-map-views-layer-node')];
+        const layerNodes = [...document.querySelectorAll('.ms-node-layer')];
         expect(layerNodes.length).toBe(1);
     });
     it('should display list of layers', () => {
@@ -96,13 +97,474 @@ describe('LayersSection component', () => {
         />, document.getElementById("container"));
         const sectionNode = document.querySelector('.ms-map-views-section');
         expect(sectionNode).toBeTruthy();
-        const layerNodes = [...document.querySelectorAll('.ms-map-views-layer-node')];
+        const layerNodes = [...document.querySelectorAll('.ms-node-layer')];
         expect(layerNodes.length).toBe(2);
-        const layerButtonNodes = [...layerNodes[0].querySelectorAll('button')];
-        expect(layerButtonNodes.length).toBe(2);
-        const changedLayerNodes = [...document.querySelectorAll('.ms-map-views-layer-node.changed')];
-        expect(changedLayerNodes.length).toBe(1);
-        const changedLayerButtonNodes = [...changedLayerNodes[0].querySelectorAll('button')];
-        expect(changedLayerButtonNodes.length).toBe(3);
+        expect([...layerNodes[0].querySelectorAll('button')]
+            .map(layerButtonNode => layerButtonNode.querySelector('.glyphicon').getAttribute('class')))
+            .toEqual([ 'glyphicon glyphicon-checkbox-off', 'glyphicon glyphicon-plug' ]);
+        expect([...layerNodes[1].querySelectorAll('button')]
+            .map(layerButtonNode => layerButtonNode.querySelector('.glyphicon').getAttribute('class')))
+            .toEqual([ 'glyphicon glyphicon-checkbox-on', 'glyphicon glyphicon-unplug' ]);
+    });
+    it('should display list of groups', () => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [
+                    {
+                        id: 'layer.01',
+                        visibility: true
+                    }
+                ],
+                groups: [
+                    {
+                        id: 'group_01.group_02',
+                        visibility: false
+                    }
+                ]
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                },
+                {
+                    id: 'group_01.group_02',
+                    title: 'Group 02',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                },
+                {
+                    id: 'layer.02',
+                    type: 'wfs',
+                    title: 'Layer 02',
+                    visibility: false,
+                    group: 'group_01.group_02'
+                }
+            ]}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const groupsHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header');
+        expect(groupsHeaders.length).toBe(2);
+        expect([...groupsHeaders].map(group =>
+            group.querySelector('.ms-node-title').innerText
+        )).toEqual(['Group 01', 'Group 02']);
+        expect(
+            [...groupsHeaders].map(group =>
+                [...group.querySelectorAll('button > .glyphicon')]
+                    .map((glyph) => glyph.getAttribute('class'))
+            ))
+            .toEqual([
+                [ 'glyphicon glyphicon-bottom', 'glyphicon glyphicon-checkbox-on', 'glyphicon glyphicon-plug' ],
+                [ 'glyphicon glyphicon-bottom', 'glyphicon glyphicon-checkbox-off', 'glyphicon glyphicon-unplug' ]
+            ]);
+    });
+    it('should trigger onChangeLayer by clicking on the unlink button of layers', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: []
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onChangeLayer={(id, value) => {
+                try {
+                    expect(id).toBe('layer.01');
+                    expect(value).toEqual({ id: 'layer.01', visibility: false });
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const layerHeaders = document.querySelectorAll('.ms-node-layer > .ms-node-header');
+        expect(layerHeaders.length).toBe(1);
+        const buttons = layerHeaders[0].querySelectorAll('button');
+        expect(buttons.length).toBe(2);
+        Simulate.click(buttons[1]);
+    });
+    it('should trigger onResetLayer by clicking on the link button of layers', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [
+                    {
+                        id: 'layer.01',
+                        visibility: true
+                    }
+                ],
+                groups: []
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onResetLayer={(id) => {
+                try {
+                    expect(id).toBe('layer.01');
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const layerHeaders = document.querySelectorAll('.ms-node-layer > .ms-node-header');
+        expect(layerHeaders.length).toBe(1);
+        const buttons = layerHeaders[0].querySelectorAll('button');
+        expect(buttons.length).toBe(2);
+        Simulate.click(buttons[1]);
+    });
+    it('should trigger onChangeGroup by clicking on the unlink button of groups', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: []
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onChangeGroup={(id, value) => {
+                try {
+                    expect(id).toBe('group_01');
+                    expect(value).toEqual({ id: 'group_01', visibility: true });
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const groupHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header');
+        expect(groupHeaders.length).toBe(1);
+        const buttons = groupHeaders[0].querySelectorAll('button');
+        expect(buttons.length).toBe(3);
+        Simulate.click(buttons[2]);
+    });
+    it('should trigger onResetGroup by clicking on the link button of groups', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: [
+                    {
+                        id: 'group_01',
+                        visibility: false
+                    }
+                ]
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onResetGroup={(id) => {
+                try {
+                    expect(id).toBe('group_01');
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const groupHeaders = document.querySelectorAll('.ms-node-group > .ms-node-header');
+        expect(groupHeaders.length).toBe(1);
+        const buttons = groupHeaders[0].querySelectorAll('button');
+        expect(buttons.length).toBe(3);
+        Simulate.click(buttons[2]);
+    });
+
+    it('should show toolbar with search input and global link and unlink buttons', () => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: []
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const formGroup = document.querySelectorAll('.form-group');
+        expect(formGroup.length).toBe(2);
+        const input = formGroup[1].querySelector('input');
+        expect(input).toBeTruthy();
+        const buttons = formGroup[1].querySelectorAll('button');
+        expect(buttons.length).toBe(2);
+    });
+
+    it('should trigger onChange after clicking to link button', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: [
+                    {
+                        id: 'group_01',
+                        visibility: false
+                    }
+                ]
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onChange={(value) => {
+                try {
+                    expect(value).toEqual({ groups: undefined, layers: undefined });
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const formGroup = document.querySelectorAll('.form-group');
+        expect(formGroup.length).toBe(2);
+        const buttons = formGroup[1].querySelectorAll('button');
+        expect(buttons.length).toBe(2);
+        Simulate.click(buttons[0]);
+    });
+
+    it('should trigger onChange after clicking to unlink button', (done) => {
+        ReactDOM.render(<LayersSection
+            view={{
+                id: 'view.01',
+                layers: [],
+                groups: [
+                    {
+                        id: 'group_01',
+                        visibility: false
+                    }
+                ]
+            }}
+            groups={[
+                {
+                    id: 'group_01',
+                    title: 'Group 01',
+                    visibility: true,
+                    expanded: true
+                }
+            ]}
+            layers={[
+                {
+                    id: 'layer.01',
+                    type: 'vector',
+                    title: 'Layer 01',
+                    group: 'group_01',
+                    visibility: false,
+                    features: [
+                        {
+                            type: 'Feature',
+                            id: 'feature.01',
+                            geometry: {
+                                type: 'Polygon',
+                                coordinates: [[[0, 0], [0, 1], [1, 1], [0, 0]]]
+                            },
+                            properties: {}
+                        }
+                    ]
+                }
+            ]}
+            onChange={(value) => {
+                try {
+                    expect(value).toEqual({
+                        groups: [ { id: 'group_01', visibility: false } ],
+                        layers: [ { id: 'layer.01', visibility: false } ]
+                    });
+                } catch (e) {
+                    done(e);
+                }
+                done();
+            }}
+            expandedSections={{ layers: true }}
+            isClippingAvailable
+        />, document.getElementById("container"));
+        const formGroup = document.querySelectorAll('.form-group');
+        expect(formGroup.length).toBe(2);
+        const buttons = formGroup[1].querySelectorAll('button');
+        expect(buttons.length).toBe(2);
+        Simulate.click(buttons[1]);
     });
 });
diff --git a/web/client/epics/__tests__/mapviews-test.js b/web/client/epics/__tests__/mapviews-test.js
index 94e910ef0a..4717bf4bcf 100644
--- a/web/client/epics/__tests__/mapviews-test.js
+++ b/web/client/epics/__tests__/mapviews-test.js
@@ -90,6 +90,8 @@ describe('mapviews epics', () => {
                     UPDATE_ADDITIONAL_LAYER
                 ]);
 
+                expect(actions[3].options.visibility).toBe(false);
+                expect(actions[4].options.visibility).toBe(true);
                 expect(actions[4].options.style).toBeTruthy();
                 expect(actions[4].options.style).toEqual({
                     format: 'geostyler',
@@ -114,9 +116,13 @@ describe('mapviews epics', () => {
             }
         }, {
             layers: {
+                groups: [
+                    { id: 'group_01', visibility: false },
+                    { id: 'group_02', visibility: false }
+                ],
                 flat: [
-                    { id: 'layer.01', type: '3dtiles', visibility: true },
-                    { id: 'layer.02', type: 'vector', visibility: true, features: [feature] }
+                    { id: 'layer.01', group: 'group_01', type: '3dtiles', visibility: true },
+                    { id: 'layer.02', group: 'group_02', type: 'vector', visibility: true, features: [feature] }
                 ]
             },
             maptype: {
@@ -160,6 +166,12 @@ describe('mapviews epics', () => {
                                 clippingLayerResourceId: 'resource.01',
                                 clippingPolygonFeatureId: 'feature.01'
                             }
+                        ],
+                        groups: [
+                            {
+                                id: 'group_02',
+                                visibility: true
+                            }
                         ]
                     }
                 ],
diff --git a/web/client/epics/mapviews.js b/web/client/epics/mapviews.js
index accf8bbe6f..e53a40f903 100644
--- a/web/client/epics/mapviews.js
+++ b/web/client/epics/mapviews.js
@@ -40,14 +40,15 @@ import {
 } from '../selectors/mapviews';
 import { BROWSE_DATA } from '../actions/layers';
 import { CLOSE_FEATURE_GRID } from '../actions/featuregrid';
-import { layersSelector } from '../selectors/layers';
+import { layersSelector, rawGroupsSelector } from '../selectors/layers';
 import {
     isShallowEqualBy
 } from '../utils/ReselectUtils';
 import { getResourceFromLayer } from '../api/MapViews';
 
-import { MAP_VIEWS_LAYERS_OWNER, formatClippingFeatures } from '../utils/MapViewsUtils';
+import { MAP_VIEWS_LAYERS_OWNER, formatClippingFeatures, isViewLayerChanged, mergeViewGroups, mergeViewLayers } from '../utils/MapViewsUtils';
 import { isCesium } from '../selectors/maptype';
+import { getDerivedLayersVisibility } from '../utils/LayersUtils';
 
 const deepCompare = isShallowEqualBy();
 
@@ -117,22 +118,32 @@ export const updateMapViewsLayers = (action$, store) =>
             const state = store.getState();
             const previousView = getPreviousView(state);
             const currentView =  getSelectedMapView(state);
-            const { layers = [], mask = {}, id: viewId } = currentView || {};
+            const { layers = [], groups = [], mask = {}, id: viewId } = currentView || {};
             const shouldUpdate = !!(
                 action.type === VISUALIZATION_MODE_CHANGED
                 || !deepCompare(previousView?.layers || [], layers)
                 || !deepCompare(previousView?.mask || {}, mask)
+                || !deepCompare(previousView?.groups || [], groups)
             );
             if (!shouldUpdate) {
                 return Observable.of(
                     setPreviousView(currentView)
                 );
             }
+            const mapLayers = layersSelector(state);
+            const mergedGroups = mergeViewGroups(
+                rawGroupsSelector(state),
+                currentView, true);
+            const mergedLayers = mergeViewLayers(mapLayers, currentView);
+            const updatedLayers = getDerivedLayersVisibility(mergedLayers, mergedGroups);
+            const changedLayers = updatedLayers.filter((uLayer) => {
+                const currentLayer = (mapLayers || []).find(layer => layer.id === uLayer.id);
+                return isViewLayerChanged(uLayer, currentLayer);
+            });
             const resources = getMapViewsResources(state);
             return updateResourcesObservable(currentView, store)
                 .switchMap((allResources) => {
                     const checkedResources = allResources.filter(({ error }) => !error);
-                    const mapLayers = layersSelector(state);
                     const updatedResources = checkedResources.filter(resource => resource.updated);
                     const maskLayerResource = isString(mask.resourceId) && checkedResources.find((resource) => resource.id === mask.resourceId);
                     return Observable.of(
@@ -144,8 +155,7 @@ export const updateMapViewsLayers = (action$, store) =>
                         ] : []),
                         setPreviousView(currentView),
                         removeAdditionalLayer({ owner: MAP_VIEWS_LAYERS_OWNER }),
-                        ...layers
-                            .filter((layer) => !!mapLayers.find(mapLayer => mapLayer.id === layer.id))
+                        ...changedLayers
                             .map((layer) => {
                                 const clipPolygonLayerResource = isString(layer.clippingLayerResourceId) && checkedResources.find((resource) => resource.id === layer.clippingLayerResourceId);
                                 const clippingPolygon = isString(layer.clippingPolygonFeatureId)
@@ -156,7 +166,7 @@ export const updateMapViewsLayers = (action$, store) =>
                                     'override',
                                     {
                                         ...layer,
-                                        clippingPolygon
+                                        ...(clippingPolygon && { clippingPolygon })
                                     }
                                 );
                             }),
diff --git a/web/client/plugins/TOC/components/DefaultGroup.jsx b/web/client/plugins/TOC/components/DefaultGroup.jsx
index 5b4ba4a559..1b27b1a071 100644
--- a/web/client/plugins/TOC/components/DefaultGroup.jsx
+++ b/web/client/plugins/TOC/components/DefaultGroup.jsx
@@ -49,6 +49,7 @@ const DefaultGroupNode = ({
         onChange,
         nodeType,
         nodeTypes,
+        config,
         itemComponent: NodeTool
     };
     return (
diff --git a/web/client/plugins/TOC/components/DefaultLayer.jsx b/web/client/plugins/TOC/components/DefaultLayer.jsx
index aa04502634..2a6ddcddde 100644
--- a/web/client/plugins/TOC/components/DefaultLayer.jsx
+++ b/web/client/plugins/TOC/components/DefaultLayer.jsx
@@ -6,7 +6,7 @@
  * LICENSE file in the root directory of this source tree.
 */
 
-import React from 'react';
+import React, { useRef, useLayoutEffect, useState } from 'react';
 import { castArray, find } from 'lodash';
 import { Glyphicon } from 'react-bootstrap';
 import { isInsideResolutionsLimits, getLayerTypeGlyph } from '../../../utils/LayersUtils';
@@ -44,6 +44,86 @@ const getLayerVisibilityWarningMessageId = (node, config = {}) => {
     }
     return '';
 };
+
+const NodeLegend = ({
+    config,
+    node,
+    visible,
+    onChange
+}) => {
+
+    if (config?.layerOptions?.hideLegend) {
+        return null;
+    }
+    const layerType = node?.type;
+    if (['wfs', 'vector'].includes(layerType)) {
+        const hasStyle = node?.style?.format === 'geostyler' && node?.style?.body?.rules?.length > 0;
+        return hasStyle
+            ? (
+                <>
+                    <li>
+                        {visible ? <VectorLegend
+                            style={node?.style}
+                        /> : null}
+                    </li>
+                </>
+            )
+            : null;
+    }
+    if (layerType === 'wms') {
+        return (
+            <>
+                <li>
+                    {visible ? <WMSLegend
+                        node={node}
+                        currentZoomLvl={config?.zoom}
+                        scales={config?.scales}
+                        language={config?.language}
+                        {...config?.layerOptions?.legendOptions}
+                        onChange={onChange}
+                    /> : null}
+                </li>
+            </>
+        );
+    }
+    if (layerType === 'arcgis') {
+        return (
+            <>
+                <li>
+                    {visible ? <ArcGISLegend
+                        node={node}
+                    /> : null}
+                </li>
+            </>
+        );
+    }
+    return null;
+};
+
+const NodeContent = ({
+    error,
+    config,
+    node,
+    visible,
+    onChange,
+    items
+}) => {
+    if (error) {
+        return null;
+    }
+    const contentProps = {
+        config,
+        node,
+        onChange,
+        visible
+    };
+    return <>
+        {items.map(({ Component, name }) => {
+            return (<Component key={name} {...contentProps} />);
+        })}
+        <NodeLegend {...contentProps} />
+    </>;
+};
 /**
  * DefaultLayerNode renders internal part of the layer node
  * @prop {string} node layer node properties
@@ -51,6 +131,7 @@ const getLayerVisibilityWarningMessageId = (node, config = {}) => {
  * @prop {function} onChange return the changes of a specific node
  * @prop {object} config optional configuration available for the nodes
  * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ]
+ * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ]
  * @prop {function} onSelect return the current selected node on click event
  * @prop {string} nodeType node type
  * @prop {object} nodeTypes constant values for node types
@@ -67,6 +148,7 @@ const DefaultLayerNode = ({
     sortHandler,
     config = {},
     nodeToolItems = [],
+    nodeContentItems = [],
     onSelect,
     nodeType,
     nodeTypes,
@@ -76,68 +158,21 @@ const DefaultLayerNode = ({
     nodeIcon
 }) => {
 
-    const getContent = () => {
-
-        // currently the only content of the layer is the legend
-        // so we hide it if not visible
-        if (error || config?.layerOptions?.hideLegend) {
-            return null;
-        }
-
-        const layerType = node?.type;
-        if (['wfs', 'vector'].includes(layerType)) {
-            const hasStyle = node?.style?.format === 'geostyler' && node?.style?.body?.rules?.length > 0;
-            return hasStyle
-                ? (
-                    <>
-                        <li>
-                            <VectorLegend
-                                style={node?.style}
-                            />
-                        </li>
-                    </>
-                )
-                : null;
-        }
-        if (layerType === 'wms') {
-            return (
-                <>
-                    <li>
-                        <WMSLegend
-                            node={node}
-                            currentZoomLvl={config?.zoom}
-                            scales={config?.scales}
-                            language={config?.language}
-                            {...config?.layerOptions?.legendOptions}
-                            onChange={onChange}
-                        />
-                    </li>
-                </>
-            );
-        }
-        if (layerType === 'arcgis') {
-            return (
-                <>
-                    <li>
-                        <ArcGISLegend
-                            node={node}
-                        />
-                    </li>
-                </>
-            );
-        }
-        return null;
-    };
+    const contentNode = useRef();
+    const [hasContent, setHasContent] = useState(false);
+    useLayoutEffect(() => {
+        setHasContent(!!contentNode?.current?.children?.length);
+    }, [error, node, config]);
 
     const forceExpanded = config?.expanded !== undefined;
     const expanded = forceExpanded ? config?.expanded : node?.expanded;
-    const content = getContent(error);
 
     const componentProps = {
         node,
         onChange,
         nodeType,
         nodeTypes,
+        config,
         itemComponent: NodeTool
     };
 
@@ -160,7 +195,7 @@ const DefaultLayerNode = ({
                     <>
                         {sortHandler}
                         <ExpandButton
-                            hide={!(!forceExpanded && content)}
+                            hide={!(!forceExpanded && hasContent)}
                             expanded={expanded}
                             onChange={onChange}
                         />
@@ -184,9 +219,16 @@ const DefaultLayerNode = ({
                     </>
                 }
             />
-            {expanded && content ? <ul>
-                {content}
-            </ul> : null}
+            <ul ref={contentNode} style={!expanded || !hasContent ? { display: 'none' } : {}}>
+                <NodeContent
+                    error={error}
+                    config={config}
+                    node={node}
+                    onChange={onChange}
+                    visible={expanded}
+                    items={nodeContentItems}
+                />
+            </ul>
             <OpacitySlider
                 hide={!!error || config?.hideOpacitySlider || ['3dtiles', 'model'].includes(node?.type)}
                 opacity={node?.opacity}
@@ -256,6 +298,7 @@ const DefaultLayer = ({
     sortable,
     config,
     nodeToolItems = [],
+    nodeContentItems = [],
     nodeItems = [],
     nodeTypes,
     theme
@@ -300,6 +343,7 @@ const DefaultLayer = ({
         mutuallyExclusive,
         config,
         nodeToolItems,
+        nodeContentItems,
         onSelect: handleOnSelect,
         nodeType,
         error,
diff --git a/web/client/plugins/TOC/components/ExpandButton.jsx b/web/client/plugins/TOC/components/ExpandButton.jsx
index cb23de7110..4460cc5616 100644
--- a/web/client/plugins/TOC/components/ExpandButton.jsx
+++ b/web/client/plugins/TOC/components/ExpandButton.jsx
@@ -32,6 +32,7 @@ function ExpandButton({
             className="ms-node-expand"
             onClick={(event) => {
                 event.stopPropagation();
+                event.preventDefault();
                 if (!disabled) {
                     onChange({ expanded: !expanded });
                 }
diff --git a/web/client/plugins/TOC/components/LayersTree.jsx b/web/client/plugins/TOC/components/LayersTree.jsx
index d3597b722a..94dd104080 100644
--- a/web/client/plugins/TOC/components/LayersTree.jsx
+++ b/web/client/plugins/TOC/components/LayersTree.jsx
@@ -62,6 +62,7 @@ const loopGroupCondition = (groupNode, condition) => {
  * @prop {string} theme layers tree theme, one of undefined or `legend`
  * @prop {string} className additional class name for the layer tree
  * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ]
+ * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ]
  * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ]
  * @prop {object} singleDefaultGroup if true it hides the default group nodes
  * @prop {string} noFilteredResultsMsgId message id for no result on filter
@@ -87,6 +88,7 @@ const LayersTree = ({
     className,
     nodeItems,
     nodeToolItems,
+    nodeContentItems,
     singleDefaultGroup = isSingleDefaultGroup(tree),
     theme
 }) => {
@@ -204,6 +206,7 @@ const LayersTree = ({
                             onSelect={onSelect}
                             nodeItems={nodeItems}
                             nodeToolItems={nodeToolItems}
+                            nodeContentItems={nodeContentItems}
                         />
                     );
                 })}
diff --git a/web/client/plugins/TOC/components/NodeTool.jsx b/web/client/plugins/TOC/components/NodeTool.jsx
index 9ba1c95b00..e307373d08 100644
--- a/web/client/plugins/TOC/components/NodeTool.jsx
+++ b/web/client/plugins/TOC/components/NodeTool.jsx
@@ -49,6 +49,7 @@ function NodeTool({
             tooltip={tooltipProp}
             onClick={(event) => {
                 event.stopPropagation();
+                event.preventDefault();
                 if (!disabled) {
                     onClick(event);
                 }
diff --git a/web/client/plugins/TOC/components/TOC.jsx b/web/client/plugins/TOC/components/TOC.jsx
index 6801d3f446..c53bc8d795 100644
--- a/web/client/plugins/TOC/components/TOC.jsx
+++ b/web/client/plugins/TOC/components/TOC.jsx
@@ -34,6 +34,7 @@ import {
  * @prop {string} filterText filter to apply to layers title
  * @prop {string} theme layers tree theme, one of undefined or `legend`
  * @prop {string} className additional class name for the layer tree
+ * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ]
  * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ]
  * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ]
  * @prop {object} singleDefaultGroup if true it hides the default group nodes
@@ -74,6 +75,7 @@ export function ControlledTOC({
     className,
     nodeItems,
     nodeToolItems,
+    nodeContentItems,
     singleDefaultGroup,
     theme
 }) {
@@ -100,6 +102,7 @@ export function ControlledTOC({
             config={config}
             nodeItems={nodeItems}
             nodeToolItems={nodeToolItems}
+            nodeContentItems={nodeContentItems}
             singleDefaultGroup={singleDefaultGroup}
         />
     );
@@ -108,6 +111,7 @@ export function ControlledTOC({
 /**
  * TOC component that supports map configuration
  * @prop {object} map map configuration
+ * @prop {function} onChangeNode return the changed node configuration
  * @prop {function} onChangeMap return the changed map configuration
  * @prop {array} selectedNodes list of selected node identifiers
  * @prop {function} onSelectNode return the current selected node on click event
@@ -115,6 +119,7 @@ export function ControlledTOC({
  * @prop {string} className additional class name for the layer tree
  * @prop {array} nodeItems list of node component to customize specific nodes, expected structure [ { name, Component, selector } ]
  * @prop {array} nodeToolItems list of node tool component to customize specific tool available on a node, expected structure [ { name, Component } ]
+ * @prop {array} nodeContentItems list of node content component to customize specific content available after expanding the node, expected structure [ { name, Component } ]
  * @prop {object} singleDefaultGroup if true it hides the default group nodes
  * @prop {object} config optional configuration available for the nodes
  * @prop {number} config.resolution map resolution
@@ -138,15 +143,18 @@ export function ControlledTOC({
  */
 function TOC({
     map = { layers: [], groups: [] },
+    onChangeNode = () => {},
     onChangeMap = () => {},
     selectedNodes = [],
     onSelectNode = () => {},
     config,
     className,
     nodeToolItems,
+    nodeContentItems,
     singleDefaultGroup,
     nodeItems,
-    theme
+    theme,
+    filterText
 }) {
     const { layers } = splitMapAndLayers(map) || {};
     const tree = denormalizeGroups(layers.flat || [], layers.groups || []).groups;
@@ -173,6 +181,7 @@ function TOC({
         }
     }
     function handleUpdateNode(nodeId, nodeType, options) {
+        onChangeNode(nodeId, nodeType, options);
         const updatedNode = changeNodeConfiguration({
             groups: layers.groups,
             layers: layers.flat
@@ -188,6 +197,7 @@ function TOC({
             className={className}
             theme={theme}
             tree={tree}
+            filterText={filterText}
             selectedNodes={selectedNodesIdsToObject(selectedNodes, layers.flat, tree)}
             onSelectNode={onSelectNode}
             onSort={handleOnSort}
@@ -206,6 +216,7 @@ function TOC({
             }}
             nodeItems={nodeItems}
             nodeToolItems={nodeToolItems}
+            nodeContentItems={nodeContentItems}
             singleDefaultGroup={singleDefaultGroup}
         />
     );
diff --git a/web/client/plugins/TOC/components/VisibilityCheck.jsx b/web/client/plugins/TOC/components/VisibilityCheck.jsx
index 1281afef79..8005c2dafe 100644
--- a/web/client/plugins/TOC/components/VisibilityCheck.jsx
+++ b/web/client/plugins/TOC/components/VisibilityCheck.jsx
@@ -40,6 +40,7 @@ const VisibilityCheck = ({
             className={`ms-visibility-check${value ? ' active' : ''}`}
             onClick={(event) => {
                 event.stopPropagation();
+                event.preventDefault();
                 onChange(!value);
             }}
             onContextMenu={(event) => {
diff --git a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx
index b5c9647e32..7aab1562cc 100644
--- a/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx
+++ b/web/client/plugins/TOC/components/__tests__/DefaultLayer-test.jsx
@@ -100,9 +100,27 @@ describe('test DefaultLayer module component', () => {
         }}/>, document.getElementById("container"));
         const expand = document.querySelector('.ms-node-expand');
         expect(expand).toBeTruthy();
-        expect(document.querySelector('.ms-node-layer ul')).toBeFalsy();
+        expect(document.querySelector('.ms-node-layer ul').style.display).toBe('none');
         TestUtils.Simulate.click(expand);
-        expect(document.querySelector('.ms-node-layer ul')).toBeTruthy();
+    });
+
+    it('should include custom content with nodeContentItems prop when expanding layer node', () => {
+        const l = {
+            id: 'layer00',
+            name: 'layer00',
+            title: 'Layer',
+            visibility: true,
+            type: 'wms',
+            expanded: true
+        };
+        function CustomContent() {
+            return <div id="custom-content"></div>;
+        }
+        ReactDOM.render(<Layer node={l} nodeContentItems={[{ name: 'CustomContent', Component: CustomContent }]}/>, document.getElementById("container"));
+        const expand = document.querySelector('.ms-node-expand');
+        expect(expand).toBeTruthy();
+        expect(document.querySelector('.ms-node-layer ul').style.display).toBe('');
+        expect(document.querySelector('.ms-node-layer #custom-content')).toBeTruthy();
     });
 
     it('tests opacity tool', () => {
diff --git a/web/client/plugins/mapviews/MapViews.jsx b/web/client/plugins/mapviews/MapViews.jsx
index 624a7799f9..d7710054fe 100644
--- a/web/client/plugins/mapviews/MapViews.jsx
+++ b/web/client/plugins/mapviews/MapViews.jsx
@@ -19,7 +19,7 @@ import {
     hideViews
 } from '../../actions/mapviews';
 import mapviews from '../../reducers/mapviews';
-import { layersSelector } from '../../selectors/layers';
+import { layersSelector, rawGroupsSelector } from '../../selectors/layers';
 import { currentLocaleSelector } from '../../selectors/locale';
 import epics from '../../epics/mapviews';
 import {
@@ -33,6 +33,8 @@ import {
 } from '../../selectors/mapviews';
 import Loader from '../../components/misc/Loader';
 import { MAP_VIEWS_CONFIG_KEY } from '../../utils/MapViewsUtils';
+import { flattenArrayOfObjects } from '../../utils/LayersUtils';
+import { isObject } from 'lodash';
 
 const MapViewsSupport = lazy(() => import('../../components/mapviews/MapViewsSupport'));
 
@@ -92,8 +94,9 @@ const ConnectedMapViews = connect(
         isMapViewsActive,
         isMapViewsHidden,
         isMapViewsInitialized,
-        getMapViewsUpdateUUID
-    ], (selectedId, views, layers, locale, mapConfig, resources, active, hide, initialized, updateUUID) => ({
+        getMapViewsUpdateUUID,
+        rawGroupsSelector
+    ], (selectedId, views, layers, locale, mapConfig, resources, active, hide, initialized, updateUUID, groups) => ({
         selectedId,
         views,
         layers: layers.filter(({ group }) => group !== 'background'),
@@ -103,7 +106,8 @@ const ConnectedMapViews = connect(
         active,
         hide,
         initialized,
-        updateUUID
+        updateUUID,
+        groups: flattenArrayOfObjects(groups).filter(isObject)
     })),
     {
         onSelectView: selectView,
diff --git a/web/client/selectors/__tests__/mapviews-test.js b/web/client/selectors/__tests__/mapviews-test.js
index 95f82bcd7d..a4f104bb8b 100644
--- a/web/client/selectors/__tests__/mapviews-test.js
+++ b/web/client/selectors/__tests__/mapviews-test.js
@@ -99,7 +99,48 @@ describe('mapviews selectors', () => {
                 ]
             }
         };
-        expect(getMapViews(state)).toBe(state.mapviews.views);
+        expect(getMapViews(state)).toEqual(state.mapviews.views);
+    });
+    it('getMapViews remove missing layers and groups', () => {
+        const state = {
+            layers: {
+                flat: [{ id: 'layer.01', group: 'group_01' }],
+                groups: [{ id: 'group_01' }]
+            },
+            mapviews: {
+                views: [
+                    {
+                        center: {
+                            longitude: 8.936900,
+                            latitude: 44.395224,
+                            height: 0
+                        },
+                        cameraPosition: {
+                            longitude: 8.939256,
+                            latitude: 44.386982,
+                            height: 655
+                        },
+                        id: 'view.1',
+                        title: 'Map view',
+                        description: '',
+                        duration: 10,
+                        flyTo: true,
+                        zoom: 16,
+                        bbox: [
+                            8.920925,
+                            44.390840,
+                            8.948118,
+                            44.405544
+                        ],
+                        layers: [{ id: 'layer.01' }, { id: 'layer.02' }],
+                        groups: [{ id: 'group_01' }, { id: 'group_02' }]
+                    }
+                ]
+            }
+        };
+        const newMapViews = getMapViews(state);
+        expect(newMapViews[0].layers).toEqual([{ id: 'layer.01' }]);
+        expect(newMapViews[0].groups).toEqual([{ id: 'group_01' }]);
     });
     it('getMapViewsResources', () => {
         const state = {
@@ -205,7 +246,7 @@ describe('mapviews selectors', () => {
                 active: true
             }
         };
-        expect(getSelectedMapView(state)).toBe(state.mapviews.views[0]);
+        expect(getSelectedMapView(state)).toEqual(state.mapviews.views[0]);
         state = {
             mapviews: {
                 ...state.mapviews,
diff --git a/web/client/selectors/mapviews.js b/web/client/selectors/mapviews.js
index 133efd7e25..fd2c5b68e7 100644
--- a/web/client/selectors/mapviews.js
+++ b/web/client/selectors/mapviews.js
@@ -6,10 +6,30 @@
  * LICENSE file in the root directory of this source tree.
  */
 
+import { DEFAULT_GROUP_ID } from "../utils/LayersUtils";
+import { layersSelector } from "./layers";
+
 export const isMapViewsActive = state => !!state?.mapviews?.active;
 export const isMapViewsHidden = state => !!state?.mapviews?.hide;
 export const getSelectedMapViewId = state => !isMapViewsHidden(state) && isMapViewsActive(state) && state?.mapviews?.selectedId;
-export const getMapViews = state => state?.mapviews?.views;
+export const getMapViews = state => {
+    const layers = layersSelector(state);
+    const layersIds = layers.map(layer => layer.id);
+    const groupsIds = layers.map(layer => layer.group || DEFAULT_GROUP_ID);
+    return state?.mapviews?.views ? state.mapviews.views.map(view => {
+        const viewLayers = view?.layers;
+        const viewGroups = view?.groups;
+        return {
+            ...view,
+            ...(viewLayers && {
+                layers: viewLayers.filter(viewLayer => layersIds.includes(viewLayer.id))
+            }),
+            ...(viewGroups && {
+                groups: viewGroups.filter(viewGroup => groupsIds.includes(viewGroup.id))
+            })
+        };
+    }) : undefined;
+};
 export const getMapViewsResources = state => state?.mapviews?.resources;
 export const getResourceById = (state, id) => getMapViewsResources(state)?.find(resource => resource.id === id);
 export const getPreviousView = state => state?.mapviews?.previousView;
diff --git a/web/client/themes/default/less/map-views.less b/web/client/themes/default/less/map-views.less
index 364858160f..24b9ecd8af 100644
--- a/web/client/themes/default/less/map-views.less
+++ b/web/client/themes/default/less/map-views.less
@@ -34,12 +34,6 @@
             }
             .border-color-var(@theme-vars[main-border-color]);
         }
-        .ms-map-views-layer-node {
-            &.changed {
-                .border-left-color-var(@theme-vars[success]);
-            }
-            .border-color-var(@theme-vars[main-border-color]);
-        }
         .ms-map-views-section,
         .ms-map-views-layers-options-header {
             .background-color-var(@theme-vars[main-bg]);
@@ -135,8 +129,11 @@
     top: 0;
     left: 40px;
     width: 500px;
-    .btn-default {
-        border-color: transparent !important;
+    .ms-layers-tree {
+        padding: 0;
+        + .ms-layers-tree {
+            margin-top: 0.5rem;
+        }
     }
     .form-group.inline {
         display: flex;
@@ -285,31 +282,6 @@
         align-items: center;
         justify-content: center;
     }
-    .ms-map-views-layer-node {
-        border: 1px solid transparent;
-        padding: 8px;
-        margin-bottom: 4px;
-        &.changed {
-            border-left: 4px solid transparent;
-        }
-        .ms-map-views-layer-node-head {
-            display: flex;
-            align-items: center;
-            .ms-map-views-layer-node-title {
-                flex: 1;
-                text-overflow: ellipsis;
-                overflow: hidden;
-                white-space: nowrap;
-            }
-        }
-        .ms-map-views-layer-node-body {
-            padding-top: 16px;
-            padding-left: 32px;
-        }
-        .ms-map-views-layer-clipping {
-            position: relative;
-        }
-    }
     .ms-map-views-layers-options-header {
         position: sticky;
         z-index: 2;
@@ -322,12 +294,6 @@
             margin: 0;
         }
     }
-    .ms-map-views-layers-options-body {
-        width: 100%;
-        padding: 0;
-        margin: 0;
-        list-style: none;
-    }
     .ms-map-views-section {
         display: flex;
         align-items: center;
diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json
index bca47de03e..b30947ab5d 100644
--- a/web/client/translations/data.da-DK.json
+++ b/web/client/translations/data.da-DK.json
@@ -3409,7 +3409,12 @@
       "showViewsGeometries": "Show views positions",
       "addInitialView": "Click on the plus button to add a new view",
       "removeView": "Remove view",
-      "resetLayerOverrides": "Reset changes to align layer to default value",
+      "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content",
+      "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content",
+      "layersLinked": "Visibiliy and opacity properties are connected to the main table of content",
+      "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content",
+      "groupsLinked": "Visibiliy property is connected to the main table of content",
+      "groupsUnlinked": "Visibiliy property is disconnected from the main table of content",
       "hideLayer": "Hide layer",
       "showLayer": "Show layer",
       "undoChanges": "Undo changes",
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index df6b185b0e..5c61e664ab 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -3715,7 +3715,12 @@
             "showViewsGeometries": "Views-Positionen anzeigen",
             "addInitialView": "Klicken Sie auf die Plus-Schaltfläche, um eine neue Ansicht hinzuzufügen",
             "removeView": "Ansicht entfernen",
-            "resetLayerOverrides": "Änderungen zurücksetzen, um die Ebene auf den Standardwert auszurichten",
+            "linkAllNodes": "Sichtbarkeits- und Opazitätseigenschaften von Ebenen und Gruppen werden mit dem Hauptinhaltsverzeichnis verknüpft",
+            "unlinkAllNodes": "Sichtbarkeits- und Opazitätseigenschaften von Ebenen und Gruppen werden vom Hauptinhaltsverzeichnis getrennt",
+            "layersLinked": "Sichtbarkeits- und Opazitätseigenschaften sind mit dem Hauptinhaltsverzeichnis verknüpft",
+            "layersUnlinked": "Sichtbarkeits- und Opazitätseigenschaften sind vom Hauptinhaltsverzeichnis getrennt",
+            "groupsLinked": "Sichtbarkeitseigenschaft ist mit dem Hauptinhaltsverzeichnis verknüpft",
+            "groupsUnlinked": "Sichtbarkeitseigenschaft ist vom Hauptinhaltsverzeichnis getrennt",
             "hideLayer": "Ebene ausblenden",
             "showLayer": "Ebene anzeigen",
             "undoChanges": "Änderungen rückgängig machen",
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index d0d7e09dd9..02595d66d6 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -3688,7 +3688,12 @@
             "showViewsGeometries": "Show views positions",
             "addInitialView": "Click on the plus button to add a new view",
             "removeView": "Remove view",
-            "resetLayerOverrides": "Reset changes to align layer to default value",
+            "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content",
+            "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content",
+            "layersLinked": "Visibiliy and opacity properties are connected to the main table of content",
+            "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content",
+            "groupsLinked": "Visibiliy property is connected to the main table of content",
+            "groupsUnlinked": "Visibiliy property is disconnected from the main table of content",
             "hideLayer": "Hide layer",
             "showLayer": "Show layer",
             "undoChanges": "Undo changes",
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index 05ee0381eb..95460de5c8 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -3677,7 +3677,12 @@
             "showViewsGeometries": "Mostrar posiciones de vistas",
             "addInitialView": "Haga clic en el botón más para agregar una nueva vista",
             "removeView": "Eliminar vista",
-            "resetLayerOverrides": "Restablecer cambios para alinear la capa al valor predeterminado",
+            "linkAllNodes": "Las propiedades de visibilidad y opacidad de las capas y los grupos se conectarán con la tabla de contenido principal",
+            "unlinkAllNodes": "Las propiedades de visibilidad y opacidad de las capas y los grupos se desconectarán de la tabla de contenido principal",
+            "layersLinked": "Las propiedades de visibilidad y opacidad están conectadas a la tabla de contenido principal",
+            "layersUnlinked": "Las propiedades de visibilidad y opacidad están desconectadas de la tabla de contenido principal",
+            "groupsLinked": "La propiedad de visibilidad está conectada a la tabla de contenido principal",
+            "groupsUnlinked": "La propiedad de visibilidad está desconectada de la tabla de contenido principal",
             "hideLayer": "Ocultar capa",
             "showLayer": "Mostrar capa",
             "undoChanges": "Deshacer cambios",
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index 61c8d8f522..f1cbd8864b 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -3678,7 +3678,12 @@
             "showViewsGeometries": "Afficher les positions des vues",
             "addInitialView": "Cliquez sur le bouton plus pour ajouter une nouvelle vue",
             "removeView": "Supprimer la vue",
-            "resetLayerOverrides": "Réinitialise les modifications pour aligner le calque sur la valeur par défaut",
+            "linkAllNodes": "Les propriétés de visibilité et d'opacité des calques et des groupes seront connectées à la table des matières principale",
+            "unlinkAllNodes": "Les propriétés de visibilité et d'opacité des calques et des groupes seront déconnectées de la table des matières principale",
+            "layersLinked": "Les propriétés de visibilité et d'opacité sont connectées à la table des matières principale",
+            "layersUnlinked": "Les propriétés de visibilité et d'opacité sont déconnectées de la table des matières principale",
+            "groupsLinked": "La propriété de visibilité est connectée à la table des matières principale",
+            "groupsUnlinked": "La propriété de visibilité est déconnectée de la table des matières principale",
             "hideLayer": "Masquer le calque",
             "showLayer": "Afficher le calque",
             "undoChanges": "Annuler les modifications",
diff --git a/web/client/translations/data.is-IS.json b/web/client/translations/data.is-IS.json
index 0a63e5d63a..c4dac582a1 100644
--- a/web/client/translations/data.is-IS.json
+++ b/web/client/translations/data.is-IS.json
@@ -3433,7 +3433,12 @@
       "showViewsGeometries": "Show views positions",
       "addInitialView": "Click on the plus button to add a new view",
       "removeView": "Remove view",
-      "resetLayerOverrides": "Reset changes to align layer to default value",
+      "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content",
+      "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content",
+      "layersLinked": "Visibiliy and opacity properties are connected to the main table of content",
+      "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content",
+      "groupsLinked": "Visibiliy property is connected to the main table of content",
+      "groupsUnlinked": "Visibiliy property is disconnected from the main table of content",
       "hideLayer": "Hide layer",
       "showLayer": "Show layer",
       "undoChanges": "Undo changes",
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index 9c2e32872f..8d2905dbc3 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -3676,7 +3676,12 @@
             "showViewsGeometries": "Mostra le posizioni delle viste",
             "addInitialView": "Fai clic sul pulsante più per aggiungere una nuova vista",
             "removeView": "Rimuovi vista",
-            "resetLayerOverrides": "Ripristina le modifiche per allineare il livello al valore predefinito",
+            "linkAllNodes": "Le proprietà di visibilità e opacità di livelli e gruppi saranno collegate alla lista dei layer principale",
+            "unlinkAllNodes": "Le proprietà di visibilità e opacità di livelli e gruppi saranno scollegate dalla lista dei layer principale",
+            "layersLinked": "Le proprietà di visibilità e opacità sono collegate alla lista dei layer principale",
+            "layersUnlinked": "Le proprietà di visibilità e opacità sono scollegate dalla lista dei layer principale",
+            "groupsLinked": "La proprietà di visibilità è collegata alla lista dei layer principale",
+            "groupsUnlinked": "La proprietà di visibilità è scollegata dalla lista dei layer principale",
             "hideLayer": "Nascondi layer",
             "showLayer": "Mostra layer",
             "undoChanges": "Annulla modifiche",
diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json
index f88e2c7bf2..79610ea350 100644
--- a/web/client/translations/data.nl-NL.json
+++ b/web/client/translations/data.nl-NL.json
@@ -3676,7 +3676,12 @@
             "showViewsGeometries": "Toon positie van de overzichten",
             "addInitialView": "Klik op de plusknop om een nieuw overzicht toe te voegen",
             "removeView": "Verwijder overzicht",
-            "resetLayerOverrides": "Aanpassingen resetten naar standaardwaarden",
+            "linkAllNodes": "Visibiliy and opacity properties of layers and groups will be connected with the main table of content",
+            "unlinkAllNodes": "Visibiliy and opacity properties of layers and groups will be disconneded from the main table of content",
+            "layersLinked": "Visibiliy and opacity properties are connected to the main table of content",
+            "layersUnlinked": "Visibiliy and opacity properties are disconnected from the main table of content",
+            "groupsLinked": "Visibiliy property is connected to the main table of content",
+            "groupsUnlinked": "Visibiliy property is disconnected from the main table of content",
             "hideLayer": "Verberg laag",
             "showLayer": "Toon laag",
             "undoChanges": "Aanpassingen ongedaan maken",
diff --git a/web/client/utils/MapViewsUtils.js b/web/client/utils/MapViewsUtils.js
index e66030ca0b..63cc35cc0a 100644
--- a/web/client/utils/MapViewsUtils.js
+++ b/web/client/utils/MapViewsUtils.js
@@ -5,7 +5,7 @@
  * This source code is licensed under the BSD-style license found in the
  * LICENSE file in the root directory of this source tree.
  */
-import isNumber from 'lodash/isNumber';
+import { isUndefined, omitBy, isNumber, isObject } from 'lodash';
 
 const METERS_PER_DEGREES = 111194.87428468118;
 
@@ -99,6 +99,77 @@ export const mergeViewLayers = (layers, { layers: viewLayers = [] } = {}) => {
         return layer;
     });
 };
+/**
+ * detect if a view layer is different from the map layers
+ * @param {object} viewLayer layer object
+ * @param {object} mapLayer layer object
+ */
+export const isViewLayerChanged = (viewLayer, mapLayer) => {
+    return viewLayer.visibility !== mapLayer.visibility
+    || viewLayer.opacity !== mapLayer.opacity
+    || viewLayer.clippingLayerResourceId !== mapLayer.clippingLayerResourceId
+    || viewLayer.clippingPolygonFeatureId !== mapLayer.clippingPolygonFeatureId
+    || viewLayer.clippingPolygonUnion !== mapLayer.clippingPolygonUnion;
+};
+/**
+ * pick view layer properties
+ * @param {object} node layer object
+ */
+export const pickViewLayerProperties = (node) => {
+    return omitBy({
+        id: node.id,
+        visibility: node.visibility,
+        opacity: node.opacity,
+        clippingLayerResourceId: node.clippingLayerResourceId,
+        clippingPolygonFeatureId: node.clippingPolygonFeatureId,
+        clippingPolygonUnion: node.clippingPolygonUnion
+    }, isUndefined);
+};
+/**
+ * pick view group properties
+ * @param {object} node group object
+ */
+export const pickViewGroupProperties = (node) => {
+    return omitBy({
+        id: node.id,
+        visibility: node.visibility
+    }, isUndefined);
+};
+/**
+ * merge the configuration of view groups in the main groups array
+ * @param {array} rawGroups array of group object
+ * @param {object} view map view configuration
+ * @param {boolean} recursive apply recursive merge instead of flat one
+ */
+export const mergeViewGroups = (groups, { groups: viewGroups = [] } = {}, recursive) => {
+    if (viewGroups.length === 0) {
+        return groups || [];
+    }
+    if (recursive) {
+        const recursiveMerge = (nodes) => {
+            return nodes.map((node) => {
+                if (isObject(node)) {
+                    const viewGroup = viewGroups.find(vGroup => vGroup.id === node.id);
+                    return {
+                        ...node,
+                        ...viewGroup,
+                        ...(node.nodes && { nodes: recursiveMerge(node.nodes) }),
+                        changed: true
+                    };
+                }
+                return node;
+            });
+        };
+        return recursiveMerge(groups || []);
+    }
+    return (groups || []).map((group) => {
+        const viewGroup = viewGroups.find(vGroup => vGroup.id === group.id);
+        if (viewGroup) {
+            return { ...group, ...viewGroup, changed: true };
+        }
+        return group;
+    });
+};
 /**
  * Exclude all geometry but polygons and ensure each feature has an identifier
  * @param {array} features array of GeoJSON features
diff --git a/web/client/utils/__tests__/MapViewsUtils-test.js b/web/client/utils/__tests__/MapViewsUtils-test.js
index 75a9532b0a..9518af5660 100644
--- a/web/client/utils/__tests__/MapViewsUtils-test.js
+++ b/web/client/utils/__tests__/MapViewsUtils-test.js
@@ -12,7 +12,11 @@ import {
     formatClippingFeatures,
     getZoomFromHeight,
     getHeightFromZoom,
-    cleanMapViewSavedPayload
+    cleanMapViewSavedPayload,
+    isViewLayerChanged,
+    pickViewLayerProperties,
+    pickViewGroupProperties,
+    mergeViewGroups
 } from '../MapViewsUtils';
 
 describe('Test MapViewsUtils', () => {
@@ -390,4 +394,55 @@ describe('Test MapViewsUtils', () => {
             ]
         );
     });
+    it('isViewLayerChanged', () => {
+        expect(isViewLayerChanged({ }, { })).toBe(false);
+        expect(isViewLayerChanged({ title: 'Old title' }, { title: 'New title' })).toBe(false);
+        expect(isViewLayerChanged({ visibility: false }, { visibility: true })).toBe(true);
+        expect(isViewLayerChanged({ opacity: 0 }, { opacity: 1 })).toBe(true);
+        expect(isViewLayerChanged({ clippingLayerResourceId: '01' }, { clippingLayerResourceId: '02' })).toBe(true);
+        expect(isViewLayerChanged({ clippingPolygonFeatureId: '01' }, { clippingPolygonFeatureId: '02' })).toBe(true);
+        expect(isViewLayerChanged({ clippingPolygonUnion: false }, { clippingPolygonUnion: true })).toBe(true);
+    });
+    it('pickViewLayerProperties', () => {
+        expect(pickViewLayerProperties({
+            id: '01',
+            title: 'Layer',
+            url: '/url/to/wms',
+            type: 'wms',
+            layer: 'layer',
+            visibility: true,
+            opacity: 1,
+            clippingLayerResourceId: '01',
+            clippingPolygonFeatureId: '01',
+            clippingPolygonUnion: false,
+            expanded: false
+        })).toEqual({
+            id: '01',
+            visibility: true,
+            opacity: 1,
+            clippingLayerResourceId: '01',
+            clippingPolygonFeatureId: '01',
+            clippingPolygonUnion: false
+        });
+    });
+    it('pickViewGroupProperties', () => {
+        expect(pickViewGroupProperties({
+            id: '01',
+            title: 'Group',
+            visibility: true,
+            expanded: false
+        })).toEqual({
+            id: '01',
+            visibility: true
+        });
+    });
+    it('mergeViewGroups', () => {
+        expect(mergeViewGroups([{ id: '01', visibility: false }])).toEqual([{ id: '01', visibility: false }]);
+        expect(mergeViewGroups([{ id: '01', visibility: false }, { id: '02', visibility: false }], { groups: [{ id: '01', visibility: true }] }))
+            .toEqual([{ id: '01', visibility: true, changed: true }, { id: '02', visibility: false }]);
+        expect(mergeViewGroups(
+            [{ id: '01', visibility: false, nodes: ['layer01', { id: '02', visibility: false }] }],
+            { groups: [{ id: '02', visibility: true }] }
+        ), true).toEqual([ { id: '01', visibility: false, nodes: [ 'layer01', { id: '02', visibility: false } ] } ]);
+    });
 });