diff --git a/geonode_mapstore_client/client/js/epics/gnresource.js b/geonode_mapstore_client/client/js/epics/gnresource.js index a540a63412..f29f4f24c8 100644 --- a/geonode_mapstore_client/client/js/epics/gnresource.js +++ b/geonode_mapstore_client/client/js/epics/gnresource.js @@ -328,9 +328,10 @@ const resourceTypes = { newResourceObservable: (options) => Observable.defer(() => getNewGeoStoryConfig()) .switchMap((gnGeoStory) => { + const currentStory = options.data || {...gnGeoStory, sections: [{...gnGeoStory.sections[0], id: uuid(), + contents: [{...gnGeoStory.sections[0].contents[0], id: uuid()}]}]}; return Observable.of( - setCurrentStory(options.data || {...gnGeoStory, sections: [{...gnGeoStory.sections[0], id: uuid(), - contents: [{...gnGeoStory.sections[0].contents[0], id: uuid()}]}]}), + setCurrentStory({...currentStory, defaultGeoStoryConfig: {...currentStory}}), setEditing(true), setGeoStoryResource({ canEdit: true diff --git a/geonode_mapstore_client/client/js/epics/gnsave.js b/geonode_mapstore_client/client/js/epics/gnsave.js index 3d19b61929..f8b5f0fe57 100644 --- a/geonode_mapstore_client/client/js/epics/gnsave.js +++ b/geonode_mapstore_client/client/js/epics/gnsave.js @@ -39,7 +39,8 @@ import { enableMapThumbnailViewer, updateResource, manageLinkedResource, - setSelectedLayer + setSelectedLayer, + setResourcePathParameters } from '@js/actions/gnresource'; import { getResourceByPk, @@ -92,6 +93,7 @@ import { ProcessStatus } from '@js/utils/ResourceServiceUtils'; import { updateNode, updateSettingsParams } from '@mapstore/framework/actions/layers'; +import { setControlProperty } from '@mapstore/framework/actions/controls'; import { layersSelector, getSelectedLayer as getSelectedNode } from '@mapstore/framework/selectors/layers'; import { styleServiceSelector, getUpdatedLayer, selectedStyleSelector } from '@mapstore/framework/selectors/styleeditor'; import LayersAPI from '@mapstore/framework/api/geoserver/Layers'; @@ -239,9 +241,17 @@ export const gnSaveContent = (action$, store) => const sourcepk = get(state, 'router.location.pathname', '').split('/').pop(); return Observable.of(manageLinkedResource({resourceType: contentType, source: sourcepk, target: resource.pk, processType: ProcessTypes.LINK_RESOURCE})); } - window.location.href = parseDevHostname(resource?.detail_url); - window.location.reload(); - return Observable.empty(); + return Observable.concat( + Observable.of( + setResourcePathParameters({pk: resource?.pk}), + setControlProperty(ProcessTypes.COPY_RESOURCE, 'value', undefined) + ), + Observable.defer(() => { + window.location.href = parseDevHostname(resource?.detail_url); + window.location.reload(); + return Observable.empty(); + }) + ); } const selectedLayer = getSelectedNode(state); const currentStyle = selectedLayer?.availableStyles?.find(({ name }) => selectedLayer?.style?.includes(name)); diff --git a/geonode_mapstore_client/client/js/selectors/__tests__/resource-test.js b/geonode_mapstore_client/client/js/selectors/__tests__/resource-test.js index 8ed41fb56f..e044c70c0e 100644 --- a/geonode_mapstore_client/client/js/selectors/__tests__/resource-test.js +++ b/geonode_mapstore_client/client/js/selectors/__tests__/resource-test.js @@ -10,6 +10,7 @@ import expect from 'expect'; import { getViewedResourceType, isNewResource, + isNewResourcePk, getGeoNodeResourceDataFromGeoStory, getGeoNodeResourceFromDashboard, getResourceThumbnail, @@ -17,7 +18,9 @@ import { isThumbnailChanged, canEditPermissions, canManageResourcePermissions, - isNewMapViewerResource, + isNewMapDirty, + isNewDashboardDirty, + isNewGeoStoryDirty, defaultViewerPluginsSelector } from '../resource'; import { ResourceTypes } from '@js/utils/ResourceUtils'; @@ -62,6 +65,16 @@ describe('resource selector', () => { it('is new resource', () => { expect(isNewResource(testState)).toBeTruthy(); }); + + it('is new resource by pk', () => { + let state = {...testState, gnresource: {...testState.gnresource, params: {pk: "new"}}}; + expect(isNewResourcePk(state)).toBeTruthy(); + state.gnresource.params.pk = '1'; + expect(isNewResourcePk(state)).toBeFalsy(); + state.gnresource.params = undefined; + expect(isNewResourcePk(state)).toBeFalsy(); + }); + it('getGeoNodeResourceDataFromGeoStory', () => { expect(getGeoNodeResourceDataFromGeoStory(testState)).toEqual({ maps: [300], documents: [200, 100] }); }); @@ -92,12 +105,6 @@ describe('resource selector', () => { expect(canManageResourcePermissions(state)).toBeFalsy(); state.gnresource.data.perms = undefined; }); - it('test isNewMapViewerResource', () => { - let state = {...testState, gnresource: {...testState.gnresource, type: ResourceTypes.VIEWER, params: {pk: "new"}}}; - expect(isNewMapViewerResource(state)).toBeTruthy(); - state.gnresource.params.pk = '1'; - expect(isNewMapViewerResource(state)).toBeFalsy(); - }); it('test defaultViewerPluginsSelector', () => { let state = {...testState}; state.gnresource = {...state.gnresource, defaultViewerPlugins: ["TOC"]}; @@ -105,4 +112,328 @@ describe('resource selector', () => { state.gnresource = {...state.gnresource, defaultViewerPlugins: undefined}; expect(defaultViewerPluginsSelector(state)).toEqual([]); }); + + it('test isNewMapDirty returns false when no mapConfigRawData exists', () => { + const state = { + gnresource: { + type: ResourceTypes.MAP + }, + map: { + present: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' } + } + } + // No mapConfigRawData + }; + expect(isNewMapDirty(state)).toBeFalsy(); + }); + + it('test isNewMapDirty returns false when map has not changed from initial config', () => { + const initialConfig = { + version: 2, + map: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true } + ] + } + }; + const state = { + gnresource: { + type: ResourceTypes.MAP + }, + map: { + present: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true } + ] + } + }, + layers: { + flat: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true } + ] + }, + mapConfigRawData: initialConfig + }; + expect(isNewMapDirty(state)).toBeFalsy(); + }); + + it('test isNewMapDirty returns true when layers are added', () => { + const initialConfig = { + version: 2, + map: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true } + ] + } + }; + const state = { + gnresource: { + type: ResourceTypes.MAP + }, + map: { + present: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true }, + { id: 'layer2', type: 'wms', name: 'newLayer', visibility: true } + ] + } + }, + layers: { + flat: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true }, + { id: 'layer2', type: 'wms', name: 'newLayer', visibility: true } + ] + }, + mapConfigRawData: initialConfig + }; + expect(isNewMapDirty(state)).toBeTruthy(); + }); + + it('test isNewMapDirty ignores ellipsoid terrain layer', () => { + const initialConfig = { + version: 2, + map: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true } + ] + } + }; + const state = { + gnresource: { + type: ResourceTypes.MAP + }, + map: { + present: { + zoom: 5, + center: { x: 0, y: 0, crs: 'EPSG:4326' }, + layers: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true }, + { id: 'ellipsoid', type: 'terrain', provider: 'ellipsoid', group: 'background' } + ] + } + }, + layers: { + flat: [ + { id: 'layer1', type: 'osm', group: 'background', visibility: true }, + { id: 'ellipsoid', type: 'terrain', provider: 'ellipsoid', group: 'background' } + ] + }, + mapConfigRawData: initialConfig + }; + // Should be false because ellipsoid terrain is filtered out by compareMapChanges + expect(isNewMapDirty(state)).toBeFalsy(); + }); + + it('test isNewDashboardDirty returns true when dashboard has widgets', () => { + const state = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [ + { id: 'widget1', widgetType: 'text' } + ] + } + } + } + }; + expect(isNewDashboardDirty(state)).toBeTruthy(); + }); + + it('test isNewDashboardDirty returns false when dashboard has no widgets and no layouts', () => { + const state = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [] + } + } + } + }; + expect(isNewDashboardDirty(state)).toBeFalsy(); + }); + + it('test isNewDashboardDirty returns false when dashboard has default single layout and no widgets', () => { + const state = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [], + layouts: [{ id: 'layout-1', name: 'Main view', color: null }] + } + } + } + }; + expect(isNewDashboardDirty(state)).toBeFalsy(); + }); + + it('test isNewDashboardDirty returns true when dashboard has more than one layout', () => { + const state = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [], + layouts: [ + { id: 'layout-1', name: 'Main view', color: null }, + { id: 'layout-2', name: 'Secondary', color: null } + ] + } + } + } + }; + expect(isNewDashboardDirty(state)).toBeTruthy(); + }); + + it('test isNewDashboardDirty returns true when single layout differs from default (name or color)', () => { + const stateNameChanged = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [], + layouts: [{ id: 'layout-1', name: 'Custom view', color: null }] + } + } + } + }; + expect(isNewDashboardDirty(stateNameChanged)).toBeTruthy(); + + const stateColorChanged = { + gnresource: { + type: ResourceTypes.DASHBOARD + }, + widgets: { + containers: { + floating: { + widgets: [], + layouts: [{ id: 'layout-1', name: 'Main view', color: '#ff0000' }] + } + } + } + }; + expect(isNewDashboardDirty(stateColorChanged)).toBeTruthy(); + }); + + it('test isNewGeoStoryDirty returns false for default geostory', () => { + const defaultConfig = { + sections: [{ title: 'Default Title', contents: [{ html: '' }] }], + settings: {} + }; + const state = { + gnresource: { + type: ResourceTypes.GEOSTORY + }, + geostory: { + currentStory: { + ...defaultConfig, + defaultGeoStoryConfig: defaultConfig, + resources: [] + } + } + }; + expect(isNewGeoStoryDirty(state)).toBeFalsy(); + }); + + it('test isNewGeoStoryDirty returns true when geostory has multiple sections', () => { + const defaultConfig = { + sections: [{ title: 'Default Title', contents: [{ html: '' }] }], + settings: {} + }; + const state = { + gnresource: { + type: ResourceTypes.GEOSTORY + }, + geostory: { + currentStory: { + sections: [ + { title: 'Section 1', contents: [{ html: '' }] }, + { title: 'Section 2', contents: [{ html: '' }] } + ], + defaultGeoStoryConfig: defaultConfig, + resources: [], + settings: {} + } + } + }; + expect(isNewGeoStoryDirty(state)).toBeTruthy(); + }); + + it('test isNewGeoStoryDirty returns true when geostory has resources', () => { + const defaultConfig = { + sections: [{ title: 'Default Title', contents: [{ html: '' }] }], + settings: {} + }; + const state = { + gnresource: { + type: ResourceTypes.GEOSTORY + }, + geostory: { + currentStory: { + sections: [{ title: 'Default Title', contents: [{ html: '' }] }], + defaultGeoStoryConfig: defaultConfig, + resources: [{ id: 1, type: 'map' }], + settings: {} + } + } + }; + expect(isNewGeoStoryDirty(state)).toBeTruthy(); + }); + + it('test isNewGeoStoryDirty returns true when title section has content', () => { + const defaultConfig = { + sections: [{ title: 'Default Title', contents: [{ html: '' }] }], + settings: {} + }; + const state = { + gnresource: { + type: ResourceTypes.GEOSTORY + }, + geostory: { + currentStory: { + sections: [{ title: 'Default Title', contents: [{ html: 'Some content here' }] }], + defaultGeoStoryConfig: defaultConfig, + resources: [], + settings: {} + } + } + }; + expect(isNewGeoStoryDirty(state)).toBeTruthy(); + }); + + it('test isNewGeoStoryDirty returns false when currentData is null', () => { + const state = { + gnresource: { + type: ResourceTypes.GEOSTORY + }, + geostory: { + currentStory: null + } + }; + expect(isNewGeoStoryDirty(state)).toBeFalsy(); + }); }); diff --git a/geonode_mapstore_client/client/js/selectors/resource.js b/geonode_mapstore_client/client/js/selectors/resource.js index 6ac50c1c6d..c4cc4bdd77 100644 --- a/geonode_mapstore_client/client/js/selectors/resource.js +++ b/geonode_mapstore_client/client/js/selectors/resource.js @@ -337,16 +337,69 @@ function isResourceDataEqual(state, initialData = {}, currentData = {}) { } } -export const isNewMapViewerResource = (state) => { - const isNew = state?.gnresource?.params?.pk === "new"; - const isMapViewer = state?.gnresource?.type === ResourceTypes.VIEWER; - return isNew && isMapViewer; +export const isNewResourcePk = (state) => { + return state?.gnresource?.params?.pk === "new"; }; -export const getResourceDirtyState = (state) => { - if (isNewMapViewerResource(state)) { +export const isNewMapDirty = (state) => { + const mapConfigRawData = state?.mapConfigRawData; + if (!mapConfigRawData) { + return false; + } + const currentMapData = mapSaveSelector(state); + return !compareMapChanges(mapConfigRawData, currentMapData); +}; + +export const isNewDashboardDirty = (state) => { + const currentData = getDataPayload(state, ResourceTypes.DASHBOARD); + const widgets = currentData?.widgets || []; + const layouts = currentData?.layouts || []; + return widgets.length > 0 || + layouts.length > 1 || + (layouts.length === 1 && + (layouts[0].name !== "Main view" || layouts[0].color !== null) // Default layout name is "Main view" + ); +}; + +export const isNewGeoStoryDirty = (state) => { + const currentData = getDataPayload(state, ResourceTypes.GEOSTORY); + if (!currentData) return false; + + const defaultConfig = currentStorySelector(state)?.defaultGeoStoryConfig ?? {}; + return ( + currentData.sections?.length > 1 || // More than the default title section + currentData.sections?.[0]?.contents?.[0]?.html?.trim() || // Title section has content + currentData.sections?.[0]?.title !== defaultConfig.sections?.[0]?.title || // Title changed from default + currentData.resources?.length > 0 || // Has resources + !isEqual( // Settings changed from default + omitBy(currentData.settings || {}, isNil), + omitBy(defaultConfig.settings || {}, isNil) + ) + ); +}; + +const isNewResourceDirty = (state) => { + const resourceType = state?.gnresource?.type; + + switch (resourceType) { + case ResourceTypes.MAP: + return isNewMapDirty(state); + case ResourceTypes.VIEWER: return true; + case ResourceTypes.DASHBOARD: + return isNewDashboardDirty(state); + case ResourceTypes.GEOSTORY: + return isNewGeoStoryDirty(state); + default: + return false; } +}; + +export const getResourceDirtyState = (state) => { + if (isNewResourcePk(state)) { + return isNewResourceDirty(state); + } + const canEdit = canEditPermissions(state); const isDeleting = getCurrentResourceDeleteLoading(state); const isCopying = getCurrentResourceCopyLoading(state);