From 381509996b307ddb07d74c3bd9a09fb2ec116aa4 Mon Sep 17 00:00:00 2001 From: Suren Date: Wed, 18 Mar 2026 21:05:31 +0530 Subject: [PATCH] #12096: Add new map configuration and section duplication in Geostories (#12097) (cherry picked from commit 1c7435fcbfaa8520672b690fb41a4c90c8c51b9b) --- web/client/actions/__tests__/geostory-test.js | 19 +- web/client/actions/geostory.js | 27 +++ .../components/geostory/builder/Builder.jsx | 3 + .../geostory/builder/SectionsPreview.jsx | 55 +++++- .../__tests__/SectionsPreview-test.jsx | 28 +++ .../components/geostory/common/MapEditor.jsx | 6 +- .../common/enhancers/__tests__/map-test.jsx | 117 ++++++++++++- .../geostory/common/enhancers/map.jsx | 44 +++-- .../geostory/common/map/Controls.jsx | 53 +++++- .../geostory/common/map/CoordinatesEditor.jsx | 80 +++++++++ .../common/map/__tests__/Controls-test.jsx | 77 ++++++++ .../map/__tests__/CoordinatesEditor-test.jsx | 131 ++++++++++++++ web/client/epics/__tests__/geostory-test.js | 164 +++++++++++++++++- web/client/epics/geostory.js | 68 +++++++- web/client/plugins/GeoStoryEditor.jsx | 8 +- web/client/themes/default/less/geostory.less | 29 ++++ web/client/translations/data.ca-ES.json | 6 + web/client/translations/data.da-DK.json | 6 + web/client/translations/data.de-DE.json | 6 + web/client/translations/data.en-US.json | 6 + web/client/translations/data.es-ES.json | 6 + web/client/translations/data.fr-FR.json | 6 + web/client/translations/data.it-IT.json | 6 + web/client/translations/data.nl-NL.json | 6 + web/client/translations/data.pt-BR.json | 6 + web/client/translations/data.sv-SE.json | 6 + web/client/utils/GeoStoryUtils.js | 27 +++ .../utils/__tests__/GeoStoryUtils-test.js | 108 +++++++++++- 28 files changed, 1070 insertions(+), 34 deletions(-) create mode 100644 web/client/components/geostory/common/map/CoordinatesEditor.jsx create mode 100644 web/client/components/geostory/common/map/__tests__/CoordinatesEditor-test.jsx diff --git a/web/client/actions/__tests__/geostory-test.js b/web/client/actions/__tests__/geostory-test.js index 04e46e881f1..9f1d81c4493 100644 --- a/web/client/actions/__tests__/geostory-test.js +++ b/web/client/actions/__tests__/geostory-test.js @@ -65,7 +65,11 @@ import { enableDraw, ENABLE_DRAW, RESET_GEOSTORY, - resetGeostory + resetGeostory, + APPLY_TO_MAPS, + applyToMaps, + DUPLICATE_ITEM, + duplicateItem } from '../geostory'; describe('test geostory action creators', () => { @@ -295,4 +299,17 @@ describe('test geostory action creators', () => { const action = resetGeostory(); expect(action.type).toBe(RESET_GEOSTORY); }); + it('applyToMaps', () => { + const action = applyToMaps('center', {x: 1, y: 2, crs: 'EPSG:4326'}, 'sections[{"id":"s1"}].contents[{"id":"c1"}]'); + expect(action.type).toBe(APPLY_TO_MAPS); + expect(action.property).toBe('center'); + expect(action.value).toEqual({x: 1, y: 2, crs: 'EPSG:4326'}); + expect(action.currentContentPath).toBe('sections[{"id":"s1"}].contents[{"id":"c1"}]'); + }); + it('duplicateItem', () => { + const action = duplicateItem('sections', 'section-1'); + expect(action.type).toBe(DUPLICATE_ITEM); + expect(action.containerPath).toBe('sections'); + expect(action.itemId).toBe('section-1'); + }); }); diff --git a/web/client/actions/geostory.js b/web/client/actions/geostory.js index b8c119e8e16..d04dc452abf 100644 --- a/web/client/actions/geostory.js +++ b/web/client/actions/geostory.js @@ -47,6 +47,8 @@ export const ENABLE_DRAW = "GEOSTORY:ENABLE_DRAW"; export const GEOSTORY_EXPORT = "GEOSTORY:EXPORT"; export const GEOSTORY_IMPORT = "GEOSTORY:IMPORT"; export const RESET_GEOSTORY = "GEOSTORY:RESET"; +export const APPLY_TO_MAPS = "GEOSTORY:APPLY_TO_MAPS"; +export const DUPLICATE_ITEM = "GEOSTORY:DUPLICATE_ITEM"; /** * Adds an entry to current story. The entry can be a section, a content or anything to append in an array (even sub-content) @@ -276,3 +278,28 @@ export const geostoryImport = (file) => ({type: GEOSTORY_IMPORT, file}); * reset geostory on page unmount */ export const resetGeostory = () => ({ type: RESET_GEOSTORY }); + +/** + * Apply a map property (center or zoom) to all other map contents in the story. + * @param {string} property the map property to apply ('center' or 'zoom') + * @param {any} value the value to set + * @param {string} currentContentPath path of the current focused content (to skip) + */ +export const applyToMaps = (property, value, currentContentPath) => ({ + type: APPLY_TO_MAPS, + property, + value, + currentContentPath +}); + +/** + * Duplicates an item (section or content) in the geostory. + * The duplicate is placed after the original. + * @param {string} containerPath predicate-based path to the container array + * @param {string} itemId the id of the item to duplicate + */ +export const duplicateItem = (containerPath, itemId) => ({ + type: DUPLICATE_ITEM, + containerPath, + itemId +}); diff --git a/web/client/components/geostory/builder/Builder.jsx b/web/client/components/geostory/builder/Builder.jsx index be99d10d89e..a00203cf318 100644 --- a/web/client/components/geostory/builder/Builder.jsx +++ b/web/client/components/geostory/builder/Builder.jsx @@ -49,6 +49,7 @@ class Builder extends React.Component { onSelect: PropTypes.func, onRemove: PropTypes.func, onUpdate: PropTypes.func, + onDuplicate: PropTypes.func, selected: PropTypes.string, storyFonts: PropTypes.array }; @@ -87,6 +88,7 @@ class Builder extends React.Component { onSort, onUpdate, onSelect, + onDuplicate, storyFonts } = this.props; const SettingsButton = isSettingsChanged ? WithConfirmButton : ToolbarButton; @@ -179,6 +181,7 @@ class Builder extends React.Component { isCollapsed={isCollapsed} sections={story && story.sections} onSort={onSort} + onDuplicate={onDuplicate} /> : !isSettingsEnabled ?
diff --git a/web/client/components/geostory/builder/SectionsPreview.jsx b/web/client/components/geostory/builder/SectionsPreview.jsx index 3f7fac9e9b3..c3a02aaaef0 100644 --- a/web/client/components/geostory/builder/SectionsPreview.jsx +++ b/web/client/components/geostory/builder/SectionsPreview.jsx @@ -75,7 +75,7 @@ const ConnectedIcon = compose( const previewContents = { title: () => null, - paragraph: ({ id, contents, isCollapsed, scrollTo, onSort, sectionId, onUpdate }) => ( + paragraph: ({ id, contents, isCollapsed, scrollTo, onSort, sectionId, onUpdate, onDuplicate }) => (
, @@ -103,6 +104,14 @@ const previewContents = { className: 'square-button no-border' }} buttons={[ + { + glyph: 'duplicate', + tooltipId: "geostory.duplicateSection", + onClick: (evt) => { + evt.stopPropagation(); + if (onDuplicate) onDuplicate(containerPath, content.id); + } + }, { glyph: 'zoom-to', tooltipId: "geostory.zoomToContent", @@ -119,12 +128,13 @@ const previewContents = { onSort={onSort} scrollTo={scrollTo} isCollapsed={isCollapsed} + onDuplicate={onDuplicate} contents={content.contents}/> }; })} />
), - column: ({ sectionId, id, contents, isCollapsed, scrollTo, onSort, onUpdate, sectionType }) => ( + column: ({ sectionId, id, contents, isCollapsed, scrollTo, onSort, onUpdate, sectionType, onDuplicate }) => (
, @@ -152,6 +163,14 @@ const previewContents = { className: 'square-button no-border' }} buttons={[ + { + glyph: 'duplicate', + tooltipId: "geostory.duplicateSection", + onClick: (evt) => { + evt.stopPropagation(); + if (onDuplicate) onDuplicate(containerPath, content.id); + } + }, { glyph: 'zoom-to', visible: sectionType !== SectionTypes.CAROUSEL, @@ -164,12 +183,12 @@ const previewContents = { onUpdate={(text) => onUpdate(`sections[{"id": "${sectionId}"}].contents[{"id": "${id}"}].contents[{"id": "${content.id}"}]`, {title: text}, "merge")}/>, description: `type: ${content.type}`, body: !isCollapsed && PreviewContents - && + && }; })} />
), - immersive: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage }) => ( + immersive: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage, onDuplicate }) => (
{ + evt.stopPropagation(); + if (onDuplicate) onDuplicate(containerPath, content.id); + } + }, { glyph: 'zoom-to', tooltipId: "geostory.zoomToContent", @@ -218,12 +246,13 @@ const previewContents = { onUpdate={onUpdate} scrollTo={scrollTo} isCollapsed={isCollapsed} + onDuplicate={onDuplicate} contents={content.contents}/> }; })} />
), - carousel: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage }) => ( + carousel: ({ id, contents, isCollapsed, scrollTo, onUpdate, onSort, currentPage, onDuplicate }) => (
, tools: null, title: onUpdate(`sections[{"id": "${id}"}].contents[{"id":"${content.id}"}]`, {title: text}, "merge")}/>, @@ -264,6 +292,7 @@ const previewContents = { onUpdate={onUpdate} scrollTo={scrollTo} isCollapsed={isCollapsed} + onDuplicate={onDuplicate} sectionType={SectionTypes.CAROUSEL} contents={content.contents}/> }; @@ -283,6 +312,7 @@ const sectionToItem = ({ onSort, onSelect, onUpdate, + onDuplicate = () => {}, selected = "title_section_id1" }) => ({ contents, @@ -304,6 +334,14 @@ const sectionToItem = ({ className: 'square-button no-border' }} buttons={[ + { + glyph: 'duplicate', + tooltipId: "geostory.duplicateSection", + onClick: (evt) => { + evt.stopPropagation(); + onDuplicate('sections', id); + } + }, { onClick: scrollToHandler(id, scrollTo), glyph: 'zoom-to', @@ -324,6 +362,7 @@ const sectionToItem = ({ selected={selected} scrollTo={scrollTo} isCollapsed={isCollapsed} + onDuplicate={onDuplicate} contents={contents}/> : null }; @@ -335,7 +374,7 @@ const sectionToItem = ({ * @SectionsPreview * @param {object[]} [sections=[]] Array of sections to display */ -export default ({ sections = [], scrollTo, onSelect = () => {}, isCollapsed, currentPage, selected, onSort, onUpdate }) => ( {}, isCollapsed, currentPage, selected, onSort, onUpdate, onDuplicate }) => ( { if (itemDataTo.containerId === 'story-sections' @@ -351,5 +390,5 @@ export default ({ sections = [], scrollTo, onSelect = () => {}, isCollapsed, cur cardComponent={DraggableSideCard} size="sm" items={ - sections.map(sectionToItem({ currentPage, onSelect, isCollapsed, scrollTo, selected, onUpdate, onSort })) + sections.map(sectionToItem({ currentPage, onSelect, isCollapsed, scrollTo, selected, onUpdate, onSort, onDuplicate })) } />); diff --git a/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx b/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx index e6f3a0ae04c..470a67ccd92 100644 --- a/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx +++ b/web/client/components/geostory/builder/__tests__/SectionsPreview-test.jsx @@ -8,6 +8,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import ReactTestUtils from 'react-dom/test-utils'; import expect from 'expect'; import {Provider} from 'react-redux'; import HTML5Backend from 'react-dnd-html5-backend'; @@ -56,4 +57,31 @@ describe('SectionsPreview component', () => { expect(el.querySelectorAll('.items-list > div').length).toBe(19); // one for each card in the side grid expect(el.querySelectorAll('.mapstore-side-preview').length).toBe(19); }); + it('SectionsPreview renders duplicate buttons for sections', () => { + ReactDOM.render( + {}, getState: () => ({})}}> + + , document.getElementById("container")); + const container = document.getElementById('container'); + const duplicateButtons = container.querySelectorAll('.glyphicon-duplicate'); + expect(duplicateButtons.length).toBeGreaterThan(0); + }); + it('SectionsPreview calls onDuplicate when duplicate button is clicked', () => { + const actions = { + onDuplicate: () => {} + }; + const spy = expect.spyOn(actions, 'onDuplicate'); + ReactDOM.render( + {}, getState: () => ({})}}> + + , document.getElementById("container")); + const container = document.getElementById('container'); + const duplicateButtons = container.querySelectorAll('.glyphicon-duplicate'); + expect(duplicateButtons.length).toBeGreaterThan(0); + if (duplicateButtons.length > 0) { + const btn = duplicateButtons[0].closest('button') || duplicateButtons[0]; + ReactTestUtils.Simulate.click(btn); + expect(spy).toHaveBeenCalled(); + } + }); }); diff --git a/web/client/components/geostory/common/MapEditor.jsx b/web/client/components/geostory/common/MapEditor.jsx index 4f0be9972a2..08683fad9bf 100644 --- a/web/client/components/geostory/common/MapEditor.jsx +++ b/web/client/components/geostory/common/MapEditor.jsx @@ -69,7 +69,9 @@ const MapEditor = ({ closeNodeEditor, CloseBtn = () => (null), isDrawEnabled, - hideIdentifyOptions + hideIdentifyOptions, + onApplyToMaps = () => {}, + isCarouselSection = false }) => { return (mode === Modes.EDIT && isFocused ?
]} } diff --git a/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx b/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx index d9fc37f3330..84258143891 100644 --- a/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx +++ b/web/client/components/geostory/common/enhancers/__tests__/map-test.jsx @@ -13,8 +13,10 @@ import ReactTestUtils from 'react-dom/test-utils'; import {createSink} from 'recompose'; import Toolbar from '../../../../misc/toolbar/Toolbar'; -import withMapEnhancer, { withLocalMapState, withMapEditingAndLocalMapState, withToolbar } from '../map'; +import withMapEnhancer, { withLocalMapState, withMapEditingAndLocalMapState, withToolbar, handleMapUpdate } from '../map'; import { Provider } from 'react-redux'; +import { APPLY_TO_MAPS } from '../../../../../actions/geostory'; +import {SectionTypes} from '../../../../../utils/GeoStoryUtils'; describe("geostory media map component enhancers", () => { beforeEach((done) => { @@ -163,6 +165,119 @@ describe("geostory media map component enhancers", () => { done(); }); + it('handleMapUpdate provides onChangeMap, onApplyToMaps, and isCarouselSection props', (done) => { + const focusedContentPath = 'sections[{"id":"s1"}].contents[{"id":"c1"}].background'; + const store = { + subscribe: () => {}, + dispatch: () => {}, + getState: () => ({ + geostory: { + focusedContent: { + path: focusedContentPath + }, + currentStory: { + sections: [ + { id: 's1', type: SectionTypes.TITLE, contents: [] } + ] + } + } + }) + }; + + const Sink = handleMapUpdate(createSink(props => { + expect(props.onChangeMap).toBeA('function'); + expect(props.onApplyToMaps).toBeA('function'); + expect(props.isCarouselSection).toBe(false); + done(); + })); + + ReactDOM.render( + + {}} + focusedContent={{ path: focusedContentPath }} + /> + , + document.getElementById("container") + ); + }); + + it('handleMapUpdate sets isCarouselSection true for carousel sections', (done) => { + const focusedContentPath = 'sections[{"id":"carousel1"}].contents[{"id":"c1"}].background'; + const store = { + subscribe: () => {}, + dispatch: () => {}, + getState: () => ({ + geostory: { + focusedContent: { + path: focusedContentPath + }, + currentStory: { + sections: [ + { id: 'carousel1', type: SectionTypes.CAROUSEL, contents: [] } + ] + } + } + }) + }; + + const Sink = handleMapUpdate(createSink(props => { + expect(props.isCarouselSection).toBe(true); + done(); + })); + + ReactDOM.render( + + {}} + focusedContent={{ path: focusedContentPath }} + /> + , + document.getElementById("container") + ); + }); + + it('handleMapUpdate onApplyToMaps dispatches APPLY_TO_MAPS action', (done) => { + const focusedContentPath = 'sections[{"id":"s1"}].contents[{"id":"c1"}].background'; + const store = { + subscribe: () => {}, + dispatch: (action) => { + if (action.type === APPLY_TO_MAPS) { + expect(action.property).toBe('center'); + expect(action.value).toEqual({x: 1, y: 2}); + expect(action.currentContentPath).toBe(focusedContentPath); + done(); + } + }, + getState: () => ({ + geostory: { + focusedContent: { + path: focusedContentPath + }, + currentStory: { + sections: [ + { id: 's1', type: SectionTypes.TITLE, contents: [] } + ] + } + } + }) + }; + + const Sink = handleMapUpdate(createSink(props => { + props.onApplyToMaps('center', {x: 1, y: 2}); + })); + + ReactDOM.render( + + {}} + focusedContent={{ path: focusedContentPath }} + /> + , + document.getElementById("container") + ); + }); + }); diff --git a/web/client/components/geostory/common/enhancers/map.jsx b/web/client/components/geostory/common/enhancers/map.jsx index a62ce623c6a..77d680a0118 100644 --- a/web/client/components/geostory/common/enhancers/map.jsx +++ b/web/client/components/geostory/common/enhancers/map.jsx @@ -13,8 +13,9 @@ import {branch, compose, createEventHandler, mapPropsStream, withHandlers, withP import {createSelector} from 'reselect'; import uuid from "uuid"; -import {getCurrentFocusedContentEl, isFocusOnContentSelector, resourcesSelector} from '../../../../selectors/geostory'; -import {createMapObject} from '../../../../utils/GeoStoryUtils'; +import {getCurrentFocusedContentEl, isFocusOnContentSelector, resourcesSelector, getFocusedContentSelector, isGeoCarouselSection} from '../../../../selectors/geostory'; +import {createMapObject, getIdFromPath} from '../../../../utils/GeoStoryUtils'; +import {applyToMaps} from '../../../../actions/geostory'; import {isNearlyEqual} from '../../../../utils/MapUtils'; import Message from '../../../I18N/Message'; import ToolbarButton from '../../../misc/toolbar/ToolbarButton'; @@ -59,16 +60,35 @@ export const withFocusedContentMap = compose( /** * It Adjusts the path to update content map config obj */ -export const handleMapUpdate = withHandlers({ - onChangeMap: ({update, focusedContent = {}}) => - (path, value, mode = "merge") => { - update(`${focusedContent.path}.map.${path}`, value, mode); - }, - onChange: ({update, focusedContent = {}}) => - (path, value, mode = 'merge') => { - update(focusedContent.path + `.${path}`, value, mode); - } -}); +export const handleMapUpdate = compose( + connect( + createSelector( + getFocusedContentSelector, + state => state, + (focusedContent, state) => { + const { sectionId } = getIdFromPath(focusedContent?.path); + return { + isCarouselSection: sectionId ? isGeoCarouselSection(sectionId)(state) : false + }; + } + ), + { applyToMaps } + ), + withHandlers({ + onChangeMap: ({update, focusedContent = {}}) => + (path, value, mode = "merge") => { + update(`${focusedContent.path}.map.${path}`, value, mode); + }, + onChange: ({update, focusedContent = {}}) => + (path, value, mode = 'merge') => { + update(focusedContent.path + `.${path}`, value, mode); + }, + onApplyToMaps: ({ applyToMaps: applyToMapsAction, focusedContent = {} }) => + (property, value) => { + applyToMapsAction(property, value, focusedContent.path); + } + }) +); /** * Handle edit map toggle, map rest and open AdvancedMapEditor. diff --git a/web/client/components/geostory/common/map/Controls.jsx b/web/client/components/geostory/common/map/Controls.jsx index c85e5c9e44b..e8ae0190b42 100644 --- a/web/client/components/geostory/common/map/Controls.jsx +++ b/web/client/components/geostory/common/map/Controls.jsx @@ -6,23 +6,27 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useMemo } from 'react'; import {Form, FormGroup, ControlLabel} from 'react-bootstrap'; import Message from '../../../I18N/Message'; import Select from "react-select"; import {isNil} from "lodash"; import { applyDefaults } from '../../../../utils/GeoStoryUtils'; import { is3DVisualizationMode } from '../../../../utils/MapTypeUtils'; +import { getScales } from '../../../../utils/MapUtils'; +import Button from '../../../misc/Button'; import SwitchButton from '../../../misc/switch/SwitchButton'; import localizedProps from '../../../misc/enhancers/localizedProps'; import FeatureInfoFormatSelector from '../../../misc/FeatureInfoFormatSelector'; +import CoordinatesEditor from './CoordinatesEditor'; const SelectLocalized = localizedProps(["placeholder", "options"])(Select); export const Controls = ({ map = {zoomControl: true, mapInfoControl: false}, onChangeMap = () => { }, + onApplyToMaps = () => {}, ...props } = {}) => { const mapOptions = map && map.mapOptions || {}; @@ -32,7 +36,54 @@ export const Controls = ({ mapInfoControl: !isNil(map.mapInfoControl) ? map.mapInfoControl : false }); const is3D = is3DVisualizationMode(map); + const projection = map.projection || 'EPSG:3857'; + const scales = useMemo(() => + getScales(projection).map((scale, idx) => ({ + value: idx, + label: `1 : ${Math.round(scale)}`, + scale: Math.round(scale) + })), [projection]); + const center = map.center || {}; + const zoom = map.zoom; + const currentZoom = zoom !== undefined ? Math.round(zoom) : 0; + return (
+ {!props.isCarouselSection && +
+ + +
+ onChangeMap("center", newCenter, "replace")} + /> +
} + +
+ + +
+