From a08baaa888d363ec95103f6c09f02a15d436906c Mon Sep 17 00:00:00 2001 From: allyoucanmap Date: Mon, 11 May 2026 15:03:52 +0200 Subject: [PATCH 1/2] review dataset data payload --- .../client/js/api/geonode/v2/index.js | 6 +- .../js/epics/__tests__/gnresource-test.js | 25 +-- .../client/js/epics/gnresource.js | 209 +++++++++--------- .../client/js/epics/gnsave.js | 7 +- .../client/js/plugins/index.js | 6 +- .../client/js/reducers/gnresource.js | 9 +- .../client/js/selectors/resource.js | 41 +++- .../client/js/utils/ResourceUtils.js | 97 ++++++-- .../js/utils/__tests__/ResourceUtils-test.js | 190 ++++++++++++---- 9 files changed, 379 insertions(+), 211 deletions(-) diff --git a/geonode_mapstore_client/client/js/api/geonode/v2/index.js b/geonode_mapstore_client/client/js/api/geonode/v2/index.js index b8bd099f99..0c8a8355d5 100644 --- a/geonode_mapstore_client/client/js/api/geonode/v2/index.js +++ b/geonode_mapstore_client/client/js/api/geonode/v2/index.js @@ -270,7 +270,8 @@ export const getResourceByPk = (pk) => { return axios.get(getEndpointUrl(RESOURCES, `/${pk}`), { params: { api_preset: API_PRESET.VIEWER_COMMON, - include_i18n: true + include_i18n: true, + include: ['data'] } }) .then(({ data }) => data.resource); @@ -318,7 +319,8 @@ export const getDatasetByPk = (pk) => { return axios.get(getEndpointUrl(DATASETS, `/${pk}`), { params: { api_preset: [API_PRESET.VIEWER_COMMON, API_PRESET.DATASET], - include_i18n: true + include_i18n: true, + include: ['data'] }, ...paramsSerializer() }) diff --git a/geonode_mapstore_client/client/js/epics/__tests__/gnresource-test.js b/geonode_mapstore_client/client/js/epics/__tests__/gnresource-test.js index 9521143ed0..8c56571d16 100644 --- a/geonode_mapstore_client/client/js/epics/__tests__/gnresource-test.js +++ b/geonode_mapstore_client/client/js/epics/__tests__/gnresource-test.js @@ -14,7 +14,6 @@ import { gnViewerSetNewResourceThumbnail, closeInfoPanelOnMapClick, closeDatasetCatalogPanel, - gnZoomToFitBounds, closeResourceDetailsOnMapInfoOpen, gnUpdateResourceExtent, gnUpdateBackgroundEditEpic, @@ -28,8 +27,8 @@ import { UPDATE_RESOURCE_EXTENT_LOADING, updateResourceExtent } from '@js/actions/gnresource'; -import { clickOnMap, changeMapView, ZOOM_TO_EXTENT } from '@mapstore/framework/actions/map'; -import { SET_CONTROL_PROPERTY, setControlProperty } from '@mapstore/framework/actions/controls'; +import { clickOnMap } from '@mapstore/framework/actions/map'; +import { SET_CONTROL_PROPERTY } from '@mapstore/framework/actions/controls'; import { SHOW_NOTIFICATION } from '@mapstore/framework/actions/notifications'; @@ -257,26 +256,6 @@ describe('gnresource epics', () => { }); - it('should zoom to extent with the fitBounds control', (done) => { - const NUM_ACTIONS = 2; - const testState = {}; - testEpic(gnZoomToFitBounds, - NUM_ACTIONS, - [setControlProperty('fitBounds', 'geometry', [-180, -90, 180, 90]), changeMapView()], - (actions) => { - try { - expect(actions.length).toBe(2); - expect(actions[0].type).toBe(ZOOM_TO_EXTENT); - expect(actions[1].type).toBe(SET_CONTROL_PROPERTY); - } catch (e) { - done(e); - } - done(); - }, - testState - ); - - }); it('should update resource extent on UPDATE_RESOURCE_EXTENT action', (done) => { const NUM_ACTIONS = 3; const pk = 1; diff --git a/geonode_mapstore_client/client/js/epics/gnresource.js b/geonode_mapstore_client/client/js/epics/gnresource.js index be3f7f22d1..ec7ac793f2 100644 --- a/geonode_mapstore_client/client/js/epics/gnresource.js +++ b/geonode_mapstore_client/client/js/epics/gnresource.js @@ -100,12 +100,14 @@ import { getCataloguePath, isDefaultDatasetSubtype, resourceHasPermission, - canEditMap + canEditMap, + parseMapLayerData } from '@js/utils/ResourceUtils'; import { canAddResource, getInitialDatasetLayer, getInitialDatasetLayerStyle, + getMapLayerData, getResourceData, getResourceId, getResourceThumbnail @@ -113,7 +115,7 @@ import { import { updateAdditionalLayer } from '@mapstore/framework/actions/additionallayers'; import { STYLE_OWNER_NAME } from '@mapstore/framework/utils/StyleEditorUtils'; import { initStyleService, resetStyleEditor } from '@mapstore/framework/actions/styleeditor'; -import { CLICK_ON_MAP, resizeMap, CHANGE_MAP_VIEW, zoomToExtent, changeCRS } from '@mapstore/framework/actions/map'; +import { CLICK_ON_MAP, resizeMap, zoomToExtent, MAP_PLUGIN_LOAD } from '@mapstore/framework/actions/map'; import { purgeMapInfoResults, closeIdentify, NEW_MAPINFO_REQUEST } from '@mapstore/framework/actions/mapInfo'; import { saveError } from '@js/actions/gnsave'; import { @@ -144,7 +146,21 @@ import { searchSelector } from '@mapstore/framework/selectors/router'; import { CREATE_BACKGROUNDS_LIST, allowBackgroundsDeletion } from '@mapstore/framework/actions/backgroundselector'; import { setCanEditProjection, setProjectionsConfig } from '@mapstore/framework/actions/crsselector'; -const FIT_BOUNDS_CONTROL = 'fitBounds'; +// Wait for the Map plugin to finish mounting before dispatching zoomToExtent. +// Navigating between dataset pages (e.g. dataset_viewer -> dataset_edit_data_viewer) +// changes the PluginsContainer key in routes/Viewer.jsx, so React tears down and +// re-mounts the entire plugin tree, which includes re-registering the OL +// ZOOM_TO_EXTENT_HOOK. MAP_PLUGIN_LOAD with loaded=true fires once that re-mount +// completes; a small post-delay covers the OL component's own componentDidMount. +// The timer is a fallback for the case where the plugin tree is reused and +// MAP_PLUGIN_LOAD doesn't re-fire. +const fitBoundsAfterMapReady = (action$, extent) => + extent + ? Observable.race( + action$.ofType(MAP_PLUGIN_LOAD).filter(a => a.loaded).take(1).delay(100), + Observable.timer(800) + ).map(() => zoomToExtent(extent, 'EPSG:4326', undefined, { duration: 200 })) + : Observable.empty(); const resourceTypes = { [ResourceTypes.DATASET]: { @@ -174,60 +190,74 @@ const resourceTypes = { const newLayer = options?.isSamePreviousResource ? selectedLayer // keep configuration for other pages when resource id is the same (eg: filters) : resourceToLayerConfig(gnLayer); - const _gnLayer = {...gnLayer, layerSettings: gnLayer.data}; - return [mapConfig, {..._gnLayer, timeseries}, newLayer]; + // On same-resource transitions `gnLayer` is the resource record + // currently in state, which the SET_RESOURCE reducer has already + // stripped of its `data` field. Source the dataset payload from + // the dedicated `mapLayerData` slice instead. + const mapLayerData = options?.isSamePreviousResource + ? options.mapLayerData + : parseMapLayerData(gnLayer?.data); + return [mapConfig, {...gnLayer, timeseries}, newLayer, mapLayerData]; }) ) .switchMap((response) => { - const [mapConfig, gnLayer, newLayer] = response; + const [mapConfig, gnLayer, newLayer, mapLayerData] = response; const {minx, miny, maxx, maxy } = newLayer?.bbox?.bounds || {}; const extent = newLayer?.bbox?.bounds && [minx, miny, maxx, maxy ]; const hasNoGeometry = gnLayer?.subtype === 'tabular'; const hasDownloadPermission = gnLayer?.perms?.includes('download_resourcebase'); - return Observable.of( - configureMap({ - ...mapConfig, - map: { - ...mapConfig.map, - zoom: 20, // we are applying high zoom level to mitigate the initial tile blurring due to the zoom to event - visualizationMode: ['3dtiles'].includes(subtype) ? VisualizationModes._3D : VisualizationModes._2D, - layers: [ - ...mapConfig.map.layers, - { - ...newLayer, - isDataset: true, - _v_: Date.now() - } + return Observable.concat( + Observable.of( + configureMap({ + ...mapConfig, + map: { + ...mapConfig.map, + ...mapLayerData?.mapConfig?.map, + zoom: 20, // start zoomed in to mitigate initial tile blurring before the deferred fit lands + visualizationMode: ['3dtiles'].includes(subtype) ? VisualizationModes._3D : VisualizationModes._2D, + layers: [ + ...mapConfig.map.layers, + { + ...newLayer, + isDataset: true, + _v_: Date.now() + } + ] + } + }), + // Always dispatch — an undefined payload resets the slice so a + // projection list from a previously-loaded dataset does not leak + // into a dataset that has no persisted crsSelector config. + setProjectionsConfig(mapLayerData?.mapConfig?.crsSelector), + setControlProperty('toolbar', 'expanded', false), + forceUpdateMapLayout(), + selectNode(newLayer.id, 'layer', false), + ...(!options?.isSamePreviousResource ? [setResource({...gnLayer, hasNoGeometry})] : []), + setResourceId(pk), + ...((hasNoGeometry || page === 'dataset_edit_data_viewer') + ? [ + browseData(newLayer), + ...(hasDownloadPermission ? [] : [setDatasetEditPermissionsError('gnviewer.noEditPermissions')]) ] - } - }), - ...(extent - ? [ setControlProperty(FIT_BOUNDS_CONTROL, 'geometry', extent) ] - : []), - setControlProperty('toolbar', 'expanded', false), - forceUpdateMapLayout(), - selectNode(newLayer.id, 'layer', false), - ...(!options?.isSamePreviousResource ? [setResource({...gnLayer, hasNoGeometry})] : []), - setResourceId(pk), - ...((hasNoGeometry || page === 'dataset_edit_data_viewer') - ? [ - browseData(newLayer), - ...(hasDownloadPermission ? [] : [setDatasetEditPermissionsError('gnviewer.noEditPermissions')]) - ] - : []), - ...(page === 'dataset_edit_layer_settings' - ? [ - showSettings(newLayer.id, "layers", {opacity: newLayer.opacity ?? 1}), - setControlProperty("layersettings", "activeTab", query.tab ?? "general"), - updateAdditionalLayer(newLayer.id, STYLE_OWNER_NAME, 'override', {}), - resizeMap() - ] - : []), - ...(newLayer?.bboxError - ? [warningNotification({ title: "gnviewer.invalidBbox", message: "gnviewer.invalidBboxMsg" })] - : []), - ...(gnLayer?.data?.crsSelector ? [changeCRS(gnLayer?.data?.crsSelector?.currentProjection)] : []), - ...(gnLayer?.data?.crsSelector ? [setProjectionsConfig(gnLayer?.data?.crsSelector)] : []) + : []), + ...(page === 'dataset_edit_layer_settings' + ? [ + showSettings(newLayer.id, "layers", {opacity: newLayer.opacity ?? 1}), + setControlProperty("layersettings", "activeTab", query.tab ?? "general"), + updateAdditionalLayer(newLayer.id, STYLE_OWNER_NAME, 'override', {}), + resizeMap() + ] + : []), + ...(newLayer?.bboxError + ? [warningNotification({ title: "gnviewer.invalidBbox", message: "gnviewer.invalidBboxMsg" })] + : []), + // unblock the Viewer route so the Map plugin actually mounts + // before fitBoundsAfterMapReady fires the deferred zoomToExtent; + // otherwise the ZOOM_TO_EXTENT hook is not registered and the + // fit falls back to legacyZoomToExtent against default state. + loadingResourceConfig(false) + ), + fitBoundsAfterMapReady(options.action$, extent) ); }); } @@ -296,28 +326,31 @@ const resourceTypes = { const newLayer = gnLayer ? resourceToLayerConfig(gnLayer) : null; const { minx, miny, maxx, maxy } = newLayer?.bbox?.bounds || {}; const extent = newLayer?.bbox?.bounds && [ minx, miny, maxx, maxy ]; - return Observable.of( - configureMap(newLayer - ? { - ...mapConfig, - map: { - ...mapConfig?.map, - ...(queryDatasetPk !== undefined && { - visualizationMode: ['3dtiles'].includes(quryDatasetSubtype) - ? VisualizationModes._3D - : VisualizationModes._2D - }), - layers: [ - ...(mapConfig?.map?.layers || []), - newLayer - ] + return Observable.concat( + Observable.of( + configureMap(newLayer + ? { + ...mapConfig, + map: { + ...mapConfig?.map, + ...(queryDatasetPk !== undefined && { + visualizationMode: ['3dtiles'].includes(quryDatasetSubtype) + ? VisualizationModes._3D + : VisualizationModes._2D + }), + layers: [ + ...(mapConfig?.map?.layers || []), + newLayer + ] + } } - } - : mapConfig), - ...(extent - ? [ setControlProperty(FIT_BOUNDS_CONTROL, 'geometry', extent) ] - : []), - setControlProperty('toolbar', 'expanded', false) + : mapConfig), + setControlProperty('toolbar', 'expanded', false), + // unblock the Viewer route so the Map plugin actually mounts; + // see fitBoundsAfterMapReady comment. + loadingResourceConfig(false) + ), + fitBoundsAfterMapReady(options.action$, extent) ); }); } @@ -478,7 +511,6 @@ const getResetActions = (state, isSameResource) => { ] ), setControlProperty('rightOverlay', 'enabled', false), - setControlProperty(FIT_BOUNDS_CONTROL, 'geometry', null), // reset style editor state to avoid persistence service configuration in between resource pages initStyleService(), resetStyleEditor(), @@ -521,7 +553,7 @@ export const gnViewerRequestNewResourceConfig = (action$, store) => setResourceType(action.resourceType), setResourcePathParameters(action?.options?.params) ), - newResourceObservable({ query }), + newResourceObservable({ query, action$ }), Observable.of( loadingResourceConfig(false) ) @@ -573,6 +605,7 @@ export const gnViewerRequestResourceConfig = (action$, store) => ...action.options, isSamePreviousResource, resourceData, + mapLayerData: getMapLayerData(state), selectedLayer: isSamePreviousResource && {...getInitialDatasetLayer(state), style: getInitialDatasetLayerStyle(state)}, params: {...action?.options?.params, query}, action$ @@ -762,37 +795,6 @@ export const gnManageLinkedResource = (action$, store) => ); }); -const MAX_EXTENT_WEB_MERCATOR = [-180, -85, 180, 85]; - -function validateGeometry(extent, projection) { - if (extent && ['EPSG:900913', 'EPSG:3857'].includes(projection)) { - const [minx, miny, maxx, maxy] = extent; - const [eMinx, eMiny, eMaxx, eMaxy] = MAX_EXTENT_WEB_MERCATOR; - return [ - minx < eMinx ? eMinx : minx, - (miny < eMiny || miny > eMaxy) ? eMiny : miny, - maxx > eMaxx ? eMaxx : maxx, - (maxy > eMaxy || maxy < eMiny) ? eMaxy : maxy - ]; - } - return extent; -} - -export const gnZoomToFitBounds = (action$) => - action$.ofType(SET_CONTROL_PROPERTY) - .filter(action => action.control === FIT_BOUNDS_CONTROL && !!action.value) - .switchMap((action) => - action$.ofType(CHANGE_MAP_VIEW) - .take(1) - .switchMap(() => { - const extent = validateGeometry(action.value); - return Observable.of( - zoomToExtent(extent, 'EPSG:4326', undefined, { duration: 0 }), - setControlProperty(FIT_BOUNDS_CONTROL, 'geometry', null) - ); - }) - ); - const getResourceWithDetail = (resource) => ({ ...resource, /* store information related to detail */ @@ -921,7 +923,6 @@ export default { closeDatasetCatalogPanel, closeResourceDetailsOnMapInfoOpen, gnManageLinkedResource, - gnZoomToFitBounds, gnSelectResourceEpic, gnUpdateResourceExtent, gnUpdateEditProjectionEpic diff --git a/geonode_mapstore_client/client/js/epics/gnsave.js b/geonode_mapstore_client/client/js/epics/gnsave.js index 356b26bb1f..28ddc877e5 100644 --- a/geonode_mapstore_client/client/js/epics/gnsave.js +++ b/geonode_mapstore_client/client/js/epics/gnsave.js @@ -192,7 +192,10 @@ const SaveAPI = { ...body, data: { ...body?.data, - dimensions: timeseries?.has_time ? getDimensions({...body?.data, has_time: true}) : [] + layerSettings: { + ...body?.data?.layerSettings, + dimensions: timeseries?.has_time ? getDimensions({...currentResource, has_time: true}) : [] + } }, ...(timeseries && { has_time: timeseries?.has_time }) }; @@ -209,7 +212,7 @@ const SaveAPI = { if (timeseries) { const layerId = layersSelector(state)?.find((l) => l.pk === resource?.pk)?.id; // actions to be dispacted are added to response array - return [resource, updateNode(layerId, 'layers', { dimensions: get(resource, 'data.dimensions', []) }), ...actions]; + return [resource, updateNode(layerId, 'layers', { dimensions: get(resource, 'data.layerSettings.dimensions', []) }), ...actions]; } return [resource, ...actions]; }); diff --git a/geonode_mapstore_client/client/js/plugins/index.js b/geonode_mapstore_client/client/js/plugins/index.js index 23317da558..34cbed2810 100644 --- a/geonode_mapstore_client/client/js/plugins/index.js +++ b/geonode_mapstore_client/client/js/plugins/index.js @@ -24,6 +24,7 @@ import SecurityPopup from "@mapstore/framework/plugins/SecurityPopup"; import BackgroundSelector from '@mapstore/framework/plugins/BackgroundSelector'; import MetadataExplorer from '@mapstore/framework/plugins/MetadataExplorer'; import CameraPosition from '@mapstore/framework/plugins/CameraPosition'; +import CRSSelector from '@mapstore/framework/plugins/CRSSelector'; import OperationPlugin from '@js/plugins/Operation'; import ExecutionTrackerPlugin from '@js/plugins/ExecutionTracker'; @@ -95,6 +96,7 @@ export const plugins = { BackgroundSelectorPlugin: BackgroundSelector, MetadataExplorerPlugin: MetadataExplorer, CameraPositionPlugin: CameraPosition, + CRSSelectorPlugin: CRSSelector, LayerDownloadPlugin: toModulePlugin( 'LayerDownload', () => import(/* webpackChunkName: 'plugins/layer-download' */ '@mapstore/framework/plugins/LayerDownload'), @@ -469,10 +471,6 @@ export const plugins = { 'SearchByBookmark', () => import(/* webpackChunkName: 'plugins/searchByBookmark' */ '@mapstore/framework/plugins/SearchByBookmark') ), - CRSSelectorPlugin: toModulePlugin( - 'CRSSelector', - () => import(/* webpackChunkName: 'plugins/CRSSelector' */ '@mapstore/framework/plugins/CRSSelector') - ), SettingsPlugin: toModulePlugin( 'Settings', () => import(/* webpackChunkName: 'plugins/settings' */ '@mapstore/framework/plugins/Settings') diff --git a/geonode_mapstore_client/client/js/reducers/gnresource.js b/geonode_mapstore_client/client/js/reducers/gnresource.js index 539ed5b42f..15d80dc70a 100644 --- a/geonode_mapstore_client/client/js/reducers/gnresource.js +++ b/geonode_mapstore_client/client/js/reducers/gnresource.js @@ -44,7 +44,9 @@ import { import { cleanCompactPermissions, getGeoLimitsFromCompactPermissions, - getResourceAdditionalProperties + getResourceAdditionalProperties, + parseMapLayerData, + ResourceTypes } from '@js/utils/ResourceUtils'; const defaultState = { @@ -88,10 +90,15 @@ function gnresource(state = defaultState, action) { updatedResource.linkedResources = linkedResources; } + // Persist the dataset config payload in its own slice field so it + // survives same-resource page transitions, where the SET_RESOURCE + // reducer otherwise strips `data` from `state.gnresource.data`. + const isDataset = state.type === ResourceTypes.DATASET; return {...state, error: null, initialResource: { ...actionData }, data: updatedResource, + ...(isDataset && { mapLayerData: parseMapLayerData(data) }), loading: false, isNew: false }; diff --git a/geonode_mapstore_client/client/js/selectors/resource.js b/geonode_mapstore_client/client/js/selectors/resource.js index 1909140c3c..0c867d1689 100644 --- a/geonode_mapstore_client/client/js/selectors/resource.js +++ b/geonode_mapstore_client/client/js/selectors/resource.js @@ -12,7 +12,7 @@ import { compareMapChanges } from '@mapstore/framework/utils/MapUtils'; import { currentStorySelector } from '@mapstore/framework/selectors/geostory'; import { originalDataSelector } from '@mapstore/framework/selectors/dashboard'; import { widgetsConfig } from '@mapstore/framework/selectors/widgets'; -import { ResourceTypes, RESOURCE_PUBLISHING_PROPERTIES, RESOURCE_OPTIONS_PROPERTIES, resourceToLayerConfig } from '@js/utils/ResourceUtils'; +import { ResourceTypes, RESOURCE_PUBLISHING_PROPERTIES, RESOURCE_OPTIONS_PROPERTIES, resourceToLayerConfig, STYLE_SUPPORTED_LAYER_TYPES } from '@js/utils/ResourceUtils'; import { getCurrentResourceDeleteLoading, getCurrentResourceCopyLoading @@ -29,6 +29,7 @@ import isNil from 'lodash/isNil'; import { generateContextResource } from '@mapstore/framework/selectors/contextcreator'; import { layerSettingSelector, getSelectedLayer as getSelectedNode } from '@mapstore/framework/selectors/layers'; import { saveLayer } from '@mapstore/framework/utils/LayersUtils'; +import { crsProjectionsConfigSelector } from '@mapstore/framework/selectors/crsselector'; const RESOURCE_MANAGEMENT_PROPERTIES_KEYS = Object.keys({...RESOURCE_PUBLISHING_PROPERTIES, ...RESOURCE_OPTIONS_PROPERTIES}); @@ -95,6 +96,13 @@ export const getResourceData = (state) => { return state?.gnresource?.data; }; +// Returns the dataset persisted payload `{ layerSettings, mapConfig }` from +// `resource.data`. Source of truth for same-resource page transitions where +// `gnresource.data` has been stripped of its `data` field by SET_RESOURCE. +export const getMapLayerData = (state) => { + return state?.gnresource?.mapLayerData ?? { layerSettings: {}, mapConfig: {} }; +}; + export const getLayerResourceData = (state) => { return state?.gnresource?.layerDataset; }; @@ -177,16 +185,29 @@ export const getDataPayload = (state, resourceType) => { currentLayerSettings = omitBy(currentLayerSettings, (value, key) => key === "opacity" && value === 1); // skip default value const selectedLayer = getSelectedNode(state); - const omitKeys = ['extendedParams', 'availableStyles', 'infoFormats', 'style']; + const omitKeys = [ + 'extendedParams', + 'availableStyles', + 'infoFormats', + ...(STYLE_SUPPORTED_LAYER_TYPES.includes(state?.gnresource?.subtype) ? ['style'] : []) + ]; const data = saveLayer(selectedLayer ?? {}); - const crsSelector = state?.crsselector?.config; - const currentProjection = mapSelector(state)?.projection; - return omit({ - ...data, - ...currentLayerSettings, - ...(selectedLayer && {fields: selectedLayer?.fields ?? {}}), - ...(crsSelector && {crsSelector: {...crsSelector, currentProjection}}) - }, omitKeys); + const mapConfig = mapSaveSelector(state); + const crsSelectorConfig = crsProjectionsConfigSelector(state); + return { + layerSettings: omit({ + ...data, + ...currentLayerSettings, + ...(selectedLayer && { fields: selectedLayer?.fields ?? {} }) + }, omitKeys), + mapConfig: { + map: pick(mapConfig?.map || {}, [ + 'projection', + 'projections' + ]), + ...(!isEmpty(crsSelectorConfig) && { crsSelector: crsSelectorConfig }) + } + }; } default: return null; diff --git a/geonode_mapstore_client/client/js/utils/ResourceUtils.js b/geonode_mapstore_client/client/js/utils/ResourceUtils.js index 0e7ef7af16..843e4745d2 100644 --- a/geonode_mapstore_client/client/js/utils/ResourceUtils.js +++ b/geonode_mapstore_client/client/js/utils/ResourceUtils.js @@ -35,7 +35,15 @@ function getExtentFromResource({ extent }) { // if the extent is greater than the max extent of the WGS84 return null const WGS84_MAX_EXTENT = [-180, -90, 180, 90]; if (minx < WGS84_MAX_EXTENT[0] || miny < WGS84_MAX_EXTENT[1] || maxx > WGS84_MAX_EXTENT[2] || maxy > WGS84_MAX_EXTENT[3]) { - return null; + return { + crs: 'EPSG:4326', + bounds: { + minx: WGS84_MAX_EXTENT[0], + miny: WGS84_MAX_EXTENT[1], + maxx: WGS84_MAX_EXTENT[2], + maxy: WGS84_MAX_EXTENT[3] + } + }; } const bbox = { crs: 'EPSG:4326', @@ -210,9 +218,11 @@ export const resourceToLayerConfig = (resource) => { ptype, subtype, sourcetype, - data: layerSettings + data } = resource; + const layerSettings = data?.layerSettings ?? data; + const title = getLocalizedValues(resource, 'title', defaultTitle); @@ -224,13 +234,7 @@ export const resourceToLayerConfig = (resource) => { } }; - const extendedParams = { - pk, - mapLayer: { - dataset: resource - }, - ...defaultStyleParams - }; + const extendedParams = { pk }; if (subtype === '3dtiles') { const { url: tilesetUrl } = links.find(({ extension }) => (extension === '3dtiles')) || {}; @@ -241,6 +245,7 @@ export const resourceToLayerConfig = (resource) => { url: parseDevHostname(tilesetUrl || ''), ...(bbox && { bbox }), visibility: true, + ...layerSettings, extendedParams }; } @@ -256,11 +261,11 @@ export const resourceToLayerConfig = (resource) => { }], ...(bbox && { bbox }), visibility: true, + ...layerSettings, extendedParams }; } if (subtype === 'flatgeobuf') { - const defaultGeomType = 'GeometryCollection'; const geometryType = attributeSet.find(attr => attr.attribute === 'geometryType')?.attribute_type || defaultGeomType; @@ -274,6 +279,7 @@ export const resourceToLayerConfig = (resource) => { url: parseDevHostname(fgbUrl || ''), ...(bbox && { bbox }), visibility: true, + ...layerSettings, extendedParams }; } @@ -293,6 +299,7 @@ export const resourceToLayerConfig = (resource) => { ...(bbox && { bbox }), title, visibility: true, + ...layerSettings, extendedParams }; } @@ -335,12 +342,12 @@ export const resourceToLayerConfig = (resource) => { visibility: true, ...(params && { params }), ...(dimensions.length > 0 && ({ dimensions })), - extendedParams, ...(fields && { fields }), ...(sourcetype === SOURCE_TYPES.REMOTE && !wmsUrl.includes('/geoserver/') && { serverType: ServerTypes.NO_VENDOR }), - ...layerSettings + ...layerSettings, + extendedParams }; } }; @@ -693,17 +700,18 @@ export function cleanStyles(styles = [], excluded = []) { export function getGeoNodeMapLayers(data) { return (data?.map?.layers || []) - .filter(layer => layer?.extendedParams?.mapLayer) + .filter(layer => layer?.extendedParams?.pk) .map((layer, index) => { return { - ...(layer?.extendedParams?.mapLayer && { + ...(layer.extendedParams.mapLayer?.pk && { pk: layer.extendedParams.mapLayer.pk }), - current_style: layer.style || '', extra_params: { msId: layer.id }, - ...(layer.type === 'wms' && { current_style: layer.style || '' }), + ...(layer.type === 'wms' && { + current_style: layer.style || '' + }), name: layer.name || '', order: index, opacity: layer.opacity ?? 1, @@ -718,7 +726,27 @@ export function toGeoNodeMapConfig(data) { } const maplayers = getGeoNodeMapLayers(data); return { - maplayers + maplayers, + data: { + ...data, + map: { + ...data?.map, + layers: (data?.map?.layers || []).map((layer) => { + return { + ...layer, + // clean up extended params + ...(layer?.extendedParams?.pk && { + extendedParams: { + pk: layer.extendedParams.pk, + ...(layer.extendedParams.mapLayer?.pk && { + mapLayer: { pk: layer.extendedParams.mapLayer.pk } + }) + } + }) + }; + }) + } + } }; } @@ -736,8 +764,10 @@ export function toMapStoreMapConfig(resource, baseConfig) { style: mapLayer.current_style || layer.style || '' }), extendedParams: { - ...layer.extendedParams, - mapLayer + pk: mapLayer.dataset?.pk ?? layer.extendedParams?.pk, + ...(mapLayer.pk !== undefined && { + mapLayer: { pk: mapLayer.pk } + }) } }; } @@ -975,6 +1005,35 @@ export const getResourceAdditionalProperties = (_resource = {}) => { }; }; +// Normalizes a dataset resource's `data` payload to the shape +// `{ layerSettings, mapConfig: { map?, crsSelector? } }`. Legacy records stored +// the layer settings as the top-level `data` object and projection state under +// `data.crsSelector`; new records nest both halves explicitly. Idempotent on +// already-normalized payloads so the caller can run it without checking. +export const parseMapLayerData = (data) => { + if (!data || typeof data !== 'object') { + return { layerSettings: {}, mapConfig: {} }; + } + if ('layerSettings' in data || 'mapConfig' in data) { + return { + layerSettings: data.layerSettings ?? {}, + mapConfig: data.mapConfig ?? {} + }; + } + const legacyCrsSelector = data.crsSelector; + return { + layerSettings: omit(data, ['crsSelector']), + mapConfig: { + ...(legacyCrsSelector?.currentProjection && { + map: { projection: legacyCrsSelector.currentProjection } + }), + ...(legacyCrsSelector?.projectionList && { + crsSelector: { projectionList: legacyCrsSelector.projectionList } + }) + } + }; +}; + export const parseCatalogResource = (resource, user) => { const { formatDetailUrl, diff --git a/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js b/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js index 9c6315d5ed..2a2b0375cc 100644 --- a/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js +++ b/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js @@ -192,11 +192,7 @@ describe('Test Resource Utils', () => { url: 'geoserver/wms', style: 'geonode:style', availableStyles: [{ name: 'custom:style', title: 'My Style', format: 'css', metadata: {} }], - extendedParams: { - mapLayer: { - pk: 10 - } - }, + extendedParams: { pk: 1, mapLayer: { pk: 10 } }, opacity: 0.5, visibility: false } @@ -231,11 +227,7 @@ describe('Test Resource Utils', () => { url: 'geoserver/wms', style: 'geonode:style', availableStyles: [{ name: 'custom:style', title: 'My Style' }], - extendedParams: { - mapLayer: { - pk: 10 - } - } + extendedParams: { pk: 1, mapLayer: { pk: 10 } } } ] } @@ -305,18 +297,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { - mapLayer: { - pk: 10, - current_style: 'geonode:style01', - extra_params: { - msId: '03' - }, - dataset: { - pk: 1 - } - } - } + extendedParams: { pk: 1, mapLayer: { pk: 10 } } } ] } @@ -383,18 +364,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { - mapLayer: { - pk: 10, - current_style: 'geonode:style01', - extra_params: { - msId: '03' - }, - dataset: { - pk: 1 - } - } - }, + extendedParams: { pk: 1, mapLayer: { pk: 10 } }, featureInfo: { template: "
test
", format: FEATURE_INFO_FORMAT } } ] @@ -471,18 +441,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { - mapLayer: { - pk: 10, - current_style: 'geonode:style01', - extra_params: { - msId: '03' - }, - dataset: { - pk: 1 - } - } - } + extendedParams: { pk: 1, mapLayer: { pk: 10 } } } ] } @@ -543,6 +502,145 @@ describe('Test Resource Utils', () => { expect(layers[0].featureInfo).toEqual({ template, format: FEATURE_INFO_FORMAT }); }); + it('getGeoNodeMapLayers omits pk for fresh-added layers (no maplayer.pk yet)', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'geonode:layer', + extendedParams: { pk: 1 } + }] + } + }; + const mapLayers = getGeoNodeMapLayers(data); + expect(mapLayers.length).toBe(1); + expect(mapLayers[0].pk).toBe(undefined); + expect(mapLayers[0].name).toBe('geonode:layer'); + }); + + it('getGeoNodeMapLayers filters out layers without extendedParams.pk', () => { + const data = { + map: { + layers: [ + { id: '01', type: 'osm', source: 'osm' }, + { id: '02', type: 'vector', features: [] }, + { id: '03', type: 'wms', name: 'remote:wms', extendedParams: {} } + ] + } + }; + expect(getGeoNodeMapLayers(data)).toEqual([]); + }); + + it('toGeoNodeMapConfig cleans up extendedParams to { pk, mapLayer: { pk } } and drops other keys', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'geonode:layer', + extendedParams: { + pk: 1, + mapLayer: { pk: 10 }, + defaultStyle: { name: 'foo', title: 'bar' }, + unrelated: 'should be dropped' + } + }] + } + }; + const result = toGeoNodeMapConfig(data); + expect(result.data.map.layers[0].extendedParams).toEqual({ + pk: 1, + mapLayer: { pk: 10 } + }); + }); + + it('toGeoNodeMapConfig cleanup omits mapLayer for fresh-add layers', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'geonode:layer', + extendedParams: { pk: 1 } + }] + } + }; + const result = toGeoNodeMapConfig(data); + expect(result.data.map.layers[0].extendedParams).toEqual({ pk: 1 }); + }); + + it('toMapStoreMapConfig removes geonode layers without a matching maplayer (orphans)', () => { + const resource = { + maplayers: [], + data: { + map: { + layers: [ + { id: '01', type: 'osm', source: 'osm', group: 'background', visibility: true }, + { + id: '03', + type: 'wms', + name: 'geonode:layer', + extendedParams: { pk: 1, mapLayer: { pk: 10 } } + } + ] + } + } + }; + const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(result.map.layers).toEqual([ + { id: '01', type: 'osm', source: 'osm', group: 'background', visibility: true } + ]); + }); + + it('toMapStoreMapConfig falls back to layer.extendedParams.pk when mapLayer.dataset is missing', () => { + const resource = { + maplayers: [{ + pk: 10, + extra_params: { msId: '03' } + }], + data: { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'geonode:layer', + extendedParams: { pk: 1, mapLayer: { pk: 10 } } + }] + } + } + }; + const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(result.map.layers[0].extendedParams).toEqual({ + pk: 1, + mapLayer: { pk: 10 } + }); + }); + + it('toGeoNodeMapConfig → toMapStoreMapConfig round-trip preserves the extendedParams shape', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'geonode:layer', + style: 'geonode:style', + extendedParams: { pk: 1, mapLayer: { pk: 10 } } + }] + } + }; + const saved = toGeoNodeMapConfig(data); + const resource = { + ...saved, + maplayers: saved.maplayers.map(ml => ({ ...ml, dataset: { pk: 1 } })) + }; + const reloaded = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(reloaded.map.layers[0].extendedParams).toEqual({ + pk: 1, + mapLayer: { pk: 10 } + }); + }); + it('should parse style name into accepted format', () => { const styleObj = { name: 'testName', From e2d90796c9bba4fd08fb575da834de1a37fe7269 Mon Sep 17 00:00:00 2001 From: stefano bovio Date: Wed, 6 May 2026 12:04:58 +0200 Subject: [PATCH 2/2] Fix #2515 Only WMS maplayers are persisted correctly in the resource payload (#2517) --- .../client/js/utils/ResourceUtils.js | 9 +- .../js/utils/__tests__/ResourceUtils-test.js | 236 +++++++++++++++++- 2 files changed, 231 insertions(+), 14 deletions(-) diff --git a/geonode_mapstore_client/client/js/utils/ResourceUtils.js b/geonode_mapstore_client/client/js/utils/ResourceUtils.js index 843e4745d2..ed26abd109 100644 --- a/geonode_mapstore_client/client/js/utils/ResourceUtils.js +++ b/geonode_mapstore_client/client/js/utils/ResourceUtils.js @@ -234,7 +234,10 @@ export const resourceToLayerConfig = (resource) => { } }; - const extendedParams = { pk }; + const extendedParams = { + pk, + alternate + }; if (subtype === '3dtiles') { const { url: tilesetUrl } = links.find(({ extension }) => (extension === '3dtiles')) || {}; @@ -712,7 +715,7 @@ export function getGeoNodeMapLayers(data) { ...(layer.type === 'wms' && { current_style: layer.style || '' }), - name: layer.name || '', + name: layer?.extendedParams?.alternate || layer.name || '', order: index, opacity: layer.opacity ?? 1, visibility: layer.visibility @@ -738,6 +741,7 @@ export function toGeoNodeMapConfig(data) { ...(layer?.extendedParams?.pk && { extendedParams: { pk: layer.extendedParams.pk, + alternate: layer.extendedParams.alternate, ...(layer.extendedParams.mapLayer?.pk && { mapLayer: { pk: layer.extendedParams.mapLayer.pk } }) @@ -765,6 +769,7 @@ export function toMapStoreMapConfig(resource, baseConfig) { }), extendedParams: { pk: mapLayer.dataset?.pk ?? layer.extendedParams?.pk, + alternate: mapLayer.dataset?.alternate ?? layer.extendedParams?.alternate ?? layer.name, ...(mapLayer.pk !== undefined && { mapLayer: { pk: mapLayer.pk } }) diff --git a/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js b/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js index 2a2b0375cc..39cd3e1da9 100644 --- a/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js +++ b/geonode_mapstore_client/client/js/utils/__tests__/ResourceUtils-test.js @@ -251,7 +251,8 @@ describe('Test Resource Utils', () => { msId: '03' }, dataset: { - pk: 1 + pk: 1, + alternate: 'geonode:layer' } } ], @@ -297,7 +298,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { pk: 1, mapLayer: { pk: 10 } } + extendedParams: { pk: 1, alternate: 'geonode:layer', mapLayer: { pk: 10 } } } ] } @@ -314,7 +315,8 @@ describe('Test Resource Utils', () => { msId: '03' }, dataset: { - pk: 1 + pk: 1, + alternate: 'geonode:layer' } } ], @@ -364,7 +366,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { pk: 1, mapLayer: { pk: 10 } }, + extendedParams: { pk: 1, alternate: 'geonode:layer', mapLayer: { pk: 10 } }, featureInfo: { template: "
test
", format: FEATURE_INFO_FORMAT } } ] @@ -382,7 +384,8 @@ describe('Test Resource Utils', () => { msId: '03' }, dataset: { - pk: 1 + pk: 1, + alternate: 'geonode:layer' } } ], @@ -441,7 +444,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', url: 'geoserver/wms', style: 'geonode:style01', - extendedParams: { pk: 1, mapLayer: { pk: 10 } } + extendedParams: { pk: 1, alternate: 'geonode:layer', mapLayer: { pk: 10 } } } ] } @@ -532,7 +535,7 @@ describe('Test Resource Utils', () => { expect(getGeoNodeMapLayers(data)).toEqual([]); }); - it('toGeoNodeMapConfig cleans up extendedParams to { pk, mapLayer: { pk } } and drops other keys', () => { + it('toGeoNodeMapConfig cleans up extendedParams to { pk, alternate, mapLayer: { pk } } and drops other keys', () => { const data = { map: { layers: [{ @@ -541,6 +544,7 @@ describe('Test Resource Utils', () => { name: 'geonode:layer', extendedParams: { pk: 1, + alternate: 'geonode:layer', mapLayer: { pk: 10 }, defaultStyle: { name: 'foo', title: 'bar' }, unrelated: 'should be dropped' @@ -551,6 +555,7 @@ describe('Test Resource Utils', () => { const result = toGeoNodeMapConfig(data); expect(result.data.map.layers[0].extendedParams).toEqual({ pk: 1, + alternate: 'geonode:layer', mapLayer: { pk: 10 } }); }); @@ -562,12 +567,12 @@ describe('Test Resource Utils', () => { id: '03', type: 'wms', name: 'geonode:layer', - extendedParams: { pk: 1 } + extendedParams: { pk: 1, alternate: 'geonode:layer' } }] } }; const result = toGeoNodeMapConfig(data); - expect(result.data.map.layers[0].extendedParams).toEqual({ pk: 1 }); + expect(result.data.map.layers[0].extendedParams).toEqual({ pk: 1, alternate: 'geonode:layer' }); }); it('toMapStoreMapConfig removes geonode layers without a matching maplayer (orphans)', () => { @@ -605,7 +610,7 @@ describe('Test Resource Utils', () => { id: '03', type: 'wms', name: 'geonode:layer', - extendedParams: { pk: 1, mapLayer: { pk: 10 } } + extendedParams: { pk: 1, alternate: 'geonode:layer', mapLayer: { pk: 10 } } }] } } @@ -613,6 +618,7 @@ describe('Test Resource Utils', () => { const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); expect(result.map.layers[0].extendedParams).toEqual({ pk: 1, + alternate: 'geonode:layer', mapLayer: { pk: 10 } }); }); @@ -625,18 +631,19 @@ describe('Test Resource Utils', () => { type: 'wms', name: 'geonode:layer', style: 'geonode:style', - extendedParams: { pk: 1, mapLayer: { pk: 10 } } + extendedParams: { pk: 1, alternate: 'geonode:layer', mapLayer: { pk: 10 } } }] } }; const saved = toGeoNodeMapConfig(data); const resource = { ...saved, - maplayers: saved.maplayers.map(ml => ({ ...ml, dataset: { pk: 1 } })) + maplayers: saved.maplayers.map(ml => ({ ...ml, dataset: { pk: 1, alternate: 'geonode:layer' } })) }; const reloaded = toMapStoreMapConfig(resource, { map: { layers: [] } }); expect(reloaded.map.layers[0].extendedParams).toEqual({ pk: 1, + alternate: 'geonode:layer', mapLayer: { pk: 10 } }); }); @@ -1367,4 +1374,209 @@ describe('Test Resource Utils', () => { }); }); }); + describe('alternate is propagated through extendedParams', () => { + // parseDevHostname references __DEVTOOLS__ (a webpack DefinePlugin global + // not declared in the karma test config); the 3dtiles/cog/flatgeobuf + // branches call it, so define it here to avoid ReferenceError + let prevDevtools; + before(() => { + prevDevtools = window.__DEVTOOLS__; + window.__DEVTOOLS__ = false; + }); + after(() => { + window.__DEVTOOLS__ = prevDevtools; + }); + + it('resourceToLayerConfig (default WMS) includes alternate in extendedParams', () => { + const newLayer = resourceToLayerConfig({ + alternate: 'geonode:layer_name', + links: [{ + extension: 'html', + link_type: 'OGC:WMS', + name: 'OGC WMS Service', + mime: 'text/html', + url: '/geoserver/wms' + }], + title: 'Layer title', + perms: [], + pk: 1 + }); + expect(newLayer.extendedParams).toEqual({ pk: 1, alternate: 'geonode:layer_name' }); + }); + + it('resourceToLayerConfig (3dtiles) includes alternate in extendedParams', () => { + const newLayer = resourceToLayerConfig({ + alternate: 'geonode:tileset', + subtype: '3dtiles', + links: [{ extension: '3dtiles', url: '/tileset.json' }], + title: 'Tileset', + perms: [], + pk: 2 + }); + expect(newLayer.extendedParams).toEqual({ pk: 2, alternate: 'geonode:tileset' }); + }); + + it('resourceToLayerConfig (cog) includes alternate in extendedParams', () => { + const newLayer = resourceToLayerConfig({ + alternate: 'geonode:cog_layer', + subtype: 'cog', + links: [{ extension: 'cog', url: '/raster.tif' }], + title: 'COG', + perms: [], + pk: 3 + }); + expect(newLayer.extendedParams).toEqual({ pk: 3, alternate: 'geonode:cog_layer' }); + }); + + it('resourceToLayerConfig (flatgeobuf) includes alternate in extendedParams', () => { + const newLayer = resourceToLayerConfig({ + alternate: 'geonode:fgb_layer', + subtype: 'flatgeobuf', + attribute_set: [], + links: [{ extension: 'flatgeobuf', url: '/data.fgb' }], + title: 'FGB', + perms: [], + pk: 4 + }); + expect(newLayer.extendedParams).toEqual({ pk: 4, alternate: 'geonode:fgb_layer' }); + }); + + it('resourceToLayerConfig (arcgis) includes alternate in extendedParams', () => { + const newLayer = resourceToLayerConfig({ + alternate: 'remoteWorkspace:1', + title: 'Layer title', + perms: [], + links: [{ + extension: 'html', + link_type: 'image', + mime: 'text/html', + name: 'ArcGIS REST ImageServer', + url: '/MapServer' + }], + pk: 5, + ptype: 'gxp_arcrestsource' + }); + expect(newLayer.extendedParams).toEqual({ pk: 5, alternate: 'remoteWorkspace:1' }); + }); + + it('getGeoNodeMapLayers prefers extendedParams.alternate over layer.name', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'fallback:name', + extendedParams: { pk: 1, alternate: 'geonode:from_alternate', mapLayer: { pk: 10 } } + }] + } + }; + const mapLayers = getGeoNodeMapLayers(data); + expect(mapLayers.length).toBe(1); + expect(mapLayers[0].name).toBe('geonode:from_alternate'); + }); + + it('getGeoNodeMapLayers falls back to layer.name when extendedParams.alternate is missing', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'fallback:name', + extendedParams: { pk: 1 } + }] + } + }; + const mapLayers = getGeoNodeMapLayers(data); + expect(mapLayers[0].name).toBe('fallback:name'); + }); + + it('toMapStoreMapConfig sets alternate from mapLayer.dataset.alternate (preferred)', () => { + const resource = { + maplayers: [{ + pk: 10, + extra_params: { msId: '03' }, + dataset: { pk: 1, alternate: 'dataset:alternate' } + }], + data: { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'layer:name', + extendedParams: { pk: 1, alternate: 'stale:alternate', mapLayer: { pk: 10 } } + }] + } + } + }; + const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(result.map.layers[0].extendedParams.alternate).toBe('dataset:alternate'); + }); + + it('toMapStoreMapConfig falls back to layer.extendedParams.alternate when dataset.alternate is missing', () => { + const resource = { + maplayers: [{ + pk: 10, + extra_params: { msId: '03' }, + dataset: { pk: 1 } + }], + data: { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'layer:name', + extendedParams: { pk: 1, alternate: 'stored:alternate', mapLayer: { pk: 10 } } + }] + } + } + }; + const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(result.map.layers[0].extendedParams.alternate).toBe('stored:alternate'); + }); + + it('toMapStoreMapConfig falls back to layer.name when neither alternate is available', () => { + const resource = { + maplayers: [{ + pk: 10, + extra_params: { msId: '03' }, + dataset: { pk: 1 } + }], + data: { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'layer:name', + extendedParams: { pk: 1, mapLayer: { pk: 10 } } + }] + } + } + }; + const result = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(result.map.layers[0].extendedParams.alternate).toBe('layer:name'); + }); + + it('round-trip via toGeoNodeMapConfig → toMapStoreMapConfig preserves alternate', () => { + const data = { + map: { + layers: [{ + id: '03', + type: 'wms', + name: 'layer:name', + style: 'geonode:style', + extendedParams: { pk: 1, alternate: 'geonode:roundtrip', mapLayer: { pk: 10 } } + }] + } + }; + const saved = toGeoNodeMapConfig(data); + // maplayer.name should come from extendedParams.alternate + expect(saved.maplayers[0].name).toBe('geonode:roundtrip'); + const resource = { + ...saved, + maplayers: saved.maplayers.map(ml => ({ ...ml, dataset: { pk: 1, alternate: 'geonode:roundtrip' } })) + }; + const reloaded = toMapStoreMapConfig(resource, { map: { layers: [] } }); + expect(reloaded.map.layers[0].extendedParams.alternate).toBe('geonode:roundtrip'); + }); + }); });