diff --git a/web/client/actions/__tests__/featuregrid-test.js b/web/client/actions/__tests__/featuregrid-test.js index 995a17cf300..c54e54a1880 100644 --- a/web/client/actions/__tests__/featuregrid-test.js +++ b/web/client/actions/__tests__/featuregrid-test.js @@ -71,8 +71,6 @@ import { OPEN_ADVANCED_SEARCH, initPlugin, INIT_PLUGIN, - sizeChange, - SIZE_CHANGE, START_SYNC_WMS, startSyncWMS, storeAdvancedSearchFilter, @@ -310,15 +308,6 @@ describe('Test correctness of featurgrid actions', () => { expect(retval.type).toBe(UPDATE_FILTER); expect(retval.update).toBe(update); }); - it('Test sizeChange', () => { - const size = 0.5; - const dockProps = {maxDockSize: 0.7, minDockSize: 0.1}; - const retval = sizeChange(size, dockProps); - expect(retval).toExist(); - expect(retval.type).toBe(SIZE_CHANGE); - expect(retval.size).toBe(size); - expect(retval.dockProps).toEqual(dockProps); - }); it('Test storeAdvancedSearchFilter', () => { const filterObj = {name: "A"}; const retval = storeAdvancedSearchFilter(filterObj); diff --git a/web/client/actions/featuregrid.js b/web/client/actions/featuregrid.js index a90803c6bd2..c1645bb2cf0 100644 --- a/web/client/actions/featuregrid.js +++ b/web/client/actions/featuregrid.js @@ -32,7 +32,6 @@ export const SET_LAYER = 'FEATUREGRID:SET_LAYER'; export const UPDATE_FILTER = 'QUERY:UPDATE_FILTER'; export const CHANGE_PAGE = 'FEATUREGRID:CHANGE_PAGE'; export const GEOMETRY_CHANGED = 'FEATUREGRID:GEOMETRY_CHANGED'; -export const DOCK_SIZE_FEATURES = 'DOCK_SIZE_FEATURES'; export const TOGGLE_TOOL = 'FEATUREGRID:TOGGLE_TOOL'; export const CUSTOMIZE_ATTRIBUTE = 'FEATUREGRID:CUSTOMIZE_ATTRIBUTE'; export const CLOSE_FEATURE_GRID_CONFIRM = 'ASK_CLOSE_FEATURE_GRID_CONFIRM'; @@ -47,7 +46,6 @@ export const DEACTIVATE_GEOMETRY_FILTER = 'FEATUREGRID:DEACTIVATE_GEOMETRY_FILTE export const OPEN_ADVANCED_SEARCH = 'FEATUREGRID:ADVANCED_SEARCH'; export const ZOOM_ALL = 'FEATUREGRID:ZOOM_ALL'; export const INIT_PLUGIN = 'FEATUREGRID:INIT_PLUGIN'; -export const SIZE_CHANGE = 'FEATUREGRID:SIZE_CHANGE'; export const TOGGLE_SHOW_AGAIN_FLAG = 'FEATUREGRID:TOGGLE_SHOW_AGAIN_FLAG'; export const UPDATE_EDITORS_OPTIONS = 'FEATUREGRID:UPDATE_EDITORS_OPTIONS'; export const LAUNCH_UPDATE_FILTER_FUNC = 'FEATUREGRID:LAUNCH_UPDATE_FILTER_FUNC'; @@ -194,12 +192,6 @@ export function setFeatures(features) { }; } -export function dockSizeFeatures(dockSize) { - return { - type: DOCK_SIZE_FEATURES, - dockSize: dockSize - }; -} export function sort(sortBy, sortOrder) { return { type: SORT_BY, @@ -346,13 +338,6 @@ export function startSyncWMS() { type: START_SYNC_WMS }; } -export function sizeChange(size, dockProps) { - return { - type: SIZE_CHANGE, - size, - dockProps - }; -} export const moreFeatures = (pages) => { return { type: LOAD_MORE_FEATURES, diff --git a/web/client/components/data/featuregrid/FeatureEditorFallback.jsx b/web/client/components/data/featuregrid/FeatureEditorFallback.jsx index 146ac5f7d42..d21e941739b 100644 --- a/web/client/components/data/featuregrid/FeatureEditorFallback.jsx +++ b/web/client/components/data/featuregrid/FeatureEditorFallback.jsx @@ -6,27 +6,15 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useMemo } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { mapLayoutValuesSelector } from '../../../selectors/maplayout'; +import React from 'react'; import Loader from '../../misc/Loader'; -const FeatureEditorFallback = ({ size, dockStyle }) => { - const containerStyle = useMemo(() => { - return { height: `${size * 100}%`, ...dockStyle }; - }, [size, dockStyle]); +const FeatureEditorFallback = () => { return ( -
+
); }; -export default connect(createSelector( - state => state?.featuregrid?.dockSize, - state => mapLayoutValuesSelector(state, { transform: true }), - (size, dockStyle) => ({ - size, - dockStyle - })))(FeatureEditorFallback); +export default FeatureEditorFallback; diff --git a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx index a9571203f14..37fe00ab3b9 100644 --- a/web/client/components/data/featuregrid/toolbars/Toolbar.jsx +++ b/web/client/components/data/featuregrid/toolbars/Toolbar.jsx @@ -128,7 +128,7 @@ const standardButtons = { visible={selectedCount <= 1 && mode === "VIEW"} onClick={events.settings} glyph="features-grid-set"/>), - syncGridFilterToMap: ({disabled, isSyncActive = false, showSyncOnMapButton = true, events = {}, syncPopover = { dockSize: "32.2%" }, showPopoverSync, hideSyncPopover}) => ( ( -
, - style: { - bottom: syncPopover.dockSize - } + }} } />), syncTimeParameter: ({timeSync, showTimeSyncButton = false, events = {}}) => ( { + const contentResizeRef = useResizeObserver({ + onResize, + watch: ['bottom'] + }); + return ( + + {header} + +
{background}
+
{top}
+ +
{leftColumn}
+ + {children} + +
{rightColumn}
+
{columns}
+
+
{bottom}
+
+ {footer} +
+ ); +}; + +export default MapViewerLayout; diff --git a/web/client/components/layout/__tests__/MapViewerLayout-test.jsx b/web/client/components/layout/__tests__/MapViewerLayout-test.jsx new file mode 100644 index 00000000000..1119cead730 --- /dev/null +++ b/web/client/components/layout/__tests__/MapViewerLayout-test.jsx @@ -0,0 +1,110 @@ +/* + * Copyright 2026, 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 expect from 'expect'; +import ReactDOM from 'react-dom'; +import MapViewerLayout from '../MapViewerLayout'; + +describe("Test MapViewerLayout Component", () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('renders with basic props', () => { + ReactDOM.render( + +
+
, + document.getElementById("container") + ); + + expect(document.getElementById('MAPVIEWER')).toExist(); + expect(document.getElementsByClassName('MAP_CLASS')[0]).toExist(); + expect(document.getElementsByClassName('ms-map-viewer-layout-body')[0]).toExist(); + expect(document.getElementsByClassName('ms-map-viewer-layout-content')[0]).toExist(); + expect(document.getElementsByClassName('content')[0]).toExist(); + }); + + it('renders header and footer', () => { + ReactDOM.render( + } + footer={
} + > +
+
, + document.getElementById("container") + ); + + expect(document.getElementsByClassName('header')[0]).toExist(); + expect(document.getElementsByClassName('footer')[0]).toExist(); + expect(document.getElementsByClassName('content')[0]).toExist(); + }); + + it('renders background, top and bottom containers', () => { + ReactDOM.render( + } + top={
} + bottom={
} + > +
+
, + document.getElementById("container") + ); + + // background is inside a _fill _absolute container + expect(document.getElementsByClassName('background')[0]).toExist(); + // top and bottom containers + expect(document.getElementsByClassName('top')[0]).toExist(); + expect(document.getElementsByClassName('bottom')[0]).toExist(); + }); + + it('renders left and right columns', () => { + ReactDOM.render( + } + rightColumn={
} + > +
+
, + document.getElementById("container") + ); + + expect(document.getElementsByClassName('ms-map-viewer-layout-left-column')[0]).toExist(); + expect(document.getElementsByClassName('left-column')[0]).toExist(); + expect(document.getElementsByClassName('ms-map-viewer-layout-right-column')[0]).toExist(); + expect(document.getElementsByClassName('right-column')[0]).toExist(); + }); + + it('renders additional columns container', () => { + ReactDOM.render( + , +
+ ]} + > +
+
, + document.getElementById("container") + ); + + expect(document.getElementsByClassName('ms-map-viewer-layout-columns')[0]).toExist(); + expect(document.getElementsByClassName('extra-col-1')[0]).toExist(); + expect(document.getElementsByClassName('extra-col-2')[0]).toExist(); + }); +}); + diff --git a/web/client/components/layout/__tests__/hooks/useResizeObserver-test.jsx b/web/client/components/layout/__tests__/hooks/useResizeObserver-test.jsx new file mode 100644 index 00000000000..ed815778104 --- /dev/null +++ b/web/client/components/layout/__tests__/hooks/useResizeObserver-test.jsx @@ -0,0 +1,93 @@ +/* + * Copyright 2026, 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 expect from 'expect'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import useResizeObserver from '../../hooks/useResizeObserver'; + +describe('useResizeObserver', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + + it('should observe element and call onResize when size changes', (done) => { + const onResizeSpy = expect.createSpy(); + + const Component = () => { + const elementRef = useResizeObserver({ + onResize: onResizeSpy + }); + + return
Test
; + }; + + act(() => { + ReactDOM.render(, document.getElementById('container')); + }); + + // Wait for ResizeObserver to trigger and debounce + setTimeout(() => { + expect(onResizeSpy).toHaveBeenCalled(); + done(); + }, 200); + }); + + it('should debounce resize callbacks', (done) => { + const onResizeSpy = expect.createSpy(); + + const Component = () => { + const elementRef = useResizeObserver({ + onResize: onResizeSpy, + debounceTime: 200 + }); + + return
Test
; + }; + + act(() => { + ReactDOM.render(, document.getElementById('container')); + }); + + // Wait for debounce + setTimeout(() => { + expect(onResizeSpy).toHaveBeenCalled(); + done(); + }, 250); + }); + + it('should disconnect observer on unmount', () => { + const Component = () => { + const elementRef = useResizeObserver({ + onResize: () => {} + }); + + return
Test
; + }; + + act(() => { + ReactDOM.render(, document.getElementById('container')); + }); + + act(() => { + ReactDOM.unmountComponentAtNode(document.getElementById('container')); + }); + + // Test passes if unmount completes without errors + expect(true).toBe(true); + }); +}); + diff --git a/web/client/components/layout/hooks/useResizeObserver.js b/web/client/components/layout/hooks/useResizeObserver.js new file mode 100644 index 00000000000..693fb05b456 --- /dev/null +++ b/web/client/components/layout/hooks/useResizeObserver.js @@ -0,0 +1,71 @@ +import { useRef, useEffect, useMemo } from 'react'; +import { debounce } from 'lodash'; + +const DEFAULT_KEYS = ['width', 'height']; + +const useResizeObserver = ({ + onResize = () => {}, + watch = DEFAULT_KEYS, + debounceTime = 100 +} = {}) => { + const elementRef = useRef(null); + const prevRef = useRef({}); + + const debouncedResize = useMemo( + () => + debounce((element) => { + if (!element) return; + + const rect = element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const values = { + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + right: viewportWidth - rect.right, + bottom: viewportHeight - rect.bottom + }; + + const changed = {}; + + watch.forEach((key) => { + const value = values[key]; + if (prevRef.current[key] !== value) { + changed[key] = value; + } + }); + + if (Object.keys(changed).length > 0) { + prevRef.current = { + ...prevRef.current, + ...changed + }; + onResize(changed); + } + }, debounceTime), + [onResize, watch, debounceTime] + ); + + useEffect(() => { + const element = elementRef.current; + if (!element || watch.length === 0) return null; + + const observer = new ResizeObserver(() => { + debouncedResize(element); + }); + + observer.observe(element); + + return () => { + observer.disconnect(); + debouncedResize.cancel(); + }; + }, [debouncedResize, watch]); + + return elementRef; +}; + +export default useResizeObserver; diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json index 556e4dc4b6e..6f391b7ba56 100644 --- a/web/client/configs/localConfig.json +++ b/web/client/configs/localConfig.json @@ -409,6 +409,7 @@ { "name": "Map", "cfg": { + "containerPosition": "background", "mapOptions": { "openlayers": { "interactions": { @@ -475,7 +476,12 @@ } }, "Home", - "FeatureEditor", + { + "name": "FeatureEditor", + "cfg": { + "containerPosition": "bottom" + } + }, "LayerDownload", { "name": "QueryPanel", diff --git a/web/client/containers/MapViewer.jsx b/web/client/containers/MapViewer.jsx index fe8e816f939..c7f9159d2fc 100644 --- a/web/client/containers/MapViewer.jsx +++ b/web/client/containers/MapViewer.jsx @@ -16,9 +16,10 @@ const urlQuery = url.parse(window.location.href, true).query; import ConfigUtils from '../utils/ConfigUtils'; import { getMonitoredState } from '../utils/PluginsUtils'; import ModulePluginsContainer from "../product/pages/containers/ModulePluginsContainer"; -import { createShallowSelectorCreator } from '../utils/ReselectUtils'; -import BorderLayout from '../components/layout/BorderLayout'; +import MapViewerLayout from '../components/layout/MapViewerLayout'; +import { updateMapLayout } from '../actions/maplayout'; +import { createShallowSelectorCreator } from '../utils/ReselectUtils'; const PluginsContainer = connect( createShallowSelectorCreator(isEqual)( state => state.plugins, @@ -45,7 +46,9 @@ class MapViewer extends React.Component { plugins: PropTypes.object, loaderComponent: PropTypes.func, onLoaded: PropTypes.func, - component: PropTypes.any + component: PropTypes.any, + onContentResize: PropTypes.func, + mapLayout: PropTypes.object }; static defaultProps = { @@ -59,6 +62,22 @@ class MapViewer extends React.Component { this.props.loadMapConfig(); } + handleContentResize = (changed) => { + if (changed.bottom !== undefined) { + const bottomOffset = Math.max(0, changed.bottom - 35); + const {boundingMapRect, layout, boundingSidebarRect} = this.props.mapLayout; + + this.props.onContentResize({ + ...layout, + ...boundingSidebarRect, + boundingMapRect: { + ...boundingMapRect, + bottom: bottomOffset + } + }); + } + }; + render() { return ( )} />); } } -export default MapViewer; +export default connect((state) => ({ + mapLayout: state.maplayout +}), { onContentResize: updateMapLayout })(MapViewer); diff --git a/web/client/epics/__tests__/maplayout-test.js b/web/client/epics/__tests__/maplayout-test.js index 47da19f9576..4c7f256c425 100644 --- a/web/client/epics/__tests__/maplayout-test.js +++ b/web/client/epics/__tests__/maplayout-test.js @@ -277,8 +277,8 @@ describe('map layout epics', () => { actions.map((action) => { expect(action.type).toBe(UPDATE_MAP_LAYOUT); expect(action.layout).toEqual({ - left: 0, right: 0, bottom: '100%', dockSize: 100, transform: "translate(0, -0px)", height: "calc(100% - 0px)", - boundingMapRect: {bottom: "100%", dockSize: 100, left: 0, right: 0}, + bottom: 0, left: 0, right: 0, transform: "translate(0, -0px)", height: "calc(100% - 0px)", + boundingMapRect: {bottom: 0, left: 0, right: 0}, boundingSidebarRect: { right: 0, left: 0, bottom: 0 }, leftPanel: false, rightPanel: false @@ -289,7 +289,7 @@ describe('map layout epics', () => { } done(); }; - const state = {featuregrid: {open: true, dockSize: 1}}; + const state = {featuregrid: {open: true}}; testEpic(updateMapLayoutEpic, 1, openFeatureGrid(), epicResult, state); }); }); diff --git a/web/client/epics/maplayout.js b/web/client/epics/maplayout.js index 05b9067e448..04c946a4b7d 100644 --- a/web/client/epics/maplayout.js +++ b/web/client/epics/maplayout.js @@ -10,7 +10,7 @@ import Rx from 'rxjs'; import {UPDATE_DOCK_PANELS, updateMapLayout, FORCE_UPDATE_MAP_LAYOUT} from '../actions/maplayout'; import {TOGGLE_CONTROL, SET_CONTROL_PROPERTY, SET_CONTROL_PROPERTIES, setControlProperty} from '../actions/controls'; import { MAP_CONFIG_LOADED } from '../actions/config'; -import {SIZE_CHANGE, CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID, closeFeatureGrid} from '../actions/featuregrid'; +import {CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID, closeFeatureGrid} from '../actions/featuregrid'; import { CLOSE_IDENTIFY, @@ -32,14 +32,14 @@ import { mapInfoDetailsSettingsFromIdSelector, isMouseMoveIdentifyActiveSelector import {head, get, findIndex, keys} from 'lodash'; -import { isFeatureGridOpen, getDockSize } from '../selectors/featuregrid'; +import { isFeatureGridOpen } from '../selectors/featuregrid'; import {DEFAULT_MAP_LAYOUT} from "../utils/LayoutUtils"; import {dockPanelsSelector} from "../selectors/maplayout"; /** * Capture that cause layout change to update the proper object. * Configures a map layout based on state of panels. - * @param {external:Observable} action$ manages `MAP_CONFIG_LOADED`, `SIZE_CHANGE`, `CLOSE_FEATURE_GRID`, `OPEN_FEATURE_GRID`, `CLOSE_IDENTIFY`, `NO_QUERYABLE_LAYERS`, `LOAD_FEATURE_INFO`, `TOGGLE_MAPINFO_STATE`, `TOGGLE_CONTROL`, `SET_CONTROL_PROPERTY`. + * @param {external:Observable} action$ manages `MAP_CONFIG_LOADED`, `CLOSE_FEATURE_GRID`, `OPEN_FEATURE_GRID`, `CLOSE_IDENTIFY`, `NO_QUERYABLE_LAYERS`, `LOAD_FEATURE_INFO`, `TOGGLE_MAPINFO_STATE`, `TOGGLE_CONTROL`, `SET_CONTROL_PROPERTY`. * @param store * @memberof epics.mapLayout * @return {external:Observable} emitting {@link #actions.map.updateMapLayout} action @@ -49,7 +49,6 @@ export const updateMapLayoutEpic = (action$, store) => action$.ofType( MAP_CONFIG_LOADED, - SIZE_CHANGE, CLOSE_FEATURE_GRID, OPEN_FEATURE_GRID, CLOSE_IDENTIFY, @@ -125,21 +124,17 @@ export const updateMapLayoutEpic = (action$, store) => mapInfoEnabledSelector(state) && isMapInfoOpen(state) && !isMouseMoveIdentifyActiveSelector(state) && {right: mapLayout.right.md} || null ].filter(panel => panel)) || {right: 0}; - const dockSize = getDockSize(state) * 100; - const bottom = isFeatureGridOpen(state) && {bottom: dockSize + '%', dockSize} - || {bottom: 0}; // To avoid map from de-centering when performing scale zoom - const transform = isFeatureGridOpen(state) && {transform: 'translate(0, -' + mapLayout.bottom.sm + 'px)'} || {transform: 'none'}; const height = {height: 'calc(100% - ' + mapLayout.bottom.sm + 'px)'}; const boundingMapRect = { - ...bottom, + bottom: 0, // To avoid map from de-centering when performing scale zoom ...leftPanels, ...rightPanels }; Object.keys(boundingMapRect).forEach(key => { - if (['left', 'right', 'dockSize'].includes(key)) { + if (['left', 'right'].includes(key)) { boundingMapRect[key] = boundingMapRect[key] + (boundingSidebarRect[key] ?? 0); } else { const totalOffset = (parseFloat(boundingMapRect[key]) + parseFloat(boundingSidebarRect[key] ?? 0)); diff --git a/web/client/plugins/featuregrid/FeatureEditor.jsx b/web/client/plugins/featuregrid/FeatureEditor.jsx index 835d7940491..17c1105f896 100644 --- a/web/client/plugins/featuregrid/FeatureEditor.jsx +++ b/web/client/plugins/featuregrid/FeatureEditor.jsx @@ -7,37 +7,27 @@ */ import React, { useMemo } from 'react'; import {connect} from 'react-redux'; -import {createSelector, createStructuredSelector} from 'reselect'; +import {createStructuredSelector} from 'reselect'; import {bindActionCreators} from 'redux'; import { get, pick, isEqual } from 'lodash'; import {compose, lifecycle, defaultProps } from 'recompose'; -import ReactDock from 'react-dock'; import ContainerDimensions from 'react-container-dimensions'; import Grid from '../../components/data/featuregrid/FeatureGrid'; import BorderLayout from '../../components/layout/BorderLayout'; import { toChangesMap} from '../../utils/FeatureGridUtils'; -import { sizeChange, setUp, setSyncTool } from '../../actions/featuregrid'; -import {mapLayoutValuesSelector} from '../../selectors/maplayout'; +import { setUp, setSyncTool } from '../../actions/featuregrid'; import {paginationInfo, describeSelector, attributesJSONSchemaSelector, wfsURLSelector, typeNameSelector, isSyncWmsActive} from '../../selectors/query'; -import {modeSelector, changesSelector, newFeaturesSelector, hasChangesSelector, selectedLayerFieldsSelector, selectedFeaturesSelector, getDockSize} from '../../selectors/featuregrid'; +import {modeSelector, changesSelector, newFeaturesSelector, hasChangesSelector, selectedLayerFieldsSelector, selectedFeaturesSelector} from '../../selectors/featuregrid'; import {getPanels, getHeader, getFooter, getDialogs, getEmptyRowsView, getFilterRenderers} from './panels/index'; import {gridTools, gridEvents, pageEvents, toolbarEvents} from './index'; import useFeatureValidation from './hooks/useFeatureValidation'; +import withResize from './hoc/withResize'; const EMPTY_ARR = []; const EMPTY_OBJ = {}; -const Dock = connect(createSelector( - getDockSize, - state => mapLayoutValuesSelector(state, {transform: true}), - (size, dockStyle) => ({ - size, - dockStyle - }) -) -)(ReactDock); /** * @name FeatureEditor * @memberof plugins @@ -171,24 +161,13 @@ const Dock = connect(createSelector( * ``` * */ -const FeatureDock = (props = { +const Editor = (props = { tools: EMPTY_OBJ, dialogs: EMPTY_OBJ, select: EMPTY_ARR }) => { const virtualScroll = props.virtualScroll ?? true; const maxZoom = props?.pluginCfg?.maxZoom; - const dockProps = { - dimMode: "none", - defaultSize: 0.35, - fluid: true, - isVisible: props.open, - maxDockSize: 0.7, - minDockSize: 0.1, - position: "bottom", - setDockSize: () => {}, - zIndex: 1060 - }; const items = props?.items ?? []; const toolbarItems = items.filter(({target}) => target === 'toolbar'); const filterRenderers = useMemo(() => { @@ -208,72 +187,70 @@ const FeatureDock = (props = { }); return ( -
- { props.onSizeChange(size, dockProps); }}> - {props.open && - ( - { ({ height }) => - // added height to solve resize issue in firefox, edge and ie - - {getDialogs(props.tools)} - - } - - ) - } - -
); + + { ({ height }) => + // added height to solve resize issue in firefox, edge and ie + + {getDialogs(props.tools)} + + } + + ); }; + +// Wrap Editor with resize HOC +const ResizableEditor = withResize(Editor); + export const selector = createStructuredSelector({ open: state => get(state, "featuregrid.open"), customEditorsOptions: state => get(state, "featuregrid.customEditorsOptions"), @@ -340,10 +317,9 @@ const EditorPlugin = compose( gridTools: gridTools.map((t) => ({ ...t, events: bindActionCreators(t.events, dispatch) - })), - onSizeChange: (...params) => dispatch(sizeChange(...params)) + })) }) ) -)(FeatureDock); +)(ResizableEditor); export default EditorPlugin; diff --git a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx index d748c5ae538..8edea45a05f 100644 --- a/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx +++ b/web/client/plugins/featuregrid/__tests__/FeatureEditor-test.jsx @@ -23,8 +23,7 @@ describe('FeatureEditor plugin component', () => { multiselect: false, drawing: false, newFeatures: [], - features: [], - dockSize: 0.35 + features: [] } }; const props = { diff --git a/web/client/plugins/featuregrid/hoc/style.less b/web/client/plugins/featuregrid/hoc/style.less new file mode 100644 index 00000000000..6a020479a20 --- /dev/null +++ b/web/client/plugins/featuregrid/hoc/style.less @@ -0,0 +1,27 @@ +.ms-resize-container { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + + .ms-resize-handle { + height: 4px; + cursor: row-resize; + background-color: transparent; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + border-top: 2px solid transparent; + transition: border-color 0.2s; + &:hover { + border-top-color: #ccc; + } + } + + .ms-resize-content { + flex: 1; + overflow: hidden; + } +} \ No newline at end of file diff --git a/web/client/plugins/featuregrid/hoc/withResize.jsx b/web/client/plugins/featuregrid/hoc/withResize.jsx new file mode 100644 index 00000000000..a53ff2044a1 --- /dev/null +++ b/web/client/plugins/featuregrid/hoc/withResize.jsx @@ -0,0 +1,88 @@ +import React, { useEffect, useRef, useState } from "react"; +import './style.less'; + +/** + * HOC that wraps a component in a resizable container. + * @param {React.Component} Component - The component to wrap + * @returns {React.Component} A component wrapped in a resizable div + * + * Props: + * @prop {boolean} resizeContainer - If true, enables resize functionality (default: true) + * @prop {number} defaultHeight - Initial height in pixels (default: 300) + * @prop {number} minHeight - Minimum height in pixels (default: 75) + * @prop {number} maxHeight - Maximum height in pixels (default: 70% of the window inner height) + */ +const withResize = (Component) => { + return (props) => { + const { resizeContainer = true, defaultHeight = 300, minHeight = 75, maxHeight = '70%' } = props; + const [height, setHeight] = useState(defaultHeight); + const [isResizing, setIsResizing] = useState(false); + const containerRef = useRef(null); + const startYRef = useRef(0); + const startHeightRef = useRef(0); + + useEffect(() => { + const maxAllowedHeight = typeof maxHeight === 'number' + ? maxHeight + : (window.innerHeight * (maxHeight.replace('%', '')) / 100); + + const handlePointerMove = (e) => { + if (!isResizing) return; + + const deltaY = e.clientY - startYRef.current; + const newHeight = startHeightRef.current - deltaY; + const clampedHeight = Math.max(minHeight, Math.min(newHeight, maxAllowedHeight)); + setHeight(clampedHeight); + }; + + const handlePointerUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + } + + return () => { + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + }; + }, [isResizing, minHeight, maxHeight]); + + const handleMouseDown = (e) => { + e.preventDefault(); + setIsResizing(true); + startYRef.current = e.clientY; + startHeightRef.current = height; + }; + + // If resizeContainer is false, just render in a normal div + if (!resizeContainer) { + return ( +
+ +
+ ); + } + + // Render with resize functionality + return ( +
+
+
+ +
+
+ ); + }; +}; + +export default withResize; diff --git a/web/client/plugins/featuregrid/panels/index.jsx b/web/client/plugins/featuregrid/panels/index.jsx index beb296a2020..f0414fba343 100644 --- a/web/client/plugins/featuregrid/panels/index.jsx +++ b/web/client/plugins/featuregrid/panels/index.jsx @@ -28,7 +28,6 @@ import HeaderComp from '../../../components/data/featuregrid/Header'; import ToolbarComp from '../../../components/data/featuregrid/toolbars/Toolbar'; import { getAttributeFilter, - getDockSize, getTitleSelector, hasChangesSelector, hasGeometrySelector, @@ -49,7 +48,6 @@ import { isFilterByViewportSupported, selectedLayerSelector } from '../../../selectors/featuregrid'; -import { mapLayoutValuesSelector } from '../../../selectors/maplayout'; import {isCesium, mapTypeSelector} from '../../../selectors/maptype'; import { featureCollectionResultSelector, @@ -82,8 +80,7 @@ const Toolbar = connect( hasNewFeatures: hasNewFeaturesSelector, hasGeometry: hasGeometrySelector, syncPopover: (state) => ({ - showAgain: showAgainSelector(state), - dockSize: mapLayoutValuesSelector(state, {dockSize: true}).dockSize + 3.2 + "%" + showAgain: showAgainSelector(state) }), isDrawing: isDrawingSelector, isSimpleGeom: isSimpleGeomSelector, @@ -104,7 +101,6 @@ const Toolbar = connect( isSnappingLoading, snappingConfig, mapType: mapTypeSelector, - editorHeight: getDockSize, viewportFilter: isViewportFilterActive, isFilterByViewportSupported, layer: selectedLayerSelector diff --git a/web/client/reducers/__tests__/featuregrid-test.js b/web/client/reducers/__tests__/featuregrid-test.js index 7b7d20a22ef..83657ad1469 100644 --- a/web/client/reducers/__tests__/featuregrid-test.js +++ b/web/client/reducers/__tests__/featuregrid-test.js @@ -47,7 +47,6 @@ import featuregrid from '../featuregrid'; import { setFeatures, - dockSizeFeatures, setLayer, toggleTool, customizeAttribute, @@ -76,7 +75,6 @@ import { closeFeatureGrid, toggleShowAgain, initPlugin, - sizeChange, storeAdvancedSearchFilter, setUp, setTimeSync, @@ -215,10 +213,6 @@ describe('Test the featuregrid reducer', () => { expect(state.features).toExist(); expect(state.features.length).toBe(1); }); - it('dockSizeFeatures', () => { - let state = featuregrid( {}, dockSizeFeatures(200)); - expect(state.dockSize).toBe(200); - }); it('toggleEditMode edit', () => { let state = featuregrid( {}, toggleEditMode()); expect(state.multiselect).toBeTruthy(); @@ -378,16 +372,6 @@ describe('Test the featuregrid reducer', () => { expect(state.localType).toBe("Point"); }); - it('SIZE_CHANGE', () => { - let state = featuregrid({}, sizeChange(0.5, {maxDockSize: 0.7, minDockSize: 0.1})); - expect(state.dockSize).toBe(0.5); - state = featuregrid({}, sizeChange(0.8, {maxDockSize: 0.7, minDockSize: 0.1})); - expect(state.dockSize).toBe(0.7); - state = featuregrid({}, sizeChange(0.05, {maxDockSize: 0.7, minDockSize: 0.1})); - expect(state.dockSize).toBe(0.1); - state = featuregrid({}, sizeChange(0.5)); - expect(state.dockSize).toBe(0.5); - }); it("storeAdvancedSearchFilter", () => { const filterObj = {test: 'test'}; let state = featuregrid({selectedLayer: "test_layer"}, storeAdvancedSearchFilter(filterObj)); diff --git a/web/client/reducers/featuregrid.js b/web/client/reducers/featuregrid.js index 13a20138850..3c84642fca2 100644 --- a/web/client/reducers/featuregrid.js +++ b/web/client/reducers/featuregrid.js @@ -21,7 +21,6 @@ import { SAVE_ERROR, CLEAR_CHANGES, CHANGE_PAGE, - DOCK_SIZE_FEATURES, SET_LAYER, TOGGLE_TOOL, CUSTOMIZE_ATTRIBUTE, @@ -38,7 +37,6 @@ import { CLOSE_FEATURE_GRID, UPDATE_FILTER, INIT_PLUGIN, - SIZE_CHANGE, STORE_ADVANCED_SEARCH_FILTER, GRID_QUERY_RESULT, LOAD_MORE_FEATURES, @@ -77,7 +75,6 @@ const emptyResultsState = { drawing: false, newFeatures: [], features: [], - dockSize: 0.35, customEditorsOptions: { "rules": [] }, @@ -142,8 +139,7 @@ const applyNewChanges = (features, changedFeatures, updates, updatesGeom) => * multiselect: false, * drawing: false, * newFeatures: [], - * features: [], - * dockSize: 0.35 + * features: [] * } * * @memberof reducers @@ -208,8 +204,6 @@ function featuregrid(state = emptyResultsState, action) { return Object.assign({}, state, {select: [], changes: []}); case SET_FEATURES: return Object.assign({}, state, {features: action.features}); - case DOCK_SIZE_FEATURES: - return Object.assign({}, state, {dockSize: action.dockSize}); case SET_LAYER: return Object.assign({}, state, {selectedLayer: action.id}); case TOGGLE_TOOL: @@ -411,17 +405,6 @@ function featuregrid(state = emptyResultsState, action) { useLayerFilter: action.useLayerFilter ?? state.useLayerFilter // if not present, keep current }; } - case SIZE_CHANGE : { - const maxDockSize = action.dockProps && action.dockProps.maxDockSize; - const minDockSize = action.dockProps && action.dockProps.minDockSize; - const size = maxDockSize && minDockSize && minDockSize <= action.size && maxDockSize >= action.size && action.size - || maxDockSize && maxDockSize < action.size && maxDockSize - || minDockSize && minDockSize > action.size && minDockSize - || action.size; - return Object.assign({}, state, { - dockSize: size - }); - } case STORE_ADVANCED_SEARCH_FILTER : { return Object.assign({}, state, {advancedFilters: Object.assign({}, state.advancedFilters, {[state.selectedLayer]: action.filterObj})}); } diff --git a/web/client/selectors/__tests__/featuregrid-test.js b/web/client/selectors/__tests__/featuregrid-test.js index 54a0f7fce93..6b69e8864fc 100644 --- a/web/client/selectors/__tests__/featuregrid-test.js +++ b/web/client/selectors/__tests__/featuregrid-test.js @@ -29,7 +29,6 @@ import { canEditSelector, showAgainSelector, hasSupportedGeometry, - getDockSize, selectedLayerNameSelector, queryOptionsSelector, showTimeSync, @@ -474,11 +473,6 @@ describe('Test featuregrid selectors', () => { }); - it('test getDockSize', () => { - expect(getDockSize({ featuregrid: {dockSize: 0.5} })).toBe(0.5); - expect(getDockSize({})).toBe(undefined); - }); - it('showTimeSync', () => { expect(showTimeSync({featuregrid: initialState.featuregrid})).toBeFalsy(); const state = { diff --git a/web/client/selectors/featuregrid.js b/web/client/selectors/featuregrid.js index 8f460fa4831..67d9e5dc95c 100644 --- a/web/client/selectors/featuregrid.js +++ b/web/client/selectors/featuregrid.js @@ -166,7 +166,6 @@ export const isSimpleGeomSelector = state => isSimpleGeomType(geomTypeSelectedFe * @param {object} state applications state * @return {boolean} true if the geometry is supported, false otherwise */ -export const getDockSize = state => state.featuregrid && state.featuregrid.dockSize; /** * get selected layer name * @function diff --git a/web/client/test-resources/geostore/data/context_1.json b/web/client/test-resources/geostore/data/context_1.json index f1d0a33c5f4..8bb36391605 100644 --- a/web/client/test-resources/geostore/data/context_1.json +++ b/web/client/test-resources/geostore/data/context_1.json @@ -27,7 +27,8 @@ "scalebar": { "container": "#footer-scalebar-container" } - } + }, + "containerPosition": "background" } }, { diff --git a/web/client/themes/default/less/common.less b/web/client/themes/default/less/common.less index cdd0a48df47..6344ea47d74 100644 --- a/web/client/themes/default/less/common.less +++ b/web/client/themes/default/less/common.less @@ -136,6 +136,19 @@ box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); } +// Map Viewer Layout - Disable pointer events on the main content container but enable them for all its children +// This allows the map underneath to receive pointer events while still allowing interaction with layout children +.ms-map-viewer-layout-main-content { + pointer-events: none; + + .ms-map-viewer-layout-content > *, + .ms-map-viewer-layout-left-column > *, + .ms-map-viewer-layout-right-column > *, + .ms-map-viewer-layout-columns > * { + pointer-events: auto; + } +} + .no-border { border: none !important; border-top: none !important; diff --git a/web/client/utils/ContextCreatorUtils.js b/web/client/utils/ContextCreatorUtils.js index 37b69713b19..0b9e5ff5559 100644 --- a/web/client/utils/ContextCreatorUtils.js +++ b/web/client/utils/ContextCreatorUtils.js @@ -53,6 +53,30 @@ export const migrateContextConfiguration = (context) => { }; } } + // migration for FeatureEditor to add containerPosition: 'footer' if not present + if (plugin.name === 'FeatureEditor') { + if (plugin?.cfg?.containerPosition !== 'footer') { + return { + ...plugin, + cfg: { + ...plugin.cfg, + containerPosition: 'footer' + } + }; + } + } + // migrate for Map to add containerPosition: 'background' if not present + if (plugin.name === 'Map') { + if (plugin?.cfg?.containerPosition !== 'background') { + return { + ...plugin, + cfg: { + ...plugin.cfg, + containerPosition: 'background' + } + }; + } + } return plugin; })]; })) diff --git a/web/client/utils/__tests__/ContextCreatorUtils-test.js b/web/client/utils/__tests__/ContextCreatorUtils-test.js index 4e9c2c27c93..b82d734701e 100644 --- a/web/client/utils/__tests__/ContextCreatorUtils-test.js +++ b/web/client/utils/__tests__/ContextCreatorUtils-test.js @@ -30,7 +30,7 @@ describe('Test the ContextCreatorUtils', () => { }); expect(newContext).toEqual({ plugins: { - desktop: [{ name: 'Map' }, { name: 'DeleteResource' }, { name: 'MapFooter', cfg: { containerPosition: 'footer' }}] + desktop: [{ name: 'Map', cfg: { containerPosition: 'background' } }, { name: 'DeleteResource' }, { name: 'MapFooter', cfg: { containerPosition: 'footer' }}] } }); });