diff --git a/pom.xml b/pom.xml
index 239bdb945d..2f7a27a96b 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,7 +11,7 @@
DEV
9.0.90
8080
- 2.2-SNAPSHOT
+ 2.3-SNAPSHOT
2.3.1
1.6-SNAPSHOT
5.3.18
diff --git a/project/standard/templates/configs/pluginsConfig.json b/project/standard/templates/configs/pluginsConfig.json
index bdefe15b4b..49c05cc823 100644
--- a/project/standard/templates/configs/pluginsConfig.json
+++ b/project/standard/templates/configs/pluginsConfig.json
@@ -511,7 +511,7 @@
]
},
{
- "name": "DeleteMap",
+ "name": "DeleteResource",
"glyph": "trash",
"title": "plugins.DeleteMap.title",
"hidden": false,
diff --git a/web/client/components/I18N/LangBar.jsx b/web/client/components/I18N/LangBar.jsx
index 30c3f6769d..ec1c67f3b0 100644
--- a/web/client/components/I18N/LangBar.jsx
+++ b/web/client/components/I18N/LangBar.jsx
@@ -40,7 +40,9 @@ class LangBar extends React.Component {
className={this.props.className}>
{ },
onSetStep: () => { },
onShowTutorial: () => { },
@@ -267,7 +267,7 @@ export default class ContextCreator extends React.Component {
pluginsToUpload: [],
onShowBackToPageConfirmation: () => { },
showBackToPageConfirmation: false,
- backToPageDestRoute: '/context-manager',
+ backToPageDestRoute: '/',
backToPageConfirmationMessage: 'contextCreator.undo',
tutorials: CONTEXT_TUTORIALS,
tutorialsList: false,
diff --git a/web/client/components/contextcreator/__tests__/ContextCreator-test.jsx b/web/client/components/contextcreator/__tests__/ContextCreator-test.jsx
index d97a4c86a5..1f7eb58a1a 100644
--- a/web/client/components/contextcreator/__tests__/ContextCreator-test.jsx
+++ b/web/client/components/contextcreator/__tests__/ContextCreator-test.jsx
@@ -69,7 +69,7 @@ describe('ContextCreator component', () => {
expect(saveBtn.childNodes[0].innerHTML).toBe('save');
ReactTestUtils.Simulate.click(saveBtn); // <-- trigger event callback
// check destination path
- expect(spyonSave).toHaveBeenCalledWith("/context-manager");
+ expect(spyonSave).toHaveBeenCalledWith("/");
});
it('custom destination', () => {
const eng = {
diff --git a/web/client/components/home/Home.jsx b/web/client/components/home/Home.jsx
index 8e545b9518..f3e43ef044 100644
--- a/web/client/components/home/Home.jsx
+++ b/web/client/components/home/Home.jsx
@@ -13,7 +13,6 @@ import PropTypes from 'prop-types';
import { Glyphicon, Tooltip } from 'react-bootstrap';
import OverlayTrigger from '../misc/OverlayTrigger';
import Message from '../../components/I18N/Message';
-import ConfirmModal from '../../components/misc/ResizableModal';
import { pick } from "lodash";
import { goToHomePage } from '../../actions/router';
@@ -21,9 +20,6 @@ class Home extends React.Component {
static propTypes = {
icon: PropTypes.string,
onCheckMapChanges: PropTypes.func,
- onCloseUnsavedDialog: PropTypes.func,
- displayUnsavedDialog: PropTypes.bool,
- renderUnsavedMapChangesDialog: PropTypes.bool,
tooltipPosition: PropTypes.string,
bsStyle: PropTypes.string,
hidden: PropTypes.bool
@@ -36,9 +32,6 @@ class Home extends React.Component {
static defaultProps = {
icon: "home",
- onCheckMapChanges: () => {},
- onCloseUnsavedDialog: () => {},
- renderUnsavedMapChangesDialog: true,
tooltipPosition: 'left',
bsStyle: 'primary',
hidden: false
@@ -48,47 +41,21 @@ class Home extends React.Component {
const { tooltipPosition, hidden, ...restProps} = this.props;
let tooltip = { } ;
return hidden ? false : (
-
-
-
-
- }
- buttons={[{
- bsStyle: "primary",
- text: ,
- onClick: this.goHome
- }, {
- text: ,
- onClick: this.props.onCloseUnsavedDialog
- }]}
- fitContent
- >
-
-
-
-
-
+
+
+
);
}
checkUnsavedChanges = () => {
- if (this.props.renderUnsavedMapChangesDialog) {
- this.props.onCheckMapChanges(this.goHome);
- } else {
- this.props.onCloseUnsavedDialog();
- this.goHome();
- }
+ this.goHome();
}
goHome = () => {
diff --git a/web/client/components/import/dragZone/DragZone.jsx b/web/client/components/import/dragZone/DragZone.jsx
index 7f4c2d2e07..601a23604c 100644
--- a/web/client/components/import/dragZone/DragZone.jsx
+++ b/web/client/components/import/dragZone/DragZone.jsx
@@ -26,7 +26,7 @@ export default ({
disableClick
ref={onRef}
id="DRAGDROP_IMPORT_ZONE"
- style={{ position: "relative", height: '100%', ...style }}
+ style={{ position: "absolute", top: 0, left: 0, height: '100%', ...style }}
accept={accept}
onDrop={onDrop}
onDragEnter={onDragEnter}
@@ -40,7 +40,7 @@ export default ({
left: 0,
background: 'rgba(0,0,0,0.75)',
color: '#fff',
- zIndex: 2000,
+ zIndex: 4000,
display: 'flex',
textAlign: 'center'
}}>
diff --git a/web/client/components/layout/BorderLayout.jsx b/web/client/components/layout/BorderLayout.jsx
index 91ab672dce..84464ea920 100644
--- a/web/client/components/layout/BorderLayout.jsx
+++ b/web/client/components/layout/BorderLayout.jsx
@@ -34,9 +34,9 @@ export default ({id, children, header, footer, columns, height, style = {}, clas
flex: 1,
overflowY: "auto"
}}>
-
+
{height ?
{children}
: children}
-
+
{height ? {columns}
: columns}
{footer}
diff --git a/web/client/components/misc/enhancers/tooltip.jsx b/web/client/components/misc/enhancers/tooltip.jsx
index 3a6403c194..e3b1156252 100644
--- a/web/client/components/misc/enhancers/tooltip.jsx
+++ b/web/client/components/misc/enhancers/tooltip.jsx
@@ -23,6 +23,7 @@ import { omit } from 'lodash';
* @prop {string} [tooltipId] if present will show a localized tooltip using the tooltipId as msgId
* @prop {string} [tooltipPosition="top"]
* @prop {string} tooltipTrigger see react overlay trigger
+ * @prop {object} tooltipParams parameter to pass to the tooltip message id
* @example
* render() {
* const Cmp = tooltip((props) => ); // or simply tooltip(El);
@@ -32,12 +33,12 @@ import { omit } from 'lodash';
*/
export default branch(
({tooltip, tooltipId} = {}) => tooltip || tooltipId,
- (Wrapped) => ({tooltip, tooltipId, tooltipPosition = "top", tooltipTrigger, keyProp, idDropDown, args, ...props} = {}) => ( ({tooltip, tooltipId, tooltipPosition = "top", tooltipTrigger, keyProp, idDropDown, tooltipParams, args, ...props} = {}) => ({tooltipId ? : tooltip}}> ),
+ overlay={{tooltipId ? : tooltip} }> ),
// avoid to pass non needed props
- (Wrapped) => (props) => {props.children}
+ (Wrapped) => (props) => {props.children}
);
diff --git a/web/client/components/share/SharePanel.jsx b/web/client/components/share/SharePanel.jsx
index 962faefefa..91f2c48e21 100644
--- a/web/client/components/share/SharePanel.jsx
+++ b/web/client/components/share/SharePanel.jsx
@@ -98,7 +98,8 @@ class SharePanel extends React.Component {
addMarker: PropTypes.func,
viewerOptions: PropTypes.object,
mapType: PropTypes.string,
- updateMapView: PropTypes.func
+ updateMapView: PropTypes.func,
+ onClearShareResource: PropTypes.func
};
static defaultProps = {
@@ -118,7 +119,8 @@ class SharePanel extends React.Component {
isScrollPosition: false,
hideMarker: () => {},
addMarker: () => {},
- updateMapView: () => {}
+ updateMapView: () => {},
+ onClearShareResource: () => {}
};
static contextTypes = {
@@ -182,6 +184,9 @@ class SharePanel extends React.Component {
});
}
}
+ componentWillUnmount() {
+ this.props.onClearShareResource();
+ }
initializeDefaults = (props) => {
const coordinate = this.getCoordinates(props);
diff --git a/web/client/configs/localConfig.json b/web/client/configs/localConfig.json
index 7703f683e3..3e65db8510 100644
--- a/web/client/configs/localConfig.json
+++ b/web/client/configs/localConfig.json
@@ -62,7 +62,9 @@
{"name": "geostorymode", "path": "geostory.mode"},
{"name": "featuregridmode", "path": "featuregrid.mode"},
{"name": "userrole", "path": "security.user.role"},
- {"name": "printEnabled", "path": "print.capabilities"}
+ {"name": "printEnabled", "path": "print.capabilities"},
+ {"name": "resourceCanEdit", "path": "resources.initialSelectedResource.canEdit"},
+ {"name": "resourceDetails", "path": "resources.initialSelectedResource.attributes.details"}
],
"userSessions": {
"enabled": true
@@ -373,6 +375,12 @@
{ "name": "WidgetsTray" }
],
"desktop": ["Details",
+ {
+ "name": "BrandNavbar",
+ "cfg": {
+ "containerPosition": "header"
+ }
+ },
{
"name": "Map",
"cfg": {
@@ -562,7 +570,12 @@
"declineUrl" : "http://www.google.com"
}
},
- "OmniBar", "Login", "Save", "SaveAs", "Expander", "Undo", "Redo", "FullScreen", "GlobeViewSwitcher", "SearchServicesConfig", "SearchByBookmark", "WidgetsBuilder", {
+ "OmniBar", "Login",
+ { "name": "ResourceDetails", "cfg": { "resourceType": "MAP" }},
+ { "name": "Save", "cfg": { "resourceType": "MAP" }},
+ { "name": "SaveAs", "cfg": { "resourceType": "MAP" }},
+ { "name": "DeleteResource", "cfg": { "resourceType": "MAP", "redirectTo": "/" }},
+ "Expander", "Undo", "Redo", "FullScreen", "GlobeViewSwitcher", "SearchServicesConfig", "SearchByBookmark", "WidgetsBuilder", {
"name": "Widgets"
},
"WidgetsTray",
@@ -575,7 +588,6 @@
"Playback",
"FeedbackMask",
"StyleEditor",
- "DeleteMap",
"SidebarMenu",
{ "name": "MapViews" }
],
@@ -696,48 +708,139 @@
"FeedbackMask"
],
"common": [{
- "name": "OmniBar",
- "cfg": {
- "className": "navbar shadow navbar-home"
- }
- }, {
- "name": "ManagerMenu",
+ "name": "BrandNavbar",
+ "cfg": {
+ "rightMenuItems": [{
+ "type": "link",
+ "href": "https://docs.mapstore.geosolutionsgroup.com/",
+ "target": "blank",
+ "glyph": "book",
+ "labelId": "Documentation",
+ "variant": "default"
+ }, {
+ "type": "link",
+ "href": "https://github.com/geosolutions-it/MapStore2",
+ "target": "blank",
+ "label": "GitHub",
+ "glyph": "github",
+ "variant": "default"
+ }]
+ }
+ },
+ { "name": "ManagerMenu" },
+ "Login","Language", "ScrollTop", "Notifications"],
+ "maps": [
+ { "name": "HomeDescription"},
+ {
+ "name": "ResourcesGrid",
"cfg": {
- "enableContextManager": true
+ "id": "featured",
+ "titleId": "manager.featuredMaps",
+ "pageSize": 4,
+ "cardLayoutStyle": "grid",
+ "order": null,
+ "hideWithNoResults": true,
+ "defaultQuery": {
+ "f": "featured"
+ }
}
- }, "Login","Language", "NavMenu", "Attribution", "ScrollTop", "Notifications"],
- "maps": ["HomeDescription", "Fork", "MapSearch", "CreateNewMap", "FeaturedMaps", "ContentTabs",
-
+ },
{
- "name": "Maps",
+ "name": "ResourcesGrid",
"cfg": {
- "mapsOptions": {
- "start": 0,
- "limit": 12
- },
- "fluid": true
+ "id": "catalog",
+ "titleId": "resources.contents.title",
+ "queryPage": true,
+ "menuItems": [
+ {
+ "labelId": "resourcesCatalog.addResource",
+ "disableIf": "{!state('userrole')}",
+ "type": "dropdown",
+ "variant": "primary",
+ "size": "sm",
+ "responsive": true,
+ "noCaret": true,
+ "items": [
+ {
+ "labelId": "resourcesCatalog.createMap",
+ "type": "link",
+ "href": "#/viewer/new"
+ },
+ {
+ "labelId": "resourcesCatalog.createDashboard",
+ "type": "link",
+ "href": "#/dashboard/"
+ },
+ {
+ "labelId": "resourcesCatalog.createGeoStory",
+ "type": "link",
+ "href": "#/geostory/newgeostory/"
+ },
+ {
+ "labelId": "resourcesCatalog.createContext",
+ "type": "link",
+ "href": "#/context-creator/new",
+ "disableIf": "{state('userrole') !== 'ADMIN'}"
+ }
+ ]
+ }
+ ]
}
- }, {
- "name": "Dashboards",
+ },
+ {
+ "name": "ResourcesFiltersForm",
"cfg": {
- "mapsOptions": {
- "start": 0,
- "limit": 12
- },
- "fluid": true
+ "resourcesGridId": "catalog"
}
},
{
- "name": "GeoStories",
+ "name": "EditContext"
+ },
+ {
+ "name": "DeleteResource"
+ },
+ {
+ "name": "ResourceDetails"
+ },
+ {
+ "name": "Share",
"cfg": {
- "mapsOptions": {
- "start": 0,
- "limit": 12
+ "draggable": false,
+ "advancedSettings": false,
+ "showAPI": false,
+ "embedOptions": {
+ "showTOCToggle": false
+ },
+ "map": {
+ "embedOptions": {
+ "showTOCToggle": true
+ }
+ },
+ "geostory": {
+ "embedOptions": {
+ "showTOCToggle": false,
+ "allowFullScreen":false
+ },
+ "shareUrlRegex": "(h[^#]*)#\\/geostory\\/([^\\/]*)\\/([A-Za-z0-9]*)",
+ "shareUrlReplaceString": "$1geostory-embedded.html#/$3",
+ "advancedSettings": {
+ "hideInTab": "embed",
+ "homeButton": true,
+ "sectionId": true
+ }
},
- "fluid": true
+ "dashboard": {
+ "shareUrlRegex": "(h[^#]*)#\\/dashboard\\/([A-Za-z0-9]*)",
+ "shareUrlReplaceString": "$1dashboard-embedded.html#/$2",
+ "embedOptions": {
+ "showTOCToggle": false,
+ "showConnectionsParamToggle": true
+ }
+ }
}
- }
- , "Footer", {
+ },
+ { "name": "Footer"},
+ {
"name": "Cookie",
"cfg": {
"externalCookieUrl" : "",
@@ -749,6 +852,9 @@
"FeedbackMask"
],
"dashboard": [
+ { "name": "ResourceDetails", "cfg": { "resourceType": "DASHBOARD" } },
+ { "name": "Save", "cfg": { "resourceType": "DASHBOARD" }},
+ { "name": "SaveAs", "cfg": { "resourceType": "DASHBOARD" }},
"Details",
"AddWidgetDashboard",
"MapConnectionDashboard",
@@ -769,10 +875,6 @@
}
},
"Language",
- "NavMenu",
- "DashboardSave",
- "DashboardSaveAs",
- "Attribution",
{
"name": "Home",
"override": {
@@ -789,13 +891,13 @@
}
}
},
- { "name": "DeleteDashboard" },
+ { "name": "DeleteResource", "cfg": { "resourceType": "DASHBOARD", "redirectTo": "/" }},
{ "name": "DashboardExport" },
{ "name": "DashboardImport" },
- { "name": "OmniBar",
+ {
+ "name": "BrandNavbar",
"cfg": {
- "containerPosition": "header",
- "className": "navbar shadow navbar-home"
+ "containerPosition": "header"
}
},
{ "name": "Share",
@@ -900,11 +1002,13 @@
{ "name": "FeedbackMask" }
],
"geostory": [
+ { "name": "ResourceDetails", "cfg": { "resourceType": "GEOSTORY" } },
+ { "name": "Save", "cfg": { "resourceType": "GEOSTORY" }},
+ { "name": "SaveAs", "cfg": { "resourceType": "GEOSTORY" }},
{
- "name": "OmniBar",
+ "name": "BrandNavbar",
"cfg": {
- "containerPosition": "header",
- "className": "navbar shadow navbar-home"
+ "containerPosition": "header"
}
},
{
@@ -924,17 +1028,13 @@
}
},
"Language",
- "NavMenu",
- "Attribution",
"Home",
{
"name": "GeoStory"
},
- { "name": "DeleteGeoStory" },
+ { "name": "DeleteResource", "cfg": { "resourceType": "GEOSTORY", "redirectTo": "/" }},
{ "name": "GeoStoryExport" },
{ "name": "GeoStoryImport" },
- "GeoStorySave",
- "GeoStorySaveAs",
"MapEditor",
"MediaEditor",
{
@@ -979,23 +1079,20 @@
],
"context-creator": [
{
- "name": "OmniBar",
+ "name": "BrandNavbar",
"cfg": {
- "containerPosition": "header",
- "className": "navbar shadow navbar-home"
+ "containerPosition": "header"
}
},
"Redirect",
"Login",
"Language",
- "NavMenu",
- "Attribution",
"Tutorial",
{
"name": "ContextCreator",
"cfg": {
"documentationBaseURL": "https://mapstore.geosolutionsgroup.com/mapstore/docs/api/plugins",
- "backToPageDestRoute": "/context-manager",
+ "backToPageDestRoute": "/",
"backToPageConfirmationMessage": "contextCreator.undo"
}
},
diff --git a/web/client/configs/pluginsConfig.json b/web/client/configs/pluginsConfig.json
index 8198bef388..ca14e7bce3 100644
--- a/web/client/configs/pluginsConfig.json
+++ b/web/client/configs/pluginsConfig.json
@@ -514,7 +514,7 @@
]
},
{
- "name": "DeleteMap",
+ "name": "DeleteResource",
"glyph": "trash",
"title": "plugins.DeleteMap.title",
"hidden": false,
diff --git a/web/client/containers/MapViewer.jsx b/web/client/containers/MapViewer.jsx
index ce5317defb..fe8e816f93 100644
--- a/web/client/containers/MapViewer.jsx
+++ b/web/client/containers/MapViewer.jsx
@@ -17,6 +17,7 @@ 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';
const PluginsContainer = connect(
createShallowSelectorCreator(isEqual)(
@@ -43,7 +44,8 @@ class MapViewer extends React.Component {
loadMapConfig: PropTypes.func,
plugins: PropTypes.object,
loaderComponent: PropTypes.func,
- onLoaded: PropTypes.func
+ onLoaded: PropTypes.func,
+ component: PropTypes.any
};
static defaultProps = {
@@ -64,6 +66,7 @@ class MapViewer extends React.Component {
params={this.props.params}
loaderComponent={this.props.loaderComponent}
onLoaded={this.props.onLoaded}
+ component={this.props.component || BorderLayout}
/>);
}
}
diff --git a/web/client/epics/__tests__/geostory-test.js b/web/client/epics/__tests__/geostory-test.js
index 4108717d7b..e5c1cab285 100644
--- a/web/client/epics/__tests__/geostory-test.js
+++ b/web/client/epics/__tests__/geostory-test.js
@@ -250,40 +250,44 @@ describe('Geostory Epics', () => {
mockAxios.onGet().reply(200, TEST_STORY);
testEpic(loadGeostoryEpic, NUM_ACTIONS, loadGeostory("sampleStory"), (actions) => {
expect(actions.length).toBe(NUM_ACTIONS);
- actions.map((a, i) => {
- switch (a.type) {
- case LOADING_GEOSTORY:
- expect(a.name).toBe("loading");
- expect(a.value).toBe(i === 1);
- break;
- case SET_CURRENT_STORY:
- if (a.story.sections) {
- a.story.sections[0].id = get(TEST_STORY, 'sections[0].id');
- a.story.sections[1].id = get(TEST_STORY, 'sections[1].id');
- a.story.sections[2].id = get(TEST_STORY, 'sections[2].id');
- a.story.sections[3].id = get(TEST_STORY, 'sections[3].id');
- a.story.sections[4].id = get(TEST_STORY, 'sections[4].id');
- expect(a.story).toEqual(TEST_STORY);
- } else {
- expect(a.story).toEqual({});
+ try {
+ actions.map((a, i) => {
+ switch (a.type) {
+ case LOADING_GEOSTORY:
+ expect(a.name).toBe("loading");
+ expect(a.value).toBe(i === 1);
+ break;
+ case SET_CURRENT_STORY:
+ if (a.story.sections) {
+ a.story.sections[0].id = get(TEST_STORY, 'sections[0].id');
+ a.story.sections[1].id = get(TEST_STORY, 'sections[1].id');
+ a.story.sections[2].id = get(TEST_STORY, 'sections[2].id');
+ a.story.sections[3].id = get(TEST_STORY, 'sections[3].id');
+ a.story.sections[4].id = get(TEST_STORY, 'sections[4].id');
+ expect(a.story).toEqual(TEST_STORY);
+ } else {
+ expect(a.story).toEqual({});
+ }
+ break;
+ case SET_RESOURCE: {
+ expect(a.resource).toExist();
+ break;
}
- break;
- case SET_RESOURCE: {
- expect(a.resource).toExist();
- break;
- }
- case CHANGE_MODE: {
- expect(a.mode).toBe(Modes.EDIT);
- break;
- }
- case GEOSTORY_LOADED: {
- expect(a.id).toExist();
- break;
- }
- default: expect(true).toBe(false);
- break;
- }
- });
+ case CHANGE_MODE: {
+ expect(a.mode).toBe(Modes.EDIT);
+ break;
+ }
+ case GEOSTORY_LOADED: {
+ expect(a.id).toExist();
+ break;
+ }
+ default: expect(true).toBe(false);
+ break;
+ }
+ });
+ } catch (e) {
+ done(e);
+ }
done();
}, {
geostory: {},
diff --git a/web/client/epics/feedbackMask.js b/web/client/epics/feedbackMask.js
index 3b9a356be3..6f47242046 100644
--- a/web/client/epics/feedbackMask.js
+++ b/web/client/epics/feedbackMask.js
@@ -106,6 +106,7 @@ export const updateDashboardVisibility = action$ =>
return Rx.Observable.merge(
updateObservable,
action$.ofType(LOGIN_SUCCESS, LOGOUT, LOCATION_CHANGE)
+ .filter(action => !(action.type === LOCATION_CHANGE && action?.payload?.action === 'REPLACE')) // action REPLACE is used to manage pending changes
.switchMap(() => updateObservable)
.takeUntil(action$.ofType(DETECTED_NEW_PAGE))
);
@@ -125,6 +126,7 @@ export const updateGeoStoryFeedbackMaskVisibility = action$ =>
return Rx.Observable.merge(
updateObservable,
action$.ofType(LOGIN_SUCCESS, LOGOUT, LOCATION_CHANGE)
+ .filter(action => !(action.type === LOCATION_CHANGE && action?.payload?.action === 'REPLACE')) // action REPLACE is used to manage pending changes
.switchMap(() => updateObservable)
.takeUntil(action$.ofType(DETECTED_NEW_PAGE))
);
@@ -146,6 +148,7 @@ export const updateContextFeedbackMaskVisibility = action$ =>
return Rx.Observable.merge(
updateObservable,
action$.ofType(LOGIN_SUCCESS, LOGOUT, LOCATION_CHANGE)
+ .filter(action => !(action.type === LOCATION_CHANGE && action?.payload?.action === 'REPLACE')) // action REPLACE is used to manage pending changes
.switchMap(() => updateObservable)
.takeUntil(action$.ofType(DETECTED_NEW_PAGE))
);
diff --git a/web/client/epics/geostory.js b/web/client/epics/geostory.js
index 8c9a7005c8..078a8b814b 100644
--- a/web/client/epics/geostory.js
+++ b/web/client/epics/geostory.js
@@ -333,7 +333,7 @@ export const loadGeostoryEpic = (action$, {getState = () => {}}) => action$
...data,
sections: sectionsWithId
} : data;
- return ({ data: newData, isStatic: true, canEdit: true });
+ return ({ data: newData, isStatic: true, canCopy: true });
});
}
return getResource(id);
@@ -360,7 +360,7 @@ export const loadGeostoryEpic = (action$, {getState = () => {}}) => action$
// initialize editing only for new or static sources
// or verify if user can edit when current mode is equal to EDIT
...(isStatic || isEditMode
- ? [ setEditing((resource && resource.canEdit || isAdmin)) ]
+ ? [ setEditing((resource && (resource.canEdit || resource.canCopy) || isAdmin)) ]
: []),
geostoryLoaded(id),
setCurrentStory(story),
@@ -550,6 +550,7 @@ export const handlePendingGeoStoryChanges = action$ =>
action$.ofType(
SAVED, LOCATION_CHANGE, LOGOUT
)
+ .filter(action => !(action.type === LOCATION_CHANGE && action?.payload?.action === 'REPLACE')) // action REPLACE is used to manage pending changes
.take(1)
.switchMap(() => Observable.of(setPendingChanges(false)))
)
diff --git a/web/client/epics/widgets.js b/web/client/epics/widgets.js
index 5e3b98f308..685da1810c 100644
--- a/web/client/epics/widgets.js
+++ b/web/client/epics/widgets.js
@@ -105,7 +105,7 @@ const getValidLocationChange = action$ =>
action$.ofType(SAVING_MAP, MAP_CREATED, MAP_ERROR)
.startWith({type: MAP_CONFIG_LOADED}) // just dummy action to trigger the first switchMap
.switchMap(action => action.type === SAVING_MAP ? Rx.Observable.never() : action$)
- .filter(({type} = {}) => type === LOCATION_CHANGE);
+ .filter(({type, payload} = {}) => type === LOCATION_CHANGE && payload.action !== 'REPLACE'); // action REPLACE is used to manage pending changes
/**
* Action flow to add/Removes dependencies for a widgets.
* Trigger `mapSync` property of a widget and sets `dependenciesMap` object to map `dependency` prop onto widget props.
diff --git a/web/client/hooks/__tests__/usePluginItems-test.js b/web/client/hooks/__tests__/usePluginItems-test.js
index cc6dc856fd..e5f4708a1f 100644
--- a/web/client/hooks/__tests__/usePluginItems-test.js
+++ b/web/client/hooks/__tests__/usePluginItems-test.js
@@ -33,17 +33,19 @@ describe('usePluginItems', () => {
it('should reload the list of confiugred items if they change', () => {
const plugin01 = {
name: 'Plugin01',
- Component: () => null
+ Component: () => null,
+ position: 1
};
const plugin02 = {
name: 'Plugin02',
- Component: () => null
+ Component: () => null,
+ position: 2
};
act(() => {
ReactDOM.render( , document.getElementById('container'));
});
- expect(document.querySelector('#component').innerText).toBe('Plugin02,Plugin01');
+ expect(document.querySelector('#component').innerText).toBe('Plugin01,Plugin02');
act(() => {
ReactDOM.render(, document.getElementById('container'));
diff --git a/web/client/hooks/usePluginItems.js b/web/client/hooks/usePluginItems.js
index 8cf3ccbdf0..92b9dd4a78 100644
--- a/web/client/hooks/usePluginItems.js
+++ b/web/client/hooks/usePluginItems.js
@@ -35,7 +35,7 @@ const usePluginItems = ({
}, dependencies = []) => {
function configurePluginItems(props) {
return [...props.items]
- .sort((a, b) => a.position > b.position ? 1 : -1)
+ .sort((a, b) => a.position - b.position)
.map(plg => ({
...plg,
Component: plg.Component
diff --git a/web/client/observables/geostore.js b/web/client/observables/geostore.js
index 0ce919cf13..1714cfc563 100644
--- a/web/client/observables/geostore.js
+++ b/web/client/observables/geostore.js
@@ -167,8 +167,8 @@ export const getResource = (id, { includeAttributes = true, withData = true, wit
// when includeAttributes is false we should return an empty array
// to keep the order of response in the .map argument
: new Promise(resolve => resolve([]))),
- ...(withData ? [Observable.defer(() =>API.getData(id, { baseURL }))] : []),
- ...(withPermissions ? [Observable.defer( () => API.getResourcePermissions(id, {}, true))] : [])
+ ...(withData ? [Observable.defer(() =>API.getData(id, { baseURL }))] : [Promise.resolve(undefined)]),
+ ...(withPermissions ? [Observable.defer( () => API.getResourcePermissions(id, {}, true))] : [Promise.resolve(undefined)])
]).map(([resource, attributes, data, permissions]) => ({
...resource,
attributes: (attributes || []).reduce((acc, curr) => ({
diff --git a/web/client/plugins/BurgerMenu.jsx b/web/client/plugins/BurgerMenu.jsx
index 2be17c5e4a..bfad383010 100644
--- a/web/client/plugins/BurgerMenu.jsx
+++ b/web/client/plugins/BurgerMenu.jsx
@@ -45,6 +45,24 @@ const AnchorElement = ({children, href, target, onClick}) => (
{children}
);
+const BurgerMenuMenuItem = ({
+ active,
+ onClick,
+ glyph,
+ labelId,
+ className
+}) => {
+ return (
+ onClick(!active)}
+ >
+
+
+ );
+};
+
class BurgerMenu extends React.Component {
static propTypes = {
id: PropTypes.string,
@@ -56,7 +74,8 @@ class BurgerMenu extends React.Component {
onDetach: PropTypes.func,
controls: PropTypes.object,
panelStyle: PropTypes.object,
- panelClassName: PropTypes.string
+ panelClassName: PropTypes.string,
+ className: PropTypes.string
};
static contextTypes = {
@@ -66,6 +85,7 @@ class BurgerMenu extends React.Component {
static defaultProps = {
id: "mapstore-burger-menu",
+ className: 'square-button',
items: [],
onItemClick: () => {},
title: ,
@@ -157,7 +177,7 @@ class BurgerMenu extends React.Component {
render() {
return (
- );
}
}
+const BurgerMenuPlugin = connect((state) =>({
+ controls: state.controls,
+ active: burgerMenuSelector(state)
+}), {
+ onInit: setControlProperty.bind(null, 'burgermenu', 'enabled', true),
+ onDetach: setControlProperty.bind(null, 'burgermenu', 'enabled', false)
+})(BurgerMenu);
+
/**
* Menu button that can contain other plugins entries.
* Usually rendered inside {@link #plugins.OmniBar|plugins.OmniBar}
@@ -195,19 +224,19 @@ class BurgerMenu extends React.Component {
export default createPlugin(
'BurgerMenu',
{
- component: connect((state) =>({
- controls: state.controls,
- active: burgerMenuSelector(state)
- }), {
- onInit: setControlProperty.bind(null, 'burgermenu', 'enabled', true),
- onDetach: setControlProperty.bind(null, 'burgermenu', 'enabled', false)
- })(BurgerMenu),
+ component: BurgerMenuPlugin,
containers: {
OmniBar: {
name: "burgermenu",
position: 2,
tool: true,
priority: 1
+ },
+ BrandNavbar: {
+ position: 8,
+ priority: 2,
+ target: 'right-menu',
+ Component: connect(() => ({ id: 'ms-burger-menu', className: 'square-button-md' }))(BurgerMenuPlugin)
}
}
}
diff --git a/web/client/plugins/ContentTabs.jsx b/web/client/plugins/ContentTabs.jsx
index 50bdb1d396..3a95dc0f12 100644
--- a/web/client/plugins/ContentTabs.jsx
+++ b/web/client/plugins/ContentTabs.jsx
@@ -28,6 +28,7 @@ const selectedSelector = createSelector(
const DefaultTitle = ({ item = {}, index }) => { item.title || `Tab ${index}` } ;
/**
+ * @deprecated
* @name ContentTabs
* @memberof plugins
* @class
diff --git a/web/client/plugins/Contexts.jsx b/web/client/plugins/Contexts.jsx
index d60ce0d6ae..36844a9cb6 100644
--- a/web/client/plugins/Contexts.jsx
+++ b/web/client/plugins/Contexts.jsx
@@ -186,6 +186,7 @@ const ContextsPlugin = compose(
/**
* Plugin for context resources browsing.
* Can be rendered inside {@link #plugins.ContentTabs|ContentTabs} plugin
+ * @deprecated
* @name Contexts
* @memberof plugins
* @class
diff --git a/web/client/plugins/DashboardSave.jsx b/web/client/plugins/DashboardSave.jsx
index 22006601a0..3d17fa8f31 100644
--- a/web/client/plugins/DashboardSave.jsx
+++ b/web/client/plugins/DashboardSave.jsx
@@ -45,6 +45,7 @@ const SaveBaseDialog = compose(
/**
* Implements "save" button for dashboards, to render in the {@link #plugins.BurgerMenu|BurgerMenu}}
+ * @deprecated
* @class
* @name DashboardSave
* @memberof plugins
diff --git a/web/client/plugins/Dashboards.jsx b/web/client/plugins/Dashboards.jsx
index 7923dcf16c..0075ea1856 100644
--- a/web/client/plugins/Dashboards.jsx
+++ b/web/client/plugins/Dashboards.jsx
@@ -35,6 +35,7 @@ const dashboardsCountSelector = createSelector(
* Plugin for Dashboards resources browsing.
* Can be rendered inside {@link #plugins.ContentTabs|ContentTabs} plugin
* and adds an entry to the {@link #plugins.NavMenu|NavMenu}
+ * @deprecated
* @name Dashboards
* @memberof plugins
* @class
diff --git a/web/client/plugins/DeleteDashboard.jsx b/web/client/plugins/DeleteDashboard.jsx
index 929d2504ab..cd054a3a51 100644
--- a/web/client/plugins/DeleteDashboard.jsx
+++ b/web/client/plugins/DeleteDashboard.jsx
@@ -19,7 +19,10 @@ import { getDashboardId, dashboardResource } from '../selectors/dashboard';
import { deleteDialogSelector } from '../selectors/dashboards';
import { isLoggedIn } from '../selectors/security';
import Message from '../components/I18N/Message';
-
+/**
+ * @deprecated
+ *
+ */
class DeleteConfirmDialog extends React.Component {
static propTypes = {
diff --git a/web/client/plugins/DeleteGeoStory.jsx b/web/client/plugins/DeleteGeoStory.jsx
index ec57ad6b1c..124e204ba6 100644
--- a/web/client/plugins/DeleteGeoStory.jsx
+++ b/web/client/plugins/DeleteGeoStory.jsx
@@ -19,7 +19,10 @@ import { deleteGeostory } from '../actions/geostories';
import { geostoryIdSelector, deleteDialogSelector, resourceSelector } from '../selectors/geostory';
import { isLoggedIn } from '../selectors/security';
import Message from '../components/I18N/Message';
-
+/**
+ * @deprecated
+ *
+ */
class DeleteConfirmDialog extends React.Component {
static propTypes = {
diff --git a/web/client/plugins/DeleteMap.jsx b/web/client/plugins/DeleteMap.jsx
index 25b832b15b..5c5d1bbf9b 100644
--- a/web/client/plugins/DeleteMap.jsx
+++ b/web/client/plugins/DeleteMap.jsx
@@ -19,7 +19,10 @@ import { toggleControl } from '../actions/controls';
import { mapIdSelector } from '../selectors/mapInitialConfig';
import { showConfirmDeleteMapModalSelector } from '../selectors/controls';
import Message from '../components/I18N/Message';
-
+/**
+ * @deprecated
+ *
+ */
class DeleteConfirmDialog extends React.Component {
static propTypes = {
diff --git a/web/client/plugins/FeaturedMaps.jsx b/web/client/plugins/FeaturedMaps.jsx
index a4ef9e630d..217091cd43 100644
--- a/web/client/plugins/FeaturedMaps.jsx
+++ b/web/client/plugins/FeaturedMaps.jsx
@@ -184,6 +184,7 @@ const updateFeaturedMapsStream = mapPropsStream(props$ =>
/**
* FeaturedMaps plugin. Shows featured resources in a grid.
* Typically used in the {@link #pages.Maps|home page}.
+ * @deprecated
* @name FeaturedMaps
* @prop {string} cfg.pageSize change the page size (only desktop)
* @prop {object} cfg.shareOptions configuration applied to share panel grouped by category name
diff --git a/web/client/plugins/GeoStories.jsx b/web/client/plugins/GeoStories.jsx
index d2feba800d..c96e083dd9 100644
--- a/web/client/plugins/GeoStories.jsx
+++ b/web/client/plugins/GeoStories.jsx
@@ -34,6 +34,7 @@ const geostoriesCountSelector = createSelector(
/**
* Plugin for browsing GeoStory resources. Can render in {@link #plugins.ContentTabs|ContentTabs}
* and adds an entry to the {@link #plugins.NavMenu|NavMenu}
+ * @deprecated
* @name Geostories
* @class
* @memberof plugins
diff --git a/web/client/plugins/GeoStorySave.jsx b/web/client/plugins/GeoStorySave.jsx
index e89cf3efd4..110d2db5bd 100644
--- a/web/client/plugins/GeoStorySave.jsx
+++ b/web/client/plugins/GeoStorySave.jsx
@@ -65,6 +65,7 @@ const SaveBaseDialog = compose(
/**
* Implements "save" button for geostories, to render in the {@link #plugins.BurgerMenu|BurgerMenu}}
+ * @deprecated
* @class
* @name GeoStorySave
* @memberof plugins
diff --git a/web/client/plugins/Home.jsx b/web/client/plugins/Home.jsx
index 9bb9583a52..5277e967e4 100644
--- a/web/client/plugins/Home.jsx
+++ b/web/client/plugins/Home.jsx
@@ -15,29 +15,7 @@ import Message from './locale/Message';
import { Glyphicon } from 'react-bootstrap';
import Home from '../components/home/Home';
import { connect } from 'react-redux';
-import { checkPendingChanges } from '../actions/pendingChanges';
-import { setControlProperty } from '../actions/controls';
-import {burgerMenuSelector, unsavedMapSelector, unsavedMapSourceSelector} from '../selectors/controls';
-import { feedbackMaskSelector } from '../selectors/feedbackmask';
-import ConfigUtils from '../utils/ConfigUtils';
-
-const checkUnsavedMapChanges = (action) => {
- return dispatch => {
- dispatch(checkPendingChanges(action, 'gohome'));
- };
-};
-
-const HomeConnected = connect((state) => ({
- renderUnsavedMapChangesDialog: ConfigUtils.getConfigProp('unsavedMapChangesDialog'),
- displayUnsavedDialog: unsavedMapSelector(state)
- && unsavedMapSourceSelector(state) === 'gohome'
- && (feedbackMaskSelector(state).currentPage === 'viewer'
- || feedbackMaskSelector(state).currentPage === 'geostory'
- || feedbackMaskSelector(state).currentPage === 'dashboard')
-}), {
- onCheckMapChanges: checkUnsavedMapChanges,
- onCloseUnsavedDialog: setControlProperty.bind(null, 'unsavedMap', 'enabled', false)
-})(Home);
+import {burgerMenuSelector} from '../selectors/controls';
/**
* Renders a button that redirects to the home page.
@@ -63,7 +41,7 @@ const HomeConnected = connect((state) => ({
* @memberof plugins
*/
export default {
- HomePlugin: assign(HomeConnected, {
+ HomePlugin: assign(Home, {
Toolbar: {
name: 'home',
position: 1,
@@ -87,7 +65,7 @@ export default {
tool: connect(() => ({
bsStyle: 'primary',
tooltipPosition: 'bottom'
- }))(HomeConnected),
+ }))(Home),
priority: 3
},
SidebarMenu: {
@@ -97,11 +75,16 @@ export default {
bsStyle: 'tray',
tooltipPosition: 'left',
text:
- }))(HomeConnected),
+ }))(Home),
selector: (state) => ({
style: { display: burgerMenuSelector(state) ? 'none' : null }
}),
priority: 4
+ },
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 6,
+ priority: 5
}
}),
reducers: {},
diff --git a/web/client/plugins/Language.jsx b/web/client/plugins/Language.jsx
index f9a23a8221..41e8ea7ec0 100644
--- a/web/client/plugins/Language.jsx
+++ b/web/client/plugins/Language.jsx
@@ -32,6 +32,11 @@ export default {
position: 5,
tool: true,
priority: 1
+ },
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 5,
+ priority: 2
}
}),
reducers: {}
diff --git a/web/client/plugins/Login.jsx b/web/client/plugins/Login.jsx
index aff7e10c2e..07ac035e22 100644
--- a/web/client/plugins/Login.jsx
+++ b/web/client/plugins/Login.jsx
@@ -93,20 +93,37 @@ class LoginTool extends React.Component {
}
}
+const LoginNavComponent = connect((state) => ({
+ renderButtonContent: () => {return ; },
+ bsStyle: 'primary',
+ isAdmin: isAdminUserSelector(state)
+}))(LoginNav);
+
export default createPlugin('Login', {
component: connect((state) => ({isAdmin: isAdminUserSelector(state)}))(LoginTool),
containers: {
OmniBar: {
name: "login",
position: 3,
- tool: connect((state) => ({
- renderButtonContent: () => {return ; },
- bsStyle: 'primary',
- isAdmin: isAdminUserSelector(state)
- }))(LoginNav),
+ tool: LoginNavComponent,
tools: [UserDetails, PasswordReset, Login],
priority: 1
},
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 9,
+ priority: 3,
+ Component: (props) => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+ },
SidebarMenu: {
name: "login",
position: 2,
diff --git a/web/client/plugins/Maps.jsx b/web/client/plugins/Maps.jsx
index 3d99982645..148ce1e013 100644
--- a/web/client/plugins/Maps.jsx
+++ b/web/client/plugins/Maps.jsx
@@ -162,6 +162,7 @@ const MapsPlugin = compose(
* Plugin for maps resources browsing.
* Can be rendered inside {@link #plugins.ContentTabs|ContentTabs} plugin
* and adds an entry to the {@link #plugins.NavMenu|NavMenu}
+ * @deprecated
* @name Maps
* @memberof plugins
* @class
diff --git a/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx b/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx
new file mode 100644
index 0000000000..ddcbbe0270
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024, 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 { createPlugin } from "../../utils/PluginsUtils";
+import FlexBox from './components/FlexBox';
+import Menu from './components/Menu';
+import usePluginItems from '../../hooks/usePluginItems';
+import Button from './components/Button';
+import tooltip from '../../components/misc/enhancers/tooltip';
+import Spinner from './components/Spinner';
+import Icon from './components/Icon';
+const ButtonWithTooltip = tooltip(Button);
+
+function BrandNavbarMenuItem({
+ className,
+ loading,
+ glyph,
+ glyphType = 'glyphicon',
+ labelId,
+ onClick
+}) {
+ return (
+
+
+ {loading ? : }
+
+
+ );
+}
+
+function BrandNavbar({
+ size,
+ variant,
+ leftMenuItems = [],
+ rightMenuItems = [],
+ items
+}, context) {
+ const { loadedPlugins } = context;
+ const configuredItems = usePluginItems({ items, loadedPlugins });
+ const pluginLeftMenuItems = configuredItems.filter(({ target }) => target === 'left-menu').map(item => ({ ...item, type: 'plugin' }));
+ const pluginRightMenuItems = configuredItems.filter(({ target }) => target === 'right-menu').map(item => ({ ...item, type: 'plugin' }));
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
+
+
+export default createPlugin('BrandNavbar', {
+ component: BrandNavbar
+});
diff --git a/web/client/plugins/ResourcesCatalog/DeleteResource.jsx b/web/client/plugins/ResourcesCatalog/DeleteResource.jsx
new file mode 100644
index 0000000000..0bed42538a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/DeleteResource.jsx
@@ -0,0 +1,133 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState } from 'react';
+import { createPlugin } from "../../utils/PluginsUtils";
+import ConfirmDialog from './components/ConfirmDialog';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import Persistence from '../../api/persistence';
+import { searchResources } from './actions/resources';
+import { getPendingChanges } from './selectors/save';
+import { push } from 'connected-react-router';
+import useIsMounted from './hooks/useIsMounted';
+
+function DeleteResource({
+ resource,
+ component,
+ onRefresh,
+ redirectTo,
+ onPush
+}) {
+ const Component = component;
+ const [showModal, setShowModal] = useState(false);
+ const [deleting, setDeleting] = useState(false);
+ const [errorId, setErrorId] = useState(false);
+ const isMounted = useIsMounted();
+
+ function handleCancel() {
+ setShowModal(false);
+ }
+ function handleDelete() {
+ if (!deleting) {
+ setDeleting(true);
+ setErrorId(false);
+ Persistence.getApi()
+ .deleteResource({ id: resource.id }, { deleteLinkedResources: true })
+ .toPromise()
+ .then((response) => response?.toPromise ? response.toPromise() : response)
+ .then(() => isMounted(() => {
+ if (redirectTo) {
+ onPush(redirectTo);
+ } else {
+ onRefresh();
+ setShowModal(false);
+ }
+ }))
+ .catch((error) => isMounted(() => {
+ setErrorId(`resourcesCatalog.deleteError.error${error.status || 'default'}`);
+ }))
+ .finally(() => isMounted(() => {
+ setDeleting(false);
+ }));
+ }
+ }
+ if (!(resource?.id && resource?.canDelete)) {
+ return null;
+ }
+ return (
+ <>
+ {Component ? setShowModal(true)}
+ /> : null}
+
+ >
+ );
+}
+
+const deleteResourcesConnect = connect(
+ createStructuredSelector({
+ resource: (state, props) => {
+ if (props.resource) {
+ return props.resource;
+ }
+ const pendingChanges = getPendingChanges(state, { resourceType: 'MAP', ...props });
+ return pendingChanges?.resource;
+ }
+ }),
+ {
+ onRefresh: searchResources.bind(null, { refresh: true }),
+ onPush: push
+ }
+);
+
+const DeleteResourcePlugin = deleteResourcesConnect(DeleteResource);
+
+export default createPlugin('DeleteResource', {
+ component: () => null,
+ containers: {
+ ResourcesGrid: {
+ target: 'card-options',
+ position: 1,
+ priority: 4,
+ Component: DeleteResourcePlugin
+ },
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 4,
+ priority: 3,
+ Component: DeleteResourcePlugin
+ },
+ SidebarMenu: {
+ position: 300,
+ tool: DeleteResourcePlugin,
+ priority: 1
+ },
+ BurgerMenu: {
+ position: 5,
+ tool: DeleteResourcePlugin,
+ priority: 2
+ }
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/EditContext.jsx b/web/client/plugins/ResourcesCatalog/EditContext.jsx
new file mode 100644
index 0000000000..a38bde50db
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/EditContext.jsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024, 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 { createPlugin } from "../../utils/PluginsUtils";
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { userSelector } from '../../selectors/security';
+
+function EditContext({
+ resource,
+ component
+}) {
+ const Component = component;
+ if (resource?.canEdit && resource?.category?.name === 'CONTEXT') {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ return null;
+}
+
+const editContextSelector = connect(
+ createStructuredSelector({
+ user: userSelector
+ })
+);
+
+export default createPlugin('EditContext', {
+ component: () => null,
+ containers: {
+ ResourcesGrid: {
+ target: 'card-buttons',
+ Component: editContextSelector(EditContext),
+ position: 1
+ }
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/Footer.jsx b/web/client/plugins/ResourcesCatalog/Footer.jsx
new file mode 100644
index 0000000000..c05361b243
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/Footer.jsx
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2024, 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 { createPlugin } from "../../utils/PluginsUtils";
+import HTML from '../../components/I18N/HTML';
+import Text from './components/Text';
+
+function Footer({
+
+}) {
+
+ return (
+
+
+
+
+
+ );
+}
+
+
+export default createPlugin('Footer', {
+ component: Footer
+});
diff --git a/web/client/plugins/ResourcesCatalog/HomeDescription.jsx b/web/client/plugins/ResourcesCatalog/HomeDescription.jsx
new file mode 100644
index 0000000000..4dcf17869a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/HomeDescription.jsx
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2024, 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 { createPlugin } from "../../utils/PluginsUtils";
+import HTML from '../../components/I18N/HTML';
+import Text from './components/Text';
+import { Jumbotron } from 'react-bootstrap';
+
+function HomeDescription({
+
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+export default createPlugin('HomeDescription', {
+ component: HomeDescription
+});
diff --git a/web/client/plugins/ResourcesCatalog/ResourceDetails.jsx b/web/client/plugins/ResourcesCatalog/ResourceDetails.jsx
new file mode 100644
index 0000000000..d924e70c8d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/ResourceDetails.jsx
@@ -0,0 +1,360 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect } from 'react';
+import { createPlugin } from '../../utils/PluginsUtils';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import resourcesReducer from './reducers/resources';
+import {
+ resetSelectedResource,
+ searchResources,
+ setSelectedResource,
+ setShowDetails,
+ updateSelectedResource
+} from './actions/resources';
+import {
+ getSelectedResource,
+ getMonitoredStateSelector,
+ getRouterLocation,
+ getShowDetails
+} from './selectors/resources';
+import { getPendingChanges } from './selectors/save';
+import ResourcePermissions from './containers/ResourcePermissions';
+import ResourceAbout from './containers/ResourceAbout';
+import { updateResource } from '../../observables/geostore';
+import { userSelector } from '../../selectors/security';
+import ResourcesPanelWrapper from './components/ResourcesPanelWrapper';
+import TargetSelectorPortal from './components/TargetSelectorPortal';
+import useResourcePanelWrapper from './hooks/useResourcePanelWrapper';
+import { withResizeDetector } from 'react-resize-detector';
+import { requestResource, facets } from './api/resources';
+import { isEmpty } from 'lodash';
+import PendingStatePrompt from './containers/PendingStatePrompt';
+import ResourceDetailsComponent from './containers/ResourceDetails';
+import Button from './components/Button';
+import { getResourceTypesInfo, getResourceId } from './utils/ResourcesUtils';
+import Icon from './components/Icon';
+import Text from './components/Text';
+import FlexBox from './components/FlexBox';
+import tooltip from '../../components/misc/enhancers/tooltip';
+
+const ButtonWithTooltip = tooltip(Button);
+
+const tabComponents = {
+ permissions: ResourcePermissions,
+ about: ResourceAbout
+};
+
+function ResourceDetails({
+ targetSelector,
+ headerNodeSelector = '#ms-brand-navbar',
+ navbarNodeSelector = '',
+ footerNodeSelector = '',
+ width,
+ height,
+ show,
+ onShow,
+ tabs = [
+ {
+ "type": "tab",
+ "id": "info",
+ "labelId": "resourcesCatalog.info",
+ "items": [
+ {
+ "type": "text",
+ "labelId": "resourcesCatalog.columnName",
+ "editable": true,
+ "path": "name"
+ },
+ {
+ "type": "text",
+ "labelId": "resourcesCatalog.columnDescription",
+ "editable": true,
+ "path": "description"
+ },
+ {
+ "type": "text",
+ "labelId": "resourcesCatalog.columnCreatedBy",
+ "path": "creator",
+ "disableIf": "{!state('userrole')}"
+ },
+ {
+ "type": "date",
+ "labelId": "resourcesCatalog.columnCreated",
+ "path": "creation"
+ },
+ {
+ "type": "text",
+ "labelId": "resourcesCatalog.columnLastModifiedBy",
+ "path": "editor",
+ "disableIf": "{!state('userrole')}"
+ },
+ {
+ "type": "date",
+ "labelId": "resourcesCatalog.columnLastModified",
+ "path": "lastUpdate"
+ },
+ {
+ "type": "text",
+ "labelId": "resourcesCatalog.contactDetails",
+ "path": "attributes.contactDetails",
+ "editable": true
+ },
+ {
+ "type": "tag",
+ "labelId": "resourcesCatalog.columnTags",
+ "path": "tags",
+ "editable": true,
+ "facet": "tag",
+ "itemColor": "color",
+ "disableIf": "{!state('userrole')}",
+ "filter": "filter{tag.in}"
+ },
+ {
+ "type": "boolean",
+ "labelId": "resourcesCatalog.columnAdvertised",
+ "path": "advertised",
+ "disableIf": "{!state('resourceCanEdit')}",
+ "editable": true
+ },
+ {
+ "type": "boolean",
+ "labelId": "resourcesCatalog.columnFeatured",
+ "path": "attributes.featured",
+ "disableIf": "{state('userrole') !== 'ADMIN'}",
+ "editable": true
+ }
+ ]
+ },
+ {
+ "type": "permissions",
+ "id": "permissions",
+ "labelId": "resourcesCatalog.permissions",
+ "disableIf": "{!state('resourceCanEdit')}",
+ "items": [true]
+ },
+ {
+ "type": "about",
+ "id": "about",
+ "labelId": "resourcesCatalog.about",
+ "disableIf": "{!state('resourceCanEdit') && (!state('resourceDetails') || state('resourceDetails') === 'NODATA')}",
+ "items": [true]
+ }
+ ],
+ ...props
+}) {
+
+ const {
+ stickyTop,
+ stickyBottom
+ } = useResourcePanelWrapper({
+ headerNodeSelector,
+ navbarNodeSelector,
+ footerNodeSelector,
+ width,
+ height,
+ active: true
+ });
+
+ const [editing, setEditing] = useState();
+ const [error, setError] = useState(false);
+ const [confirmModal, setConfirmModal] = useState(false);
+
+ useEffect(() => {
+ return () => {
+ props.onSelect(null, props.resourcesGridId);
+ onShow(false);
+ };
+ }, []);
+
+ const shouldUseConfirmModal = (force) => !force && props.resourceType === undefined && !isEmpty(props.pendingChanges?.changes);
+
+ function handleToggleEditing(force) {
+ if (editing && shouldUseConfirmModal(force)) {
+ setConfirmModal('editing');
+ return;
+ }
+ setEditing(!editing);
+ setError(false);
+ return;
+ }
+
+ function handleClose(force) {
+ if (shouldUseConfirmModal(force)) {
+ setConfirmModal('close');
+ return;
+ }
+ if (props.resourceType === undefined) {
+ props.onSelect(null, props.resourcesGridId);
+ }
+ onShow(false);
+ setEditing(false);
+ setError(false);
+ return;
+ }
+
+ function handleConfirm() {
+ const isClose = confirmModal === 'close';
+ setConfirmModal(false);
+ props.onReset();
+ if (isClose) {
+ handleClose(true);
+ return;
+ }
+ handleToggleEditing(true);
+ }
+
+ return (
+
+
+
+
+ {props.resourceType === undefined ? setConfirmModal(false)}
+ onConfirm={handleConfirm}
+ pendingState={!isEmpty(props.pendingChanges?.changes)}
+ titleId="resourcesCatalog.detailsPendingChangesTitle"
+ descriptionId="resourcesCatalog.detailsPendingChangesDescription"
+ cancelId="resourcesCatalog.detailsPendingChangesCancel"
+ confirmId="resourcesCatalog.detailsPendingChangesConfirm"
+ variant="danger"
+ /> : null}
+
+ );
+}
+
+const resourceDetailsConnect = connect(
+ createStructuredSelector({
+ resource: getSelectedResource,
+ pendingChanges: getPendingChanges,
+ user: userSelector,
+ monitoredState: getMonitoredStateSelector,
+ location: getRouterLocation,
+ show: getShowDetails
+ }),
+ {
+ onSelect: setSelectedResource,
+ onChange: updateSelectedResource,
+ onSearch: searchResources,
+ onReset: resetSelectedResource,
+ onShow: setShowDetails
+ }
+);
+
+function BrandNavbarDetailsButton({
+ resource: selectedResource,
+ pendingChanges,
+ resourceType,
+ onSelect,
+ onShow,
+ show
+}) {
+
+ if (!resourceType) {
+ return null;
+ }
+ const resource = selectedResource ? undefined : {
+ ...pendingChanges?.initialResource,
+ category: {
+ name: resourceType
+ }
+ };
+ const { title } = getResourceTypesInfo(resource || selectedResource);
+ return (
+
+
+ {title}
+
+ {
+ if (resource) {
+ onSelect(resource);
+ }
+ onShow(true);
+ }}
+ borderTransparent
+ >
+
+
+
+ );
+}
+
+export default createPlugin('ResourceDetails', {
+ component: resourceDetailsConnect(withResizeDetector(ResourceDetails)),
+ containers: {
+ BrandNavbar: {
+ priority: 1,
+ target: 'right-menu',
+ Component: resourceDetailsConnect(BrandNavbarDetailsButton),
+ doNotHide: true,
+ position: 1
+ },
+ ResourcesGrid: {
+ priority: 2,
+ target: 'card-buttons',
+ position: 2,
+ Component: connect(
+ createStructuredSelector({
+ selectedResource: getSelectedResource
+ }),
+ {
+ onSelect: setSelectedResource,
+ onShow: setShowDetails
+ }
+ )(({ resourcesGridId, resource, onSelect, component, selectedResource, onShow }) => {
+ const Component = component;
+ function handleClick() {
+ if (getResourceId(selectedResource) !== getResourceId(resource)) {
+ onSelect(resource, resourcesGridId);
+ onShow(true, resourcesGridId);
+ }
+ }
+ return (
+
+ );
+ }),
+ doNotHide: true
+ }
+ },
+ epics: {},
+ reducers: {
+ resources: resourcesReducer
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/ResourcesFiltersForm.jsx b/web/client/plugins/ResourcesCatalog/ResourcesFiltersForm.jsx
new file mode 100644
index 0000000000..cd8847733d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/ResourcesFiltersForm.jsx
@@ -0,0 +1,195 @@
+/*
+ * Copyright 2024, 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 { createPlugin } from '../../utils/PluginsUtils';
+import url from 'url';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import resourcesReducer from './reducers/resources';
+import FiltersForm from './components/FiltersForm';
+import { getMonitoredStateSelector, getRouterLocation, getShowFiltersForm } from './selectors/resources';
+import { searchResources, setShowFiltersForm } from './actions/resources';
+import ResourcesFiltersFormButton from './containers/ResourcesFiltersFormButton';
+import useParsePluginConfigExpressions from './hooks/useParsePluginConfigExpressions';
+import useFilterFacets from './hooks/useFilterFacets';
+import { facetsRequest } from './api/resources';
+import ResourcesPanelWrapper from './components/ResourcesPanelWrapper';
+import TargetSelectorPortal from './components/TargetSelectorPortal';
+import useResourcePanelWrapper from './hooks/useResourcePanelWrapper';
+import { withResizeDetector } from 'react-resize-detector';
+import { userSelector } from '../../selectors/security';
+
+function ResourcesFiltersForm({
+ id = 'ms-filter-form',
+ resourcesGridId,
+ onClose,
+ onSearch,
+ extent = {
+ layers: [
+ {
+ type: 'osm',
+ title: 'Open Street Map',
+ name: 'mapnik',
+ source: 'osm',
+ group: 'background',
+ visibility: true
+ }
+ ],
+ style: {
+ color: '#397AAB',
+ opacity: 0.8,
+ fillColor: '#397AAB',
+ fillOpacity: 0.4,
+ weight: 4
+ }
+ },
+ fields: fieldsProp = [
+ {
+ type: 'search'
+ },
+ {
+ type: 'group',
+ labelId: 'resourcesCatalog.customFiltersTitle',
+ items: [
+ {
+ id: 'my-resources',
+ labelId: 'resourcesCatalog.myResources',
+ type: 'filter',
+ disableIf: '{!state("userrole")}'
+ },
+ {
+ id: 'map',
+ labelId: 'resourcesCatalog.mapsFilter',
+ type: 'filter'
+ },
+ {
+ id: 'dashboard',
+ labelId: 'resourcesCatalog.dashboardsFilter',
+ type: 'filter'
+ },
+ {
+ id: 'geostory',
+ labelId: 'resourcesCatalog.geostoriesFilter',
+ type: 'filter'
+ },
+ {
+ id: 'context',
+ labelId: 'resourcesCatalog.contextsFilter',
+ type: 'filter'
+ }
+ ]
+ },
+ {
+ type: 'divider'
+ },
+ {
+ type: 'select',
+ facet: "group",
+ disableIf: '{!state("userrole")}'
+ },
+ {
+ type: 'select',
+ facet: "context"
+ },
+ {
+ type: 'date-range',
+ filterKey: 'creation',
+ labelId: 'resourcesCatalog.creationFilter'
+ }
+ ],
+ monitoredState,
+ customFilters,
+ location,
+ show,
+ targetSelector,
+ headerNodeSelector = '#ms-brand-navbar',
+ navbarNodeSelector = '',
+ footerNodeSelector = '',
+ width,
+ height,
+ user
+}) {
+
+ const { query } = url.parse(location.search, true);
+
+ const parsedConfig = useParsePluginConfigExpressions(monitoredState, {
+ extent,
+ fields: fieldsProp
+ });
+
+ const {
+ stickyTop,
+ stickyBottom
+ } = useResourcePanelWrapper({
+ headerNodeSelector,
+ navbarNodeSelector,
+ footerNodeSelector,
+ width,
+ height,
+ active: true
+ });
+
+ const {
+ fields
+ } = useFilterFacets({
+ query,
+ fields: parsedConfig.fields,
+ request: facetsRequest,
+ customFilters
+ }, [user]);
+
+ return (
+
+
+ onSearch({ params }, resourcesGridId)}
+ onClear={() => onSearch({ clear: true }, resourcesGridId)}
+ onClose={() => onClose(resourcesGridId)}
+ />
+
+
+ );
+}
+
+const ResourcesGridPlugin = connect(
+ createStructuredSelector({
+ user: userSelector,
+ location: getRouterLocation,
+ monitoredState: getMonitoredStateSelector,
+ show: getShowFiltersForm
+ }),
+ {
+ onClose: setShowFiltersForm.bind(null, false),
+ onSearch: searchResources
+ }
+)(withResizeDetector(ResourcesFiltersForm));
+
+export default createPlugin('ResourcesFiltersForm', {
+ component: ResourcesGridPlugin,
+ containers: {
+ ResourcesGrid: {
+ target: 'menu-items-left',
+ Component: ResourcesFiltersFormButton
+ }
+ },
+ epics: {},
+ reducers: {
+ resources: resourcesReducer
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/ResourcesGrid.jsx b/web/client/plugins/ResourcesCatalog/ResourcesGrid.jsx
new file mode 100644
index 0000000000..c73b470708
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/ResourcesGrid.jsx
@@ -0,0 +1,191 @@
+/*
+ * Copyright 2024, 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, { useRef } from 'react';
+import { createPlugin } from '../../utils/PluginsUtils';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { getResources, getRouterLocation, getSelectedResource } from './selectors/resources';
+import resourcesReducer from './reducers/resources';
+
+import usePluginItems from '../../hooks/usePluginItems';
+import ConnectedResourcesGrid from './containers/ResourcesGrid';
+import { hashLocationToHref } from './utils/ResourcesFiltersUtils';
+import { requestResources } from './api/resources';
+import { getResourceTypesInfo, getResourceStatus, getResourceId } from './utils/ResourcesUtils';
+
+/**
+* @module ResourcesGrid
+*/
+
+/**
+ * renders a grid of resource cards, providing the ability to create pages to show a filtered / curated list of resources. For example, a landing page showing only geostories, one page per category or group with a title, some text, etc.
+ * @name ResourcesGrid.
+ * @prop {string} defaultQuery The pre-set filter to be applied by default
+ * @prop {object} order an object defining sort options for resource grid.
+ * @prop {object} extent the extent used in filters side menu to limit search within set bounds.
+ * @prop {array} menuItems contains menu for Add resources button.
+ * @prop {array} filtersFormItems Provides config for various filter metrics.
+ * @prop {number} pageSize number of resources per page. Used in pagination.
+ * @prop {string} targetSelector selector for parent node of resource
+ * @prop {string} headerNodeSelector selector for rendered header.
+ * @prop {string} navbarNodeSelector selector for rendered navbar.
+ * @prop {string} footerNodeSelector selector for rendered footer.
+ * @prop {string} containerSelector selector for rendered resource card grid container.
+ * @prop {string} scrollContainerSelector selector for outer container of resource cards rendered. This is the parent on which scrolling takes place.
+ * @prop {boolean} pagination Provides a config to allow for pagination
+ * @prop {boolean} disableDetailPanel Provides a config to allow resource details to be viewed when selected.
+ * @prop {boolean} disableFilters Provides a config to enable/disable filtering of resources
+ * @prop {array} resourceCardActionsOrder order in which `cfg.items` will be rendered
+ * @prop {boolean} enableGeoNodeCardsMenuItems Provides a config to allow for card menu items to be enabled/disabled.
+ * @prop {boolean} panel when enabled, the component render the list of resources, filters and details preview inside a panel
+ * @prop {string} cardLayoutStyle when specified, the card layout option is forced and the button to toggle card layout is hidden
+ * @prop {string} defaultCardLayoutStyle default layout card style. One of 'list'|'grid'
+ * @prop {array} detailsTabs array of tab object representing the structure of the displayed info properties (see tabs in {@link module:DetailViewer})
+ * @example
+ * {
+ * "name": "ResourcesGrid",
+ * "cfg": {
+ * targetSelector: '#custom-resources-grid',
+ * containerSelector: '.ms-container',
+ * menuItems: [],
+ * filtersFormItems: [],
+ * defaultQuery: {
+ * f: 'dataset'
+ * },
+ * pagination: false,
+ * disableDetailPanel: true,
+ * disableFilters: true,
+ * enableGeoNodeCardsMenuItems: true
+ * }
+ * }
+ */
+function ResourcesGrid({
+ items,
+ order = {
+ defaultLabelId: 'resourcesCatalog.orderBy',
+ options: [
+ {
+ label: 'Most recent',
+ labelId: 'resourcesCatalog.mostRecent',
+ value: '-creation'
+ },
+ {
+ label: 'Less recent',
+ labelId: 'resourcesCatalog.lessRecent',
+ value: 'creation'
+ },
+ {
+ label: 'A Z',
+ labelId: 'resourcesCatalog.aZ',
+ value: 'name'
+ },
+ {
+ label: 'Z A',
+ labelId: 'resourcesCatalog.zA',
+ value: '-name'
+ }
+ ]
+ },
+ metadata = {
+ list: [
+ {
+ path: 'name',
+ target: 'header',
+ width: 20,
+ labelId: 'resourcesCatalog.columnName'
+ },
+ {
+ path: 'description',
+ width: 40,
+ labelId: 'resourcesCatalog.columnDescription'
+ },
+ {
+ path: 'lastUpdate',
+ type: 'date',
+ format: 'MMM Do YY, h:mm:ss a',
+ width: 20,
+ icon: { glyph: 'clock-o' },
+ labelId: 'resourcesCatalog.columnLastModified',
+ noDataLabelId: 'resourcesCatalog.emptyNA'
+ },
+ {
+ path: 'creator',
+ target: 'footer',
+ filter: 'filter{creator.in}',
+ icon: { glyph: 'user', type: 'glyphicon' },
+ width: 20,
+ labelId: 'resourcesCatalog.columnCreatedBy',
+ noDataLabelId: 'resourcesCatalog.emptyUnknown',
+ disableIf: '{!state("userrole")}'
+ }
+ ],
+ grid: [
+ {
+ path: 'name',
+ target: 'header'
+ },
+ {
+ path: 'creator',
+ target: 'footer',
+ filter: 'filter{creator.in}',
+ icon: { glyph: 'user', type: 'glyphicon' },
+ noDataLabelId: 'resourcesCatalog.emptyUnknown',
+ disableIf: '{!state("userrole")}',
+ tooltipId: 'resourcesCatalog.columnCreatedBy'
+ }
+ ]
+ },
+ ...props
+}, context) {
+
+ const { loadedPlugins } = context;
+
+ const configuredItems = usePluginItems({ items, loadedPlugins }, []);
+
+ const updatedLocation = useRef();
+ updatedLocation.current = props.location;
+ function handleFormatHref(options) {
+ return hashLocationToHref({
+ location: updatedLocation.current,
+ excludeQueryKeys: ['page'],
+ ...options
+ });
+ }
+
+ return (
+
+ );
+}
+
+const ResourcesGridPlugin = connect(
+ createStructuredSelector({
+ location: getRouterLocation,
+ resources: getResources,
+ selectedResource: getSelectedResource
+ })
+)(ResourcesGrid);
+
+export default createPlugin('ResourcesGrid', {
+ component: ResourcesGridPlugin,
+ containers: {},
+ epics: {},
+ reducers: {
+ resources: resourcesReducer
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/Save.jsx b/web/client/plugins/ResourcesCatalog/Save.jsx
new file mode 100644
index 0000000000..97060950a4
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/Save.jsx
@@ -0,0 +1,201 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState } from 'react';
+import uuid from 'uuid/v1';
+import { createPlugin } from "../../utils/PluginsUtils";
+import PendingStatePrompt from './containers/PendingStatePrompt';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { isEmpty } from 'lodash';
+import { getPendingChanges } from './selectors/save';
+import Persistence from '../../api/persistence';
+import { setSelectedResource } from './actions/resources';
+import { mapSaveError, mapSaved, mapInfoLoaded, configureMap } from '../../actions/config';
+import { userSelector } from '../../selectors/security';
+import { storySaved, geostoryLoaded, setResource as setGeoStoryResource, setCurrentStory, saveGeoStoryError } from '../../actions/geostory';
+import { dashboardSaveError, dashboardSaved, dashboardLoaded } from '../../actions/dashboard';
+import { convertDependenciesMappingForCompatibility } from '../../utils/WidgetsUtils';
+import { show } from '../../actions/notifications';
+
+function addNameToResource(resource) {
+ return {
+ ...resource,
+ metadata: {
+ ...resource?.metadata,
+ name: resource?.metadata?.name || `${resource?.category || 'Resource'}-${uuid()}`
+ }
+ };
+}
+
+function Save({
+ pendingChanges,
+ resourceType,
+ onSelect,
+ onSuccess,
+ onError,
+ user,
+ onNotification,
+ component,
+ menuItem
+}) {
+ const [loading, setLoading] = useState(false);
+
+ const changes = !isEmpty(pendingChanges.changes);
+ const saveResource = pendingChanges.saveResource;
+
+ function handleSave() {
+ if (saveResource && !loading) {
+ setLoading(true);
+ const api = Persistence.getApi();
+ api.updateResource(addNameToResource(saveResource))
+ .toPromise()
+ .then((resourceId) => api.getResource(resourceId, { includeAttributes: true, withData: false }).toPromise())
+ .then(resource => ({ ...resource, category: { name: resourceType } }))
+ .then((resource) => {
+ onSelect(resource);
+ onSuccess(resourceType, resource, saveResource?.data);
+ onNotification({
+ id: 'RESOURCE_SAVE_SUCCESS',
+ title: 'saveDialog.saveSuccessTitle',
+ message: 'saveDialog.saveSuccessMessage'
+ }, 'success');
+ })
+ .catch((error) => {
+ onError(resourceType, error);
+ onNotification({
+ id: 'RESOURCE_SAVE_ERROR',
+ title: `resourcesCatalog.resourceError.errorTitle`,
+ message: `resourcesCatalog.resourceError.error${error.status || 'Default'}`
+ }, 'error');
+ })
+ .finally(() => setLoading(false));
+ }
+ }
+
+ if (!(user && pendingChanges?.resource?.canEdit)) {
+ return null;
+ }
+ const Component = component;
+ return (
+ <>
+
+ >
+ );
+}
+
+const saveConnect = connect(
+ createStructuredSelector({
+ user: userSelector,
+ pendingChanges: getPendingChanges
+ }),
+ {
+ onSelect: setSelectedResource,
+ onNotification: show,
+ onSuccess: (resourceType, resource, data) => {
+ return (dispatch) => {
+ if (resourceType === 'MAP') {
+ dispatch(configureMap(data, resource.id));
+ dispatch(mapInfoLoaded(resource, resource.id));
+ dispatch(mapSaved(resource.id));
+ return;
+ }
+ if (resourceType === 'DASHBOARD') {
+ dispatch(dashboardSaved(resource.id));
+ dispatch(dashboardLoaded(resource, convertDependenciesMappingForCompatibility(data)));
+ return;
+ }
+ if (resourceType === 'GEOSTORY') {
+ dispatch(storySaved(resource.id));
+ dispatch(geostoryLoaded(resource.id));
+ dispatch(setCurrentStory(data));
+ dispatch(setGeoStoryResource(resource));
+ return;
+ }
+ };
+ },
+ onError: (resourceType, error) => {
+ return (dispatch) => {
+ const { status, statusText, data, message, ...other} = error;
+ if (resourceType === 'MAP') {
+ dispatch(mapSaveError(status ? { status, statusText, data } : message || other));
+ return;
+ }
+ if (resourceType === 'DASHBOARD') {
+ dispatch(dashboardSaveError(status ? { status, statusText, data } : message || other));
+ }
+ if (resourceType === 'GEOSTORY') {
+ dispatch(saveGeoStoryError(status ? { status, statusText, data } : message || other));
+ return;
+ }
+ };
+ }
+ }
+);
+
+const SavePlugin = saveConnect(Save);
+
+SavePlugin.defaultProps = {
+ resourceType: 'MAP'
+};
+
+const ConnectedPendingStatePrompt = saveConnect(({
+ user,
+ pendingChanges
+}) => {
+ if (!(user && (pendingChanges?.resource?.canCopy || pendingChanges?.resource?.canEdit))) {
+ return null;
+ }
+ const changes = !isEmpty(pendingChanges.changes);
+ return (
+
+ );
+});
+
+ConnectedPendingStatePrompt.defaultProps = {
+ resourceType: 'MAP'
+};
+
+export default createPlugin('Save', {
+ component: ConnectedPendingStatePrompt,
+ containers: {
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 2,
+ priority: 3,
+ Component: SavePlugin,
+ doNotHide: true
+ },
+ BurgerMenu: {
+ position: 30,
+ tool: SavePlugin,
+ priority: 2,
+ doNotHide: true
+ },
+ SidebarMenu: {
+ position: 30,
+ tool: SavePlugin,
+ priority: 1,
+ doNotHide: true
+ }
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/SaveAs.jsx b/web/client/plugins/ResourcesCatalog/SaveAs.jsx
new file mode 100644
index 0000000000..19a5e4ed20
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/SaveAs.jsx
@@ -0,0 +1,238 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState } from 'react';
+import { createPlugin } from "../../utils/PluginsUtils";
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { isEmpty, omit } from 'lodash';
+import { getPendingChanges } from './selectors/save';
+import Persistence from '../../api/persistence';
+import { setSelectedResource } from './actions/resources';
+import { mapSaveError, mapSaved, mapInfoLoaded, configureMap } from '../../actions/config';
+import { userSelector } from '../../selectors/security';
+import { push } from 'connected-react-router';
+import { getResourceTypesInfo } from './utils/ResourcesUtils';
+import { storySaved, geostoryLoaded, setResource as setGeoStoryResource, setCurrentStory, saveGeoStoryError } from '../../actions/geostory';
+import { dashboardSaveError, dashboardSaved, dashboardLoaded } from '../../actions/dashboard';
+import { convertDependenciesMappingForCompatibility } from '../../utils/WidgetsUtils';
+import { show } from '../../actions/notifications';
+import InputControl from './components/InputControl';
+import ConfirmDialog from './components/ConfirmDialog';
+
+function parseResourcePayload(resource, { name, resourceType } = {}) {
+ return {
+ ...resource,
+ permission: undefined,
+ category: resourceType,
+ metadata: {
+ ...resource?.metadata,
+ name,
+ attributes: omit(resource?.metadata?.attributes || {}, ['thumbnail', 'details'])
+ }
+ };
+}
+
+function SaveAs({
+ pendingChanges,
+ resourceType,
+ onSelect,
+ onSuccess,
+ onError,
+ user,
+ onPush,
+ onNotification,
+ component,
+ menuItem
+}) {
+
+ const saveResource = pendingChanges.saveResource;
+
+ const [loading, setLoading] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+ const [name, setName] = useState('');
+
+ const changes = !isEmpty(pendingChanges.changes);
+
+ function handleSaveAs() {
+ if (saveResource) {
+ setLoading(true);
+ const api = Persistence.getApi();
+ const contextId = saveResource?.metadata?.attributes?.context;
+ Promise.all([
+ api.createResource(parseResourcePayload(saveResource, { name, resourceType })).toPromise()
+ .then((resourceId) => api.getResource(resourceId, { includeAttributes: true, withData: false }).toPromise()),
+ contextId !== undefined
+ ? api.getResource(contextId, { withData: false }).toPromise()
+ : Promise.resolve(null)
+ ])
+ .then(([resource, context]) => ({
+ ...resource,
+ category: { name: resourceType },
+ ...(context !== null && {
+ '@extras': {
+ context
+ }
+ })
+ }))
+ .then((resource) => {
+ onSelect(resource);
+ onSuccess(resourceType, resource, saveResource?.data);
+ onNotification({
+ id: 'RESOURCE_SAVE_SUCCESS',
+ title: 'saveDialog.saveSuccessTitle',
+ message: 'saveDialog.saveSuccessMessage'
+ }, 'success');
+ setShowModal(false);
+ setName('');
+ const { viewerPath } = getResourceTypesInfo(resource);
+ if (viewerPath) {
+ onPush(viewerPath);
+ }
+ })
+ .catch((error) => {
+ onError(resourceType, error);
+ onNotification({
+ id: 'RESOURCE_SAVE_ERROR',
+ title: `resourcesCatalog.resourceError.errorTitle`,
+ message: `resourcesCatalog.resourceError.error${error.status || 'Default'}`
+ }, 'error');
+ })
+ .finally(() => setLoading(false));
+ }
+ }
+
+ function handleCancel() {
+ setShowModal(false);
+ }
+
+ function handleConfirm() {
+ handleSaveAs();
+ }
+
+ if (!((pendingChanges?.resource?.canCopy || pendingChanges?.resource?.canEdit) && user)) {
+ return null;
+ }
+
+ const hideIndicator = !!pendingChanges?.resource?.canEdit;
+
+ const messagePrefix = pendingChanges?.initialResource?.id === undefined
+ ? 'createNewResource'
+ : 'copyResource';
+
+ const Component = component;
+ return (
+ <>
+ setShowModal(true)}
+ labelId="saveDialog.saveAsTooltip"
+ menuItem={menuItem}
+ glyph="floppy-open"
+ loading={loading}
+ />
+
+
+
+ >
+ );
+}
+
+const saveAsConnect = connect(
+ createStructuredSelector({
+ user: userSelector,
+ pendingChanges: getPendingChanges
+ }),
+ {
+ onNotification: show,
+ onPush: push,
+ onSelect: setSelectedResource,
+ onSuccess: (resourceType, resource, data) => {
+ return (dispatch) => {
+ if (resourceType === 'MAP') {
+ dispatch(configureMap(data, resource.id));
+ dispatch(mapInfoLoaded(resource, resource.id));
+ dispatch(mapSaved(resource.id));
+ return;
+ }
+ if (resourceType === 'DASHBOARD') {
+ dispatch(dashboardSaved(resource.id));
+ dispatch(dashboardLoaded(resource, convertDependenciesMappingForCompatibility(data)));
+ return;
+ }
+ if (resourceType === 'GEOSTORY') {
+ dispatch(storySaved(resource.id));
+ dispatch(geostoryLoaded(resource.id));
+ dispatch(setCurrentStory(data));
+ dispatch(setGeoStoryResource(resource));
+ return;
+ }
+ };
+ },
+ onError: (resourceType, error) => {
+ return (dispatch) => {
+ const { status, statusText, data, message, ...other} = error;
+ if (resourceType === 'MAP') {
+ dispatch(mapSaveError(status ? { status, statusText, data } : message || other));
+ return;
+ }
+ if (resourceType === 'DASHBOARD') {
+ dispatch(dashboardSaveError(status ? { status, statusText, data } : message || other));
+ }
+ if (resourceType === 'GEOSTORY') {
+ dispatch(saveGeoStoryError(status ? { status, statusText, data } : message || other));
+ return;
+ }
+ };
+ }
+ }
+);
+
+const SaveAsPlugin = saveAsConnect(SaveAs);
+
+SaveAsPlugin.defaultProps = {
+ resourceType: 'MAP'
+};
+
+export default createPlugin('SaveAs', {
+ component: () => null,
+ containers: {
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 3,
+ priority: 3,
+ Component: SaveAsPlugin
+ },
+ BurgerMenu: {
+ position: 31,
+ tool: SaveAsPlugin,
+ priority: 2
+ },
+ SidebarMenu: {
+ position: 31,
+ tool: SaveAsPlugin,
+ priority: 1
+ }
+ }
+});
diff --git a/web/client/plugins/ResourcesCatalog/actions/__tests__/resources-test.js b/web/client/plugins/ResourcesCatalog/actions/__tests__/resources-test.js
new file mode 100644
index 0000000000..14bc94a245
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/actions/__tests__/resources-test.js
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024, 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 {
+ UPDATE_RESOURCES,
+ updateResources,
+ UPDATE_RESOURCES_METADATA,
+ updateResourcesMetadata,
+ LOADING_RESOURCES,
+ loadingResources,
+ DECREASE_TOTAL_COUNT,
+ decreaseTotalCount,
+ INCREASE_TOTAL_COUNT,
+ increaseTotalCount,
+ SET_SHOW_FILTERS_FORM,
+ setShowFiltersForm,
+ SET_SELECTED_RESOURCE,
+ setSelectedResource,
+ UPDATE_SELECTED_RESOURCE,
+ updateSelectedResource,
+ SEARCH_RESOURCES,
+ searchResources,
+ RESET_SEARCH_RESOURCES,
+ resetSearchResources,
+ RESET_SELECTED_RESOURCE,
+ resetSelectedResource,
+ SET_SHOW_DETAILS,
+ setShowDetails
+} from '../resources';
+import expect from 'expect';
+
+describe('resources actions', () => {
+ it('updateResources', () => {
+ expect(updateResources([], 'catalog')).toEqual({
+ type: UPDATE_RESOURCES,
+ resources: [],
+ id: 'catalog'
+ });
+ });
+ it('updateResourcesMetadata', () => {
+ expect(updateResourcesMetadata({
+ isNextPageAvailable: false,
+ params: {},
+ locationSearch: '',
+ locationPathname: '/',
+ total: 0
+ }, 'catalog')).toEqual({
+ type: UPDATE_RESOURCES_METADATA,
+ metadata: {
+ isNextPageAvailable: false,
+ params: {},
+ locationSearch: '',
+ locationPathname: '/',
+ total: 0
+ },
+ id: 'catalog'
+ });
+ });
+ it('loadingResources', () => {
+ expect(loadingResources(true, 'catalog')).toEqual({
+ type: LOADING_RESOURCES,
+ loading: true,
+ id: 'catalog'
+ });
+ });
+ it('decreaseTotalCount', () => {
+ expect(decreaseTotalCount('catalog')).toEqual({
+ type: DECREASE_TOTAL_COUNT,
+ id: 'catalog'
+ });
+ });
+ it('increaseTotalCount', () => {
+ expect(increaseTotalCount('catalog')).toEqual({
+ type: INCREASE_TOTAL_COUNT,
+ id: 'catalog'
+ });
+ });
+ it('setShowFiltersForm', () => {
+ expect(setShowFiltersForm(true, 'catalog')).toEqual({
+ type: SET_SHOW_FILTERS_FORM,
+ show: true,
+ id: 'catalog'
+ });
+ });
+ it('setSelectedResource', () => {
+ expect(setSelectedResource({ id: 1 }, 'catalog')).toEqual({
+ type: SET_SELECTED_RESOURCE,
+ selectedResource: { id: 1 },
+ id: 'catalog'
+ });
+ });
+ it('updateSelectedResource', () => {
+ expect(updateSelectedResource({ name: 'Title' }, 'catalog')).toEqual({
+ type: UPDATE_SELECTED_RESOURCE,
+ properties: { name: 'Title' },
+ id: 'catalog'
+ });
+ });
+ it('searchResources', () => {
+ expect(searchResources({ params: { page: 2 }, clear: false, refresh: false })).toEqual({
+ type: SEARCH_RESOURCES,
+ params: { page: 2 },
+ clear: false,
+ refresh: false
+ });
+ });
+ it('resetSearchResources', () => {
+ expect(resetSearchResources()).toEqual({
+ type: RESET_SEARCH_RESOURCES
+ });
+ });
+ it('resetSelectedResource', () => {
+ expect(resetSelectedResource('catalog')).toEqual({
+ type: RESET_SELECTED_RESOURCE,
+ id: 'catalog'
+ });
+ });
+ it('setShowDetails', () => {
+ expect(setShowDetails(true, 'catalog')).toEqual({
+ type: SET_SHOW_DETAILS,
+ show: true,
+ id: 'catalog'
+ });
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/actions/resources.js b/web/client/plugins/ResourcesCatalog/actions/resources.js
new file mode 100644
index 0000000000..914213d4d0
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/actions/resources.js
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2024, 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.
+ */
+
+export const UPDATE_RESOURCES = 'RESOURCES:UPDATE_RESOURCES';
+export const LOADING_RESOURCES = 'RESOURCES:LOADING_RESOURCES';
+export const UPDATE_RESOURCES_METADATA = 'RESOURCES:UPDATE_RESOURCES_METADATA';
+export const DECREASE_TOTAL_COUNT = 'RESOURCES:DECREASE_TOTAL_COUNT';
+export const INCREASE_TOTAL_COUNT = 'RESOURCES:INCREASE_TOTAL_COUNT';
+export const SET_SHOW_FILTERS_FORM = 'RESOURCES:SET_SHOW_FILTERS_FORM';
+export const SET_SHOW_DETAILS = 'RESOURCES:SET_SHOW_DETAILS';
+export const SET_SELECTED_RESOURCE = 'RESOURCES:SET_SELECTED_RESOURCE';
+export const UPDATE_SELECTED_RESOURCE = 'RESOURCES:UPDATE_SELECTED_RESOURCE';
+export const SEARCH_RESOURCES = 'RESOURCES:SEARCH_RESOURCES';
+export const RESET_SEARCH_RESOURCES = 'RESOURCES:RESET_SEARCH_RESOURCES';
+export const RESET_SELECTED_RESOURCE = 'RESOURCES:RESET_SELECTED_RESOURCE';
+
+export function updateResources(resources, id) {
+ return {
+ type: UPDATE_RESOURCES,
+ resources,
+ id
+ };
+}
+
+export function updateResourcesMetadata(metadata, id) {
+ return {
+ type: UPDATE_RESOURCES_METADATA,
+ metadata,
+ id
+ };
+}
+
+export function loadingResources(loading, id) {
+ return {
+ type: LOADING_RESOURCES,
+ loading,
+ id
+ };
+}
+
+export function decreaseTotalCount(id) {
+ return {
+ type: DECREASE_TOTAL_COUNT,
+ id
+ };
+}
+
+export function increaseTotalCount(id) {
+ return {
+ type: INCREASE_TOTAL_COUNT,
+ id
+ };
+}
+
+export function setShowFiltersForm(show, id) {
+ return {
+ type: SET_SHOW_FILTERS_FORM,
+ show,
+ id
+ };
+}
+
+export function setSelectedResource(selectedResource, id) {
+ return {
+ type: SET_SELECTED_RESOURCE,
+ selectedResource,
+ id
+ };
+}
+
+export function updateSelectedResource(properties, id) {
+ return {
+ type: UPDATE_SELECTED_RESOURCE,
+ properties,
+ id
+ };
+}
+
+export function searchResources({ params, clear, refresh }) {
+ return {
+ type: SEARCH_RESOURCES,
+ clear,
+ params,
+ refresh
+ };
+}
+
+export function resetSearchResources() {
+ return {
+ type: RESET_SEARCH_RESOURCES
+ };
+}
+
+export function resetSelectedResource(id) {
+ return {
+ type: RESET_SELECTED_RESOURCE,
+ id
+ };
+}
+
+export function setShowDetails(show, id) {
+ return {
+ type: SET_SHOW_DETAILS,
+ show,
+ id
+ };
+}
diff --git a/web/client/plugins/ResourcesCatalog/api/__tests__/resources-test.js b/web/client/plugins/ResourcesCatalog/api/__tests__/resources-test.js
new file mode 100644
index 0000000000..18766dadb8
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/api/__tests__/resources-test.js
@@ -0,0 +1,265 @@
+/*
+ * Copyright 2024, 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 {
+ requestResources
+} from '../resources';
+import expect from 'expect';
+import axios from '../../../../libs/ajax';
+import MockAdapter from 'axios-mock-adapter';
+import xml2js from 'xml2js';
+
+describe('resources api', () => {
+ let mockAxios;
+ beforeEach(() => {
+ mockAxios = new MockAdapter(axios);
+ });
+ afterEach(() => {
+ mockAxios.restore();
+ });
+ it('requestResources with empty query', (done) => {
+ mockAxios.onPost().replyOnce((config) => {
+ try {
+ expect(config.url).toBe('/extjs/search/list');
+ expect(config.params).toEqual({ includeAttributes: true, start: 0, limit: 12, sortBy: 'name', sortOrder: 'asc' });
+ let json;
+ xml2js.parseString(config.data, { explicitArray: false }, (ignore, result) => {
+ json = result;
+ });
+ expect(json).toEqual({
+ "AND": {
+ "OR": {
+ "AND": [
+ {
+ "CATEGORY": { "operator": "EQUAL_TO", "name": "MAP" }
+ },
+ {
+ "CATEGORY": { "operator": "EQUAL_TO", "name": "DASHBOARD" }
+ },
+ {
+ "CATEGORY": { "operator": "EQUAL_TO", "name": "GEOSTORY" }
+ },
+ {
+ "CATEGORY": { "operator": "EQUAL_TO", "name": "CONTEXT" }
+ }
+ ]
+ }
+ }
+ });
+ } catch (e) {
+ done(e);
+ }
+ return [200, {
+ ExtResourceList: {
+ Resource: [],
+ ResourceCount: 0
+ }
+ }];
+ });
+ requestResources()
+ .then((response) => {
+ expect(response).toEqual({
+ total: 0,
+ isNextPageAvailable: false,
+ resources: []
+ });
+ done();
+ })
+ .catch(done);
+ });
+ it('requestResources with query', (done) => {
+ mockAxios.onPost().replyOnce((config) => {
+ try {
+ expect(config.url).toBe('/extjs/search/list');
+ expect(config.params).toEqual({ includeAttributes: true, start: 24, limit: 24, sortBy: 'name', sortOrder: 'asc' });
+ let json;
+ xml2js.parseString(config.data, { explicitArray: false }, (ignore, result) => {
+ json = result;
+ });
+ expect(json).toEqual({
+ "AND": {
+ "FIELD": [
+ {
+ "field": "NAME",
+ "operator": "ILIKE",
+ "value": "%A%"
+ },
+ {
+ "field": "CREATION",
+ "operator": "GREATER_THAN_OR_EQUAL_TO",
+ "value": "2025-01-22T00:00:00"
+ },
+ {
+ "field": "CREATION",
+ "operator": "LESS_THAN_OR_EQUAL_TO",
+ "value": "2025-01-24T23:59:59"
+ },
+ {
+ "field": "LASTUPDATE",
+ "operator": "GREATER_THAN_OR_EQUAL_TO",
+ "value": "2025-01-22T00:00:00"
+ },
+ {
+ "field": "LASTUPDATE",
+ "operator": "LESS_THAN_OR_EQUAL_TO",
+ "value": "2025-01-24T23:59:59"
+ }
+ ],
+ "ATTRIBUTE": { "name": "featured", "operator": "EQUAL_TO", "type": "STRING", "value": "true" },
+ "GROUP": {
+ "operator": 'IN',
+ "names": "group01"
+ },
+ "OR": [
+ {
+ "AND": {
+ "CATEGORY": { "operator": "EQUAL_TO", "name": "MAP" },
+ "OR": {
+ "ATTRIBUTE": { "name": "context", "operator": "EQUAL_TO", "type": "STRING", "value": "contextName" }
+ }
+ }
+ },
+ {
+ "FIELD": [
+ {
+ "field": "CREATOR",
+ "operator": "EQUAL_TO",
+ "value": "admin"
+ },
+ {
+ "field": "CREATOR",
+ "operator": "EQUAL_TO",
+ "value": "creator"
+ }
+ ]
+ }
+ ]
+ }
+ });
+ } catch (e) {
+ done(e);
+ }
+ return [200, {
+ ExtResourceList: {
+ Resource: [],
+ ResourceCount: 0
+ }
+ }];
+ });
+ requestResources({
+ params: {
+ 'page': 2,
+ 'pageSize': 24,
+ 'f': ['map', 'featured', 'my-resources'],
+ 'q': 'A',
+ 'filter{ctx.in}': ['contextName'],
+ 'filter{group.in}': ['group01'],
+ 'filter{creator.in}': ['creator'],
+ 'filter{creation.gte}': '2025-01-22T00:00:00',
+ 'filter{creation.lte}': '2025-01-24T23:59:59',
+ 'filter{lastUpdate.gte}': '2025-01-22T00:00:00',
+ 'filter{lastUpdate.lte}': '2025-01-24T23:59:59'
+ }
+ }, { user: { name: 'admin' } })
+ .then((response) => {
+ expect(response).toEqual({
+ total: 0,
+ isNextPageAvailable: false,
+ resources: []
+ });
+ done();
+ })
+ .catch(done);
+ });
+
+ it('requestResources with additional request for context info', (done) => {
+ mockAxios.onPost().replyOnce((config) => {
+ try {
+ expect(config.url).toBe('/extjs/search/list');
+ expect(config.params).toEqual({ includeAttributes: true, start: 0, limit: 12, sortBy: 'name', sortOrder: 'asc' });
+ } catch (e) {
+ done(e);
+ }
+ return [200, {
+ ExtResourceList: {
+ Resource: [
+ {
+ "advertised": true,
+ "Attributes": {
+ "attribute": [
+ {
+ "@type": "STRING",
+ "name": "context",
+ "value": 2
+ }
+ ]
+ },
+ "category": {
+ "id": 5,
+ "name": "MAP"
+ },
+ "id": 1,
+ "name": "Map"
+ }
+ ],
+ ResourceCount: 1
+ }
+ }];
+ });
+ mockAxios.onPost().replyOnce((config) => {
+ try {
+ expect(config.url).toBe('/extjs/search/list');
+ expect(config.params).toBe(undefined);
+ let json;
+ xml2js.parseString(config.data, { explicitArray: false }, (ignore, result) => {
+ json = result;
+ });
+ expect(json).toEqual({ OR: { FIELD: { field: 'ID', operator: 'EQUAL_TO', value: '2' } } });
+ } catch (e) {
+ done(e);
+ }
+ return [200, {
+ ExtResourceList: {
+ Resource: {
+ "category": {
+ "id": 3,
+ "name": "CONTEXT"
+ },
+ "id": 2,
+ "name": "contextName"
+ },
+ ResourceCount: 1
+ }
+ }];
+ });
+ requestResources()
+ .then((response) => {
+ expect(response).toEqual({
+ "total": 1,
+ "isNextPageAvailable": false,
+ "resources": [{
+ "advertised": true,
+ "category": { "id": 5, "name": "MAP" },
+ "id": 1,
+ "name": "Map",
+ "attributes": { "context": 2 },
+ "@extras": {
+ "context": {
+ "category": { "id": 3, "name": "CONTEXT" },
+ "id": 2,
+ "name": "contextName",
+ "attributes": {}
+ }
+ }
+ }]
+ });
+ done();
+ })
+ .catch(done);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/api/resources.js b/web/client/plugins/ResourcesCatalog/api/resources.js
new file mode 100644
index 0000000000..d9a95b726e
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/api/resources.js
@@ -0,0 +1,348 @@
+/*
+ * Copyright 2024, 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 { searchListByAttributes, getResource } from '../../../observables/geostore';
+import { castArray } from 'lodash';
+import isString from 'lodash/isString';
+import GeoStoreDAO from '../../../api/GeoStoreDAO';
+
+const splitFilterValue = (value) => {
+ const parts = value.split(':');
+ return {
+ value: parts[0],
+ label: parts.length <= 2
+ ? parts[1]
+ : parts.filter((p, idx) => idx > 0).join(':')
+ };
+};
+
+const getFilter = ({
+ q,
+ user,
+ query
+}) => {
+ const f = castArray(query.f || []);
+ const ctx = castArray(query['filter{ctx.in}'] || []);
+ const categories = ['MAP', 'DASHBOARD', 'GEOSTORY', 'CONTEXT'];
+ const creators = castArray(query['filter{creator.in}'] || []);
+ const groups = castArray(query['filter{group.in}'] || []);
+ const categoriesFilters = categories.filter(category => f.includes(category.toLocaleLowerCase()));
+ const associatedContextFilters = ctx.map((ctxValue) => {
+ const { value } = splitFilterValue(ctxValue);
+ return {
+ name: ['context'],
+ operator: ['EQUAL_TO'],
+ type: ['STRING'],
+ value: [value]
+ };
+ });
+ return {
+ AND: {
+ FIELD: [
+ ...(q ? [{
+ field: ['NAME'],
+ operator: ['ILIKE'],
+ value: ['%' + q + '%']
+ }] : []),
+ ...(query['filter{creation.gte}'] ? [{
+ field: ['CREATION'],
+ operator: ['GREATER_THAN_OR_EQUAL_TO'],
+ value: [query['filter{creation.gte}']]
+ }] : []),
+ ...(query['filter{creation.lte}'] ? [{
+ field: ['CREATION'],
+ operator: ['LESS_THAN_OR_EQUAL_TO'],
+ value: [query['filter{creation.lte}']]
+ }] : []),
+ ...(query['filter{lastUpdate.gte}'] ? [{
+ field: ['LASTUPDATE'],
+ operator: ['GREATER_THAN_OR_EQUAL_TO'],
+ value: [query['filter{lastUpdate.gte}']]
+ }] : []),
+ ...(query['filter{lastUpdate.lte}'] ? [{
+ field: ['LASTUPDATE'],
+ operator: ['LESS_THAN_OR_EQUAL_TO'],
+ value: [query['filter{lastUpdate.lte}']]
+ }] : [])
+ ],
+ ATTRIBUTE: [
+ ...(f.includes('featured') ? [{
+ name: ['featured'],
+ operator: ['EQUAL_TO'],
+ type: ['STRING'],
+ value: [true]
+ }] : [])
+ ],
+ ...(groups.length && {
+ GROUP: [
+ {
+ operator: ['IN'],
+ names: groups
+ }
+ ]
+ }),
+ OR: [
+ {
+ AND: categories
+ .map(name => {
+ return (
+ !categoriesFilters.length && !associatedContextFilters.length
+ || categoriesFilters.includes(name)
+ || associatedContextFilters.length && name === 'MAP'
+ )
+ ? {
+ CATEGORY: [
+ {
+ operator: ['EQUAL_TO'],
+ name: [name]
+ }
+ ],
+ ...(name === 'MAP' && associatedContextFilters.length && {
+ OR: [
+ {
+ ATTRIBUTE: associatedContextFilters
+ }
+ ]
+ })
+ }
+ : null;
+ }).filter(value => value)
+ },
+ ...((user && f.includes('my-resources')) || creators.length ? [{
+ FIELD: [
+ ...(user && f.includes('my-resources') ? [{
+ field: ['CREATOR'],
+ operator: ['EQUAL_TO'],
+ value: [user.name]
+ }] : []),
+ ...(creators.map((value) => {
+ return {
+ field: ['CREATOR'],
+ operator: ['EQUAL_TO'],
+ value: [value]
+ };
+ }))
+ ]
+ }] : [])
+ ]
+ }
+ };
+};
+
+export const requestResources = ({
+ params
+} = {}, { user } = {}) => {
+
+ const {
+ page = 1,
+ pageSize = 12,
+ sort = 'name',
+ customFilters,
+ q,
+ ...query
+ } = params || {};
+ const sortBy = sort.replace('-', '');
+ const sortOrder = sort.includes('-') ? 'desc' : 'asc';
+ return searchListByAttributes(getFilter({
+ q,
+ user,
+ query
+ }),
+ {
+ params: {
+ includeAttributes: true,
+ start: parseFloat(page - 1) * pageSize,
+ limit: pageSize,
+ sortBy,
+ sortOrder
+ }
+ })
+ .toPromise()
+ .then((response) => {
+ // missing canCopy, canDelete, canEdit
+ // missing filter by user
+ const resources = response.results;
+
+ const associatedContextsIds = resources.map(resource => resource?.attributes?.context).filter(contextId => contextId !== undefined);
+
+ return (associatedContextsIds.length
+ ? searchListByAttributes({
+ OR: {
+ FIELD: associatedContextsIds.map((contextId) => {
+ return {
+ field: ['ID'],
+ operator: ['EQUAL_TO'],
+ value: [contextId]
+ };
+ })
+ }
+ })
+ .toPromise().then((contextsResponse) => contextsResponse.results)
+ : Promise.resolve([])
+ )
+ .then((contexts) => {
+ return {
+ total: response.totalCount,
+ isNextPageAvailable: page < (response?.totalCount / pageSize),
+ resources: resources.map((resource) => {
+ const context = contexts.find(ctx => ctx.id === resource?.attributes?.context);
+ if (context) {
+ return {
+ ...resource,
+ '@extras': {
+ context
+ }
+ };
+ }
+ return resource;
+ })
+ };
+ });
+ });
+};
+
+const parseDetailsSettings = (detailsSettings) => {
+ if (isString(detailsSettings)) {
+ try {
+ return JSON.parse(detailsSettings);
+ } catch (e) {
+ return {};
+ }
+ }
+ return detailsSettings || {};
+};
+
+export const requestResource = ({ resource, user }) => {
+ return getResource(resource.id, { includeAttributes: true, withData: false, withPermissions: !!user })
+ .toPromise()
+ .then(({ permissions, attributes, data, ...res }) => {
+ const detailsSettings = parseDetailsSettings(resource?.attributes?.detailsSettings);
+ return {
+ ...resource,
+ ...res,
+ permissions: permissions || [],
+ attributes: {
+ ...attributes,
+ detailsSettings
+ }
+ };
+ });
+};
+
+export const facets = [
+ {
+ id: 'context',
+ type: 'select',
+ labelId: 'resourcesCatalog.filterMapsByContext',
+ key: 'filter{ctx.in}',
+ getLabelValue: (item) => {
+ const { label } = splitFilterValue(item.value);
+ return label;
+ },
+ getFilterByField: (field, value) => {
+ return { label: value, value };
+ },
+ loadItems: ({ params, config }) => {
+ const { page, pageSize, q } = params;
+ return searchListByAttributes(
+ {
+ AND: {
+ FIELD: [
+ ...(q ? [{
+ field: ['NAME'],
+ operator: ['ILIKE'],
+ value: ['%' + q + '%']
+ }] : [])
+ ],
+ CATEGORY: {
+ operator: ['EQUAL_TO'],
+ name: ['CONTEXT']
+ }
+ }
+ },
+ {
+ ...config,
+ params: {
+ start: parseFloat(page) * pageSize,
+ limit: pageSize
+ }
+ })
+ .toPromise()
+ .then((response) => {
+ return {
+ items: response.results.map((item) => {
+ const value = `${item.id}:${item.name}`;
+ return {
+ ...item,
+ filterValue: value,
+ value,
+ label: `${item.name}`
+ };
+ }),
+ isNextPageAvailable: (page + 1) < (response?.totalCount / pageSize)
+ };
+ });
+ }
+ },
+ {
+ id: 'group',
+ type: 'select',
+ labelId: 'resourcesCatalog.groups',
+ key: 'filter{group.in}',
+ getLabelValue: (item) => {
+ return item.value;
+ },
+ getFilterByField: (field, value) => {
+ return { label: value, value };
+ },
+ loadItems: ({ params, config }) => {
+ const { page, pageSize, q } = params;
+ return GeoStoreDAO.getGroups(q ? `*${q}*` : '*', {
+ ...config,
+ params: {
+ start: parseFloat(page) * pageSize,
+ limit: pageSize,
+ all: true
+ }
+ })
+ .then((response) => {
+ const groups = castArray(response?.ExtGroupList?.Group).map((item) => {
+ return {
+ ...item,
+ filterValue: item.groupName,
+ value: item.groupName,
+ label: `${item.groupName}`
+ };
+ });
+ const totalCount = response?.ExtGroupList?.GroupCount;
+ return {
+ items: groups,
+ isNextPageAvailable: (page + 1) < (totalCount / pageSize)
+ };
+ });
+ }
+ }
+];
+
+
+export const facetsRequest = ({
+ fields
+}) => {
+ return Promise.resolve({
+ fields: fields.map((field) => {
+ if (field.facet) {
+ const facet = facets.find(f => f.id === field.facet);
+ return {
+ ...facet,
+ ...field
+ };
+ }
+ return field;
+ })
+ });
+};
diff --git a/web/client/plugins/ResourcesCatalog/components/ALink.jsx b/web/client/plugins/ResourcesCatalog/components/ALink.jsx
new file mode 100644
index 0000000000..8708e830a2
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ALink.jsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024, 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 PropTypes from 'prop-types';
+
+function ALink({ href, readOnly, children, ...props }) {
+ return readOnly || !href ? <>{children}> : {children} ;
+}
+
+ALink.propTypes = {
+ href: PropTypes.string,
+ readOnly: PropTypes.bool.isRequired,
+ children: PropTypes.any
+};
+
+ALink.defaultProps = {
+ href: '',
+ readOnly: false
+};
+
+export default ALink;
diff --git a/web/client/plugins/ResourcesCatalog/components/Button.jsx b/web/client/plugins/ResourcesCatalog/components/Button.jsx
new file mode 100644
index 0000000000..342a19ef46
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Button.jsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024, 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, { forwardRef } from 'react';
+import { Button as ButtonRB } from 'react-bootstrap';
+
+const Button = forwardRef(({
+ children,
+ variant,
+ size,
+ square,
+ className,
+ borderTransparent,
+ ...props
+}, ref) => {
+ return (
+
+ {children}
+
+ );
+});
+
+export default Button;
diff --git a/web/client/plugins/ResourcesCatalog/components/ConfirmDialog.jsx b/web/client/plugins/ResourcesCatalog/components/ConfirmDialog.jsx
new file mode 100644
index 0000000000..c90d274fe3
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ConfirmDialog.jsx
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024, 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 Modal from '../../../components/misc/Modal';
+import Message from '../../../components/I18N/Message';
+import Button from './Button';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import Spinner from './Spinner';
+import { Alert } from 'react-bootstrap';
+
+function ConfirmDialog({
+ show,
+ onCancel,
+ onConfirm,
+ titleId,
+ descriptionId,
+ errorId,
+ disabled,
+ cancelId = 'no',
+ confirmId = 'yes',
+ variant = 'danger',
+ loading,
+ children,
+ preventHide
+}) {
+
+ function handleHide() {
+ if (!loading && !preventHide) {
+ onCancel();
+ }
+ }
+
+ if (!show) {
+ return null;
+ }
+ return (
+
+
+
+ {titleId ? : null}
+
+ {descriptionId ?
+
+ : null}
+ {children}
+ {errorId
+ ?
+
+
+ : null}
+
+
+ onCancel()}>
+
+
+ onConfirm()}>
+
+ {loading ? <>{' '} > : null}
+
+
+
+
+ );
+}
+
+export default ConfirmDialog;
diff --git a/web/client/plugins/ResourcesCatalog/components/DetailsHeader.jsx b/web/client/plugins/ResourcesCatalog/components/DetailsHeader.jsx
new file mode 100644
index 0000000000..dda7540fb7
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/DetailsHeader.jsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024, 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 { useInView } from 'react-intersection-observer';
+import Button from './Button';
+import Icon from './Icon';
+import Spinner from './Spinner';
+import DetailsThumbnail from './DetailsThumbnail';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import { getResourceId } from '../utils/ResourcesUtils';
+
+function DetailsHeader({
+ resource,
+ editing,
+ onChangeThumbnail,
+ onClose,
+ tools,
+ loading,
+ getResourceTypesInfo = () => ({})
+}) {
+
+ const [titleNodeRef, titleInView] = useInView();
+ const {
+ icon,
+ thumbnailUrl,
+ title
+ } = getResourceTypesInfo(resource) || {};
+
+
+ return (
+ <>
+
+
+
+
+ {(!titleInView && title) ? <> {' '}> : null}
+ {(!titleInView && title) ? title : null}
+
+
+ {(!titleInView && title) ? tools : null}
+
+
+
+
+
+
+
+
+
+
+
+
+ {!loading ? : }{' '}
+ {title}
+
+
+ {tools}
+
+ >
+ );
+}
+
+export default DetailsHeader;
diff --git a/web/client/plugins/ResourcesCatalog/components/DetailsInfo.jsx b/web/client/plugins/ResourcesCatalog/components/DetailsInfo.jsx
new file mode 100644
index 0000000000..4a5639d38d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/DetailsInfo.jsx
@@ -0,0 +1,317 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState } from 'react';
+import castArray from 'lodash/castArray';
+import isEmpty from 'lodash/isEmpty';
+import moment from 'moment';
+import { Checkbox } from 'react-bootstrap';
+
+import Button from './Button';
+import Tabs from './Tabs';
+import Message from '../../../components/I18N/Message';
+import SelectInfiniteScroll from './SelectInfiniteScroll';
+import ALink from './ALink';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import InputControl from './InputControl';
+
+const replaceTemplateString = (properties, str) => {
+ return Object.keys(properties).reduce((updatedStr, key) => {
+ const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
+ return updatedStr.replace(regex, properties[key]);
+ }, str);
+};
+
+const getDateRangeValue = (startValue, endValue, format) => {
+ if (startValue && endValue) {
+ return `${moment(startValue).format(format)} - ${moment(endValue).format(format)}`;
+ }
+ return moment(startValue ? startValue : endValue).format(format);
+};
+const isEmptyValue = (value) => {
+ if (Array.isArray(value)) {
+ return isEmpty(value);
+ }
+ if (typeof value === 'object') {
+ return isEmpty(value) || (isEmpty(value.start) && isEmpty(value.end));
+ }
+ return value === 'None' || !value;
+};
+const isStyleLabel = (style) => style === "label";
+const isFieldLabelOnly = ({style, value}) => isEmptyValue(value) && isStyleLabel(style);
+
+const DetailInfoFieldLabel = ({ field }) => {
+ const label = field.labelId ? : field.label;
+ return isStyleLabel(field.style) && field.href
+ ? ({label} )
+ : label;
+};
+
+function DetailsInfoField({ field, children, className }) {
+ const values = castArray(field.value);
+ const isLinkLabel = isFieldLabelOnly(field);
+ return (
+
+
+ {!isLinkLabel ?
+ {children(values)}
+ : null}
+
+ );
+}
+
+function DetailsHTML({ value, placeholder }) {
+ const [expand, setExpand] = useState(false);
+ if (placeholder) {
+ const Component = expand ? 'div' : FlexBox;
+ return (
+
+ {expand
+ ?
+ : {placeholder} }
+ setExpand(!expand)}>
+
+
+ );
+ }
+ return (
+
+ );
+}
+
+
+function DetailsInfoFieldEditing({ field, onChange }) {
+ if (field.type === 'text') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+ onChange({ [field.path]: val })}
+ />
+ ))}
+
+ );
+ }
+ if (field.type === 'boolean') {
+ return (
+
+
+ onChange({ [field.path]: event.target.checked })}>
+
+
+
+
+ );
+ }
+ if (field.type === 'tag' && field.loadItems) {
+ return (
+
+ {() => {
+ return {
+ item: value,
+ className: 'ms-tag',
+ style: { '--tag-color': value[field.itemColor] },
+ value: value[field.itemValue || 'value'],
+ label: value[field.itemLabel || 'value']
+ };
+ })}
+ multi
+ placeholder={field.placeholderId}
+ onChange={(selected) => {
+ onChange({ [field.path]: selected.map(({ item }) => item )});
+ }}
+ loadOptions={({ q, config, ...params }) => field.loadItems({
+ config,
+ params: {
+ ...params,
+ ...(q && { q }),
+ page: params.page - 1
+ }
+ })
+ .then((response) => {
+ return {
+ ...response,
+ results: response.items.map((item) => ({
+ selectOption: {
+ item,
+ className: 'ms-tag',
+ style: { '--tag-color': item[field.itemColor] },
+ value: item[field.itemValue || 'value'],
+ label: item[field.itemLabel || 'value']
+ }
+ }))
+ };
+ })}
+ />}
+
+ );
+ }
+ return null;
+}
+
+function DetailsInfoFields({ fields, formatHref, editing, onChange, query = {} }) {
+ return (
+ {fields.map((field, filedIndex) => {
+
+ if (editing && field.editable) {
+ return ;
+ }
+
+ if (field.type === 'link') {
+ return (
+
+ {(values) => values.map((value, idx) => {
+ return field.href
+ ? {value}
+ : {value.value} ;
+ })}
+
+ );
+ }
+ if (field.type === 'query') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+ ({
+ ...acc,
+ [key]: replaceTemplateString(value, field.queryTemplate[key])
+ }), {})
+ : field.query,
+ pathname: field.pathname
+ })}>{field.valueKey ? value[field.valueKey] : value}
+ ))}
+
+ );
+ }
+ if (field.type === 'date') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+ {(value?.start || value?.end) ? getDateRangeValue(value.start, value.end, field.format || 'MMMM Do YYYY') : moment(value).format(field.format || 'MMMM Do YYYY')}
+ ))}
+
+ );
+ }
+ if (field.type === 'html') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+
+ ))}
+
+ );
+ }
+ if (field.type === 'text') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+ {value}
+ ))}
+
+ );
+ }
+ if (field.type === 'tag') {
+ return (
+
+ {(values) => values.map((value, idx) => (
+
+ {value[field.itemValue || 'value']}
+
+ ))}
+
+ );
+ }
+ if (field.type === 'boolean') {
+ return (
+
+
+
+
+
+
+
+ );
+ }
+ return null;
+ })}
+ );
+}
+
+const defaultTabComponents = {
+ 'tab': DetailsInfoFields
+};
+
+const parseTabItems = (items) => {
+ return (items || []).filter(({value, style, type }) => {
+ return type === 'boolean' || !(isEmptyValue(value) && !isStyleLabel(style));
+ });
+};
+const isDefaultTabType = (type) => type === 'tab';
+
+function DetailsInfo({
+ tabs = [],
+ tabComponents: tabComponentsProp,
+ className,
+ ...props
+}) {
+
+ const tabComponents = {
+ ...tabComponentsProp,
+ ...defaultTabComponents
+ };
+
+ const filteredTabs = tabs
+ .filter((tab) => !tab?.disableIf)
+ .map((tab) =>
+ ({
+ ...tab,
+ items: isDefaultTabType(tab.type) && !props.editing ? parseTabItems(tab?.items) : tab?.items,
+ Component: tabComponents[tab.type] || tabComponents.tab
+ }))
+ .filter(tab => !isEmpty(tab?.items));
+ const [selectedTabId, onSelect] = useState(filteredTabs?.[0]?.id);
+ return (
+ ({
+ title: ,
+ eventKey: tab?.id,
+ component:
+ }))}
+ />
+ );
+}
+
+export default DetailsInfo;
diff --git a/web/client/plugins/ResourcesCatalog/components/DetailsThumbnail.jsx b/web/client/plugins/ResourcesCatalog/components/DetailsThumbnail.jsx
new file mode 100644
index 0000000000..d3c69a060f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/DetailsThumbnail.jsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024, 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, { useRef } from 'react';
+import Thumbnail from '../../../components/misc/Thumbnail';
+import Icon from './Icon';
+import Button from './Button';
+import tooltip from '../../../components/misc/enhancers/tooltip';
+import FlexBox from './FlexBox';
+import Text from './Text';
+const ButtonWithToolTip = tooltip(Button);
+
+function DetailsThumbnail({
+ icon,
+ editing,
+ thumbnail,
+ width,
+ height,
+ onChange
+}) {
+ const thumbnailRef = useRef(null);
+ const handleUpload = () => {
+ const input = thumbnailRef?.current?.querySelector('input');
+ if (input) {
+ input.click();
+ }
+ };
+
+ return (
+
+ {icon && !thumbnail ? : null}
+ {editing
+ ? <>
+ {
+ onChange(data);
+ }}
+ thumbnailOptions={{
+ contain: false,
+ width,
+ height,
+ type: 'image/jpg',
+ quality: 0.5
+ }}
+ />
+
+ handleUpload()}
+ tooltipId="resourcesCatalog.uploadImage"
+ tooltipPosition={"top"}
+ >
+
+
+ onChange('')}
+ tooltipId="resourcesCatalog.removeThumbnail"
+ tooltipPosition={"top"}
+ >
+
+
+
+ >
+ : <>
+ {thumbnail ? : null}
+ >}
+
+ );
+}
+
+export default DetailsThumbnail;
diff --git a/web/client/plugins/ResourcesCatalog/components/FilterAccordion.jsx b/web/client/plugins/ResourcesCatalog/components/FilterAccordion.jsx
new file mode 100644
index 0000000000..dffec5d8b5
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FilterAccordion.jsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect } from "react";
+import uniq from 'lodash/uniq';
+import isEmpty from 'lodash/isEmpty';
+import PropTypes from "prop-types";
+
+import Button from "./Button";
+import Icon from "./Icon";
+import useLocalStorage from "../hooks/useLocalStorage";
+import Message from "../../../components/I18N/Message";
+import Spinner from "./Spinner";
+import useIsMounted from "../hooks/useIsMounted";
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+const AccordionTitle = ({
+ expanded,
+ onClick,
+ loading,
+ children
+}) => {
+
+ return (
+
+
+
+ {children}
+
+
+
+ {loading
+ ?
+ :
+ }
+
+ );
+};
+
+/**
+ * FilterAccordion component
+ * @prop {string} title of the accordion
+ * @prop {string} titleId translation path of the title on the accordion
+ * @prop {string} noItemsMsgId default message when no items present
+ * @prop {string} identifier string
+ * @prop {function} content function to render child items
+ * @prop {function} loadItems function to fetch accordion items
+ * @prop {array} items accordion items available without the need to fetch
+ * @prop {string} query string
+*/
+const FilterAccordion = ({
+ title,
+ titleId,
+ noItemsMsgId,
+ identifier,
+ content,
+ loadItems,
+ items,
+ query,
+ root
+}) => {
+ const isMounted = useIsMounted();
+
+ const [accordionsExpanded, setAccordionsExpanded] = useLocalStorage('accordionsExpanded', []);
+ const [accordionItems, setAccordionItems] = useState(items);
+ const [loading, setLoading] = useState(false);
+
+ const isExpanded = accordionsExpanded.includes(identifier);
+
+ const onClick = () => {
+ const expandedList = isExpanded
+ ? accordionsExpanded.filter(accordionExpanded => accordionExpanded !== identifier)
+ : uniq(accordionsExpanded.concat(identifier));
+ setAccordionsExpanded(expandedList);
+ };
+
+ useEffect(() => {
+ if (loadItems && typeof loadItems === 'function') {
+ if (isExpanded && !loading) {
+ setLoading(true);
+ loadItems({ page_size: 999999 })
+ .then((response) => {
+ isMounted(() => setAccordionItems(response.items));
+ })
+ .finally(()=> isMounted(() => setLoading(false)));
+ }
+ }
+ }, [isExpanded, JSON.stringify(query)]);
+
+ return (
+
+
+ {titleId ? : title}
+
+ {isExpanded ?
+ {loading ? null : !isEmpty(accordionItems)
+ ? content(accordionItems)
+ : !loading ? : null
+ }
+ : null}
+
+ );
+};
+
+FilterAccordion.propTypes = {
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ titleId: PropTypes.string,
+ identifier: PropTypes.string,
+ noItemsMsgId: PropTypes.string,
+ content: PropTypes.func,
+ loadItems: PropTypes.func,
+ items: PropTypes.array,
+ query: PropTypes.object
+};
+
+FilterAccordion.defaultProps = {
+ title: null,
+ identifier: "",
+ content: () => null,
+ noItemsMsgId: "resourcesCatalog.emptyFilterItems"
+};
+export default FilterAccordion;
diff --git a/web/client/plugins/ResourcesCatalog/components/FilterByExtent.jsx b/web/client/plugins/ResourcesCatalog/components/FilterByExtent.jsx
new file mode 100644
index 0000000000..3e34998bfb
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FilterByExtent.jsx
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useRef } from 'react';
+import { FormGroup, Checkbox } from 'react-bootstrap';
+import BaseMap from '../../../components/map/BaseMap';
+import mapType from '../../../components/map/enhancers/mapType';
+import Message from '../../../components/I18N/Message';
+import {
+ boundsToExtentString,
+ getFeatureFromExtent
+} from '../utils/ResourcesCoordinatesUtils';
+import ZoomTo from './ZoomTo';
+
+const Map = mapType(BaseMap);
+Map.displayName = 'Map';
+
+function FilterByExtent({
+ id,
+ extent,
+ projection,
+ onChange,
+ vectorLayerStyle,
+ layers,
+ labelId = 'resourcesCatalog.extent'
+}) {
+
+ const enabled = !!extent;
+ const [currentExtent, setCurrentExtent] = useState();
+ const countInitialMapMoveEnd = useRef(0);
+
+ function handleOnSwitch(event) {
+ onChange({
+ extent: event.target.checked
+ ? currentExtent
+ : undefined
+ });
+ }
+
+ function handleOnMapViewChanges(center, zoom, bbox) {
+ const { bounds, crs } = bbox;
+ const newExtent = boundsToExtentString(bounds, crs);
+ // map triggers two move end event on mount
+ if (countInitialMapMoveEnd.current < 2) {
+ countInitialMapMoveEnd.current += 1;
+ } else if (enabled) {
+ onChange({
+ extent: newExtent
+ });
+ }
+ setCurrentExtent(newExtent);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+FilterByExtent.defaultProps = {
+ projection: 'EPSG:3857'
+};
+
+export default FilterByExtent;
diff --git a/web/client/plugins/ResourcesCatalog/components/FilterDateRange.jsx b/web/client/plugins/ResourcesCatalog/components/FilterDateRange.jsx
new file mode 100644
index 0000000000..b065d67392
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FilterDateRange.jsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2024, 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 { DateTimePicker } from 'react-widgets';
+import { FormGroup } from 'react-bootstrap';
+import moment from 'moment';
+import momentLocalizer from 'react-widgets/lib/localizers/moment';
+
+import Message from '../../../components/I18N/Message';
+
+momentLocalizer(moment);
+
+function FilterDateRange({
+ query = {},
+ filterKey = 'date',
+ labelId = 'resourcesCatalog.dateFilter',
+ onChange
+}) {
+
+ const format = 'YYYY-MM-DD';
+ const dateFromFilterKey = `filter{${filterKey}.gte}`;
+ const dateToFilterKey = `filter{${filterKey}.lte}`;
+ const dateFromValue = query[dateFromFilterKey] ? moment(query[dateFromFilterKey]).toDate() : undefined;
+ const dateToValue = query[dateToFilterKey] ? moment(query[dateToFilterKey]).toDate() : undefined;
+
+ function parseDate(value, time) {
+ const date = moment(value);
+ if (date.isValid()) {
+ return `${date.format(format)}${time}`;
+ }
+ return null;
+ }
+
+ return (
+ <>
+
+
+ {
+ onChange({
+ [dateFromFilterKey]: parseDate(value, 'T00:00:00')
+ });
+ }}
+ />
+
+
+
+ {
+ onChange({
+ [dateToFilterKey]: parseDate(value, 'T23:59:59')
+ });
+ }}
+ />
+
+ >
+ );
+}
+
+FilterDateRange.defaultProps = {};
+
+export default FilterDateRange;
diff --git a/web/client/plugins/ResourcesCatalog/components/FilterGroup.jsx b/web/client/plugins/ResourcesCatalog/components/FilterGroup.jsx
new file mode 100644
index 0000000000..0669d61b88
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FilterGroup.jsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect } from 'react';
+import isEmpty from 'lodash/isEmpty';
+import PropTypes from "prop-types";
+
+import { Message } from "../../../components/I18N/I18N";
+
+import Spinner from "./Spinner";
+import useIsMounted from "../hooks/useIsMounted";
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+const Title = ({
+ loading,
+ children
+}) => {
+
+ return (
+
+ {children}
+ {loading ? <>{' '} > : null}
+
+ );
+};
+
+const FilterGroup = ({
+ items: itemsProp,
+ loadItems,
+ title,
+ titleId,
+ query,
+ content,
+ loadingItemsMsgId,
+ noItemsMsgId,
+ root
+}) => {
+ const isMounted = useIsMounted();
+
+ const [groupItems, setGroupItems] = useState(itemsProp);
+ const [loading, setLoading] = useState(false);
+
+ const shouldRequestItems = loadItems && typeof loadItems === 'function';
+
+ useEffect(() => {
+ if (shouldRequestItems) {
+ if (!loading) {
+ setLoading(true);
+ loadItems({ page_size: 999999 })
+ .then((response) =>{
+ isMounted(() => setGroupItems(response.items));
+ })
+ .finally(()=> isMounted(() => setLoading(false)));
+ }
+ }
+ }, [JSON.stringify(query), shouldRequestItems]);
+
+ // avoid to use groupItems when not async to get the latest updated items
+ const items = shouldRequestItems ? groupItems : itemsProp;
+
+ return (
+
+
+ {titleId ? : title}
+
+
+ {loading ?
+
+ : !isEmpty(items) ? content(items)
+ : !loading ? : null
+ }
+
+
+ );
+};
+
+
+FilterGroup.propTypes = {
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ titleId: PropTypes.string,
+ noItemsMsgId: PropTypes.string,
+ content: PropTypes.func,
+ loadItems: PropTypes.func,
+ items: PropTypes.array,
+ query: PropTypes.object
+};
+
+FilterGroup.defaultProps = {
+ title: null,
+ content: () => null,
+ noItemsMsgId: "resourcesCatalog.emptyFilterItems",
+ loadingItemsMsgId: "resourcesCatalog.loadingItems"
+};
+export default FilterGroup;
diff --git a/web/client/plugins/ResourcesCatalog/components/FilterItems.jsx b/web/client/plugins/ResourcesCatalog/components/FilterItems.jsx
new file mode 100644
index 0000000000..1fca23575b
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FilterItems.jsx
@@ -0,0 +1,416 @@
+/*
+ * Copyright 2024, 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 castArray from 'lodash/castArray';
+import isNil from 'lodash/isNil';
+import omit from 'lodash/omit';
+import debounce from 'lodash/debounce';
+import PropTypes from 'prop-types';
+import { FormGroup, Checkbox } from 'react-bootstrap';
+import ReactSelect from 'react-select';
+import localizedProps from '../../../components/misc/enhancers/localizedProps';
+import { getMessageById } from '../../../utils/LocaleUtils';
+import FilterByExtent from './FilterByExtent';
+import FilterDateRange from './FilterDateRange';
+import Icon from './Icon';
+
+import FilterAccordion from "./FilterAccordion";
+import Tabs from "./Tabs";
+import SelectInfiniteScroll from './SelectInfiniteScroll';
+import FilterGroup from './FilterGroup';
+
+import { getFilterByField as defaultGetFilterByField } from '../utils/ResourcesFiltersUtils';
+import InputControl from './InputControl';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+const SelectSync = localizedProps('placeholder')(ReactSelect);
+
+function Label({item} = {}, { messages }) {
+ return (
+
+
+
+ {item.icon ? : item.image ? : null}
+ {item.labelId ? getMessageById(messages, item.labelId) : item.label}
+
+
+ {!isNil(item.count) ? {`(${item.count})`} : null}
+
+ );
+}
+Label.contextTypes = {
+ messages: PropTypes.object
+};
+
+function Facet({
+ item,
+ active,
+ onChange
+}) {
+ const filterValue = item.filterValue || item.id;
+ return (
+
+ event.key === 'Enter' ? onChange() : null}
+ style={{ display: 'block', width: 0, height: 0, overflow: 'hidden', opacity: 0, padding: 0, margin: 0 }}
+ />
+
+
+ );
+}
+
+function ExtentFilterWithDebounce({
+ id,
+ labelId,
+ query,
+ timeDebounce,
+ layers,
+ vectorLayerStyle,
+ onChange
+}) {
+ const extentChange = debounce((extent) => {
+ onChange({ extent });
+ }, timeDebounce);
+ return (
+ {
+ extentChange(extent);
+ })}
+ />
+ );
+}
+function FilterItem({
+ id,
+ values,
+ onChange,
+ extentProps,
+ timeDebounce,
+ field,
+ root
+}, { messages }) {
+
+ // remove global search parameters
+ // to avoid conflict with filed search
+ const additionalParams = omit(values, ['q', 'page', 'pageSize']);
+
+ if (field.type === 'search') {
+ return (
+ onChange({ q })}
+ />
+ );
+ }
+ if (field.type === 'extent') {
+ return (
+
+ );
+ }
+ if (field.type === 'date-range') {
+ return (
+
+ );
+ }
+ if (field.type === 'select' && field.loadItems) {
+
+ const filterKey = field.key;
+ const isFacet = field.style === 'facet';
+ const filterValues = castArray(values[filterKey] || []);
+ const getFilterByField = field?.getFilterByField || defaultGetFilterByField;
+ const currentValues = filterValues.filter(v => getFilterByField(field, v));
+ const otherValues = filterValues.filter(v => !getFilterByField(field, v));
+ const getLabelValue = field.getLabelValue
+ ? field.getLabelValue
+ : (item) => `${item.labelId ? getMessageById(messages, item.labelId) : item.label || ''}${item.count !== undefined ? `(${item.count})` : ''}`;
+ return (
+
+ {field.labelId ? getMessageById(messages, field.labelId) : field.label}
+ {
+ const selectedFilter = getFilterByField(field, value);
+ return {
+ value,
+ label: selectedFilter ? getLabelValue(selectedFilter) : value
+ };
+ })}
+ multi
+ placeholder={field.placeholderId}
+ onChange={(selected) => {
+ let _selected = selected.map(({ value }) => value);
+ _selected = isFacet ? _selected.concat(otherValues) : _selected;
+ onChange({
+ [filterKey]: _selected
+ });
+ }}
+ loadOptions={({ q, config, ...params }) => field.loadItems({
+ config,
+ params: {
+ ...params,
+ ...additionalParams, // filter queries
+ ...(q && { q }),
+ page: params.page - 1
+ }
+ })
+ .then((response) => {
+ return {
+ ...response,
+ results: response.items.map((item) => ({
+ ...item,
+ selectOption: {
+ value: item.filterValue,
+ label: getLabelValue(item)
+ }
+ }))
+ };
+ })}
+ />
+
+ );
+ }
+ if (field.type === 'select') {
+ const {
+ id: formId,
+ labelId,
+ label,
+ placeholderId,
+ description,
+ options: optionsField
+ } = field;
+ const key = `${id}-${formId}`;
+ const filterKey = `filter{${formId}.in}`;
+
+ const currentValues = values[filterKey] || [];
+ const options = (optionsField || [])?.map(option => ({ value: option, label: option }));
+ const getFilterLabelById = (value) => options.find(option => option.value === value)?.label;
+ return (
+
+ {labelId ? getMessageById(messages, labelId) : label}
+ ({ value, label: getFilterLabelById(value) || value }))}
+ multi
+ placeholder={placeholderId}
+ onChange={(selected) => {
+ onChange({
+ [filterKey]: selected.map(({ value }) => value)
+ });
+ }}
+ options={options}
+ />
+ {description &&
+
+ {description}
+
}
+
+ );
+ }
+ if (field.type === 'group') {
+ return ( field.loadItems({
+ params: {
+ ...params,
+ ...additionalParams
+ }
+ })}}
+ items={field.items}
+ root={root}
+ content={(groupItems) => (
+ )
+ }
+ />);
+ }
+ if (field.type === 'divider') {
+ return
;
+ }
+ if (field.type === 'link') {
+ return {field.labelId && getMessageById(messages, field.labelId) || field.label} ;
+ }
+ if (field.type === 'filter') {
+ const filterKey = field.filterKey || "f";
+ const customFilters = castArray( values[filterKey] || []);
+ const getFilterValue = (item) => item.filterValue || item.id;
+ const isFacet = (item) => item.style === 'facet';
+ const renderFacet = ({item, active, onChangeFacet, renderChild}) => {
+ return (
+
+
+ {item.items && renderChild && {renderChild()}
}
+
+ );
+ };
+
+ const filterChild = () => {
+ return field.items && field.items.map((item) => {
+ const active = customFilters.find(value => value === getFilterValue(item));
+ const onChangeFilter = () => {
+ onChange({
+ f: active
+ ? customFilters.filter(value => value !== getFilterValue(item))
+ : [...customFilters.filter(value => field.id !== value), getFilterValue(item), getFilterValue(field)]
+ });
+ };
+ return (
+
+ {isFacet(item)
+ ? renderFacet({item, active, onChangeFacet: onChangeFilter})
+ :
+
+
+ }
+
+ );
+ } );
+ };
+ const active = customFilters.find(value => value === getFilterValue(field));
+ const parentFilterIds = [
+ getFilterValue(field),
+ ...(field.items
+ ? field.items.map((item) => getFilterValue(item))
+ : [])
+ ];
+ const onChangeFilterParent = () => {
+ onChange({
+ [filterKey]: active
+ ? customFilters.filter(value => !parentFilterIds.includes(value))
+ : [...customFilters, getFilterValue(field)]
+ });
+ };
+ return isFacet(field)
+ ? renderFacet({
+ item: field,
+ active,
+ onChangeFacet: onChangeFilterParent,
+ renderChild: filterChild
+ }) : (
+
+
+
+ {filterChild()}
+
+
+ );
+ }
+ if (field.type === 'accordion' && !field.facet && field.id) {
+ return ( field.loadItems({
+ params: {
+ ...params,
+ ...additionalParams
+ }
+ })}}
+ root={root}
+ items={field.items}
+ content={(accordionItems) => (
+ )
+ }
+ />);
+ }
+ if (field.type === 'tabs') {
+ const key = `${id}-${field.id}`;
+ return (
+ ({
+ title: item.labelId ? getMessageById(messages, item.labelId) : item.label,
+ component:
+ }))}
+ />
+ );
+ }
+ return null;
+}
+
+FilterItem.contextTypes = {
+ messages: PropTypes.object
+};
+
+function FilterItems({ items, ...props }) {
+ return items.map((field, idx) =>
+
+ );
+}
+
+FilterItems.defaultProps = {
+ id: PropTypes.string,
+ items: PropTypes.array,
+ values: PropTypes.object,
+ onChange: PropTypes.func
+};
+
+FilterItems.defaultProps = {
+ items: [],
+ values: {},
+ onChange: () => {}
+};
+
+export default FilterItems;
diff --git a/web/client/plugins/ResourcesCatalog/components/FiltersForm.jsx b/web/client/plugins/ResourcesCatalog/components/FiltersForm.jsx
new file mode 100644
index 0000000000..ad6ad96a6f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FiltersForm.jsx
@@ -0,0 +1,130 @@
+/*
+ * Copyright 2024, 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, { memo } from 'react';
+import PropTypes from 'prop-types';
+import Button from './Button';
+import Message from '../../../components/I18N/Message';
+import Icon from './Icon';
+import isEqual from 'lodash/isEqual';
+import FilterItems from './FilterItems';
+import isEmpty from 'lodash/isEmpty';
+import omit from 'lodash/omit';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+/**
+ * FilterForm component allows to configure a list of field that can be used to apply filter on the page
+ * @name FiltersForm
+ * @memberof components
+ * @prop {string} id the thumbnail is scaled based on the following configuration
+ */
+function FiltersForm({
+ id,
+ style,
+ styleContainerForm,
+ query,
+ fields,
+ onChange,
+ onClose,
+ onClear,
+ extentProps,
+ timeDebounce,
+ filters,
+ setFilters
+}) {
+
+ const handleFieldChange = (newParam) => {
+ onChange(newParam);
+ };
+
+ return (
+
+
+
+
+ {' '}
+
+
+
+
+
+ onClose()}
+ square
+ borderTransparent
+ >
+
+
+
+
+
+
+
+ );
+}
+
+FiltersForm.defaultProps = {
+ id: PropTypes.string,
+ style: PropTypes.object,
+ styleContainerForm: PropTypes.object,
+ query: PropTypes.object,
+ fields: PropTypes.array,
+ onChange: PropTypes.func,
+ onClose: PropTypes.func,
+ onClear: PropTypes.func,
+ extentProps: PropTypes.object,
+ submitOnChangeField: PropTypes.bool,
+ timeDebounce: PropTypes.number,
+ formParams: PropTypes.object
+
+};
+
+FiltersForm.defaultProps = {
+ query: {},
+ fields: [],
+ onChange: () => {},
+ onClose: () => {},
+ onClear: () => {},
+ submitOnChangeField: true,
+ timeDebounce: 500,
+ formParams: {}
+};
+
+const arePropsEqual = (prevProps, nextProps) => {
+ return isEqual(prevProps.query, nextProps.query)
+ && isEqual(prevProps.fields, nextProps.fields)
+ && isEqual(prevProps.filters, nextProps.filters);
+};
+
+
+export default memo(FiltersForm, arePropsEqual);
diff --git a/web/client/plugins/ResourcesCatalog/components/FlexBox.jsx b/web/client/plugins/ResourcesCatalog/components/FlexBox.jsx
new file mode 100644
index 0000000000..c46585e0aa
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/FlexBox.jsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2024, 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, { forwardRef } from 'react';
+
+const addPrefix = (value) => {
+ return value ? `_${value}` : undefined;
+};
+
+const FlexBox = forwardRef(({
+ children,
+ className,
+ classNames = [],
+ component = 'div',
+ inline,
+ column,
+ gap,
+ wrap,
+ centerChildren,
+ centerChildrenHorizontally,
+ centerChildrenVertically,
+ ...props
+}, ref) => {
+ const Component = component;
+ return (
+ cls).join(' ')}
+ >
+ {children}
+
+ );
+});
+
+export const FlexFill = forwardRef(({
+ children,
+ className,
+ classNames = [],
+ component = 'div',
+ flexBox,
+ ...props
+}, ref) => {
+ const Component = flexBox ? FlexBox : component;
+ return (
+ cls).join(' ')}
+ >
+ {children}
+
+ );
+});
+
+FlexBox.Fill = FlexFill;
+
+export default FlexBox;
diff --git a/web/client/plugins/ResourcesCatalog/components/Icon.jsx b/web/client/plugins/ResourcesCatalog/components/Icon.jsx
new file mode 100644
index 0000000000..06d9c919e6
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Icon.jsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect } from 'react';
+import { Glyphicon } from 'react-bootstrap';
+import { loadFontAwesome } from '../../../utils/FontUtils';
+import useIsMounted from '../hooks/useIsMounted';
+
+function FaIcon({
+ name,
+ className,
+ style
+}) {
+ const [loading, setLoading] = useState(true);
+ const isMounted = useIsMounted();
+ useEffect(() => {
+ loadFontAwesome()
+ .then(() => {
+ isMounted(() => {
+ setLoading(false);
+ });
+ });
+ }, []);
+ if (loading) {
+ return null;
+ }
+ return ;
+}
+
+function Icon({
+ glyph,
+ type = 'font-awesome',
+ ...props
+}) {
+ if (type === 'font-awesome') {
+ return ;
+ }
+ if (type === 'glyphicon') {
+ return ;
+ }
+ return null;
+}
+
+Icon.defaultProps = {};
+
+export default Icon;
diff --git a/web/client/plugins/ResourcesCatalog/components/InputControl.jsx b/web/client/plugins/ResourcesCatalog/components/InputControl.jsx
new file mode 100644
index 0000000000..e341836727
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/InputControl.jsx
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2024, 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 { FormControl as FormControlRB } from 'react-bootstrap';
+import withDebounceOnCallback from '../../../components/misc/enhancers/withDebounceOnCallback';
+import localizedProps from '../../../components/misc/enhancers/localizedProps';
+const FormControl = localizedProps('placeholder')(FormControlRB);
+
+function InputControl({ onChange, value, ...props }) {
+ return onChange(event.target.value)}/>;
+}
+
+const InputControlWithDebounce = withDebounceOnCallback('onChange', 'value')(InputControl);
+
+export default InputControlWithDebounce;
diff --git a/web/client/plugins/ResourcesCatalog/components/Menu.jsx b/web/client/plugins/ResourcesCatalog/components/Menu.jsx
new file mode 100644
index 0000000000..b842d8fd28
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Menu.jsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2024, 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, {forwardRef} from 'react';
+import PropTypes from 'prop-types';
+import MenuItem from './MenuItem';
+import FlexBox from './FlexBox';
+
+/**
+* @module components/Menu
+*/
+
+/**
+ * Menu component
+ * @name Menu
+ * @prop {array} items list of menu item
+ * @prop {string} containerClass css class of list container
+ * @prop {string} childrenClass css class of item in list
+ * @prop {string} query string to build the query url in case of link item
+ * @prop {function} formatHref function to format the href in case of link item
+ * @example
+ *
+ *
+ */
+const Menu = forwardRef(({
+ items,
+ containerClass,
+ childrenClass,
+ query,
+ formatHref,
+ size,
+ alignRight,
+ variant,
+ resourceName,
+ className,
+ menuItemComponent,
+ ...props
+}, ref) => {
+
+ return (
+
+ {items
+ .map((item, idx) => {
+ return (
+
+ );
+ })}
+
+ );
+});
+
+Menu.propTypes = {
+ items: PropTypes.array.isRequired,
+ containerClass: PropTypes.string,
+ childrenClass: PropTypes.string,
+ query: PropTypes.object,
+ formatHref: PropTypes.func
+
+};
+
+Menu.defaultProps = {
+ items: [],
+ query: {},
+ user: undefined,
+ formatHref: () => '#',
+ containerClass: ''
+};
+
+
+export default Menu;
diff --git a/web/client/plugins/ResourcesCatalog/components/MenuDropdownList.jsx b/web/client/plugins/ResourcesCatalog/components/MenuDropdownList.jsx
new file mode 100644
index 0000000000..140d2662b0
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/MenuDropdownList.jsx
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2024, 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 { createPortal } from 'react-dom';
+import PropTypes from 'prop-types';
+import Message from '../../../components/I18N/Message';
+import NavLink from './MenuNavLink';
+import Icon from './Icon';
+import { Dropdown, MenuItem, Badge } from 'react-bootstrap';
+
+const isValidBadgeValue = (badge) => !!badge || badge === 0;
+
+const itemsList = (items) => (items && items.map((item, idx) => {
+
+ const { labelId, href, badge, target, type, Component, className } = item;
+
+ if (type === 'plugin' && Component) {
+ return ( );
+ }
+
+ return (
+ {labelId && }
+ { isValidBadgeValue(badge) && {badge} }
+
+ );
+} ));
+
+/**
+ * DropdownList component
+ * @name DropdownList
+ * @memberof components.Menu.DropdownList
+ * @prop {number} id to apply to toogle
+ * @prop {array} items list od items of Dropdown
+ * @prop {string} label label to apply to toogle
+ * @prop {string} labelId alternative to label
+ * @prop {string} labelId alternative to labe
+ * @prop {object} toggleStyle inline style to apply to toogle comp
+ * @prop {string} toggleImage image to apply to toogle comp
+ * @prop {string} toggleIcon icon to apply to toogle comp
+ * @prop {string} dropdownClass the css class to apply to the comp
+ * @prop {number} tabIndex define navigation order
+ * @prop {boolean} noCaret hide/show caret icon on the dropdown
+ * @prop {number} badgeValue to apply the value to the item in list
+ * @prop {node} containerNode the node to append the child element into a DOM
+ * @example
+ *
+ *
+ */
+
+
+const MenuDropdownList = ({
+ id,
+ items = [],
+ label,
+ labelId,
+ toggleStyle,
+ toggleImage,
+ toggleIcon,
+ dropdownClass,
+ tabIndex,
+ badgeValue,
+ containerNode,
+ size,
+ noCaret,
+ alignRight,
+ variant,
+ responsive
+}) => {
+
+ const dropdownItems = items
+ .map((itm, idx) => {
+
+ if (itm.type === 'plugin' && itm.Component) {
+ return ( );
+ }
+ if (itm.type === 'divider') {
+ return ;
+ }
+ return (
+
+
+ {itm.labelId && || itm.label}
+ {isValidBadgeValue(itm.badge) && {itm.badge} }
+
+
+ {itm?.items &&
+ {itemsList(itm?.items)}
+
}
+
+ );
+ });
+
+ const DropdownToggle = (
+
+ {toggleImage
+ ?
+ : undefined
+ }
+ {
+ toggleIcon ?
+ : undefined
+ }
+ {
+ (labelId && !responsive) &&
+ || label
+ }
+ {
+ (labelId && responsive) &&
+
+
+
+
+ }
+ {isValidBadgeValue(badgeValue) && {badgeValue} }
+
+
+ );
+
+
+ return (
+
+ {DropdownToggle}
+ {containerNode
+ ? createPortal(
+ {dropdownItems}
+ , containerNode.parentNode)
+ :
+ {dropdownItems}
+ }
+
+ );
+
+};
+
+MenuDropdownList.propTypes = {
+ items: PropTypes.array.isRequired,
+ label: PropTypes.string,
+ labelId: PropTypes.string,
+ toggleStyle: PropTypes.object,
+ toggleImage: PropTypes.string,
+ state: PropTypes.object,
+ noCaret: PropTypes.bool,
+ dropdownClass: PropTypes.string,
+ tabIndex: PropTypes.number,
+ containerNode: PropTypes.element
+
+};
+
+export default MenuDropdownList;
diff --git a/web/client/plugins/ResourcesCatalog/components/MenuItem.jsx b/web/client/plugins/ResourcesCatalog/components/MenuItem.jsx
new file mode 100644
index 0000000000..8e53aacd18
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/MenuItem.jsx
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2024, 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 PropTypes from 'prop-types';
+import castArray from 'lodash/castArray';
+import { Badge } from 'react-bootstrap';
+import Message from '../../../components/I18N/Message';
+
+import DropdownList from './MenuDropdownList';
+import MenuNavLink from './MenuNavLink';
+import Icon from './Icon';
+import Button from './Button';
+
+const isValidBadgeValue = (badge) => !!badge || badge === 0;
+
+/**
+ * Menu item component
+ * @name MenuItem
+ * @memberof components.Menu.MenuItem
+ * @prop {object} item the item menu
+ * @prop {object} menuItemsProps contains pros to apply to items, to manage single permissions, build href and query url
+ * @prop {node} containerNode the node to append the child element into a DOM
+ * @prop {number} tabIndex define navigation order
+ * @prop {boolean} draggable is element is draggable
+ * @prop {function} classItem class to apply to the Item
+ * @example
+ *
+ *
+ */
+
+const MenuItem = ({ item, menuItemsProps, containerNode, tabIndex, classItem = '', size, alignRight, variant, resourceName, menuItemComponent }) => {
+
+ const { formatHref, query } = menuItemsProps || {};
+ const {
+ id,
+ type,
+ label,
+ labelId = '',
+ items = [],
+ href,
+ style,
+ badge = '',
+ image,
+ Component,
+ target,
+ className,
+ responsive,
+ noCaret,
+ glyph,
+ iconType,
+ square,
+ tooltipId,
+ src
+ } = item || {};
+ const btnClassName = `btn${variant && ` btn-${variant}` || ''}${size && ` btn-${size}` || ''}${className ? ` ${className}` : ''} _border-transparent`;
+
+ const labelNode = labelId ? : label;
+
+ const badgeValue = badge;
+ if (type === 'dropdown') {
+ return ( );
+ }
+
+ if ((type === 'custom' || type === 'plugin') && Component) {
+ return ;
+ }
+
+ if (type === 'link') {
+ return (
+
+ {glyph ? : null}
+ {glyph && labelNode ? ' ' : null}
+ {labelNode}
+
+ );
+
+ }
+
+ if (type === 'logo') {
+ const imageNode = ;
+ return ({href ? (
+
+ {imageNode}
+
+ ) : imageNode} );
+
+ }
+
+ if (type === 'button') {
+ return (
+
+ {glyph ? : null}
+ {glyph && labelNode ? ' ' : null}
+ {labelNode}
+
+ );
+ }
+
+ if (type === 'divider') {
+ return
;
+ }
+
+ if (type === 'placeholder') {
+ return ;
+ }
+
+ if (type === 'filter') {
+ const active = castArray(query.f || []).find(value => value === item.id);
+ return (
+
+ {glyph ? : null}
+ {glyph && labelNode ? ' ' : null}
+ {labelNode}
+ {isValidBadgeValue(badgeValue) && {badgeValue} }
+
+ );
+ }
+ return null;
+};
+
+MenuItem.propTypes = {
+ item: PropTypes.object.isRequired,
+ menuItemsProps: PropTypes.object.isRequired,
+ containerNode: PropTypes.element,
+ tabIndex: PropTypes.number,
+ draggable: PropTypes.bool,
+ classItem: PropTypes.string
+
+};
+
+export default MenuItem;
diff --git a/web/client/plugins/ResourcesCatalog/components/MenuNavLink.jsx b/web/client/plugins/ResourcesCatalog/components/MenuNavLink.jsx
new file mode 100644
index 0000000000..170f33837a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/MenuNavLink.jsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024, 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, { forwardRef } from 'react';
+
+const MenuNavLink = forwardRef(({
+ children,
+ className,
+ ...props
+}, ref) => {
+ return (
+
+ {children}
+
+ );
+});
+
+export default MenuNavLink;
diff --git a/web/client/plugins/ResourcesCatalog/components/PaginationCustom.jsx b/web/client/plugins/ResourcesCatalog/components/PaginationCustom.jsx
new file mode 100644
index 0000000000..2399a2b019
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/PaginationCustom.jsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024, 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, { useEffect, useState } from 'react';
+import { Pagination } from 'react-bootstrap';
+import Icon from './Icon';
+
+function PaginationCustom({
+ activePage,
+ items,
+ onSelect
+}) {
+ const [page, setPage] = useState(activePage);
+ function handleSelect(value) {
+ setPage(value);
+ onSelect(value);
+ }
+ useEffect(() => {
+ if (activePage !== page) {
+ setPage(activePage);
+ }
+ }, [activePage]);
+ return (
+ }
+ next={ }
+ ellipsis
+ boundaryLinks
+ items={items}
+ maxButtons={3}
+ activePage={page}
+ onSelect={handleSelect}
+ />
+ );
+}
+
+export default PaginationCustom;
diff --git a/web/client/plugins/ResourcesCatalog/components/Permissions.jsx b/web/client/plugins/ResourcesCatalog/components/Permissions.jsx
new file mode 100644
index 0000000000..074a3654e0
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Permissions.jsx
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect, useRef } from 'react';
+import Message from '../../../components/I18N/Message';
+import { FormControl as FormControlRB, Nav, NavItem } from 'react-bootstrap';
+import Popover from '../../../components/styleeditor/Popover';
+import Button from './Button';
+import PermissionsAddEntriesPanel from './PermissionsAddEntriesPanel';
+import PermissionsRow from './PermissionsRow';
+import Icon from './Icon';
+import localizedProps from '../../../components/misc/enhancers/localizedProps';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import Spinner from './Spinner';
+import ALink from './ALink';
+
+const FormControl = localizedProps('placeholder')(FormControlRB);
+
+function Permissions({
+ editing,
+ compactPermissions = {},
+ onChange = () => {},
+ entriesTabs = [],
+ loading,
+ permissionOptions,
+ permissionsToLists = (value) => value,
+ listsToPermissions = (value) => value,
+ showGroupsPermissions = true
+}) {
+
+ const { entries = [], groups = [] } = permissionsToLists(compactPermissions);
+ const [activeTab, setActiveTab] = useState(entriesTabs?.[0]?.id || '');
+ const [permissionsEntires, setPermissionsEntires] = useState(entries);
+ const [permissionsGroups, setPermissionsGroups] = useState(groups);
+
+ const [order, setOrder] = useState([]);
+ const [filter, setFilter] = useState('');
+
+ function handleChange(newValues) {
+ onChange(listsToPermissions({
+ entries: permissionsEntires,
+ groups: permissionsGroups,
+ ...newValues
+ }));
+ }
+
+ function handleUpdateGroup(groupId, properties) {
+ const newGroups = permissionsGroups.map(group => {
+ if (group.id === groupId) {
+ return {
+ ...group,
+ ...properties
+ };
+ }
+ return group;
+ });
+ setPermissionsGroups(newGroups);
+ handleChange({ groups: newGroups });
+ }
+
+ function handleAddNewEntry(newEntry) {
+ const newEntries = [
+ ...permissionsEntires,
+ {
+ ...newEntry,
+ permissions: 'view'
+ }
+ ];
+ setPermissionsEntires(newEntries);
+ handleChange({ entries: newEntries });
+ }
+
+ function handleRemoveEntry(newEntry) {
+ const newEntries = permissionsEntires.filter(entry => entry.id !== newEntry.id);
+ setPermissionsEntires(newEntries);
+ handleChange({ entries: newEntries });
+ }
+
+ function handleUpdateEntry(entryId, properties, noCallback) {
+ const newEntries = permissionsEntires.map(entry => {
+ if (entry.id === entryId) {
+ return {
+ ...entry,
+ ...properties
+ };
+ }
+ return entry;
+ });
+ setPermissionsEntires(newEntries);
+ if (!noCallback) {
+ handleChange({ entries: newEntries });
+ }
+ }
+
+ function sortEntries(key) {
+ const direction = !order[1];
+ setOrder([key, direction]);
+ function sortByKey(a, b) {
+ const aProperty = (a[key] || '').toLowerCase();
+ const bProperty = (b[key] || '').toLowerCase();
+ return direction
+ ? (aProperty > bProperty ? 1 : -1)
+ : (aProperty > bProperty ? -1 : 1);
+ }
+ setPermissionsEntires(
+ [...permissionsEntires]
+ .sort(sortByKey)
+ );
+ setPermissionsGroups(
+ [...permissionsGroups]
+ .sort(sortByKey)
+ );
+ }
+
+ useEffect(() => {
+ sortEntries(order[1] || 'name');
+ }, []);
+
+ const filteredEntries = permissionsEntires
+ .filter((entry) => !filter
+ || (entry?.name?.toLowerCase()?.includes(filter?.toLowerCase())
+ || entry?.permissions?.toLowerCase()?.includes(filter?.toLowerCase())));
+
+ const isMounted = useRef();
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ const hasFiltrablePermissions = !!permissionsEntires.filter((item) => item.permissions !== 'owner' && !item.is_superuser)?.length;
+
+ return (
+
+ {showGroupsPermissions ?
+
+ {permissionsEntires
+ .filter((item) => item.permissions === 'owner' && !item.is_superuser)
+ .map((item, idx) => {
+ return (
+
+
+
+ :
+
+
+
+
+ {item.avatar
+ ?
+ : }
+
+
+ {item.name}
+
+
+
+
+ );
+ })}
+ {permissionsGroups
+ .map((group, idx) => {
+ return (
+
+ { }}
+ options={permissionOptions?.[group.name] || permissionOptions?.default}
+ />
+
+ );
+ })}
+
+
: null}
+
+
+ {!hasFiltrablePermissions && !editing ? null :
+ setFilter(event.target.value)}
+ />
+ {filter && setFilter('')}>
+
+ }
+ }
+ {editing ?
+
+ {entriesTabs.map((tab) => {
+ return (
+ setActiveTab(tab.id)}
+ >
+
+
+ );
+ })}
+
+
+ {entriesTabs
+ .filter(tab => tab.id === activeTab)
+ .map(tab => {
+ return (
+ tab.request({
+ ...params,
+ entries: permissionsEntires,
+ groups: permissionsGroups
+ })}
+ onAdd={handleAddNewEntry}
+ onRemove={handleRemoveEntry}
+ responseToEntries={(response) =>
+ tab.responseToEntries({ response, entries: permissionsEntires })
+ }
+ />
+ );
+ })}
+
+
+ }>
+
+ {' '}
+
+ : null}
+
+ {hasFiltrablePermissions ?
+
+
+
+ {order[0] === 'name' && <>{' '} >}
+
+
+
+
+
+ {order[0] === 'permissions' && <>{' '} >}
+
+
+ : null}
+
+
+ {filteredEntries
+ .filter((item) => item.permissions !== 'owner' && !item.is_superuser)
+ .map((entry, idx) => {
+ return (
+
+
+ {entry.permissions !== 'owner' && editing ?
+ <>
+
+
+
+ >
+ : null}
+
+
+ );
+ })}
+
+ {(filteredEntries.length === 0 && filter) ?
+
+
+
+ : null}
+ {loading ? (
+
+
+
+
+
+ ) : null}
+
+ );
+}
+
+export default Permissions;
diff --git a/web/client/plugins/ResourcesCatalog/components/PermissionsAddEntriesPanel.jsx b/web/client/plugins/ResourcesCatalog/components/PermissionsAddEntriesPanel.jsx
new file mode 100644
index 0000000000..004f1f8159
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/PermissionsAddEntriesPanel.jsx
@@ -0,0 +1,186 @@
+/*
+ * Copyright 2024, 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, { useEffect, useState, useRef } from 'react';
+import PropTypes from 'prop-types';
+import Message from '../../../components/I18N/Message';
+import Button from './Button';
+import Icon from './Icon';
+import useInfiniteScroll from '../hooks/useInfiniteScroll';
+import PermissionsRow from './PermissionsRow';
+import Spinner from './Spinner';
+import InputControl from './InputControl';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+function PermissionsAddEntriesPanel({
+ request,
+ responseToEntries,
+ onAdd,
+ onRemove,
+ defaultPermission,
+ pageSize,
+ placeholderId
+}) {
+
+ const scrollContainer = useRef();
+ const [entries, setEntries] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [page, setPage] = useState(1);
+ const [isNextPageAvailable, setIsNextPageAvailable] = useState(false);
+ const [q, setQ] = useState('');
+ const isMounted = useRef();
+
+ useInfiniteScroll({
+ scrollContainer: scrollContainer.current,
+ shouldScroll: () => !loading && isNextPageAvailable,
+ onLoad: () => {
+ setPage(page + 1);
+ }
+ });
+
+ const updateRequest = useRef();
+ updateRequest.current = (options) => {
+ if (!loading && request) {
+ setLoading(true);
+ request({
+ q,
+ page: options.page,
+ pageSize
+ })
+ .then((response) => {
+ if (isMounted.current) {
+ const newEntries = responseToEntries(response);
+ setIsNextPageAvailable(response.isNextPageAvailable);
+ setEntries(options.page === 1 ? newEntries : [...entries, ...newEntries]);
+ setLoading(false);
+ }
+ })
+ .catch(() => {
+ if (isMounted.current) {
+ setLoading(false);
+ }
+ });
+ }
+ };
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (page > 1) {
+ updateRequest.current({ page });
+ }
+ }, [page]);
+
+ useEffect(() => {
+ setPage(1);
+ updateRequest.current({ page: 1 });
+ }, [q]);
+
+ function updateEntries(newEntry) {
+ setEntries(entries.map(entry => entry.id === newEntry.id ? newEntry : entry));
+ }
+ function handleAdd(entry) {
+ const newEntry = {
+ ...entry,
+ permissions: defaultPermission
+ };
+ onAdd(newEntry);
+ updateEntries(newEntry);
+ }
+ function handleRemove(entry) {
+ const { permissions, ...newEntry } = entry;
+ onRemove(newEntry);
+ updateEntries(newEntry);
+ }
+
+ return (
+
+
+ setQ(value)}
+ />
+ {(q && !loading) && setQ('')}>
+
+ }
+ {loading && }
+
+
+ {entries.map((entry, idx) => {
+ return (
+
+
+ {entry.permissions
+ ? handleRemove(entry)}
+ >
+
+
+ : handleAdd(entry)}
+ >
+
+
+ }
+
+
+ );
+ })}
+ {(entries.length === 0 && !loading) &&
+
+
+
+ }
+
+
+ );
+}
+
+PermissionsAddEntriesPanel.propTypes = {
+ request: PropTypes.func,
+ responseToEntries: PropTypes.func,
+ onAdd: PropTypes.func,
+ onRemove: PropTypes.func,
+ defaultPermission: PropTypes.string,
+ pageSize: PropTypes.number,
+ placeholderId: PropTypes.string
+};
+
+PermissionsAddEntriesPanel.defaultProps = {
+ defaultPermission: 'view',
+ pageSize: 20,
+ onAdd: () => {},
+ onRemove: () => {},
+ responseToEntries: res => res.resources,
+ placeholderId: 'resourcesCatalog.filterBy'
+};
+
+export default PermissionsAddEntriesPanel;
diff --git a/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx b/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx
new file mode 100644
index 0000000000..68c88cdf97
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/PermissionsRow.jsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024, 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 PropTypes from 'prop-types';
+import Select from 'react-select';
+import Icon from './Icon';
+import Message from '../../../components/I18N/Message';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+function PermissionsRow({
+ type,
+ name,
+ options,
+ hideOptions,
+ hideIcon,
+ permissions,
+ avatar,
+ children,
+ clearable,
+ onChange
+}) {
+
+ const valueOption = options.find(option => option.value === permissions);
+
+ const valueNode = onChange
+ ? (
+ ({ value, label: label ? {label} : }))}
+ value={permissions}
+ onChange={(option) => onChange({ permissions: option?.value || '' })}
+ />
+ ) : ({valueOption?.labelId ? : null} );
+
+ return (
+
+
+ {(!hideIcon && (type || avatar)) &&
+ {avatar
+ ?
+ : }
+ }
+ {name}
+
+
+ {children}
+
+ {!hideOptions ?
+ {valueNode}
+
: null}
+
+ );
+}
+
+PermissionsRow.propTypes = {
+ options: PropTypes.array,
+ clearable: PropTypes.bool,
+ onChange: PropTypes.func
+};
+
+PermissionsRow.defaultProps = {
+ options: [
+ {
+ value: 'view',
+ labelId: 'resourcesCatalog.viewPermission'
+ },
+ {
+ value: 'download',
+ labelId: 'resourcesCatalog.downloadPermission'
+ },
+ {
+ value: 'edit',
+ labelId: 'resourcesCatalog.editPermission'
+ },
+ {
+ value: 'manage',
+ labelId: 'resourcesCatalog.managePermission'
+ }
+ ],
+ clearable: false,
+ onChange: () => { }
+};
+
+export default PermissionsRow;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourceCard.jsx b/web/client/plugins/ResourcesCatalog/components/ResourceCard.jsx
new file mode 100644
index 0000000000..263ca6f34f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourceCard.jsx
@@ -0,0 +1,433 @@
+/*
+ * Copyright 2024, 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, { forwardRef, useState } from 'react';
+import Message from '../../../components/I18N/Message';
+import Icon from './Icon';
+import Button from './Button';
+import Spinner from './Spinner';
+import ResourceStatus from './ResourceStatus';
+import ResourceCardActionButtons from './ResourceCardActionButtons';
+import ALink from './ALink';
+import moment from 'moment';
+import castArray from 'lodash/castArray';
+import { isObject, get } from 'lodash';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import tooltip from '../../../components/misc/enhancers/tooltip';
+const ButtonWithTooltip = tooltip(Button);
+
+const ResourceCardButton = ({
+ glyph,
+ iconType,
+ labelId,
+ onClick,
+ square,
+ variant,
+ borderTransparent,
+ ...props
+}) => {
+ function handleOnClick(event) {
+ event.stopPropagation();
+ if (onClick) {
+ onClick(event);
+ }
+ }
+ return (
+
+ {glyph ? <>> : null}
+ {glyph && labelId ? ' ' : null}
+ {labelId && !square ? : null}
+
+ );
+};
+
+const ResourceCardWrapper = ({
+ children,
+ viewerUrl,
+ readOnly,
+ resource,
+ active,
+ interactive,
+ ...props
+}) => {
+ const showViewerLink = !!(!readOnly && viewerUrl);
+ return (
+
+ {showViewerLink ? (
+
+ ) : null}
+ {children}
+
+ );
+};
+
+const ResourceCardMetadataValue = tooltip(({
+ value,
+ entry,
+ readOnly,
+ formatHref,
+ query,
+ ...props
+}) => {
+
+ const getFilterActiveClassName = (filter, val) => {
+ const filters = castArray(query[filter] || []);
+ return filters.includes(val) ? ' active' : '';
+ };
+
+ const getProperties = () => {
+ if (isObject(value)) {
+ return {
+ value: value[entry.itemValue],
+ color: value[entry.itemColor]
+ };
+ }
+ return {
+ value
+ };
+ };
+
+ const properties = getProperties();
+
+ return (
+
+ {entry.type === 'date' && entry.format && properties.value
+ ? moment(properties.value).format(entry.format)
+ : properties.value}
+
+ );
+});
+
+const ResourceCardMetadataEntry = ({
+ entry,
+ value,
+ formatHref,
+ readOnly,
+ query,
+ column,
+ ...props
+}) => {
+ return (
+
+ {entry.icon ? <> {' '}> : null}
+ {Array.isArray(value)
+ ? value.map((val, idx) => {
+ return ();
+ })
+ : value !== undefined
+ ?
+ : entry?.noDataLabelId
+ ?
+ : null}
+
+ );
+};
+
+const ResourceCardImage = ({
+ icon,
+ src,
+ className
+}) => {
+ const [imgError, setImgError] = useState(false);
+ return (imgError || !src) ? (
+
+
+
+
+
+ ) : (
+ setImgError(true)}
+ />
+ );
+};
+
+const ResourceCardGridBody = ({
+ icon,
+ loading,
+ downloading,
+ metadata,
+ resource,
+ formatHref,
+ readOnly,
+ query,
+ viewerUrl,
+ buttons,
+ statusItems,
+ options,
+ thumbnailUrl,
+ getResourceId
+}) => {
+
+ const headerEntry = metadata.find(entry => entry.target === 'header');
+ const footerEntry = metadata.find(entry => entry.target === 'footer');
+
+ return (
+
+
+
+
+
+
+ {(icon && !loading && !downloading) && (
+ <> {' '}>
+ )}
+ {headerEntry?.path ? : null}
+
+
+
+
+ {metadata.filter(entry => !['header', 'footer'].includes(entry.target)).map((entry) => {
+ const value = get(resource, entry.path);
+ if (!value) {
+ return null;
+ }
+ return (
+
+ );
+ })}
+
+
+ {footerEntry?.path ? : null}
+
+
+ {buttons.map(({ Component, name }) => {
+ return (
+
+ );
+ })}
+
+
+
+ {!readOnly && options?.length > 0
+ ? (
+
+ )
+ : null}
+
+ );
+};
+
+const ResourceCardListBody = ({
+ icon,
+ loading,
+ downloading,
+ metadata,
+ resource,
+ formatHref,
+ readOnly,
+ query,
+ viewerUrl,
+ options: optionsProp,
+ buttons,
+ columns,
+ getResourceId
+}) => {
+ const options = [
+ ...(buttons || []),
+ ...(optionsProp || [])
+ ];
+ return (
+
+
+ {(icon && !loading && !downloading) && (
+
+ )}
+ {(loading || downloading) && }
+
+
+ {metadata.map((entry) => {
+ const value = get(resource, entry.path);
+ const column = columns.find(col => col.path === entry.path);
+ return (
+
+ );
+ })}
+
+
+ {!readOnly && options?.length > 0
+ ? (
+
+ )
+ : null}
+
+
+ );
+};
+
+const cardBody = {
+ grid: ResourceCardGridBody,
+ list: ResourceCardListBody
+};
+
+const ResourceCard = forwardRef(({
+ data,
+ active,
+ options = [],
+ layoutCardsStyle,
+ readOnly,
+ className,
+ loading,
+ downloading,
+ statusItems,
+ buttons = [],
+ component,
+ query,
+ metadata = [],
+ columns = [],
+ getResourceTypesInfo = () => ({}),
+ formatHref,
+ getResourceId
+}, ref) => {
+
+ const resource = data;
+ const {
+ icon,
+ viewerUrl,
+ thumbnailUrl
+ } = getResourceTypesInfo(resource) || {};
+
+ const CardComponent = component || ResourceCardWrapper;
+ const CardBody = cardBody[layoutCardsStyle];
+ return (
+
+ {CardBody ? : null}
+
+ );
+});
+
+ResourceCard.defaultProps = {
+ links: [],
+ theme: 'light',
+ formatHref: () => '#',
+ featured: false
+};
+
+export default ResourceCard;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourceCardActionButtons.jsx b/web/client/plugins/ResourcesCatalog/components/ResourceCardActionButtons.jsx
new file mode 100644
index 0000000000..d19f4aa017
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourceCardActionButtons.jsx
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024, 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, { useRef } from 'react';
+import PropTypes from 'prop-types';
+import Icon from './Icon';
+import { Dropdown, MenuItem } from 'react-bootstrap';
+import Message from '../../../components/I18N/Message';
+
+const ActionMenuItem = ({
+ glyph,
+ iconType,
+ children,
+ labelId,
+ ...props
+}) => {
+ return (
+
+ {glyph ? <>{' '}> : null}
+ {labelId ? : null}
+
+ );
+};
+
+function ResourceCardActionButtons({
+ options,
+ viewerUrl,
+ resource,
+ className,
+ getResourceId = () => '',
+ ...props
+}) {
+
+ const containerNode = useRef();
+ const dropdownClassName = 'ms-card-dropdown';
+ const dropdownNode = containerNode?.current?.querySelector(`.${dropdownClassName}`);
+ const isDropdownEmpty = (dropdownNode?.children?.length || 0) === 0;
+
+ return (
+ event.stopPropagation()}
+ style={isDropdownEmpty ? { display: 'none' } : {}}
+ >
+
+
+
+
+
+ {options.map((option) => {
+ if (option.Component) {
+ const { Component } = option;
+ return ;
+ }
+ return null;
+ })}
+
+
+
+ );
+}
+
+ResourceCardActionButtons.propTypes = {
+ options: PropTypes.array,
+ resource: PropTypes.object
+};
+
+ResourceCardActionButtons.defaultProps = {
+ options: [],
+ resource: {}
+};
+
+export default ResourceCardActionButtons;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourceStatus.jsx b/web/client/plugins/ResourcesCatalog/components/ResourceStatus.jsx
new file mode 100644
index 0000000000..91447bacb8
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourceStatus.jsx
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2024, 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 Message from '../../../components/I18N/Message';
+import PropTypes from 'prop-types';
+import IconComponent from './Icon';
+import tooltip from '../../../components/misc/enhancers/tooltip';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+const Icon = ({ glyph, type, ...props }) => {
+ return (
);
+};
+
+const IconWithTooltip = tooltip(Icon);
+
+const ResourceStatus = ({ statusItems = [] }) => {
+
+ if (!statusItems?.length) {
+ return null;
+ }
+ return (
+
+ {statusItems.map((item, idx) => {
+ if (item.type === 'text') {
+ return (
+
+
+
+ );
+ }
+ if (item.type === 'icon') {
+ return (
+
+
+
+ );
+ }
+ return null;
+ })}
+
+ );
+};
+
+ResourceStatus.propTypes = {
+ statusItems: PropTypes.array
+};
+
+ResourceStatus.defaultProps = {
+ statusItems: []
+};
+
+
+export default ResourceStatus;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourcesContainer.jsx b/web/client/plugins/ResourcesCatalog/components/ResourcesContainer.jsx
new file mode 100644
index 0000000000..1fd00495a3
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourcesContainer.jsx
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2024, 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 HTML from '../../../components/I18N/HTML';
+import ResourceCard from './ResourceCard';
+import FlexBox from './FlexBox';
+import Text from './Text';
+import Spinner from './Spinner';
+
+const ResourcesContainer = (props) => {
+ const {
+ resources,
+ isCardActive,
+ containerStyle,
+ header,
+ cardOptions,
+ children,
+ footer,
+ cardLayoutStyle,
+ loading,
+ getMainMessageId = () => '',
+ onSelect,
+ theme = 'main',
+ cardButtons,
+ cardComponent,
+ query,
+ columns,
+ metadata,
+ getResourceStatus,
+ formatHref,
+ getResourceTypesInfo,
+ getResourceId
+ } = props;
+ const messageId = getMainMessageId(props);
+ return (
+
+
+ {header}
+ {children}
+
+ {resources.map((resource, idx) => {
+ const {
+ isProcessing,
+ isDownloading,
+ items: statusItems
+ } = getResourceStatus(resource);
+ // enable allowedOptions (menu cards)
+ const allowedOptions = !isProcessing ? cardOptions : [];
+ return (
+
+
+
+ );
+ })}
+ {messageId ?
+
+
+
+
+ : null}
+ {loading ?
+
+
+
+ : null}
+
+ {footer}
+
+
+ );
+};
+
+ResourcesContainer.defaultProps = {
+ resources: [],
+ loading: false,
+ formatHref: () => '#',
+ isCardActive: () => false,
+ getMessageId: () => undefined,
+ getResourceStatus: () => ({})
+};
+
+export default ResourcesContainer;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourcesMenu.jsx b/web/client/plugins/ResourcesCatalog/components/ResourcesMenu.jsx
new file mode 100644
index 0000000000..eb9339102e
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourcesMenu.jsx
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2024, 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, { forwardRef, useState, useRef, useEffect } from 'react';
+import Message from '../../../components/I18N/Message';
+import Menu from './Menu';
+
+import Spinner from './Spinner';
+import Icon from './Icon';
+import Button from './Button';
+import { Dropdown, MenuItem } from 'react-bootstrap';
+import FlexBox from './FlexBox';
+import Text from './Text';
+
+const ResourcesListHeader = ({
+ columns,
+ metadata,
+ setColumns
+}) => {
+
+ const container = useRef();
+ const [selected, setSelected] = useState();
+
+ const matchPaths = () => {
+ const columnsPaths = columns.map(entry => entry.path).join(',');
+ const metadataPaths = metadata.map(entry => entry.path).join(',');
+ return columnsPaths === metadataPaths;
+ };
+ const init = useRef();
+ init.current = () => {
+ if (!columns?.length || !matchPaths()) {
+ const total = metadata.reduce((sum, en) => sum + en.width, 0);
+ setColumns(metadata.map((entry, idx) => {
+ // compute the correct percentage in case the sum of metadata widths is not 100
+ const width = entry.width / total * 100;
+ return {
+ path: entry.path,
+ width: width,
+ right: metadata.filter((en, jdx) => jdx < idx).reduce((sum, en) => sum + (en.width / total * 100), 0),
+ left: metadata.filter((en, jdx) => jdx <= idx).reduce((sum, en) => sum + (en.width / total * 100), 0)
+ };
+ }));
+ }
+ };
+
+ useEffect(() => {
+ init.current();
+ }, []);
+
+ return (
+
+
+ {
+ event.preventDefault();
+ if (selected !== undefined && container.current) {
+ const containerNode = container.current;
+ const column = columns[selected];
+ const nextColumn = columns[selected + 1];
+ const rect = containerNode.getBoundingClientRect();
+ const newLeft = Math.round(((event.clientX - rect.x) / rect.width) * 100);
+ if (newLeft > column.right && newLeft < nextColumn.left) {
+ setColumns(columns
+ .map((prevColumn, idx) =>
+ idx === selected
+ ? { ...prevColumn, width: newLeft - column.right }
+ : (selected + 1) === idx
+ ? { ...prevColumn, width: prevColumn.left - newLeft }
+ : prevColumn)
+ .map((entry, idx, arr) => ({
+ path: entry.path,
+ width: entry.width,
+ right: arr.filter((en, jdx) => jdx < idx).reduce((sum, en) => sum + en.width, 0),
+ left: arr.filter((en, jdx) => jdx <= idx).reduce((sum, en) => sum + en.width, 0)
+ }))
+ );
+ }
+ }
+ }}
+ onPointerLeave={() => {
+ setSelected();
+ }}
+ onPointerUp={() => {
+ setSelected();
+ }}
+ onPointerDown={(event) => {
+ event.preventDefault();
+ const columnIndex = event.target.getAttribute('data-column-index');
+ if (columnIndex) {
+ setSelected(parseFloat(columnIndex));
+ }
+ }}
+ >
+ {columns.filter((column, idx) => idx < columns.length - 1).map((column, idx) => {
+ return (
);
+ })}
+ {columns.map((entry) => {
+ const property = metadata.find(en => en.path === entry.path);
+ return (
+ {property?.labelId ? : null}
+ );
+ })}
+
+
+
+ );
+};
+
+const ResourcesMenu = forwardRef(({
+ menuItems,
+ style,
+ totalResources,
+ loading,
+ hideCardLayoutButton,
+ cardLayoutStyle,
+ setCardLayoutStyle,
+ orderConfig,
+ query,
+ formatHref,
+ titleId,
+ theme = 'main',
+ menuItemsLeft = [],
+ columns,
+ setColumns,
+ metadata
+}, ref) => {
+
+ const {
+ defaultLabelId,
+ options: orderOptions = [],
+ variant: orderVariant,
+ align: orderAlign = 'right'
+ } = orderConfig || {};
+
+ const selectedSort = orderOptions.find(({ value }) => query?.sort === value);
+ function handleToggleCardLayoutStyle() {
+ setCardLayoutStyle(cardLayoutStyle === 'grid' ? 'list' : 'grid');
+ }
+
+ const orderButtonNode = orderOptions.length > 0 &&
+
+
+
+
+
+ {orderOptions.map(({ labelId, value }) => {
+ return (
+
+
+
+ );
+ })}
+
+ ;
+
+ return (
+
+ {titleId
+ ?
+
+
+ : null}
+
+
+ {menuItemsLeft.map(({ Component, name }) => {
+ return ( );
+ })}
+ {orderAlign === 'left' ? orderButtonNode : null}
+
+ {loading
+ ?
+ : }
+
+
+
+ {!hideCardLayoutButton &&
+
+ }
+ {orderAlign === 'right' ? orderButtonNode : null}
+
+ {cardLayoutStyle === 'list' ? : null}
+
+ );
+});
+
+ResourcesMenu.defaultProps = {
+ orderOptions: [
+ {
+ label: 'Most recent',
+ labelId: 'resourcesCatalog.mostRecent',
+ value: '-date'
+ },
+ {
+ label: 'Less recent',
+ labelId: 'resourcesCatalog.lessRecent',
+ value: 'date'
+ },
+ {
+ label: 'A Z',
+ labelId: 'resourcesCatalog.aZ',
+ value: 'title'
+ },
+ {
+ label: 'Z A',
+ labelId: 'resourcesCatalog.zA',
+ value: '-title'
+ },
+ {
+ label: 'Most popular',
+ labelId: 'resourcesCatalog.mostPopular',
+ value: 'popular_count'
+ }
+ ],
+ defaultLabelId: 'resourcesCatalog.orderBy',
+ formatHref: () => '#'
+};
+
+export default ResourcesMenu;
diff --git a/web/client/plugins/ResourcesCatalog/components/ResourcesPanelWrapper.jsx b/web/client/plugins/ResourcesCatalog/components/ResourcesPanelWrapper.jsx
new file mode 100644
index 0000000000..ee039da508
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ResourcesPanelWrapper.jsx
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024, 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, { forwardRef } from 'react';
+import FlexBox from './FlexBox';
+
+const ResourcesPanelWrapper = forwardRef(({
+ top,
+ bottom,
+ show,
+ enabled,
+ children,
+ className,
+ editing
+}, ref) => {
+ return enabled ? (
+
+
+ {show ? children : null}
+
+
+ ) : null;
+});
+
+export default ResourcesPanelWrapper;
diff --git a/web/client/plugins/ResourcesCatalog/components/SelectInfiniteScroll.jsx b/web/client/plugins/ResourcesCatalog/components/SelectInfiniteScroll.jsx
new file mode 100644
index 0000000000..3f4ff9824e
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/SelectInfiniteScroll.jsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2024, 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, { useRef, useState, useEffect } from 'react';
+import axios from '../../../libs/ajax';
+import debounce from 'lodash/debounce';
+import ReactSelect from 'react-select';
+import localizedProps from '../../../components/misc/enhancers/localizedProps';
+
+const SelectSync = localizedProps('placeholder')(ReactSelect);
+
+function SelectInfiniteScroll({
+ loadOptions,
+ pageSize = 20,
+ debounceTime = 500,
+ ...props
+}) {
+
+ const [text, setText] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [isNextPageAvailable, setIsNextPageAvailable] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [page, setPage] = useState(1);
+ const [options, setOptions] = useState([]);
+
+ const source = useRef();
+ const debounced = useRef();
+
+ const createToken = () => {
+ if (source.current) {
+ source.current?.cancel();
+ source.current = undefined;
+ }
+ const cancelToken = axios.CancelToken;
+ source.current = cancelToken.source();
+ };
+
+ const handleUpdateOptions = useRef();
+ handleUpdateOptions.current = (args = {}) => {
+ createToken();
+ const { q } = args;
+ const query = q ?? text;
+ setLoading(true);
+ const newPage = args.page || page;
+ loadOptions({
+ q: query,
+ page: newPage,
+ pageSize,
+ config: {
+ cancelToken: source.current.token
+ }
+ })
+ .then((response) => {
+ const newOptions = response.results.map(({ selectOption }) => selectOption);
+ setOptions(newPage === 1 ? newOptions : [...options, ...newOptions]);
+ setIsNextPageAvailable(response.isNextPageAvailable);
+ setLoading(false);
+ source.current = undefined;
+ })
+ .catch(() => {
+ setOptions([]);
+ setIsNextPageAvailable(false);
+ setLoading(false);
+ source.current = undefined;
+ });
+ };
+
+ function handleInputChange(value) {
+ if (source.current) {
+ source.current?.cancel();
+ source.current = undefined;
+ }
+
+ debounced.current.cancel();
+ debounced.current(value);
+ }
+
+ useEffect(() => {
+ debounced.current = debounce((value) => {
+ if (value !== text) {
+ setText(value);
+ setPage(1);
+ setOptions([]);
+ handleUpdateOptions.current({ q: value, page: 1 });
+ }
+ }, debounceTime);
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ setText('');
+ setPage(1);
+ setOptions([]);
+ handleUpdateOptions.current({q: '', page: 1});
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (page > 1) {
+ handleUpdateOptions.current();
+ }
+ }, [page]);
+
+ return (
+ setOpen(true)}
+ onClose={() => setOpen(false)}
+ filterOptions={(currentOptions) => {
+ return currentOptions;
+ }}
+ onInputChange={(q) => handleInputChange(q)}
+ onMenuScrollToBottom={() => {
+ if (!loading && isNextPageAvailable) {
+ setPage(page + 1);
+ }
+ }}
+ />
+ );
+}
+
+export default SelectInfiniteScroll;
diff --git a/web/client/plugins/ResourcesCatalog/components/Spinner.jsx b/web/client/plugins/ResourcesCatalog/components/Spinner.jsx
new file mode 100644
index 0000000000..8df5d057f9
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Spinner.jsx
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024, 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 PropTypes from 'prop-types';
+
+function Spinner({
+ id,
+ className,
+ style,
+ children,
+ ...props
+}) {
+ const customClassName = className ? ' ' + className : '';
+ return (
+ <>
+
+ {children}
+ >
+ );
+}
+
+Spinner.propTypes = {
+ id: PropTypes.string,
+ className: PropTypes.string,
+ style: PropTypes.object
+};
+
+Spinner.defaultProps = {};
+
+export default Spinner;
diff --git a/web/client/plugins/ResourcesCatalog/components/Tabs.jsx b/web/client/plugins/ResourcesCatalog/components/Tabs.jsx
new file mode 100644
index 0000000000..4ba5505e3d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Tabs.jsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState, useEffect } from 'react';
+import PropTypes from "prop-types";
+import isNil from 'lodash/isNil';
+import { Tabs as RTabs, Tab } from 'react-bootstrap';
+import useLocalStorage from '../hooks/useLocalStorage';
+
+/**
+ * Tabs component
+ * @param {array} tabs list of components
+ * @param {string} identifier unique key (mandatory when persisting selection i.e `persistSelection: true`)
+ * @param {string} selectedTabId contains the selected tab key (controlled)
+ * (Note: `selectedTabId` works in tandem with `eventKey` on Tab). Hence it is mandatory to supply `eventKey` on Tab component
+ * @param {function} onSelect custom function to select the tab key (controlled)
+ * @param {string} className custom class name
+ * @param {boolean} persistSelection flag determines the persisting of the tab selection. By default the selection is not persisted
+ */
+const Tabs = ({
+ tabs = [],
+ identifier,
+ selectedTabId,
+ onSelect,
+ className,
+ persistSelection
+}) => {
+ const [eventKeys, setEventKeys] = useLocalStorage('tabSelected', {});
+ const persist = persistSelection && identifier;
+ const [activeKey, setActiveKey] = useState(null);
+
+ useEffect(() => {
+ const selectedKey = !isNil(selectedTabId)
+ ? selectedTabId
+ : persist ? (eventKeys[identifier] ?? 0) : 0;
+ setActiveKey(selectedKey);
+ }, [selectedTabId, persist, eventKeys, identifier]);
+
+ const onSelectTab = (key) => {
+ if (!persist) {
+ setActiveKey(key);
+ } else {
+ const updatedEventKeys = {
+ ...eventKeys,
+ [identifier]: key
+ };
+ setEventKeys(updatedEventKeys);
+ }
+ };
+ return (
+
+ {tabs.map((tab, index)=> {
+ const eventKey = !isNil(tab.eventKey) ? tab.eventKey : index;
+ const component = activeKey === eventKey ? tab.component : null;
+ return (
+
+ {component}
+ );
+ })}
+
+ );
+};
+
+Tabs.propTypes = {
+ className: PropTypes.string,
+ tabs: PropTypes.arrayOf(PropTypes.shape({
+ title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+ component: PropTypes.node,
+ eventKey: PropTypes.string
+ })),
+ identifier: PropTypes.string,
+ selectedTabId: PropTypes.string,
+ onSelect: PropTypes.func
+};
+
+Tabs.defaultProps = {
+ tabs: [],
+ className: "ms-tabs tabs-underline",
+ persistSelection: false
+};
+
+export default Tabs;
diff --git a/web/client/plugins/ResourcesCatalog/components/TargetSelectorPortal.jsx b/web/client/plugins/ResourcesCatalog/components/TargetSelectorPortal.jsx
new file mode 100644
index 0000000000..1211bdc0ce
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/TargetSelectorPortal.jsx
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2024, 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 { createPortal } from 'react-dom';
+
+function TargetSelectorPortal({ targetSelector = '', children }) {
+ const parent = targetSelector ? document.querySelector(targetSelector) : null;
+ if (parent) {
+ return createPortal(children, parent);
+ }
+ return {children} ;
+}
+
+export default TargetSelectorPortal;
diff --git a/web/client/plugins/ResourcesCatalog/components/Text.jsx b/web/client/plugins/ResourcesCatalog/components/Text.jsx
new file mode 100644
index 0000000000..f3c32f1165
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/Text.jsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024, 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, { forwardRef } from 'react';
+
+const addPrefix = (value) => {
+ return value ? `_${value}` : undefined;
+};
+
+const Text = forwardRef(({
+ children,
+ className,
+ classNames = [],
+ component = 'div',
+ fontSize,
+ ellipsis,
+ textAlign,
+ strong,
+ ...props
+}, ref) => {
+ const Component = component;
+ return (
+ cls).join(' ')}
+ >
+ {children}
+
+ );
+});
+
+export default Text;
diff --git a/web/client/plugins/ResourcesCatalog/components/ZoomTo.jsx b/web/client/plugins/ResourcesCatalog/components/ZoomTo.jsx
new file mode 100644
index 0000000000..67e6e9ea22
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/ZoomTo.jsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2024, 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 { useRef, useEffect } from 'react';
+import { reprojectBbox } from '../../../utils/CoordinatesUtils';
+import { getAdjustedExtent } from '../utils/ResourcesCoordinatesUtils';
+
+const ZoomTo = ({
+ map,
+ extent,
+ nearest = true
+}) => {
+ const once = useRef();
+ useEffect(() => {
+ if (map && extent && !once.current) {
+ const [
+ aMinx, aMiny, aMaxx, aMaxy,
+ bMinx, bMiny, bMaxx, bMaxy
+ ] = extent.split(',');
+ const projection = map.getView().getProjection().getCode();
+ let bounds;
+ const aBounds = reprojectBbox(getAdjustedExtent([aMinx, aMiny, aMaxx, aMaxy]), 'EPSG:4326', projection);
+ if (bMinx !== undefined && bMiny !== undefined && bMaxx !== undefined && bMaxy !== undefined) {
+ const bBounds = reprojectBbox(getAdjustedExtent([bMinx, bMiny, bMaxx, bMaxy]), 'EPSG:4326', projection);
+ // if there is the second bbox we should shift the minimum x value to correctly center the view
+ // the x of the [A] bounds needs to be shifted by the width of the [B] bounds
+ const minx = aBounds[0] - (bBounds[2] - bBounds[0]);
+ bounds = [minx, aBounds[1], aBounds[2], aBounds[3]];
+ } else {
+ bounds = aBounds;
+ }
+ map.getView().fit(bounds, {
+ size: map.getSize(),
+ duration: 300,
+ nearest
+ });
+ // ensure to avoid other fit action by setting once to true
+ once.current = true;
+ }
+ }, [extent]);
+
+ return null;
+};
+
+export default ZoomTo;
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ALink-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ALink-test.jsx
new file mode 100644
index 0000000000..0f83a39eaf
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ALink-test.jsx
@@ -0,0 +1,45 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ALink from '../ALink';
+
+describe('ALink component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+ it('should apply the link (a) tag if href is provided', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children[0].getAttribute('class')).toBe('link');
+ });
+ it('should not apply the link (a) tag if href is not provided', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children[0].getAttribute('class')).toBe('child');
+ });
+ it('should not apply the link (a) tag if href is provided and readOnly is true', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children[0].getAttribute('class')).toBe('child');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Button-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Button-test.jsx
new file mode 100644
index 0000000000..508e0dd06f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Button-test.jsx
@@ -0,0 +1,47 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Button from '../Button';
+
+describe('Button component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const button = document.querySelector('.btn');
+ expect(button).toBeTruthy();
+ expect(document.querySelector('.child')).toBeTruthy();
+ });
+ it('should apply custom classes based on props', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const button = document.querySelector('.btn');
+ expect(button).toBeTruthy();
+ expect(button.getAttribute('class')).toBe(' _border-transparent btn btn-md btn-primary');
+ });
+ it('should render a square button', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const button = document.querySelector('.btn');
+ expect(button).toBeTruthy();
+ expect(button.getAttribute('class')).toBe('square-button-md btn btn-default');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ConfirmDialog-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ConfirmDialog-test.jsx
new file mode 100644
index 0000000000..f8f83800c6
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ConfirmDialog-test.jsx
@@ -0,0 +1,62 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ConfirmDialog from '../ConfirmDialog';
+import { Simulate } from 'react-dom/test-utils';
+
+describe('ConfirmDialog component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const dialog = document.querySelector('[role=dialog]');
+ expect(dialog).toBeFalsy();
+ });
+ it('should render the modal when show is true', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const dialog = document.querySelector('[role=dialog]');
+ expect(dialog).toBeTruthy();
+ });
+ it('should trigger onConfirm', (done) => {
+ ReactDOM.render( {
+ done();
+ }}
+ />, document.getElementById('container'));
+ const dialog = document.querySelector('[role=dialog]');
+ expect(dialog).toBeTruthy();
+ const buttons = dialog.querySelectorAll('.btn');
+ expect(buttons.length).toBe(2);
+ Simulate.click(buttons[1]);
+ });
+ it('should trigger onCancel', (done) => {
+ ReactDOM.render( {
+ done();
+ }}
+ />, document.getElementById('container'));
+ const dialog = document.querySelector('[role=dialog]');
+ expect(dialog).toBeTruthy();
+ const buttons = dialog.querySelectorAll('.btn');
+ expect(buttons.length).toBe(2);
+ Simulate.click(buttons[0]);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsHeader-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsHeader-test.jsx
new file mode 100644
index 0000000000..2eebff004c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsHeader-test.jsx
@@ -0,0 +1,87 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import DetailsHeader from '../DetailsHeader';
+import { Simulate } from 'react-dom/test-utils';
+
+describe('DetailsHeader component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const detailsHeader = document.querySelector('.ms-details-header');
+ expect(detailsHeader).toBeTruthy();
+ });
+ it('should display the resource information', () => {
+ ReactDOM.render( {
+ return {
+ icon: {
+ glyph: 'map',
+ type: 'glyphicon'
+ },
+ title: resource.name,
+ thumbnailUrl: resource.attributes.thumbnail
+ };
+ }}
+ resource={{
+ name: 'Resource Title',
+ attributes: {
+ thumbnail: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
+ }
+ }}/>, document.getElementById('container'));
+ const detailsHeader = document.querySelector('.ms-details-header-info');
+ expect(detailsHeader).toBeTruthy();
+ const texts = detailsHeader.querySelectorAll('.ms-text');
+ expect(texts[0].innerHTML).toBe(' Resource Title');
+ const imgs = document.querySelectorAll('img');
+ expect(imgs.length).toBe(1);
+ expect(imgs[0].getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
+ });
+ it('should trigger onClose', (done) => {
+ ReactDOM.render( {
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsHeader = document.querySelector('.ms-details-header');
+ expect(detailsHeader).toBeTruthy();
+ const buttons = document.querySelectorAll('button');
+ expect(buttons.length).toBe(1);
+ Simulate.click(buttons[0]);
+ });
+ it('should trigger onChangeThumbnail', (done) => {
+ ReactDOM.render( {
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsHeader = document.querySelector('.ms-details-header');
+ expect(detailsHeader).toBeTruthy();
+ const input = document.querySelectorAll('input');
+ expect(input.length).toBe(1);
+ fetch('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')
+ .then(res => res.blob())
+ .then(blob => {
+ const file = new File([blob], "image", { type: "image/png" });
+ Simulate.change(input[0], { target: { files: [file] } });
+ });
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsInfo-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsInfo-test.jsx
new file mode 100644
index 0000000000..2ca90c4b55
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsInfo-test.jsx
@@ -0,0 +1,134 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import DetailsInfo from '../DetailsInfo';
+import { waitFor } from '@testing-library/react';
+import { Simulate } from 'react-dom/test-utils';
+
+describe('DetailsInfo component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const detailsInfo = document.querySelector('.ms-details-info');
+ expect(detailsInfo).toBeTruthy();
+ });
+ it('should render tabs items', (done) => {
+ ReactDOM.render( , document.getElementById('container'));
+ const detailsInfo = document.querySelector('.ms-details-info');
+ expect(detailsInfo).toBeTruthy();
+ waitFor(() => document.querySelector('.ms-details-info-fields'))
+ .then(() => {
+ const detailsInfoFields = document.querySelectorAll('.ms-details-info-fields');
+ expect(detailsInfoFields.length).toBe(1);
+ expect(detailsInfoFields[0].innerText).toBe('Name\nResource Name');
+ done();
+ })
+ .catch(done);
+ });
+ it('should allow editing of editable fields and trigger onChange (text)', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(value).toEqual({ name: 'Resource' });
+ } catch (e) {
+ done(e);
+ }
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsInfo = document.querySelector('.ms-details-info');
+ expect(detailsInfo).toBeTruthy();
+ waitFor(() => document.querySelector('.ms-details-info-fields'))
+ .then(() => {
+ const input = document.querySelector('input');
+ Simulate.change(input, { target: { value: 'Resource' }});
+ })
+ .catch(done);
+ });
+ it('should allow editing of editable fields and trigger onChange (boolean)', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(value).toEqual({ advertised: true });
+ } catch (e) {
+ done(e);
+ }
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsInfo = document.querySelector('.ms-details-info');
+ expect(detailsInfo).toBeTruthy();
+ waitFor(() => document.querySelector('.ms-details-info-fields'))
+ .then(() => {
+ const input = document.querySelector('input');
+ Simulate.change(input, { target: { checked: true }});
+ })
+ .catch(done);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsThumbnail-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsThumbnail-test.jsx
new file mode 100644
index 0000000000..add5d83a7c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/DetailsThumbnail-test.jsx
@@ -0,0 +1,84 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import DetailsThumbnail from '../DetailsThumbnail';
+import { Simulate } from 'react-dom/test-utils';
+
+describe('DetailsThumbnail component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const detailsThumbnail = document.querySelector('.ms-details-thumbnail');
+ expect(detailsThumbnail).toBeTruthy();
+ });
+ it('should render an image while not editing', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const detailsThumbnail = document.querySelector('.ms-details-thumbnail');
+ expect(detailsThumbnail).toBeTruthy();
+ const img = detailsThumbnail.querySelector('img');
+ expect(img.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
+ });
+ it('should render the thumbnail component while editing', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(value.indexOf('data:image/png;base64')).toBe(0);
+ } catch (e) {
+ done(e);
+ }
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsThumbnail = document.querySelector('.ms-details-thumbnail');
+ expect(detailsThumbnail).toBeTruthy();
+ const buttons = detailsThumbnail.querySelectorAll('button');
+ expect(buttons.length).toBe(2);
+ const input = detailsThumbnail.querySelectorAll('input');
+ expect(input.length).toBe(1);
+ fetch('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==')
+ .then(res => res.blob())
+ .then(blob => {
+ const file = new File([blob], "image", { type: "image/png" });
+ Simulate.change(input[0], { target: { files: [file] } });
+ });
+ });
+ it('should clear the thumbnail when clicking on the trash button', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(value).toBe('');
+ } catch (e) {
+ done(e);
+ }
+ done();
+ }}
+ />, document.getElementById('container'));
+ const detailsThumbnail = document.querySelector('.ms-details-thumbnail');
+ expect(detailsThumbnail).toBeTruthy();
+ const buttons = detailsThumbnail.querySelectorAll('button');
+ expect(buttons.length).toBe(2);
+ Simulate.click(buttons[1]);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FilterAccordion-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterAccordion-test.jsx
new file mode 100644
index 0000000000..32819f8193
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterAccordion-test.jsx
@@ -0,0 +1,72 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FilterAccordion from '../FilterAccordion';
+import { waitFor } from '@testing-library/react';
+import { Simulate } from 'react-dom/test-utils';
+
+describe('FilterAccordion component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ localStorage.removeItem('accordionsExpanded');
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filterAccordion = document.querySelector('.ms-filter-accordion');
+ expect(filterAccordion).toBeTruthy();
+ });
+ it('should expand and show the items', () => {
+ ReactDOM.render( {
+ return {items.map((item, idx) => {item.name} )} ;
+ }}
+ />, document.getElementById('container'));
+ const filterAccordion = document.querySelector('.ms-filter-accordion');
+ expect(filterAccordion).toBeTruthy();
+ expect(filterAccordion.querySelectorAll('li').length).toBe(0);
+ Simulate.click(document.querySelector('button'));
+ expect(filterAccordion.querySelectorAll('li').length).toBe(2);
+ });
+ it('should expand and show loaded items', (done) => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ items: [
+ { name: 'Item 1' },
+ { name: 'Item 2' }
+ ]
+ });
+ }}
+ content={(items) => {
+ return {items.map((item, idx) => {item.name} )} ;
+ }}
+ />, document.getElementById('container'));
+ const filterAccordion = document.querySelector('.ms-filter-accordion');
+ expect(filterAccordion).toBeTruthy();
+ Simulate.click(document.querySelector('button'));
+ waitFor(() => expect(filterAccordion.querySelectorAll('li').length).toBe(2))
+ .then(() => {
+ done();
+ })
+ .catch(done);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FilterByExtent-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterByExtent-test.jsx
new file mode 100644
index 0000000000..785a3108df
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterByExtent-test.jsx
@@ -0,0 +1,54 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FilterByExtent from '../FilterByExtent';
+import { waitFor } from '@testing-library/react';
+
+describe('FilterByExtent component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', (done) => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filterByExtent = document.querySelector('.ms-filter-by-extent');
+ expect(filterByExtent).toBeTruthy();
+ waitFor(() => expect(document.querySelector('#ms-filter-by-extent-map')).toBeTruthy())
+ .then(() => {
+ const mapContainerNode = document.querySelector('.ms-filter-by-extent-map');
+ expect(mapContainerNode.style.pointerEvents).toBe('none');
+ expect(mapContainerNode.style.opacity).toBe('0.4');
+ done();
+ })
+ .catch(done);
+ });
+ it('should enable map when extent is provided', (done) => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filterByExtent = document.querySelector('.ms-filter-by-extent');
+ expect(filterByExtent).toBeTruthy();
+ waitFor(() => expect(document.querySelector('#ms-filter-by-extent-map')).toBeTruthy())
+ .then(() => {
+ const mapContainerNode = document.querySelector('.ms-filter-by-extent-map');
+ expect(mapContainerNode.style.pointerEvents).toBe('auto');
+ expect(mapContainerNode.style.opacity).toBe('1');
+ done();
+ })
+ .catch(done);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FilterDateRange-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterDateRange-test.jsx
new file mode 100644
index 0000000000..add3ce20f5
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterDateRange-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FilterDateRange from '../FilterDateRange';
+
+describe('FilterDateRange component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const formGroups = document.querySelectorAll('.form-group');
+ expect(formGroups.length).toBe(2);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FilterGroup-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterGroup-test.jsx
new file mode 100644
index 0000000000..2bcd35ae87
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterGroup-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FilterGroup from '../FilterGroup';
+
+describe('FilterGroup component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filterGroup = document.querySelector('.ms-filter-group');
+ expect(filterGroup).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FilterItems-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterItems-test.jsx
new file mode 100644
index 0000000000..2ed7563448
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FilterItems-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FilterItems from '../FilterItems';
+
+describe('FilterItems component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FiltersForm-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FiltersForm-test.jsx
new file mode 100644
index 0000000000..ee0c57db0c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FiltersForm-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FiltersForm from '../FiltersForm';
+
+describe('FilterForm component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filtersForm = document.querySelector('.ms-filters-form');
+ expect(filtersForm).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/FlexBox-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/FlexBox-test.jsx
new file mode 100644
index 0000000000..532639a074
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/FlexBox-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import FlexBox from '../FlexBox';
+
+describe('FlexBox component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const filtersForm = document.querySelector('.ms-flex-box');
+ expect(filtersForm).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Icon-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Icon-test.jsx
new file mode 100644
index 0000000000..70bec96e36
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Icon-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Icon from '../Icon';
+
+describe('Icon component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/InputControl-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/InputControl-test.jsx
new file mode 100644
index 0000000000..7acd9cc675
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/InputControl-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import InputControl from '../InputControl';
+
+describe('InputControl component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const inputControl = document.querySelector('.form-control');
+ expect(inputControl).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Menu-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Menu-test.jsx
new file mode 100644
index 0000000000..504b56440f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Menu-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Menu from '../Menu';
+
+describe('Menu component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const menu = document.querySelector('.ms-flex-box');
+ expect(menu).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/MenuDropdownList-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuDropdownList-test.jsx
new file mode 100644
index 0000000000..332926fbf1
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuDropdownList-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import MenuDropdownList from '../MenuDropdownList';
+
+describe('MenuDropdownList component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const dropdown = document.querySelector('.dropdown');
+ expect(dropdown).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/MenuItem-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuItem-test.jsx
new file mode 100644
index 0000000000..1ecbe0830b
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuItem-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import MenuItem from '../MenuItem';
+
+describe('MenuItem component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/MenuNavLink-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuNavLink-test.jsx
new file mode 100644
index 0000000000..3520c3efc1
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/MenuNavLink-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import MenuNavLink from '../MenuNavLink';
+
+describe('MenuNavLink component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const menuNavLink = document.querySelector('a');
+ expect(menuNavLink).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/PaginationCustom-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/PaginationCustom-test.jsx
new file mode 100644
index 0000000000..14b0e09070
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/PaginationCustom-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import PaginationCustom from '../PaginationCustom';
+
+describe('PaginationCustom component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const paginationCustom = document.querySelector('.custom');
+ expect(paginationCustom).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Permissions-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Permissions-test.jsx
new file mode 100644
index 0000000000..dca480f69c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Permissions-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Permissions from '../Permissions';
+
+describe('Permissions component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const permissions = document.querySelector('.ms-permissions');
+ expect(permissions).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsAddEntriesPanel-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsAddEntriesPanel-test.jsx
new file mode 100644
index 0000000000..7c92a0e68d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsAddEntriesPanel-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import PermissionsAddEntriesPanel from '../PermissionsAddEntriesPanel';
+
+describe('PermissionsAddEntriesPanel component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const permissionsAddEntriesPanel = document.querySelector('.ms-permissions-add-entries-panel');
+ expect(permissionsAddEntriesPanel).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsRow-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsRow-test.jsx
new file mode 100644
index 0000000000..747a8bce06
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/PermissionsRow-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import PermissionsRow from '../PermissionsRow';
+
+describe('PermissionsRow component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const permissionsRow = document.querySelector('.ms-permissions-row');
+ expect(permissionsRow).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCard-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCard-test.jsx
new file mode 100644
index 0000000000..96edbf3811
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCard-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourceCard from '../ResourceCard';
+
+describe('ResourceCard component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const resourceCard = document.querySelector('.ms-resource-card');
+ expect(resourceCard).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCardActionButtons-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCardActionButtons-test.jsx
new file mode 100644
index 0000000000..d83c108d0f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceCardActionButtons-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourceCardActionButtons from '../ResourceCardActionButtons';
+
+describe('ResourceCardActionButtons component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const resourceCardActionButtons = document.querySelector('.ms-resource-card-action-buttons');
+ expect(resourceCardActionButtons).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceStatus-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceStatus-test.jsx
new file mode 100644
index 0000000000..c2bc35c39b
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourceStatus-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourceStatus from '../ResourceStatus';
+
+describe('ResourceStatus component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesContainer-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesContainer-test.jsx
new file mode 100644
index 0000000000..4e76f1efe3
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesContainer-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourcesContainer from '../ResourcesContainer';
+
+describe('ResourcesContainer component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const resourcesContainer = document.querySelector('.ms-resources-container');
+ expect(resourcesContainer).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesMenu-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesMenu-test.jsx
new file mode 100644
index 0000000000..2963e4de45
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesMenu-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourcesMenu from '../ResourcesMenu';
+
+describe('ResourcesMenu component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const resourcesMenu = document.querySelector('.ms-resources-menu');
+ expect(resourcesMenu).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesPanelWrapper-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesPanelWrapper-test.jsx
new file mode 100644
index 0000000000..ddae86d169
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ResourcesPanelWrapper-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ResourcesPanelWrapper from '../ResourcesPanelWrapper';
+
+describe('ResourcesPanelWrapper component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/SelectInfiniteScroll-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/SelectInfiniteScroll-test.jsx
new file mode 100644
index 0000000000..bd4106f606
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/SelectInfiniteScroll-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import SelectInfiniteScroll from '../SelectInfiniteScroll';
+
+describe('SelectInfiniteScroll component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const selectInfiniteScroll = document.querySelector('.Select');
+ expect(selectInfiniteScroll).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Spinner-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Spinner-test.jsx
new file mode 100644
index 0000000000..a8661bb446
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Spinner-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Spinner from '../Spinner';
+
+describe('Spinner component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const spinner = document.querySelector('.ms-spinner');
+ expect(spinner).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Tabs-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Tabs-test.jsx
new file mode 100644
index 0000000000..e6f2b1607c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Tabs-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Tabs from '../Tabs';
+
+describe('Tabs component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const tabs = document.querySelector('.ms-tabs');
+ expect(tabs).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/TargetSelectorPortal-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/TargetSelectorPortal-test.jsx
new file mode 100644
index 0000000000..c4b8ebe609
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/TargetSelectorPortal-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import TargetSelectorPortal from '../TargetSelectorPortal';
+
+describe('TargetSelectorPortal component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/Text-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/Text-test.jsx
new file mode 100644
index 0000000000..f0dd47e319
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/Text-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import Text from '../Text';
+
+describe('Text component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const text = document.querySelector('.ms-text');
+ expect(text).toBeTruthy();
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/components/__tests__/ZoomTo-test.jsx b/web/client/plugins/ResourcesCatalog/components/__tests__/ZoomTo-test.jsx
new file mode 100644
index 0000000000..03fb1ec948
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/components/__tests__/ZoomTo-test.jsx
@@ -0,0 +1,30 @@
+
+/*
+ * Copyright 2025, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import ZoomTo from '../ZoomTo';
+
+describe('ZoomTo component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not render with default', () => {
+ ReactDOM.render( , document.getElementById('container'));
+ const container = document.getElementById('container');
+ expect(container.children.length).toBe(0);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/containers/PendingStatePrompt.jsx b/web/client/plugins/ResourcesCatalog/containers/PendingStatePrompt.jsx
new file mode 100644
index 0000000000..02aa8cd34c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/PendingStatePrompt.jsx
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2024, 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, { useRef, useEffect, useState } from 'react';
+import { Prompt, withRouter } from 'react-router';
+import ConfirmDialog from '../components/ConfirmDialog';
+import { connect } from 'react-redux';
+import { push, replace } from 'connected-react-router';
+
+function PendingStatePrompt({
+ pendingState: pendingStateProp,
+ show,
+ onCancel,
+ onConfirm,
+ titleId,
+ descriptionId,
+ cancelId,
+ confirmId,
+ variant,
+ history,
+ onReplace,
+ onPush
+}) {
+ const pendingState = useRef();
+ pendingState.current = pendingStateProp;
+
+ const [confirmed, setConfirmed] = useState(false);
+ const [showModal, setShowModal] = useState(false);
+
+ // show alter when a user tries to close the browser
+ useEffect(() => {
+ function onBeforeUnload(event) {
+ if (pendingState.current) {
+ (event || window.event).returnValue = null;
+ }
+ }
+ window.addEventListener('beforeunload', onBeforeUnload);
+ return () => {
+ window.removeEventListener('beforeunload', onBeforeUnload);
+ };
+ }, []);
+
+ // disable the back button when there are pending changes
+ useEffect(() => {
+ let popState;
+ if (pendingStateProp) {
+ popState = () => {
+ window.history.go(1);
+ };
+ window.history.pushState(null, null, window.location.href);
+ window.addEventListener('popstate', popState);
+ }
+ return () => {
+ if (popState) {
+ window.removeEventListener('popstate', popState);
+ popState = undefined;
+ }
+ };
+ }, [pendingStateProp]);
+
+ function handleCancel() {
+ setShowModal(false);
+ }
+
+ function handleConfirm() {
+ const pathname = showModal.nextLocationPathname;
+ setConfirmed(true);
+ setShowModal(false);
+ setTimeout(() => {
+ onPush(pathname);
+ });
+ }
+
+ return (
+ <>
+ {
+ if (!confirmed && actionType !== 'REPLACE') {
+ setTimeout(() => onReplace(history?.location?.pathname));
+ setShowModal({
+ nextLocationPathname: nextLocation?.pathname,
+ prevLocationPathname: history?.location?.pathname
+ });
+ return false;
+ }
+ return true;
+ }}
+ />
+
+ >
+ );
+}
+
+export default connect(() => ({}), {
+ onPush: push,
+ onReplace: replace
+})(withRouter(PendingStatePrompt));
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourceAbout.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourceAbout.jsx
new file mode 100644
index 0000000000..e9c686db08
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourceAbout.jsx
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2024, 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, { useEffect, useState, lazy, Suspense } from 'react';
+import axios from '../../../libs/ajax';
+import Message from '../../../components/I18N/Message';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import { getInitialSelectedResource } from '../selectors/resources';
+import { parseNODATA } from '../utils/ResourcesUtils';
+import FlexBox from '../components/FlexBox';
+import Icon from '../components/Icon';
+import Text from '../components/Text';
+import Spinner from '../components/Spinner';
+
+const ResourceAboutEditor = lazy(() => import('./ResourceAboutEditor'));
+
+function ResourceAbout({
+ detailsUrl,
+ editing,
+ resource,
+ onChange = () => {}
+}) {
+ const details = parseNODATA(resource?.attributes?.details || '');
+ const [about, setAbout] = useState(detailsUrl ? '' : details);
+ const [loading, setLoading] = useState(true);
+ useEffect(() => {
+ if (detailsUrl) {
+ setLoading(true);
+ axios.get(detailsUrl)
+ .then(({ data }) => {
+ setAbout(data);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ } else {
+ setLoading(false);
+ }
+ }, [detailsUrl]);
+
+ if (loading || (!about && !editing)) {
+ return (
+
+
+
+ {loading ? : }
+
+
+
+
+
+
+ );
+ }
+ return (
+
+ {editing
+ ?
}>
+
+
+ :
}
+
+ );
+}
+
+const ConnectedResourceAbout = connect(
+ createStructuredSelector({
+ detailsUrl: (state, props) => {
+ return parseNODATA(getInitialSelectedResource(state, props)?.attributes?.details);
+ }
+ })
+)(ResourceAbout);
+
+export default ConnectedResourceAbout;
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourceAboutEditor.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourceAboutEditor.jsx
new file mode 100644
index 0000000000..f7350b8483
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourceAboutEditor.jsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React, { useState } from 'react';
+import { Editor } from 'react-draft-wysiwyg';
+import { Tabs, Tab, Checkbox } from 'react-bootstrap';
+import { htmlToDraftJSEditorState, draftJSEditorStateToHtml } from '../../../utils/EditorUtils';
+import Message from '../../../components/I18N/Message';
+import FlexBox from '../components/FlexBox';
+
+function ResourceAboutEditor({
+ value,
+ settings,
+ onChange
+}) {
+ const [editorState, setEditorState] = useState(htmlToDraftJSEditorState(value || ''));
+ return (
+
+
+ {
+ setEditorState(newEditorState);
+ const previousHTML = draftJSEditorStateToHtml(editorState);
+ const newHTML = draftJSEditorStateToHtml(newEditorState);
+ if (newHTML !== previousHTML) {
+ onChange({ 'attributes.details': newHTML });
+ }
+ }}
+ toolbar={{
+ options: ['fontFamily', 'blockType', 'inline', 'textAlign', 'colorPicker', 'list', 'link', 'remove', 'image'],
+ image: {
+ className: undefined,
+ component: undefined,
+ popupClassName: undefined,
+ urlEnabled: true,
+ uploadEnabled: true,
+ alignmentEnabled: true,
+ uploadCallback: (file) => new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.addEventListener('load', () => {
+ resolve({data: {link: reader.result}});
+ });
+ if (file) {
+ reader.readAsDataURL(file);
+ } else {
+ reject();
+ }
+ }),
+ previewImage: true,
+ inputAccept: 'image/gif,image/jpeg,image/jpg,image/png,image/svg',
+ alt: { present: false, mandatory: false },
+ defaultSize: {
+ height: 'auto',
+ width: 'auto'
+ }
+ }
+ }}
+ />
+
+
+
+ onChange({ 'attributes.detailsSettings.showAsModal': event.target.checked })}
+ >
+
+
+ onChange({ 'attributes.detailsSettings.showAtStartup': event.target.checked })}
+ >
+
+
+
+
+
+ );
+}
+
+export default ResourceAboutEditor;
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourceDetails.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourceDetails.jsx
new file mode 100644
index 0000000000..7712f63c21
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourceDetails.jsx
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2024, 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, { useRef } from 'react';
+import { Alert } from 'react-bootstrap';
+import useRequestResource from '../hooks/useRequestResource';
+import DetailsInfo from '../components/DetailsInfo';
+import ButtonMS from '../components/Button';
+import Icon from '../components/Icon';
+import { getResourceTypesInfo, getResourceId } from '../utils/ResourcesUtils';
+import DetailsHeader from '../components/DetailsHeader';
+import { isEmpty, isArray, isObject, get } from 'lodash';
+import useParsePluginConfigExpressions from '../hooks/useParsePluginConfigExpressions';
+import { hashLocationToHref } from '../utils/ResourcesFiltersUtils';
+import url from 'url';
+import FlexBox from '../components/FlexBox';
+import Text from '../components/Text';
+import Spinner from '../components/Spinner';
+import Message from '../../../components/I18N/Message';
+import tooltip from '../../../components/misc/enhancers/tooltip';
+
+const Button = tooltip(ButtonMS);
+
+const replaceResourcePaths = (value, resource, facets) => {
+ if (isArray(value)) {
+ return value.map(val => replaceResourcePaths(val, resource, facets));
+ }
+ if (isObject(value)) {
+ if (value.path || value.facet) {
+ const facet = facets.find(fc => fc.id === value.facet);
+ return {
+ ...facet,
+ ...value,
+ ...(value.path && { value: get(resource, value.path) })
+ };
+ }
+ return Object.keys(value).reduce((acc, key) => ({
+ ...acc,
+ [key]: replaceResourcePaths(value[key], resource, facets)
+ }), {});
+ }
+ return value;
+};
+
+function ResourceDetails({
+ user,
+ resourcesGridId,
+ resource: resourceProp,
+ onSelect,
+ onChange,
+ pendingChanges,
+ tabs = [],
+ editing,
+ setEditing,
+ onToggleEditing,
+ monitoredState,
+ location,
+ onSearch,
+ error,
+ setError,
+ onClose,
+ tabComponents,
+ setRequest,
+ updateRequest,
+ facets,
+ resourceType
+}) {
+
+ const parsedConfig = useParsePluginConfigExpressions(monitoredState, { tabs });
+
+ const {
+ resource,
+ loading,
+ updating,
+ update: handleUpdateResource
+ } = useRequestResource({
+ resourceId: getResourceId(resourceProp),
+ user,
+ resource: resourceProp,
+ setRequest,
+ updateRequest,
+ onSetSuccess: (data, isUpdate) => {
+ onSelect(data, resourcesGridId);
+ if (isUpdate) {
+ onSearch({ refresh: true }, resourcesGridId);
+ return;
+ }
+ return;
+ },
+ onUpdateStart: () => {
+ setError(false);
+ },
+ onUpdateSuccess: () => {
+ setEditing(false);
+ },
+ onUpdateError: (err) => {
+ setError(`error${err.status || 'Default'}`);
+ }
+ });
+
+ const { query } = url.parse(location.search, true);
+ const updatedLocation = useRef();
+ updatedLocation.current = location;
+ function handleFormatHref(options) {
+ return hashLocationToHref({
+ location: updatedLocation.current,
+ excludeQueryKeys: ['page'],
+ ...options
+ });
+ }
+
+ function handleOnChange(options) {
+ onChange(options, resourcesGridId);
+ }
+
+ return (
+
+
+ {resourceType === undefined && editing ? handleUpdateResource(pendingChanges.saveResource)}
+ >
+
+ : null}
+ {(resource?.canEdit || resource?.canCopy) ? onToggleEditing()}
+ >
+
+ : null}
+
+ }
+ loading={loading}
+ getResourceTypesInfo={getResourceTypesInfo}
+ onClose={() => onClose()}
+ onChangeThumbnail={(thumbnail) => handleOnChange({ attributes: { thumbnail } })}
+ />
+ {error ?
+
+ : null}
+ {!loading ? : null}
+ {(updating || loading) ?
+
+
+
+ : null}
+
+ );
+}
+
+export default ResourceDetails;
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx
new file mode 100644
index 0000000000..bb5988f979
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourcePermissions.jsx
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2024, 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, { useEffect, useState, useRef } from 'react';
+import { connect } from 'react-redux';
+import { createStructuredSelector } from 'reselect';
+import Permissions from '../components/Permissions';
+import GeoStoreDAO from '../../../api/GeoStoreDAO';
+import { userSelector } from '../../../selectors/security';
+import FlexBox from '../components/FlexBox';
+import Text from '../components/Text';
+import Icon from '../components/Icon';
+import Message from '../../../components/I18N/Message';
+import useIsMounted from '../hooks/useIsMounted';
+import Spinner from '../components/Spinner';
+import { castArray } from 'lodash';
+
+function ResourcePermissions({
+ editing,
+ resource,
+ onChange
+}) {
+
+ const [loading, setLoading] = useState(false);
+ const init = useRef(false);
+ const isMounted = useIsMounted();
+
+ useEffect(() => {
+ if (resource?.permissions === undefined && !init.current) {
+ init.current = true;
+ setLoading(true);
+ GeoStoreDAO.getResourcePermissions(resource.id, {}, true)
+ .then((permissions) => isMounted(() => {
+ onChange({
+ permissions
+ });
+ }))
+ .finally(() => isMounted(() => {
+ setLoading(false);
+ }));
+ }
+ }, [resource?.permissions]);
+
+ const permissionEntries = resource?.permissions?.map((entry) => {
+ if (entry?.group) {
+ return {
+ type: 'group',
+ id: entry?.group?.id,
+ name: entry?.group?.groupName,
+ permissions: entry?.canWrite ? 'edit' : 'view'
+ };
+ }
+ return {
+ type: 'user',
+ id: entry?.user?.id,
+ name: entry?.user?.name,
+ permissions: 'owner'
+ };
+ });
+
+ const groupsPermissions = resource?.permissions?.some(entry => !!entry.group);
+
+ if (!editing && !groupsPermissions) {
+ return (
+
+
+
+ {loading ? : }
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ {
+ const userPermissions = (resource?.permissions || []).filter((entry) => !entry.group);
+ onChange({
+ 'permissions': [
+ ...entries.filter((entry) => entry.type === 'group').map((entry) => {
+ return {
+ canRead: ['view', 'edit'].includes(entry.permissions),
+ canWrite: ['edit'].includes(entry.permissions),
+ group: {
+ id: entry.id,
+ groupName: entry.name
+ }
+ };
+ }),
+ ...userPermissions
+ ]
+ });
+ }}
+ permissionOptions={{
+ 'default': [
+ {
+ value: 'view',
+ labelId: 'resourcesCatalog.viewPermission'
+ },
+ {
+ value: 'edit',
+ labelId: 'resourcesCatalog.editPermission'
+ }
+ ]
+ }}
+ entriesTabs={[
+ {
+ id: 'group',
+ labelId: 'resourcesCatalog.groups',
+ request: ({ q, page: pageParam, pageSize }) => {
+ const page = pageParam - 1;
+ return GeoStoreDAO.getGroups(q ? `*${q}*` : '*', {
+ params: {
+ start: parseFloat(page) * pageSize,
+ limit: pageSize,
+ all: true
+ }
+ })
+ .then((response) => {
+ const groups = castArray(response?.ExtGroupList?.Group).map((item) => {
+ return {
+ ...item,
+ filterValue: item.groupName,
+ value: item.groupName,
+ label: `${item.groupName}`
+ };
+ });
+ const totalCount = response?.ExtGroupList?.GroupCount;
+ return {
+ groups,
+ isNextPageAvailable: (page + 1) < (totalCount / pageSize)
+ };
+ });
+ },
+ responseToEntries: ({ response, entries }) => {
+ return response.groups.map((group) => {
+ const permissions = (entries || []).find(entry => entry.id === group.id)?.permissions;
+ return {
+ type: 'group',
+ id: group.id,
+ name: group.groupName,
+ permissions,
+ parsed: true
+ };
+ });
+ }
+ }
+ ]}
+ />
+ );
+}
+
+const ConnectedResourcePermissions = connect(
+ createStructuredSelector({
+ user: userSelector
+ })
+)(ResourcePermissions);
+
+export default ConnectedResourcePermissions;
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourcesFiltersFormButton.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourcesFiltersFormButton.jsx
new file mode 100644
index 0000000000..cf75c2712a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourcesFiltersFormButton.jsx
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2024, 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 castArray from 'lodash/castArray';
+import Message from '../../../components/I18N/Message';
+import tooltip from '../../../components/misc/enhancers/tooltip';
+import Icon from '../components/Icon';
+import Button from '../components/Button';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { getShowFiltersForm } from '../selectors/resources';
+import { setShowFiltersForm } from '../actions/resources';
+
+const ButtonWithTooltip = tooltip(Button);
+
+const getQueryFilters = (query) => {
+ const queryFilters = Object.keys(query).reduce((acc, key) => ['sort', 'page', 'd'].includes(key)
+ ? acc
+ : [...acc, ...castArray(query[key]).map((value) => ({ key, value }))], []);
+ return queryFilters;
+};
+
+function ResourcesFiltersFormButton({
+ resourcesGridId,
+ query,
+ compact,
+ onClick
+}) {
+ const queryFilters = getQueryFilters(query);
+ const totalFilters = queryFilters.length;
+ return (
+ <>
+ {totalFilters > 0 ? onClick(resourcesGridId)}
+ className="ms-notification-circle success"
+ tooltip={ }
+ >
+ {compact ? : }
+ : onClick(resourcesGridId)}
+ >
+ {compact ? : }
+ }
+ {' '}
+ >
+ );
+}
+
+const ConnectedResourcesFiltersFormButton = connect(
+ createSelector([
+ getShowFiltersForm
+ ], (show) => ({
+ show
+ })),
+ {
+ onClick: setShowFiltersForm.bind(null, true)
+ }
+)(ResourcesFiltersFormButton);
+
+export default ConnectedResourcesFiltersFormButton;
diff --git a/web/client/plugins/ResourcesCatalog/containers/ResourcesGrid.jsx b/web/client/plugins/ResourcesCatalog/containers/ResourcesGrid.jsx
new file mode 100644
index 0000000000..733fdaf936
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/containers/ResourcesGrid.jsx
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2024, 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 { connect } from 'react-redux';
+import url from 'url';
+import { createStructuredSelector } from 'reselect';
+import { isArray } from 'lodash';
+import { withResizeDetector } from 'react-resize-detector';
+import { userSelector } from '../../../selectors/security';
+import {
+ loadingResources,
+ resetSearchResources,
+ updateResources,
+ updateResourcesMetadata
+} from '../actions/resources';
+import {
+ getResourcesLoading,
+ getResourcesError,
+ getIsFirstRequest,
+ getTotalResources,
+ getMonitoredStateSelector,
+ getRouterLocation,
+ getCurrentPage,
+ getSearch
+} from '../selectors/resources';
+import { push } from 'connected-react-router';
+import useQueryResourcesByLocation from '../hooks/useQueryResourcesByLocation';
+import useParsePluginConfigExpressions from '../hooks/useParsePluginConfigExpressions';
+import useCardLayoutStyle from '../hooks/useCardLayoutStyle';
+import useLocalStorage from '../hooks/useLocalStorage';
+import ResourcesContainer from '../components/ResourcesContainer';
+import Icon from '../components/Icon';
+import Button from '../components/Button';
+import TargetSelectorPortal from '../components/TargetSelectorPortal';
+import PaginationCustom from '../components/PaginationCustom';
+import ResourcesMenu from '../components/ResourcesMenu';
+import useResourcePanelWrapper from '../hooks/useResourcePanelWrapper';
+import FlexBox from '../components/FlexBox';
+
+const defaultGetMainMessageId = ({ id, query, user, isFirstRequest, error, resources, loading }) => {
+ const hasResources = resources?.length > 0;
+ const hasFilter = Object.keys(query || {}).filter(key => key !== 'sort').length > 0;
+ const isLoggedIn = !!user;
+ const messageId = !hasResources && !isFirstRequest && !loading
+ ? error && `resourcesCatalog.errorResourcePage`
+ || hasFilter && `resourcesCatalog.noResultsWithFilter`
+ || isLoggedIn && `resourcesCatalog.${id}Section.noContentYet`
+ || `resourcesCatalog.${id}Section.noPublicContent`
+ : undefined;
+ return messageId;
+};
+
+function ResourcesGrid({
+ id,
+ location,
+ user,
+ totalResources,
+ loading,
+ defaultQuery,
+ order = {},
+ menuItems = [],
+ pageSize = 12,
+ panel,
+ cardLayoutStyle: cardLayoutStyleProp = null,
+ defaultCardLayoutStyle: defaultCardLayoutStyleProp = 'grid',
+ selectedResource,
+ configuredItems,
+ targetSelector = '',
+ monitoredState,
+ headerNodeSelector = '#ms-brand-navbar',
+ navbarNodeSelector = '',
+ footerNodeSelector = '',
+ width,
+ height,
+ error,
+ onPush,
+ setLoading,
+ setResources,
+ setResourcesMetadata,
+ customFilters,
+ resources,
+ isFirstRequest,
+ requestResources,
+ titleId,
+ queryPage,
+ page: pageProp,
+ theme = 'main',
+ metadata: metadataProp,
+ getMainMessageId = defaultGetMainMessageId,
+ search,
+ onResetSearch,
+ hideWithNoResults,
+ getResourceStatus,
+ formatHref,
+ getResourceTypesInfo,
+ getResourceId
+}) {
+
+ const { query } = url.parse(location.search, true);
+ const _page = queryPage ? query.page : pageProp;
+
+ const page = _page ? parseFloat(_page) : 1;
+
+ const {
+ search: onSearch
+ } = useQueryResourcesByLocation({
+ id,
+ request: requestResources,
+ location,
+ onPush,
+ setLoading,
+ setResources,
+ setResourcesMetadata,
+ defaultQuery,
+ pageSize,
+ customFilters,
+ user,
+ queryPage,
+ onReset: () => onResetSearch(id),
+ search
+ });
+
+ const {
+ cardLayoutStyle,
+ setCardLayoutStyle,
+ hideCardLayoutButton
+ } = useCardLayoutStyle({
+ cardLayoutStyle: cardLayoutStyleProp,
+ defaultCardLayoutStyle: defaultCardLayoutStyleProp
+ });
+
+ const {
+ stickyTop,
+ stickyBottom
+ } = useResourcePanelWrapper({
+ headerNodeSelector,
+ navbarNodeSelector,
+ footerNodeSelector,
+ width,
+ height,
+ active: !panel
+ });
+
+ const parsedConfig = useParsePluginConfigExpressions(monitoredState, {
+ menuItems,
+ order,
+ metadata: metadataProp
+ });
+
+ const isValidItem = (target) => (item) => item.target === target && (!item?.cfg?.resourcesGridId || item?.cfg?.resourcesGridId === id);
+ const cardOptions = configuredItems.filter(isValidItem('card-options'));
+ const cardButtons = configuredItems.filter(isValidItem('card-buttons'));
+ const menuItemsLeft = configuredItems.filter(isValidItem('menu-items-left'));
+ const { Component: cardComponent } = configuredItems.find(isValidItem('card')) || {};
+ function handleUpdate(newParams) {
+ onSearch(newParams);
+ }
+
+ const [metadataColumns, setMetadataColumns] = useLocalStorage('metadataColumns', {});
+ const columnsId = user?.name ? 'authenticated' : 'anonymous';
+ const columns = metadataColumns?.[columnsId] || [];
+ const metadata = isArray(parsedConfig.metadata) ? parsedConfig.metadata : parsedConfig.metadata[cardLayoutStyle];
+
+ return (
+
+
+
+ setMetadataColumns({
+ ...metadataColumns,
+ [columnsId]: newColumns
+ })
+ }
+ getResourceStatus={getResourceStatus}
+ formatHref={formatHref}
+ getResourceTypesInfo={getResourceTypesInfo}
+ getResourceId={getResourceId}
+ />
+ }
+ footer={
+
+ {error
+ ?
+ : (!loading || !!totalResources) && {
+ handleUpdate({
+ page: value
+ });
+ }}
+ />}
+
+ }
+ user={user}
+ cardOptions={cardOptions}
+ cardButtons={cardButtons}
+ cardComponent={cardComponent}
+ isCardActive={res => getResourceId(res) === getResourceId(selectedResource)}
+ getMainMessageId={getMainMessageId}
+ getResourceStatus={getResourceStatus}
+ formatHref={formatHref}
+ getResourceTypesInfo={getResourceTypesInfo}
+ getResourceId={getResourceId}
+ />
+
+
+ );
+}
+
+const ConnectedResourcesGrid = connect(
+ createStructuredSelector({
+ user: userSelector,
+ totalResources: getTotalResources,
+ loading: getResourcesLoading,
+ location: getRouterLocation,
+ monitoredState: getMonitoredStateSelector,
+ error: getResourcesError,
+ isFirstRequest: getIsFirstRequest,
+ page: getCurrentPage,
+ search: getSearch
+ }),
+ {
+ onPush: push,
+ setLoading: loadingResources,
+ setResources: updateResources,
+ setResourcesMetadata: updateResourcesMetadata,
+ onResetSearch: resetSearchResources
+ }
+)(withResizeDetector(ResourcesGrid));
+
+export default ConnectedResourcesGrid;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useCardLayoutStyle-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useCardLayoutStyle-test.js
new file mode 100644
index 0000000000..e533c490cd
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useCardLayoutStyle-test.js
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useCardLayoutStyle from '../useCardLayoutStyle';
+import expect from 'expect';
+import { Simulate, act } from 'react-dom/test-utils';
+
+const Component = (props) => {
+ const { cardLayoutStyle, setCardLayoutStyle, hideCardLayoutButton } = useCardLayoutStyle(props);
+ return setCardLayoutStyle('list')}>{cardLayoutStyle}-{hideCardLayoutButton ? 'true' : 'false'} ;
+};
+
+describe('useCardLayoutStyle', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById('container'));
+ document.body.innerHTML = '';
+ localStorage.removeItem('layoutCardsStyle');
+ setTimeout(done);
+ });
+ it('should store layoutCardsStyle in localStorage', () => {
+ act(() => {
+ ReactDOM.render( , document.getElementById('container'));
+ });
+ let button = document.querySelector('button');
+ expect(button.innerHTML).toBe('grid-false');
+ Simulate.click(button);
+ expect(button.innerHTML).toBe('list-false');
+ expect(localStorage.getItem('layoutCardsStyle')).toBe('"list"');
+ });
+ it('should force the value if cardLayoutStyle is passed', () => {
+ act(() => {
+ ReactDOM.render( , document.getElementById('container'));
+ });
+ let button = document.querySelector('button');
+ expect(button.innerHTML).toBe('grid-true');
+ Simulate.click(button);
+ expect(button.innerHTML).toBe('grid-true');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useFilterFacets-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useFilterFacets-test.js
new file mode 100644
index 0000000000..8b46d43d5a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useFilterFacets-test.js
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useFilterFacets from '../useFilterFacets';
+import expect from 'expect';
+import { act } from 'react-dom/test-utils';
+import { waitFor } from '@testing-library/react';
+
+const Component = (props) => {
+ const { fields } = useFilterFacets(props);
+ return {fields.map((field, idx) => {field.label} )} ;
+};
+
+describe('useFilterFacets', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should not send the request if the facet property is missing', () => {
+ act(() => {
+ ReactDOM.render( Promise.resolve(null)}
+ />, document.getElementById("container"));
+ });
+ expect(document.querySelector('ul').children.length).toBe(1);
+ });
+ it('should send the request if the facet property is available in a field item', (done) => {
+ act(() => {
+ ReactDOM.render( Promise.resolve({ fields: [{ label: 'test', facet: 'test' }] })}
+ />, document.getElementById("container"));
+ });
+ expect(document.querySelector('ul').children.length).toBe(1);
+ expect(document.querySelector('ul').children[0].innerHTML).toBe('');
+ waitFor(() => expect(document.querySelector('ul').children[0].innerHTML).toBe('test'))
+ .then(() => done());
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useInfiniteScroll-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useInfiniteScroll-test.js
new file mode 100644
index 0000000000..a91df7ca88
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useInfiniteScroll-test.js
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useInfiniteScroll from '../useInfiniteScroll';
+import { act } from 'react-dom/test-utils';
+
+const Component = (props) => {
+ useInfiniteScroll(props);
+ return
;
+};
+
+describe('useInfiniteScroll', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should trigger on load while scrolling if it does reach the offset threshold', (done) => {
+ const container = document.querySelector("#container");
+ act(() => {
+ ReactDOM.render( true}
+ scrollContainer={container}
+ onLoad={() => {
+ done();
+ }}
+ />, document.getElementById("container"));
+ });
+ container.scrollTop = 400;
+ container.dispatchEvent(new window.UIEvent('scroll', { detail: 0 }));
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIsMounted-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIsMounted-test.js
new file mode 100644
index 0000000000..efe8b94f38
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useIsMounted-test.js
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useIsMounted from '../useIsMounted';
+import expect from 'expect';
+import { Simulate, act } from 'react-dom/test-utils';
+
+const Component = ({ onClick, timeoutTime }) => {
+ const isMounted = useIsMounted();
+ return ( setTimeout(() => {
+ isMounted((mounted) => {
+ onClick(mounted);
+ });
+ }, timeoutTime)}> );
+};
+
+describe('useIsMounted', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should trigger on click when the component is mounted', (done) => {
+ act(() => {
+ ReactDOM.render( {
+ expect(mounted).toBe(true);
+ done();
+ }}
+ />, document.getElementById("container"));
+ });
+ Simulate.click(document.querySelector('button'));
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useLocalStorage-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useLocalStorage-test.js
new file mode 100644
index 0000000000..e176e480a1
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useLocalStorage-test.js
@@ -0,0 +1,64 @@
+
+/*
+ * Copyright 2021, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import expect from 'expect';
+import useLocalStorage from '../useLocalStorage';
+import { Simulate, act } from 'react-dom/test-utils';
+
+const VALUE_KEY = 'test';
+
+function Component({ valueKey, newValue, defaultValue }) {
+ const [value, setValue] = useLocalStorage(valueKey, defaultValue);
+ return ( setValue(newValue)}>{value} );
+}
+
+describe('useLocalStorage', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ localStorage.removeItem(VALUE_KEY);
+ setTimeout(done);
+ });
+ it('should store new value in localStorage', () => {
+ act(() => {
+ ReactDOM.render(
+ ,
+ document.getElementById("container")
+ );
+ });
+ let button = document.querySelector('button');
+ expect(button.innerHTML).toBe('defaultValue');
+ expect(localStorage.getItem(VALUE_KEY)).toBe(null);
+ Simulate.click(button);
+ expect(button.innerHTML).toBe('newValue');
+ expect(localStorage.getItem(VALUE_KEY)).toBe('"newValue"');
+ act(() => {
+ ReactDOM.render(
+
,
+ document.getElementById("container")
+ );
+ });
+ button = document.querySelector('button');
+ expect(button).toBe(null);
+ act(() => {
+ ReactDOM.render(
+ ,
+ document.getElementById("container")
+ );
+ });
+ button = document.querySelector('button');
+ expect(button.innerHTML).toBe('newValue');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useParsePluginConfigExpressions-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useParsePluginConfigExpressions-test.js
new file mode 100644
index 0000000000..43dfb1dbb8
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useParsePluginConfigExpressions-test.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useParsePluginConfigExpressions from '../useParsePluginConfigExpressions';
+import expect from 'expect';
+
+const Component = ({ monitoredState, fields }) => {
+ const parsedConfig = useParsePluginConfigExpressions(monitoredState, { fields });
+ return {parsedConfig.fields.map((field) => {field.id} )} ;
+};
+
+describe('useParsePluginConfigExpressions', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should support monitored state expression for parsed fields', () => {
+ ReactDOM.render( , document.getElementById("container"));
+ const list = [...document.querySelector('ul').children];
+ expect(list.length).toBe(3);
+ expect(list.map(child => child.innerHTML).join(',')).toBe('no expression,USER,option');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useQueryResourcesByLocation-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useQueryResourcesByLocation-test.js
new file mode 100644
index 0000000000..278c05bb2f
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useQueryResourcesByLocation-test.js
@@ -0,0 +1,272 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useQueryResourcesByLocation from '../useQueryResourcesByLocation';
+import expect from 'expect';
+import { act, Simulate } from 'react-dom/test-utils';
+
+const Component = ({ searchParams, ...props}) => {
+ const {
+ search,
+ clear
+ } = useQueryResourcesByLocation(props);
+ return (
+
+ search(searchParams)}/>
+ clear()}/>
+
+ );
+};
+
+describe('useQueryResourcesByLocation', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should request resources on mount', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(params).toEqual({ customFilters: undefined, pageSize: 12 });
+ } catch (e) {
+ done(e);
+ }
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={(resources, id) => {
+ expect(resources).toEqual([]);
+ expect(id).toBe('catalog');
+ done();
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+
+ it('should request resources when location is changing', (done) => {
+ let count = 0;
+ act(() => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={() => {
+ count += 1;
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+
+ act(() => {
+ ReactDOM.render( {
+ count += 1;
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+ act(() => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={() => {
+ count += 1;
+ expect(count).toBe(2);
+ done();
+ }}
+ location={{
+ pathname: '/',
+ search: '?f=map',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+ });
+ it('should request resources when user change', (done) => {
+ let count = 0;
+ act(() => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={() => {
+ count += 1;
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+ act(() => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={() => {
+ count += 1;
+ expect(count).toBe(2);
+ done();
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+ });
+ it('should apply default query', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(params).toEqual({ f: ['map', 'dashboard'], customFilters: undefined, pageSize: 12 });
+ } catch (e) {
+ done(e);
+ }
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={(resources, id) => {
+ expect(resources).toEqual([]);
+ expect(id).toBe('catalog');
+ done();
+ }}
+ location={{
+ pathname: '/',
+ search: '?f=dashboard',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+ it('should use the query params page when queryPage is true', (done) => {
+ ReactDOM.render( {
+ try {
+ expect(params).toEqual({ page: '2', customFilters: undefined, pageSize: 12 });
+ } catch (e) {
+ done(e);
+ }
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ setResources={(resources, id) => {
+ expect(resources).toEqual([]);
+ expect(id).toBe('catalog');
+ done();
+ }}
+ location={{
+ pathname: '/',
+ search: '?page=2',
+ hash: ''
+ }}
+ />, document.getElementById("container"));
+ });
+
+ it('should use search method to update the query', (done) => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ location={{
+ pathname: '/',
+ search: '',
+ hash: ''
+ }}
+ onPush={({ search }) => {
+ expect(search).toBe('?f=dashboard');
+ done();
+ }}
+ />, document.getElementById("container"));
+
+ Simulate.click(document.querySelector('#search'));
+ });
+
+ it('should use clear method to update clear all the query parameters', (done) => {
+ ReactDOM.render( {
+ return Promise.resolve({
+ resources: []
+ });
+ }}
+ location={{
+ pathname: '/',
+ search: '/?f=map&f=dashboard',
+ hash: ''
+ }}
+ onPush={({ search }) => {
+ expect(search).toBe('');
+ done();
+ }}
+ />, document.getElementById("container"));
+ Simulate.click(document.querySelector('#clear'));
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useRequestResource-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useRequestResource-test.js
new file mode 100644
index 0000000000..c4517842ca
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useRequestResource-test.js
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useRequestResource from '../useRequestResource';
+import expect from 'expect';
+import { act, Simulate } from 'react-dom/test-utils';
+
+const Component = ({ newResource, ...props }) => {
+ const {
+ update
+ } = useRequestResource(props);
+ return update(newResource)}> ;
+};
+
+describe('useRequestResource', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should trigger the setRequest on mount if resource is undefined', (done) => {
+ ReactDOM.render( Promise.resolve({ id: '01' })}
+ onSetSuccess={(resource) => {
+ expect(resource.id).toBe('01');
+ done();
+ }}
+ />, document.getElementById("container"));
+ });
+ it('should trigger the updateRequest clicking the custom button', (done) => {
+ act(() => {
+ ReactDOM.render( Promise.resolve({ id: '01' })}
+ updateRequest={() => Promise.resolve({ id: '01' })}
+ onUpdateSuccess={() => {
+ done();
+ }}
+ />, document.getElementById("container"));
+ });
+ Simulate.click(document.querySelector('button'));
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/__tests__/useResourcePanelWrapper-test.js b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useResourcePanelWrapper-test.js
new file mode 100644
index 0000000000..a163a37c73
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/__tests__/useResourcePanelWrapper-test.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2024, GeoSolutions Sas.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import useResourcePanelWrapper from '../useResourcePanelWrapper';
+import expect from 'expect';
+import { act } from 'react-dom/test-utils';
+
+const Component = (props) => {
+ const {
+ stickyTop,
+ stickyBottom
+ } = useResourcePanelWrapper(props);
+ return
;
+};
+
+describe('useResourcePanelWrapper', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('should apply top and bottom styles to the component based on the existing header, navbar and footer components', () => {
+ act(() => {
+ ReactDOM.render(
+ , document.getElementById("container"));
+ });
+ const componentNode = document.querySelector('#component');
+ expect(componentNode.style.top).toBe('60px');
+ expect(componentNode.style.bottom).toBe('30px');
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useCardLayoutStyle.js b/web/client/plugins/ResourcesCatalog/hooks/useCardLayoutStyle.js
new file mode 100644
index 0000000000..30d3a8c1dc
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useCardLayoutStyle.js
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2024, 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 useLocalStorage from './useLocalStorage';
+
+const useCardLayoutStyle = ({
+ cardLayoutStyle,
+ defaultCardLayoutStyle
+} = {}) => {
+ const [_cardLayoutStyleState, setCardLayoutStyle] = useLocalStorage('layoutCardsStyle', defaultCardLayoutStyle);
+ const cardLayoutStyleState = cardLayoutStyle || _cardLayoutStyleState; // Force style when `cardLayoutStyle` is configured
+ const hideCardLayoutButton = !!cardLayoutStyle;
+ return {
+ cardLayoutStyle: cardLayoutStyleState,
+ setCardLayoutStyle,
+ hideCardLayoutButton
+ };
+};
+
+export default useCardLayoutStyle;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useFilterFacets.js b/web/client/plugins/ResourcesCatalog/hooks/useFilterFacets.js
new file mode 100644
index 0000000000..aa2917b893
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useFilterFacets.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024, 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 { useEffect, useRef, useState } from 'react';
+import useIsMounted from './useIsMounted';
+
+const fieldsContainFacets = (fields) => {
+ return fields.some(field => field.items ? fieldsContainFacets(field.items) : !!field.facet);
+};
+
+const useFilterFacets = ({
+ query,
+ fields,
+ request,
+ customFilters = []
+}, dependencies = []) => {
+
+ const [updated, setUpdated] = useState(fields);
+ const requestFacets = useRef();
+ const containsFacets = fieldsContainFacets(fields);
+ const isMounted = useIsMounted();
+
+ requestFacets.current = () => {
+ if (containsFacets) {
+ request({
+ query,
+ fields,
+ customFilters
+ })
+ .then((response) => isMounted(() => {
+ setUpdated(response?.fields || []);
+ }));
+ }
+ };
+
+ useEffect(() => {
+ requestFacets.current();
+ }, [containsFacets, ...dependencies]);
+ return {
+ fields: containsFacets ? updated : fields
+ };
+};
+
+export default useFilterFacets;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useInfiniteScroll.js b/web/client/plugins/ResourcesCatalog/hooks/useInfiniteScroll.js
new file mode 100644
index 0000000000..1713d4bce4
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useInfiniteScroll.js
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2024, 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 { useEffect, useRef } from 'react';
+
+const useInfiniteScroll = ({
+ scrollContainer,
+ shouldScroll = () => true,
+ onLoad,
+ offset = 200
+}) => {
+ const updateOnScroll = useRef({});
+ updateOnScroll.current = () => {
+ const scrollTop = scrollContainer
+ ? scrollContainer.scrollTop
+ : document.body.scrollTop || document.documentElement.scrollTop;
+ const clientHeight = scrollContainer
+ ? scrollContainer.clientHeight
+ : window.innerHeight;
+ const scrollHeight = scrollContainer
+ ? scrollContainer.scrollHeight
+ : document.body.scrollHeight || document.documentElement.scrollHeight;
+ const isScrolled = scrollTop + clientHeight >= scrollHeight - offset;
+ if (isScrolled && shouldScroll()) {
+ onLoad();
+ }
+ };
+ useEffect(() => {
+ let target = scrollContainer || window;
+ function onScroll() {
+ updateOnScroll.current();
+ }
+ target.addEventListener('scroll', onScroll);
+ return () => {
+ target.removeEventListener('scroll', onScroll);
+ };
+ }, [scrollContainer]);
+};
+
+export default useInfiniteScroll;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useIsMounted.js b/web/client/plugins/ResourcesCatalog/hooks/useIsMounted.js
new file mode 100644
index 0000000000..853f20ff7a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useIsMounted.js
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024, 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 { useRef, useEffect } from 'react';
+
+const useIsMounted = () => {
+ const isMounted = useRef();
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ };
+ }, []);
+ return (callback) => {
+ if (callback && isMounted.current) {
+ callback(!!isMounted.current);
+ }
+ return !!isMounted.current;
+ };
+};
+export default useIsMounted;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useLocalStorage.js b/web/client/plugins/ResourcesCatalog/hooks/useLocalStorage.js
new file mode 100644
index 0000000000..70940e6594
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useLocalStorage.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024, 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 { useState } from 'react';
+
+const getValue = (key, defaultValue) => {
+ if (typeof window === 'undefined') {
+ return defaultValue;
+ }
+ try {
+ const item = window.localStorage.getItem(key);
+ return item ? JSON.parse(item) : defaultValue;
+ } catch (error) {
+ return defaultValue;
+ }
+};
+
+const setValue = (key, value) => {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ //
+ }
+};
+
+const useLocalStorage = (key, defaultValue) => {
+ const [storedValue, setStoredValue] = useState(getValue(key, defaultValue));
+ const [prevStoredValue, setPrevStoredValue] = useState(storedValue);
+ if (storedValue !== prevStoredValue) {
+ setPrevStoredValue(storedValue);
+ setValue(key, storedValue);
+ }
+ return [storedValue, setStoredValue];
+};
+
+export default useLocalStorage;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useParsePluginConfigExpressions.js b/web/client/plugins/ResourcesCatalog/hooks/useParsePluginConfigExpressions.js
new file mode 100644
index 0000000000..bc8ed79c77
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useParsePluginConfigExpressions.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024, 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 { useMemo } from 'react';
+import get from 'lodash/get';
+import isArray from 'lodash/isArray';
+import isObject from 'lodash/isObject';
+import { handleExpression } from '../../../utils/PluginsUtils';
+
+const recursiveParsing = (value, parsingFunc) => {
+ if (isArray(value)) {
+ return value.map(val => recursiveParsing(val, parsingFunc));
+ }
+ if (isObject(value)) {
+ return Object.keys(value).reduce((acc, key) => ({
+ ...acc,
+ [key]: recursiveParsing(value[key], parsingFunc)
+ }), {});
+ }
+ return parsingFunc(value);
+};
+
+const recursiveFilter = (value, filterFunc) => {
+ if (isArray(value)) {
+ return value.map(val => recursiveFilter(val, filterFunc)).filter(val => val !== undefined);
+ }
+ if (isObject(value)) {
+ return filterFunc(value) ? Object.keys(value).reduce((acc, key) => {
+ return {
+ ...acc,
+ [key]: recursiveFilter(value[key], filterFunc)
+ };
+ }, {}) : undefined;
+ }
+ return value;
+};
+
+const expressionParsingFunc = (monitoredState) => (value) => {
+ try {
+ return handleExpression((path) => get(monitoredState, path), /* getPluginsContext()*/ {}, value);
+ } catch (e) {
+ return value;
+ }
+};
+
+const useParsePluginConfigExpressions = (monitoredState, payload, { filterFunc = item => !item.disableIf } = {}) => {
+ const parsedConfig = useMemo(() => {
+ const config = recursiveFilter(recursiveParsing(payload, expressionParsingFunc(monitoredState)), filterFunc);
+ return config;
+ }, [monitoredState, payload]);
+ return parsedConfig;
+};
+
+export default useParsePluginConfigExpressions;
+
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useQueryResourcesByLocation.js b/web/client/plugins/ResourcesCatalog/hooks/useQueryResourcesByLocation.js
new file mode 100644
index 0000000000..44bb648ce1
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useQueryResourcesByLocation.js
@@ -0,0 +1,223 @@
+/*
+ * Copyright 2024, 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 { useRef, useEffect } from 'react';
+import isArray from 'lodash/isArray';
+import omit from 'lodash/omit';
+import isEqual from 'lodash/isEqual';
+import url from 'url';
+import axios from '../../../libs/ajax';
+import castArray from 'lodash/castArray';
+import uniq from 'lodash/uniq';
+import { clearQueryParams } from '../utils/ResourcesFiltersUtils';
+import useIsMounted from './useIsMounted';
+
+const cleanParams = (params, exclude = ['d']) => {
+ return Object.keys(params)
+ .filter((key) => !exclude.includes(key))
+ .reduce((acc, key) =>
+ (!params[key] || params[key].length === 0)
+ ? acc : { ...acc, [key]: isArray(params[key])
+ ? params[key].map(value => value + '')
+ : `${params[key]}`
+ }, {});
+};
+
+const getParams = (locationSearch = '', { defaultPage = 1, exclude } = {}) => {
+ const { query: locationQuery } = url.parse(locationSearch || '', true);
+ const { page, ...cleanedParams } = cleanParams(locationQuery, exclude);
+ return [
+ cleanedParams,
+ page ? parseFloat(page) : defaultPage
+ ];
+};
+
+const mergeParams = (params, defaultQuery) => {
+ const updatedDefaultQuery = Object.keys(defaultQuery || {}).reduce((acc, key) => {
+ if (defaultQuery[key] && params[key]) {
+ return {
+ ...acc,
+ [key]: uniq([...castArray(defaultQuery[key]), ...castArray(params[key] )])
+ };
+ }
+ return {
+ ...acc,
+ [key]: defaultQuery[key]
+ };
+ }, {});
+ return {
+ ...params,
+ ...updatedDefaultQuery
+ };
+};
+
+const useQueryResourcesByLocation = ({
+ id,
+ setLoading = () => {},
+ setResources = () => {},
+ setResourcesMetadata = () => {},
+ request = () => Promise.resolve({}),
+ defaultQuery,
+ pageSize,
+ customFilters,
+ location,
+ onPush = () => {},
+ user,
+ queryPage,
+ search,
+ onReset = () => {}
+}) => {
+
+ const _prevLocation = useRef();
+ const requestResources = useRef();
+ const requestTimeout = useRef();
+
+ const isMounted = useIsMounted();
+
+ const source = useRef();
+ const createToken = () => {
+ if (source?.current?.cancel) {
+ source.current?.cancel();
+ source.current = undefined;
+ }
+ const cancelToken = axios.CancelToken;
+ source.current = cancelToken.source();
+ };
+
+ requestResources.current = (params) => {
+ if (requestTimeout.current) {
+ clearTimeout(requestTimeout.current);
+ requestTimeout.current = undefined;
+ }
+ createToken();
+ setLoading(true, id);
+ requestTimeout.current = setTimeout(() => {
+ const requestParams = cleanParams(mergeParams(params, defaultQuery));
+ request({
+ params: {
+ ...requestParams,
+ customFilters,
+ pageSize
+ },
+ config: {
+ cancelToken: source?.current?.token
+ }
+ }, { user })
+ .then((response) => isMounted(() => {
+ setResources(response.resources, id);
+ setResourcesMetadata({
+ isNextPageAvailable: response.isNextPageAvailable,
+ params,
+ locationSearch: location.search,
+ locationPathname: location.pathname,
+ total: response.total
+ }, id);
+ }))
+ .catch((error) => isMounted(() => {
+ if (!axios.isCancel(error)) {
+ setResources([], id);
+ setResourcesMetadata({
+ isNextPageAvailable: false,
+ params,
+ locationSearch: location.search,
+ locationPathname: location.pathname,
+ total: 0,
+ error: true
+ }, id);
+ }
+ }))
+ .finally(() => isMounted(() => {
+ setLoading(false, id);
+ }));
+ }, 300);
+ };
+
+ const _queryPage = useRef();
+ _queryPage.current = queryPage;
+
+ useEffect(() => {
+ const [currentParams, currentPage] = getParams(location.search);
+ requestResources.current({
+ ...currentParams,
+ ...(_queryPage.current && { page: currentPage })
+ });
+ }, [pageSize, JSON.stringify(defaultQuery), user]);
+
+ useEffect(() => {
+ const prevLocation = _prevLocation.current;
+ const [previousParams, previousPage] = getParams(prevLocation?.search);
+ const [currentParams, currentPage] = getParams(location.search);
+ const isPageUpdated = _queryPage.current
+ ? currentPage !== previousPage
+ : false;
+ const shouldUpdate = prevLocation === undefined
+ || isPageUpdated
+ || !isEqual(currentParams, previousParams);
+ if (shouldUpdate) {
+ requestResources.current({
+ ...currentParams,
+ ...(_queryPage.current && { page: currentPage })
+ });
+ }
+ _prevLocation.current = location;
+ }, [location]);
+
+ function handleSearch(nextParams) {
+ const { query } = url.parse(location.search, true);
+ if (nextParams?.page !== undefined && !queryPage) {
+ requestResources.current({
+ ...query,
+ page: nextParams.page
+ });
+ return;
+ }
+ const nextQuery = cleanParams({ ...omit(query, ['page']), ...nextParams }, []);
+ const nextSearch = url.format({ query: nextQuery });
+ if (location.search !== nextSearch) {
+ onPush({
+ search: nextSearch
+ });
+ }
+ return;
+ }
+
+ function handleClear() {
+ const newParams = clearQueryParams(location);
+ handleSearch(newParams);
+ }
+
+ useEffect(() => {
+ if (search?.id) {
+ if (search.clear) {
+ handleClear();
+ } else if (search.refresh) {
+ const { query } = url.parse(location.search, true);
+ requestResources.current(query);
+ } else {
+ handleSearch(search?.params);
+ }
+ onReset();
+ }
+ }, [search?.id]);
+
+ useEffect(() => {
+ return () => {
+ if (source?.current?.cancel) {
+ source.current.cancel();
+ source.current = undefined;
+ }
+ };
+ }, []);
+
+ return {
+ search: handleSearch,
+ clear: handleClear
+ };
+};
+
+export default useQueryResourcesByLocation;
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useRequestResource.js b/web/client/plugins/ResourcesCatalog/hooks/useRequestResource.js
new file mode 100644
index 0000000000..b6dc7e5d4c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useRequestResource.js
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2024, 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 { useEffect, useState, useRef } from 'react';
+import axios from '../../../libs/ajax';
+import useIsMounted from './useIsMounted';
+
+const useRequestResource = ({
+ user,
+ resourceId,
+ resource,
+ setRequest,
+ updateRequest,
+ onSetStart = () => {},
+ onSetSuccess = () => {},
+ onSetError = () => {},
+ onUpdateStart = () => {},
+ onUpdateSuccess = () => {},
+ onUpdateError = () => {}
+}) => {
+
+ const [updating, setUpdating] = useState();
+ const [loading, setLoading] = useState(false);
+
+ const requestResource = useRef();
+ const requestTimeout = useRef();
+ const source = useRef();
+
+ const isMounted = useIsMounted();
+
+ const createToken = () => {
+ if (source.current) {
+ source.current?.cancel();
+ source.current = undefined;
+ }
+ const cancelToken = axios.CancelToken;
+ source.current = cancelToken.source();
+ };
+
+ requestResource.current = (isUpdate) => {
+ if (requestTimeout.current) {
+ clearTimeout(requestTimeout.current);
+ requestTimeout.current = undefined;
+ }
+ createToken();
+ setLoading(true);
+ onSetStart();
+ requestTimeout.current = setTimeout(() => {
+ setRequest({
+ user,
+ resource,
+ config: {
+ cancelToken: source.current.token
+ }
+ })
+ .then((updatedResource) => isMounted(() => {
+ onSetSuccess(updatedResource, isUpdate);
+ }))
+ .catch((e) => isMounted(() => {
+ if (!axios.isCancel(e)) {
+ onSetError(e);
+ }
+ }))
+ .finally(() => isMounted(() => {
+ setLoading(false);
+ }));
+ }, 300);
+ };
+
+ const resourceCanEdit = resource?.canEdit;
+ useEffect(() => {
+ if (resourceId && resourceCanEdit === undefined) {
+ requestResource.current();
+ }
+ }, [resourceId, resourceCanEdit]);
+
+ return {
+ resource,
+ loading,
+ update: (newResource) => {
+ if (!updating) {
+ setUpdating(true);
+ onUpdateStart();
+ const promise = updateRequest(newResource);
+ (promise?.toPromise
+ ? promise.toPromise()
+ : promise)
+ .then(() => isMounted(() => {
+ requestResource.current(true);
+ onUpdateSuccess();
+ }))
+ .catch(err => isMounted(() => {
+ onUpdateError(err);
+ }))
+ .finally(() => isMounted(() => setUpdating(false)));
+ }
+ }
+ };
+};
+
+export default useRequestResource;
+
diff --git a/web/client/plugins/ResourcesCatalog/hooks/useResourcePanelWrapper.js b/web/client/plugins/ResourcesCatalog/hooks/useResourcePanelWrapper.js
new file mode 100644
index 0000000000..da170c8e82
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/hooks/useResourcePanelWrapper.js
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2024, 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 { useEffect, useState } from 'react';
+
+const useResourcePanelWrapper = ({
+ headerNodeSelector,
+ navbarNodeSelector,
+ footerNodeSelector,
+ width,
+ height,
+ active
+}) => {
+
+ const [stickyTop, setStickyTop] = useState(0);
+ const [stickyBottom, setStickyBottom] = useState(0);
+
+ useEffect(() => {
+ if (active) {
+ const header = headerNodeSelector ? document.querySelector(headerNodeSelector) : null;
+ const navbar = navbarNodeSelector ? document.querySelector(navbarNodeSelector) : null;
+ const footer = footerNodeSelector ? document.querySelector(footerNodeSelector) : null;
+ const { height: headerHeight = 0 } = header?.getBoundingClientRect() || {};
+ const { height: navbarHeight = 0 } = navbar?.getBoundingClientRect() || {};
+ const { height: footerHeight = 0 } = footer?.getBoundingClientRect() || {};
+ setStickyTop(headerHeight + navbarHeight);
+ setStickyBottom(footerHeight);
+ }
+ }, [width, height, active]);
+
+ return {
+ stickyTop,
+ stickyBottom
+ };
+};
+
+export default useResourcePanelWrapper;
diff --git a/web/client/plugins/ResourcesCatalog/index.js b/web/client/plugins/ResourcesCatalog/index.js
new file mode 100644
index 0000000000..b567f075bf
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/index.js
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2024, 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.
+ */
+
+export { default as ResourcesGridPlugin } from './ResourcesGrid';
+export { default as ResourcesFiltersFormPlugin} from './ResourcesFiltersForm';
+export { default as ResourceDetailsPlugin } from './ResourceDetails';
+export { default as EditContextPlugin } from './EditContext';
+export { default as DeleteResourcePlugin } from './DeleteResource';
+export { default as HomeDescriptionPlugin } from './HomeDescription';
+export { default as BrandNavbarPlugin } from './BrandNavbar';
+export { default as FooterPlugin } from './Footer';
+export { default as SavePlugin } from './Save';
+export { default as SaveAsPlugin } from './SaveAs';
diff --git a/web/client/plugins/ResourcesCatalog/reducers/__tests__/resources-test.js b/web/client/plugins/ResourcesCatalog/reducers/__tests__/resources-test.js
new file mode 100644
index 0000000000..59e28a4c7a
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/reducers/__tests__/resources-test.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024, 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 resources from '../resources';
+import {
+ updateResources,
+ updateResourcesMetadata,
+ loadingResources,
+ decreaseTotalCount,
+ increaseTotalCount,
+ setShowFiltersForm,
+ setSelectedResource,
+ updateSelectedResource,
+ searchResources,
+ resetSearchResources,
+ resetSelectedResource,
+ setShowDetails
+} from '../../actions/resources';
+import expect from 'expect';
+
+describe('resources reducer', () => {
+ it('updateResources', () => {
+ expect(resources({}, updateResources([], 'catalog'))).toEqual({ sections: { catalog: { isFirstRequest: false, resources: [] } } });
+ });
+ it('updateResourcesMetadata', () => {
+ expect(resources({}, updateResourcesMetadata({
+ isNextPageAvailable: false,
+ params: {},
+ locationSearch: '',
+ locationPathname: '/',
+ total: 0
+ }, 'catalog'))).toEqual({ sections: { catalog: { total: 0, isNextPageAvailable: false, error: undefined, params: {}, previousParams: undefined, nextParams: null, locationSearch: '', locationPathname: '/' } } });
+ });
+ it('loadingResources', () => {
+ expect(resources({}, loadingResources(true, 'catalog'))).toEqual({ sections: { catalog: { loading: true, error: false } } });
+ });
+ it('decreaseTotalCount', () => {
+ expect(resources({ sections: { catalog: { total: 2 } } }, decreaseTotalCount('catalog'))).toEqual({ sections: { catalog: { total: 1 } } });
+ });
+ it('increaseTotalCount', () => {
+ expect(resources({ sections: { catalog: { total: 1 } } }, increaseTotalCount('catalog'))).toEqual({ sections: { catalog: { total: 2 } } });
+ });
+ it('setShowFiltersForm', () => {
+ expect(resources({}, setShowFiltersForm(true, 'catalog'))).toEqual({ sections: { catalog: { showFiltersForm: true } } });
+ });
+ it('setSelectedResource', () => {
+ expect(resources({}, setSelectedResource({ id: 1, name: 'Resource' }))).toEqual({ selectedResource: { id: 1, name: 'Resource' }, initialSelectedResource: { id: 1, name: 'Resource' } });
+ });
+ it('updateSelectedResource', () => {
+ expect(resources({ selectedResource: { id: 1, name: 'Resource' }, initialSelectedResource: { id: 1, name: 'Resource' } }, updateSelectedResource({ name: 'New Resource' })))
+ .toEqual({ selectedResource: { id: 1, name: 'New Resource' }, initialSelectedResource: { id: 1, name: 'Resource' } });
+ });
+ it('searchResources', () => {
+ const newState = resources({}, searchResources({ params: { page: 2 }, clear: false, refresh: false }));
+ const { id, ...search } = newState?.search;
+ expect(id).toBeTruthy();
+ expect(search).toEqual({ params: { page: 2 }, clear: false, refresh: false });
+ });
+ it('resetSearchResources', () => {
+ expect(resources({ search: { id: 'id', params: { page: 2 }, clear: false, refresh: false } }, resetSearchResources()))
+ .toEqual({ search: null });
+ });
+ it('resetSelectedResource', () => {
+ expect(resources({ selectedResource: { id: 1, name: 'Change Resource' }, initialSelectedResource: { id: 1, name: 'Resource' } }, resetSelectedResource()))
+ .toEqual({ selectedResource: { id: 1, name: 'Resource' }, initialSelectedResource: { id: 1, name: 'Resource' } });
+ });
+ it('setShowDetails', () => {
+ expect(resources({}, setShowDetails(true)))
+ .toEqual({ showDetails: true });
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/reducers/resources.js b/web/client/plugins/ResourcesCatalog/reducers/resources.js
new file mode 100644
index 0000000000..d77ffd9161
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/reducers/resources.js
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2024, 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 isNil from 'lodash/isNil';
+import isFunction from 'lodash/isFunction';
+import set from 'lodash/fp/set';
+import uuid from 'uuid/v1';
+
+import {
+ UPDATE_RESOURCES,
+ LOADING_RESOURCES,
+ UPDATE_RESOURCES_METADATA,
+ DECREASE_TOTAL_COUNT,
+ INCREASE_TOTAL_COUNT,
+ SET_SHOW_FILTERS_FORM,
+ SET_SELECTED_RESOURCE,
+ UPDATE_SELECTED_RESOURCE,
+ SEARCH_RESOURCES,
+ RESET_SEARCH_RESOURCES,
+ RESET_SELECTED_RESOURCE,
+ SET_SHOW_DETAILS
+} from '../actions/resources';
+
+const defaultState = {};
+
+const setStateById = (state, action, newState) => {
+ if (action.id === undefined) {
+ return isFunction(newState) ? newState(state) : {
+ ...state,
+ ...newState
+ };
+ }
+ return {
+ ...state,
+ sections: {
+ ...state?.sections,
+ [action.id]: isFunction(newState) ? newState(state?.sections?.[action.id]) : {
+ ...state?.sections?.[action.id],
+ ...newState
+ }
+ }
+ };
+};
+
+function resources(state = defaultState, action) {
+ switch (action.type) {
+ case UPDATE_RESOURCES: {
+ return setStateById(state, action, {
+ isFirstRequest: false,
+ resources: action.resources
+ });
+ }
+ case UPDATE_RESOURCES_METADATA: {
+ return setStateById(state, action, {
+ total: action.metadata.total,
+ isNextPageAvailable: action.metadata.isNextPageAvailable,
+ error: action.metadata.error,
+ ...(action.metadata.params &&
+ {
+ params: action.metadata.params,
+ previousParams: state.params,
+ nextParams: null
+ }),
+ ...(!isNil(action.metadata.locationSearch) &&
+ {
+ locationSearch: action.metadata.locationSearch
+ }),
+ ...(!isNil(action.metadata.locationPathname) &&
+ {
+ locationPathname: action.metadata.locationPathname
+ })
+ });
+ }
+ case LOADING_RESOURCES: {
+ return setStateById(state, action, {
+ loading: action.loading,
+ ...(action.loading && { error: false })
+ });
+ }
+ case DECREASE_TOTAL_COUNT: {
+ return setStateById(state, action, (stateId) => ({
+ total: stateId.total - 1
+ }));
+ }
+ case INCREASE_TOTAL_COUNT: {
+ return setStateById(state, action, (stateId) => ({
+ total: stateId.total + 1
+ }));
+ }
+ case SET_SHOW_FILTERS_FORM:
+ return setStateById(state, action, {
+ showFiltersForm: !!action.show
+ });
+ case SET_SHOW_DETAILS: {
+ return setStateById(state, action, {
+ showDetails: !!action.show
+ });
+ }
+ case SET_SELECTED_RESOURCE:
+ return setStateById(state, action, {
+ initialSelectedResource: action.selectedResource,
+ selectedResource: action.selectedResource
+ });
+ case UPDATE_SELECTED_RESOURCE:
+ return setStateById(state, action, (stateId) => ({
+ ...stateId,
+ selectedResource: Object.keys(action.properties).reduce((selectedResource, path) => {
+ return set(path, action.properties[path], selectedResource);
+ }, stateId.selectedResource)
+ }));
+ case RESET_SELECTED_RESOURCE:
+ return setStateById(state, action, (stateId) => ({
+ ...stateId,
+ selectedResource: stateId.initialSelectedResource
+ }));
+ case SEARCH_RESOURCES:
+ return setStateById(state, action, {
+ search: {
+ id: uuid(),
+ params: action.params,
+ clear: action.clear,
+ refresh: action.refresh
+ }
+ });
+ case RESET_SEARCH_RESOURCES:
+ return setStateById(state, action, {
+ search: null
+ });
+ default:
+ return state;
+ }
+}
+
+export default resources;
diff --git a/web/client/plugins/ResourcesCatalog/selectors/__tests__/resources-test.js b/web/client/plugins/ResourcesCatalog/selectors/__tests__/resources-test.js
new file mode 100644
index 0000000000..435894b1fe
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/selectors/__tests__/resources-test.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2024, 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 {
+ getResources,
+ getResourcesLoading,
+ getResourcesError,
+ getIsFirstRequest,
+ getTotalResources,
+ getShowFiltersForm,
+ getInitialSelectedResource,
+ getSelectedResource,
+ getShowDetails,
+ getCurrentPage,
+ getSearch
+} from '../resources';
+import expect from 'expect';
+
+describe('resources selectors', () => {
+ it('getResources', () => {
+ expect(getResources()).toEqual([]);
+ expect(getResources({ resources: { sections: { catalog: { resources: [{ id: '01' }] } } } }, { id: 'catalog' })).toEqual([{ id: '01' }]);
+ expect(getResources({ resources: { sections: { catalog: { resources: [{ id: '01' }] } } } }, { resourcesGridId: 'catalog' })).toEqual([{ id: '01' }]);
+ });
+ it('getResourcesLoading', () => {
+ expect(getResourcesLoading()).toBe(undefined);
+ expect(getResourcesLoading({ resources: { sections: { catalog: { loading: false } } } }, { id: 'catalog' })).toBe(false);
+ expect(getResourcesLoading({ resources: { sections: { catalog: { loading: true } } } }, { resourcesGridId: 'catalog' })).toBe(true);
+ });
+ it('getResourcesError', () => {
+ expect(getResourcesError()).toBe(undefined);
+ expect(getResourcesError({ resources: { sections: { catalog: { error: false } } } }, { id: 'catalog' })).toBe(false);
+ expect(getResourcesError({ resources: { sections: { catalog: { error: true } } } }, { resourcesGridId: 'catalog' })).toBe(true);
+ });
+ it('getIsFirstRequest', () => {
+ expect(getIsFirstRequest()).toBe(true);
+ expect(getIsFirstRequest({ resources: { sections: { catalog: { isFirstRequest: false } } } }, { id: 'catalog' })).toBe(false);
+ expect(getIsFirstRequest({ resources: { sections: { catalog: { isFirstRequest: true } } } }, { resourcesGridId: 'catalog' })).toBe(true);
+ });
+ it('getTotalResources', () => {
+ expect(getTotalResources()).toBe(0);
+ expect(getTotalResources({ resources: { sections: { catalog: { total: 10 } } } }, { id: 'catalog' })).toBe(10);
+ expect(getTotalResources({ resources: { sections: { catalog: { total: 1 } } } }, { resourcesGridId: 'catalog' })).toBe(1);
+ });
+ it('getShowFiltersForm', () => {
+ expect(getShowFiltersForm()).toBe(undefined);
+ expect(getShowFiltersForm({ resources: { sections: { catalog: { showFiltersForm: false } } } }, { id: 'catalog' })).toBe(false);
+ expect(getShowFiltersForm({ resources: { sections: { catalog: { showFiltersForm: true } } } }, { resourcesGridId: 'catalog' })).toBe(true);
+ });
+ it('getInitialSelectedResource', () => {
+ expect(getInitialSelectedResource()).toBe(undefined);
+ expect(getInitialSelectedResource({ resources: { initialSelectedResource: { id: '01' } } })).toEqual({ id: '01' });
+ });
+ it('getSelectedResource', () => {
+ expect(getSelectedResource()).toBe(undefined);
+ expect(getSelectedResource({ resources: { selectedResource: { id: '01' } } })).toEqual({ id: '01' });
+ });
+ it('getShowDetails', () => {
+ expect(getShowDetails()).toBe(false);
+ expect(getShowDetails({ resources: { showDetails: true } })).toBe(true);
+ });
+ it('getCurrentPage', () => {
+ expect(getCurrentPage()).toBe(1);
+ expect(getCurrentPage({ resources: { sections: { catalog: { params: { page: 2 } } } } }, { id: 'catalog' })).toBe(2);
+ expect(getCurrentPage({ resources: { sections: { catalog: { params: { page: 3 } } } } }, { resourcesGridId: 'catalog' })).toBe(3);
+ });
+ it('getSearch', () => {
+ expect(getSearch()).toBe(null);
+ expect(getSearch({ resources: { search: { q: 'a' } } }, { id: 'catalog' })).toEqual({ q: 'a' });
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/selectors/__tests__/save-test.js b/web/client/plugins/ResourcesCatalog/selectors/__tests__/save-test.js
new file mode 100644
index 0000000000..cecc0b6f6d
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/selectors/__tests__/save-test.js
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2024, 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 {
+ getPendingChanges
+} from '../save';
+import expect from 'expect';
+
+describe('save selectors', () => {
+ it('getPendingChanges', () => {
+ expect(getPendingChanges()).toEqual(null);
+ expect(getPendingChanges({ resources: { initialSelectedResource: { id: '01', category: { name: 'MAP' } }, selectedResource: { id: '01', category: { name: 'MAP' }, title: 'Title' } } })).toEqual({
+ initialResource: { id: '01', category: { name: 'MAP' } },
+ resource: { id: '01', category: { name: 'MAP' }, title: 'Title' },
+ saveResource: { id: '01', permission: undefined, category: 'MAP', metadata: { id: '01', title: 'Title', attributes: {} } },
+ changes: { title: 'Title' }
+ });
+ const mapResourcePendingChanges = getPendingChanges({}, { resourceType: 'MAP' });
+ expect(mapResourcePendingChanges.initialResource).toEqual({ canCopy: true, category: { name: 'MAP' } });
+ expect(mapResourcePendingChanges.resource).toEqual({ canCopy: true, category: { name: 'MAP' } });
+ expect(mapResourcePendingChanges.saveResource.data).toBeTruthy();
+ expect(mapResourcePendingChanges.changes).toEqual({});
+
+ const dashboardResourcePendingChanges = getPendingChanges({}, { resourceType: 'DASHBOARD' });
+ expect(dashboardResourcePendingChanges.initialResource).toEqual({ canCopy: true, category: { name: 'DASHBOARD' } });
+ expect(dashboardResourcePendingChanges.resource).toEqual({ canCopy: true, category: { name: 'DASHBOARD' } });
+ expect(dashboardResourcePendingChanges.saveResource.data).toBeTruthy();
+ expect(dashboardResourcePendingChanges.changes).toEqual({});
+
+ const geoStoryResourcePendingChanges = getPendingChanges({}, { resourceType: 'GEOSTORY' });
+ expect(geoStoryResourcePendingChanges.initialResource).toEqual({ canCopy: true, category: { name: 'GEOSTORY' } });
+ expect(geoStoryResourcePendingChanges.resource).toEqual({ canCopy: true, category: { name: 'GEOSTORY' } });
+ expect(geoStoryResourcePendingChanges.saveResource.data).toBeFalsy();
+ expect(geoStoryResourcePendingChanges.changes).toEqual({});
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/selectors/resources.js b/web/client/plugins/ResourcesCatalog/selectors/resources.js
new file mode 100644
index 0000000000..ae5535c923
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/selectors/resources.js
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2024, 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 { getMonitoredState } from '../../../utils/PluginsUtils';
+import { getConfigProp } from '../../../utils/ConfigUtils';
+
+const getStatePart = (state, props) => {
+ const id = props?.id || props?.resourcesGridId;
+ if (id === undefined) {
+ return state?.resources;
+ }
+ return state?.resources?.sections?.[id] || {};
+};
+
+const RESOURCES = [];
+export const getResources = (state, props) => {
+ const resources = getStatePart(state, props)?.resources || RESOURCES;
+ return resources;
+};
+
+export const getResourcesLoading = (state, props) => getStatePart(state, props)?.loading;
+export const getResourcesError = (state, props) => getStatePart(state, props)?.error;
+export const getIsFirstRequest = (state, props) => getStatePart(state, props)?.isFirstRequest !== false;
+export const getTotalResources = (state, props) => getStatePart(state, props)?.total || 0;
+export const getShowFiltersForm = (state, props) => getStatePart(state, props)?.showFiltersForm;
+
+const getSelectedResourceState = (state, props) => {
+ const initialSelectedResource = getStatePart(state, props)?.initialSelectedResource;
+ const selectedResource = getStatePart(state, props)?.selectedResource;
+ if (initialSelectedResource === undefined && selectedResource === undefined) {
+ return state?.resources;
+ }
+ return { selectedResource, initialSelectedResource };
+};
+
+export const getInitialSelectedResource = (state, props) => getSelectedResourceState(state, props)?.initialSelectedResource;
+export const getSelectedResource = (state, props) => getSelectedResourceState(state, props)?.selectedResource;
+export const getShowDetails = (state, props) => !!getStatePart(state, props)?.showDetails;
+export const getCurrentPage = (state, props) => getStatePart(state, props)?.params?.page ?? 1;
+export const getSearch = (state) => state?.resources?.search || null;
+
+export const getMonitoredStateSelector = state => getMonitoredState(state, getConfigProp('monitorState'));
+export const getRouterLocation = state => state?.router?.location;
+
+
diff --git a/web/client/plugins/ResourcesCatalog/selectors/save.js b/web/client/plugins/ResourcesCatalog/selectors/save.js
new file mode 100644
index 0000000000..8b1dcb70a9
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/selectors/save.js
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2024, 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 { computePendingChanges } from '../utils/ResourcesUtils';
+import { mapSelector } from '../../../selectors/map';
+import { mapHasPendingChangesSelector, mapSaveSelector } from '../../../selectors/mapsave';
+import { dashboardHasPendingChangesSelector } from '../../../selectors/dashboardsave';
+import { dashboardResource as getDashboardResource } from '../../../selectors/dashboard';
+import { widgetsConfig } from '../../../selectors/widgets';
+import { getInitialSelectedResource, getSelectedResource } from './resources';
+import { currentStorySelector, resourceSelector, hasPendingChanges } from '../../../selectors/geostory';
+import { contextResourceSelector } from '../../../selectors/context';
+import { isEmpty, omit } from 'lodash';
+
+const defaultNewResource = (resourceType) => {
+ return { canCopy: true, category: { name: resourceType } };
+};
+
+const applyContextAttribute = (resource, contextId) => {
+ const context = contextId !== undefined
+ ? contextId
+ : resource?.attributes?.context;
+ return {
+ ...resource,
+ ...(context !== undefined && {
+ attributes: {
+ ...resource?.attributes,
+ ...(context !== undefined && { context })
+ }
+ })
+ };
+};
+
+const getResourceByType = (state, props) => {
+ const resourceType = props?.resourceType;
+ const initialResource = getInitialSelectedResource(state, props);
+ const resource = getSelectedResource(state, props);
+ const newResource = defaultNewResource(resourceType);
+ if (resourceType === 'MAP') {
+ const contextResource = contextResourceSelector(state);
+ const mapInfo = (mapSelector(state) || {})?.info;
+ const contextId = contextResource?.id !== undefined
+ ? contextResource.id
+ : mapInfo?.context; // new map has context in info property
+ const mapResource = omit(mapInfo, ['context']);
+ const mapInitialResource = applyContextAttribute(isEmpty(mapResource) ? newResource : mapResource, contextId);
+ return {
+ initialResource: resource ? initialResource : mapInitialResource,
+ resource: resource ? resource : mapInitialResource,
+ data: {
+ payload: mapSaveSelector(state),
+ pending: mapHasPendingChangesSelector(state)
+ }
+ };
+ }
+ if (resourceType === 'DASHBOARD') {
+ const dashboardResource = getDashboardResource(state);
+ const dashboardInitialResource = isEmpty(dashboardResource) ? newResource : dashboardResource;
+ return {
+ initialResource: resource ? initialResource : dashboardInitialResource,
+ resource: resource ? resource : dashboardInitialResource,
+ data: {
+ payload: widgetsConfig(state),
+ pending: dashboardHasPendingChangesSelector(state)
+ }
+ };
+ }
+ if (resourceType === 'GEOSTORY') {
+ const geoStoryResource = resourceSelector(state);
+ const geoStoryInitialResource = isEmpty(geoStoryResource) ? newResource : geoStoryResource;
+ return {
+ initialResource: resource ? initialResource : geoStoryInitialResource,
+ resource: resource ? resource : geoStoryInitialResource,
+ data: {
+ payload: currentStorySelector(state),
+ pending: hasPendingChanges(state)
+ }
+ };
+ }
+ return {
+ initialResource,
+ resource
+ };
+};
+
+export const getPendingChanges = (state, props, defaultResourceType) => {
+ const { initialResource, resource, data } = getResourceByType(state, props, defaultResourceType);
+ if (!(resource && initialResource)) {
+ return null;
+ }
+ return computePendingChanges(initialResource, resource, data);
+};
diff --git a/web/client/plugins/ResourcesCatalog/utils/ResourcesCoordinatesUtils.js b/web/client/plugins/ResourcesCatalog/utils/ResourcesCoordinatesUtils.js
new file mode 100644
index 0000000000..2c4efd5e45
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/ResourcesCoordinatesUtils.js
@@ -0,0 +1,93 @@
+
+/*
+ * Copyright 2024, 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 join from 'lodash/join';
+import isEmpty from 'lodash/isEmpty';
+import { reprojectBbox, getViewportGeometry } from '../../../utils/CoordinatesUtils';
+
+export const getFeatureFromExtent = (extent = '') => {
+ const [
+ aMinx, aMiny, aMaxx, aMaxy,
+ bMinx, bMiny, bMaxx, bMaxy
+ ] = extent
+ .split(',')
+ .map((val) => parseFloat(val));
+ return {
+ type: 'Feature',
+ geometry: {
+ type: 'MultiPolygon',
+ coordinates: [
+ [
+ [
+ [aMinx, aMiny],
+ [aMinx, aMaxy],
+ [aMaxx, aMaxy],
+ [aMaxx, aMiny],
+ [aMinx, aMiny]
+ ]
+ ],
+
+ ...(bMinx !== undefined && bMiny !== undefined && bMaxx !== undefined && bMaxy !== undefined
+ ?
+
+ [
+ [
+ [
+ [bMinx, bMiny],
+ [bMinx, bMaxy],
+ [bMaxx, bMaxy],
+ [bMaxx, bMiny],
+ [bMinx, bMiny]
+ ]
+ ]
+ ]
+
+ : [])]
+
+ },
+ properties: {}
+ };
+};
+
+/**
+ * Given a bounds { minx, miny, maxx, maxy } and a crs return the extent param as string
+ * @return {string} extent param
+ */
+export const boundsToExtentString = (bounds, fromCrs) => {
+ const { extent } = getViewportGeometry(bounds, fromCrs);
+ const extents = extent.length === 2
+ ? extent
+ : [ extent ];
+ const reprojectedExtents = fromCrs === 'EPSG:4326'
+ ? extents
+ : extents.map(ext => reprojectBbox(ext, fromCrs, 'EPSG:4326'));
+ return join(reprojectedExtents.map(ext => join(ext.map((val) => val.toFixed(4)), ',')), ',');
+};
+
+/**
+ * Get adjusted extent.
+ * When max extent [-180, -90, 180, 90] of EPSG:4326 is reprojected to EPSG:3857
+ * the result is [0,0,0,0], hence adjusting by minor fraction
+ * will give us correct extent when reprojected
+ * @param {Array} bounds
+ * @param {String} source projection
+ * @param {String} destination projection
+ * @returns {Array} adjusted extent with projections
+ */
+export const getAdjustedExtent = (bounds, source = "EPSG:4326", dest = "EPSG:3857") => {
+ let adjustedExtent = bounds;
+ if (!isEmpty(bounds) && source === "EPSG:4326" && dest === "EPSG:3857") {
+ let extent = bounds.map(e => Number(e));
+ const fractionCorrection = 0.000001;
+ if (extent[0] <= -180 && extent[1] <= -90 && extent[2] >= 180 && extent[3] >= 90) {
+ adjustedExtent = [extent[0], extent[1] + fractionCorrection, extent[2], extent[3] - fractionCorrection];
+ }
+ }
+ return adjustedExtent;
+};
diff --git a/web/client/plugins/ResourcesCatalog/utils/ResourcesFiltersUtils.js b/web/client/plugins/ResourcesCatalog/utils/ResourcesFiltersUtils.js
new file mode 100644
index 0000000000..749b0f28b8
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/ResourcesFiltersUtils.js
@@ -0,0 +1,78 @@
+/*
+ * Copyright 2024, 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 url from 'url';
+import castArray from 'lodash/castArray';
+import omit from 'lodash/omit';
+
+let filters = {};
+
+export const getFilterByField = (field, value) => {
+ const filterValue = filters?.[field.key + value];
+ if (field.style === 'facet' && filterValue?.facetName) {
+ return field.name === filterValue.facetName ? filterValue : null;
+ }
+ return filterValue;
+};
+
+export const getFilters = () => filters;
+
+export const addFilters = (newFilters) => {
+ filters = {
+ ...filters,
+ ...newFilters
+ };
+};
+
+export const hashLocationToHref = ({
+ location,
+ pathname,
+ query,
+ replaceQuery,
+ excludeQueryKeys
+}) => {
+ const { search, ...loc } = location;
+ const { query: locationQuery } = url.parse(search || '', true);
+
+ const newQuery = query
+ ? replaceQuery
+ ? { ...locationQuery, ...query }
+ : Object.keys(query).reduce((acc, key) => {
+ const value = query[key];
+ const currentQueryValues = castArray(acc[key]).filter(val => val);
+ const queryValue = currentQueryValues.indexOf(value) === -1
+ ? [...currentQueryValues, value]
+ : currentQueryValues.filter(val => val !== value);
+ return { ...acc, [key]: queryValue };
+ }, locationQuery)
+ : locationQuery;
+
+ return `#${url.format({
+ ...loc,
+ ...(pathname && { pathname }),
+ query: omit(Object.keys(newQuery).reduce((acc, newQueryKey) =>
+ !newQuery[newQueryKey] || newQuery[newQueryKey].length === 0
+ ? acc
+ : { ...acc, [newQueryKey]: newQuery[newQueryKey]}, {}), excludeQueryKeys)
+ })}`;
+};
+
+export function clearQueryParams(location) {
+ const { query } = url.parse(location.search, true);
+ const newParams = Object.keys(query)
+ .reduce((acc, key) =>
+ key.indexOf('filter') === 0
+ || key === 'f'
+ || key === 'q'
+ ? {
+ ...acc,
+ [key]: []
+ }
+ : acc, { extent: undefined });
+ return newParams;
+}
diff --git a/web/client/plugins/ResourcesCatalog/utils/ResourcesUtils.js b/web/client/plugins/ResourcesCatalog/utils/ResourcesUtils.js
new file mode 100644
index 0000000000..65b1dbce2c
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/ResourcesUtils.js
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2024, 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 { isEmpty, isEqual, omit, isArray, isObject } from 'lodash';
+import merge from 'lodash/fp/merge';
+
+const NODATA = 'NODATA';
+
+export const parseNODATA = (value) => value === NODATA ? '' : value;
+
+export const resourceTypes = {
+ MAP: {
+ icon: { glyph: '1-map', type: 'glyphicon' },
+ formatViewerPath: (resource) => {
+ const extras = resource['@extras'];
+ if (extras?.context?.name) {
+ return `/context/${extras.context.name}/${resource.id}`;
+ }
+ return `/viewer/${resource.id}`;
+ }
+ },
+ DASHBOARD: {
+ icon: { glyph: 'dashboard', type: 'glyphicon' },
+ formatViewerPath: (resource) => {
+ return `/dashboard/${resource.id}`;
+ }
+ },
+ GEOSTORY: {
+ icon: { glyph: 'geostory', type: 'glyphicon' },
+ formatViewerPath: (resource) => {
+ return `/geostory/${resource.id}`;
+ }
+ },
+ CONTEXT: {
+ icon: { glyph: 'cogs' },
+ formatViewerPath: (resource) => {
+ return `/context/${resource.name}`;
+ }
+ }
+};
+
+export const getResourceTypesInfo = (resource) => {
+ const thumbnailUrl = parseNODATA(resource?.attributes?.thumbnail);
+ const title = resource?.name || '';
+ const { icon, formatViewerPath } = resourceTypes[resource?.category?.name] || {};
+ const viewerPath = resource?.id && formatViewerPath ? formatViewerPath(resource) : undefined;
+ return {
+ title,
+ icon,
+ thumbnailUrl,
+ viewerPath,
+ viewerUrl: `#${viewerPath}`
+ };
+};
+
+export const getResourceStatus = (resource = {}) => {
+ const extras = resource['@extras'];
+ return {
+ items: [
+ ...(resource.advertised === false ? [{
+ type: 'icon',
+ tooltipId: 'resourcesCatalog.unadvertised',
+ glyph: 'eye-slash'
+ }] : []),
+ ...(extras?.context?.name ? [{
+ type: 'icon',
+ glyph: 'cogs',
+ tooltipId: 'resourcesCatalog.mapUsesContext',
+ tooltipParams: {
+ contextName: extras.context.name
+ }
+ }] : [])
+ ]
+ };
+};
+
+export const getResourceId = (resource) => {
+ return resource?.id;
+};
+
+const recursivePendingChanges = (a, b) => {
+ return Object.keys(a).reduce((acc, key) => {
+ if (!isArray(a[key]) && isObject(a[key])) {
+ const obj = recursivePendingChanges(a[key], b[key]);
+ return isEmpty(obj) ? acc : { ...acc, [key]: obj };
+ }
+ return !isEqual(a[key], b[key])
+ ? { ...acc, [key]: a[key] }
+ : acc;
+ }, {});
+};
+
+
+export const computePendingChanges = (initialResource, resource, resourceData) => {
+ const { attributes: pendingAttributes = {}, ...pendingChanges } = recursivePendingChanges(resource, initialResource);
+ const attributesKeys = [
+ 'thumbnail',
+ 'details'
+ ];
+ const categoryOptions = {
+ 'thumbnail': {
+ tail: '/raw?decode=datauri',
+ category: 'THUMBNAIL'
+ },
+ 'details': {
+ category: 'DETAILS'
+ }
+ };
+ const linkedResources = attributesKeys.reduce((acc, key) => {
+ const value = initialResource?.attributes?.[key] || NODATA;
+ const data = pendingAttributes?.[key] || NODATA;
+ if (pendingAttributes?.[key] !== undefined && value !== data) {
+ return {
+ ...acc,
+ [key]: {
+ ...categoryOptions[key],
+ value,
+ data
+ }
+ };
+ }
+ return acc;
+ }, {});
+ const attributes = omit(pendingAttributes, attributesKeys);
+ const excludedMetadata = ['permissions', 'attributes', 'data', 'category'];
+ const metadata = merge(omit(initialResource, excludedMetadata), omit(pendingChanges, excludedMetadata));
+ const mergedAttributes = merge(initialResource.attributes, attributes) || {};
+
+ return {
+ initialResource,
+ resource,
+ saveResource: {
+ id: initialResource.id,
+ ...(resourceData?.payload && { data: resourceData.payload }),
+ permission: pendingChanges.permissions ?? initialResource.permissions,
+ category: initialResource?.category?.name,
+ metadata: {
+ ...metadata,
+ attributes: Object.fromEntries(Object.keys(mergedAttributes || {}).map((key) => {
+ return [key, isObject(mergedAttributes[key])
+ ? JSON.stringify(mergedAttributes[key])
+ : mergedAttributes[key]];
+ }))
+ },
+ ...(!isEmpty(linkedResources) && { linkedResources })
+ },
+ changes: {
+ ...pendingChanges,
+ ...(!isEmpty(attributes) && { attributes }),
+ ...(!isEmpty(linkedResources) && { linkedResources }),
+ ...(resourceData?.pending && { data: true })
+ }
+ };
+};
diff --git a/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesCoordinatesUtils-test.js b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesCoordinatesUtils-test.js
new file mode 100644
index 0000000000..d5f8a413e0
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesCoordinatesUtils-test.js
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2024, 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 {
+ getFeatureFromExtent,
+ boundsToExtentString,
+ getAdjustedExtent
+} from '../ResourcesCoordinatesUtils';
+import expect from 'expect';
+
+describe('ResourcesCoordinatesUtils', () => {
+ it('getFeatureFromExtent', () => {
+ expect(getFeatureFromExtent('-180,-90,180,90')).toEqual({
+ type: 'Feature',
+ geometry: {
+ type: 'MultiPolygon',
+ coordinates: [[[[-180, -90], [-180, 90], [180, 90], [180, -90], [-180, -90]]]]
+ },
+ properties: {}
+ });
+ expect(getFeatureFromExtent('-180,-90,0,90,0,-90,180,90')).toEqual({
+ type: 'Feature',
+ geometry: {
+ type: 'MultiPolygon',
+ coordinates: [
+ [[[-180, -90], [-180, 90], [0, 90], [0, -90], [-180, -90]]],
+ [[[0, -90], [0, 90], [180, 90], [180, -90], [0, -90]]]]
+ },
+ properties: {}
+ });
+ });
+ it('boundsToExtentString', () => {
+ expect(boundsToExtentString({ minx: -180, miny: -90, maxx: 180, maxy: 90 }, 'EPSG:4326')).toBe('-180.0000,-90.0000,180.0000,90.0000');
+ });
+ it('getAdjustedExtent', () => {
+ expect(getAdjustedExtent([-180, -90, 180, 90], 'EPSG:4326')).toEqual([ -180, -89.999999, 180, 89.999999 ]);
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesFiltersUtils-test.js b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesFiltersUtils-test.js
new file mode 100644
index 0000000000..ebf7a1eff2
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesFiltersUtils-test.js
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024, 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 {
+ hashLocationToHref,
+ clearQueryParams
+} from '../ResourcesFiltersUtils';
+import expect from 'expect';
+
+describe('ResourcesFiltersUtils', () => {
+ it('hashLocationToHref', () => {
+ expect(hashLocationToHref({
+ location: {
+ search: ''
+ },
+ query: {
+ f: 'map'
+ }
+ })).toBe('#?f=map');
+ expect(hashLocationToHref({
+ location: {
+ search: '?f=map'
+ },
+ query: {
+ f: 'map'
+ }
+ })).toBe('#');
+ expect(hashLocationToHref({
+ location: {
+ search: '?f=map'
+ },
+ query: {
+ f: 'dashboard'
+ }
+ })).toBe('#?f=map&f=dashboard');
+ expect(hashLocationToHref({
+ location: {
+ search: '?f=map'
+ },
+ query: {
+ f: 'dashboard'
+ },
+ replaceQuery: true
+ })).toBe('#?f=dashboard');
+ expect(hashLocationToHref({
+ location: {
+ search: '?f=map'
+ },
+ query: {
+ f: 'dashboard'
+ },
+ excludeQueryKeys: ['f']
+ })).toBe('#');
+ });
+ it('clearQueryParams', () => {
+ expect(clearQueryParams({
+ search: '?f=map'
+ })).toEqual({ extent: undefined, f: [] });
+ expect(clearQueryParams({
+ search: '?filter{tags.in}=value'
+ })).toEqual({ extent: undefined, 'filter{tags.in}': [] });
+ expect(clearQueryParams({
+ search: '?q=value'
+ })).toEqual({ extent: undefined, 'q': [] });
+ });
+});
diff --git a/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesUtils-test.js b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesUtils-test.js
new file mode 100644
index 0000000000..81c23f2be4
--- /dev/null
+++ b/web/client/plugins/ResourcesCatalog/utils/__tests__/ResourcesUtils-test.js
@@ -0,0 +1,187 @@
+/*
+ * Copyright 2024, 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 {
+ parseNODATA,
+ getResourceTypesInfo,
+ getResourceStatus,
+ getResourceId,
+ computePendingChanges
+} from '../ResourcesUtils';
+import expect from 'expect';
+
+describe('ResourcesUtils', () => {
+ it('parseNODATA', () => {
+ expect(parseNODATA('NODATA')).toBe('');
+ expect(parseNODATA('/resource/1')).toBe('/resource/1');
+ });
+ it('getResourceTypesInfo', () => {
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'Map',
+ category: {
+ name: 'MAP'
+ },
+ attributes: {
+ thumbnail: '/thumb/2'
+ }
+ })).toEqual({ title: 'Map', icon: { glyph: '1-map', type: 'glyphicon' }, thumbnailUrl: '/thumb/2', viewerPath: '/viewer/1', viewerUrl: '#/viewer/1' });
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'Map',
+ category: {
+ name: 'MAP'
+ },
+ attributes: {
+ thumbnail: 'NODATA'
+ }
+ })).toEqual({ title: 'Map', icon: { glyph: '1-map', type: 'glyphicon' }, thumbnailUrl: '', viewerPath: '/viewer/1', viewerUrl: '#/viewer/1' });
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'Map',
+ category: {
+ name: 'MAP'
+ },
+ attributes: {
+ thumbnail: '/thumb/2'
+ },
+ '@extras': {
+ context: {
+ name: 'context'
+ }
+ }
+ })).toEqual({ title: 'Map', icon: { glyph: '1-map', type: 'glyphicon' }, thumbnailUrl: '/thumb/2', viewerPath: '/context/context/1', viewerUrl: '#/context/context/1' });
+
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'Dashboard',
+ category: {
+ name: 'DASHBOARD'
+ },
+ attributes: {
+ thumbnail: '/thumb/2'
+ }
+ })).toEqual({ title: 'Dashboard', icon: { glyph: 'dashboard', type: 'glyphicon' }, thumbnailUrl: '/thumb/2', viewerPath: '/dashboard/1', viewerUrl: '#/dashboard/1' });
+
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'GeoStory',
+ category: {
+ name: 'GEOSTORY'
+ },
+ attributes: {
+ thumbnail: '/thumb/2'
+ }
+ })).toEqual({ title: 'GeoStory', icon: { glyph: 'geostory', type: 'glyphicon' }, thumbnailUrl: '/thumb/2', viewerPath: '/geostory/1', viewerUrl: '#/geostory/1' });
+ expect(getResourceTypesInfo({
+ id: '1',
+ name: 'custom',
+ category: {
+ name: 'CONTEXT'
+ },
+ attributes: {
+ thumbnail: '/thumb/2'
+ }
+ })).toEqual({ title: 'custom', icon: { glyph: 'cogs' }, thumbnailUrl: '/thumb/2', viewerPath: '/context/custom', viewerUrl: '#/context/custom' });
+ });
+ it('getResourceStatus', () => {
+ expect(getResourceStatus()).toEqual({ items: [] });
+ expect(getResourceStatus({
+ advertised: false
+ })).toEqual({ items: [{ type: 'icon', tooltipId: 'resourcesCatalog.unadvertised', glyph: 'eye-slash' }] });
+ expect(getResourceStatus({
+ '@extras': {
+ context: {
+ name: 'Context'
+ }
+ }
+ })).toEqual({ items: [{ type: 'icon', glyph: 'cogs', tooltipId: 'resourcesCatalog.mapUsesContext', tooltipParams: { contextName: 'Context' } }] });
+ });
+ it('getResourceId', () => {
+ expect(getResourceId()).toBe(undefined);
+ expect(getResourceId({ id: 1 })).toBe(1);
+ });
+ it('computePendingChanges', () => {
+ expect(computePendingChanges({ id: 1, name: 'Title', category: { name: 'MAP' } }, { id: 1, name: 'Title', category: { name: 'MAP' } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', category: { name: 'MAP' } },
+ resource: { id: 1, name: 'Title', category: { name: 'MAP' } },
+ saveResource: { id: 1, permission: undefined, category: 'MAP', metadata: { id: 1, name: 'Title', attributes: {} } },
+ changes: {}
+ }
+ );
+
+ expect(computePendingChanges({ id: 1, name: 'Title', category: { name: 'MAP' } }, { id: 1, name: 'New Title', category: { name: 'MAP' } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', category: { name: 'MAP' } },
+ resource: { id: 1, name: 'New Title', category: { name: 'MAP' } },
+ saveResource: { id: 1, permission: undefined, category: 'MAP', metadata: { id: 1, name: 'New Title', attributes: {} } },
+ changes: {
+ name: 'New Title'
+ }
+ }
+ );
+
+ expect(computePendingChanges(
+ { id: 1, name: 'Title', attributes: { thumbnail: '/thumb' }, category: { name: 'MAP' } },
+ { id: 1, name: 'Title', attributes: { thumbnail: '' }, category: { name: 'MAP' } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', attributes: { thumbnail: '/thumb' }, category: { name: 'MAP' } },
+ resource: { id: 1, name: 'Title', attributes: { thumbnail: '' }, category: { name: 'MAP' } },
+ saveResource: {
+ id: 1,
+ permission: undefined,
+ category: 'MAP',
+ metadata: { id: 1, name: 'Title', attributes: { thumbnail: '/thumb' } },
+ linkedResources: { thumbnail: { tail: '/raw?decode=datauri', category: 'THUMBNAIL', value: '/thumb', data: 'NODATA' } }
+ },
+ changes: { linkedResources: { thumbnail: { tail: '/raw?decode=datauri', category: 'THUMBNAIL', value: '/thumb', data: 'NODATA' } } } }
+ );
+
+ expect(computePendingChanges(
+ { id: 1, name: 'Title', attributes: {}, category: { name: 'MAP' } },
+ { id: 1, name: 'Title', attributes: { thumbnail: '/thumb' }, category: { name: 'MAP' } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', attributes: { }, category: { name: 'MAP' } },
+ resource: { id: 1, name: 'Title', attributes: { thumbnail: '/thumb' }, category: { name: 'MAP' } },
+ saveResource: {
+ id: 1,
+ permission: undefined,
+ category: 'MAP',
+ metadata: { id: 1, name: 'Title', attributes: {} },
+ linkedResources: { thumbnail: { tail: '/raw?decode=datauri', category: 'THUMBNAIL', value: 'NODATA', data: '/thumb' } }
+ },
+ changes: { linkedResources: { thumbnail: { tail: '/raw?decode=datauri', category: 'THUMBNAIL', value: 'NODATA', data: '/thumb' } } } }
+ );
+
+ expect(computePendingChanges(
+ { id: 1, name: 'Title', attributes: {}, category: { name: 'MAP' } },
+ { id: 1, name: 'Title', attributes: { details: '/details' }, category: { name: 'MAP' } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', attributes: { }, category: { name: 'MAP' } },
+ resource: { id: 1, name: 'Title', attributes: { details: '/details' }, category: { name: 'MAP' } },
+ saveResource: {
+ id: 1,
+ permission: undefined,
+ category: 'MAP',
+ metadata: { id: 1, name: 'Title', attributes: {} },
+ linkedResources: { details: { category: 'DETAILS', value: 'NODATA', data: '/details' } }
+ },
+ changes: { linkedResources: { details: { category: 'DETAILS', value: 'NODATA', data: '/details' } } } }
+ );
+
+ expect(computePendingChanges({ id: 1, name: 'Title', category: { name: 'MAP' } }, { id: 1, name: 'Title', category: { name: 'MAP' } }, { pending: true, payload: { map: {} } })).toEqual(
+ {
+ initialResource: { id: 1, name: 'Title', category: { name: 'MAP' } },
+ resource: { id: 1, name: 'Title', category: { name: 'MAP' } },
+ saveResource: { id: 1, permission: undefined, category: 'MAP', metadata: { id: 1, name: 'Title', attributes: {} }, data: { map: {} } },
+ changes: { data: true }
+ }
+ );
+ });
+});
diff --git a/web/client/plugins/Save.jsx b/web/client/plugins/Save.jsx
index d611804b28..7c8cb9062e 100644
--- a/web/client/plugins/Save.jsx
+++ b/web/client/plugins/Save.jsx
@@ -22,6 +22,7 @@ const showMapSaveSelector = state => state.controls && state.controls.mapSave &&
/**
* Plugin for Save Map. Allows to re-save an existing map (using the persistence API). Note: creation of new Map is implemented by {@link #plugins.SaveAs|SaveAs} plugin.
+ * @deprecated
* @prop {boolean} [cfg.disablePermission=false] disable the permission selector in the tool. Can be used in context when permissions are not needed (resources are private only/using plugin with another API)
* @name Save
* @class
diff --git a/web/client/plugins/SaveAs.jsx b/web/client/plugins/SaveAs.jsx
index 912195d284..265d5dc329 100644
--- a/web/client/plugins/SaveAs.jsx
+++ b/web/client/plugins/SaveAs.jsx
@@ -27,6 +27,7 @@ export const omitResourceProperties = (show, resource) => {
/**
* Plugin for Create/Clone a Map. Saves the map as a new Resource (using the persistence API).
+ * @deprecated
* @prop {boolean} [cfg.disablePermission=false] disable the permission selector in the tool. Can be used in context when permissions are not needed (resources are private only/using plugin with another API)
* @name SaveAs
* @class
diff --git a/web/client/plugins/Share.jsx b/web/client/plugins/Share.jsx
index 035d47dc50..5360767c1d 100644
--- a/web/client/plugins/Share.jsx
+++ b/web/client/plugins/Share.jsx
@@ -80,12 +80,14 @@ const Share = connect(createSelector([
? [cameraPosition.longitude, cameraPosition.latitude]
: map?.center;
return center && ConfigUtils.getCenter(center);
- }
-], (isVisible, version, map, mapType, context, settings, formatCoords, point, isScrollPosition, viewerOptions, center) => ({
+ },
+ state => get(state, 'controls.share.resource.shareUrl') || location.href,
+ state => get(state, 'controls.share.resource.categoryName')
+], (isVisible, version, map, mapType, context, settings, formatCoords, point, isScrollPosition, viewerOptions, center, shareUrl, categoryName) => ({
isVisible,
- shareUrl: location.href,
- shareApiUrl: getApiUrl(location.href),
- shareConfigUrl: getConfigUrl(location.href, ConfigUtils.getConfigProp('geoStoreUrl')),
+ shareUrl,
+ shareApiUrl: getApiUrl(shareUrl),
+ shareConfigUrl: getConfigUrl(shareUrl, ConfigUtils.getConfigProp('geoStoreUrl')),
version,
viewerOptions,
mapType,
@@ -103,14 +105,51 @@ const Share = connect(createSelector([
},
formatCoords: formatCoords,
point,
- isScrollPosition})), {
+ isScrollPosition,
+ categoryName})), {
onClose: toggleControl.bind(null, 'share', null),
hideMarker,
updateMapView,
onUpdateSettings: setControlProperty.bind(null, 'share', 'settings'),
onChangeFormat: changeFormat,
- addMarker: addMarker
-})(SharePanel);
+ addMarker: addMarker,
+ onClearShareResource: setControlProperty.bind(null, 'share', 'resource', undefined)
+})(({ categoryName, ...props }) => {
+ const categoryCfg = props[categoryName];
+ return ;
+});
+
+const ActionCardShareButton = connect(
+ () => ({}),
+ {
+ onToggle: toggleControl.bind(null, 'share', null),
+ setShareResource: setControlProperty.bind(null, 'share', 'resource')
+ }
+)(({
+ resource,
+ viewerUrl,
+ onToggle,
+ setShareResource,
+ component
+}) => {
+ const Component = component;
+ function handleToggle() {
+ const baseURL = location && (location.origin + location.pathname);
+ const shareUrl = baseURL + viewerUrl;
+ setShareResource({
+ shareUrl,
+ categoryName: (resource?.category?.name || '').toLowerCase()
+ });
+ onToggle();
+ }
+ return ( );
+});
const SharePlugin = createPlugin('Share', {
@@ -149,6 +188,13 @@ const SharePlugin = createPlugin('Share', {
tooltip: "share.title",
icon: ,
action: toggleControl.bind(null, 'share', null)
+ },
+ ResourcesGrid: {
+ priority: 1,
+ target: 'card-options',
+ doNotHide: true,
+ Component: ActionCardShareButton,
+ position: 1
}
},
epics: shareEpics,
diff --git a/web/client/plugins/SidebarMenu.jsx b/web/client/plugins/SidebarMenu.jsx
index 32bb6dcdf6..f49adae3a4 100644
--- a/web/client/plugins/SidebarMenu.jsx
+++ b/web/client/plugins/SidebarMenu.jsx
@@ -26,9 +26,42 @@ import './sidebarmenu/sidebarmenu.less';
import {lastActiveToolSelector, sidebarIsActiveSelector, isSidebarWithFullHeight} from "../selectors/sidebarmenu";
import {setLastActiveItem} from "../actions/sidebarmenu";
import Message from "../components/I18N/Message";
+import { ButtonWithTooltip } from '../components/misc/Button';
const TDropdownButton = tooltip(DropdownButton);
+function SidebarMenuItem({
+ active,
+ onClick,
+ menuItem,
+ glyph,
+ labelId,
+ className
+}) {
+ return menuItem
+ ? (
+ onClick(!active)}
+ >
+
+
+ )
+ : (
+ onClick(!active)}
+ tooltipId={labelId}
+ tooltipPosition="left"
+ >
+
+
+ );
+}
+
class SidebarMenu extends React.Component {
static propTypes = {
className: PropTypes.string,
@@ -200,7 +233,7 @@ class SidebarMenu extends React.Component {
const menuItems = items.map((item) => {
if (item.tool) {
const CustomMenuItem = item.tool;
- return ;
+ return ;
}
const ConnectedItem = connect((item?.selector ?? dummySelector),
(dispatch, ownProps) => {
@@ -243,6 +276,7 @@ class SidebarMenu extends React.Component {
tool={SidebarElement}
tools={this.getTools('sidebar', height)}
panels={this.getPanels(this.props.items)}
+ toolComponent={SidebarMenuItem}
/> }
diff --git a/web/client/plugins/__tests__/ContextCreator-test.jsx b/web/client/plugins/__tests__/ContextCreator-test.jsx
index 6f95dab229..8819068af1 100644
--- a/web/client/plugins/__tests__/ContextCreator-test.jsx
+++ b/web/client/plugins/__tests__/ContextCreator-test.jsx
@@ -47,7 +47,7 @@ describe('ContextCreator plugin', () => {
ReactTestUtils.Simulate.click(button); // <-- trigger event callback
// check destination path
expect(actions.length).toBeGreaterThanOrEqualTo(1);
- expect(actions[1].destLocation).toBe("/context-manager");
+ expect(actions[1].destLocation).toBe("/");
});
it('custom destination', () => {
const plugins = [
diff --git a/web/client/plugins/__tests__/TOCItemsSettings-test.jsx b/web/client/plugins/__tests__/TOCItemsSettings-test.jsx
index 357f3dac65..3f17fc654a 100644
--- a/web/client/plugins/__tests__/TOCItemsSettings-test.jsx
+++ b/web/client/plugins/__tests__/TOCItemsSettings-test.jsx
@@ -38,7 +38,7 @@ const THEMATIC_LAYER_ITEM = {
const SETTINGS_SELECTOR = '.ms-side-panel';
const NAV_SELECTOR = 'ul.nav-tabs';
const TAB_INDEX_SELECTOR = `${NAV_SELECTOR} > li`;
-const TAB_CONTENT_SELECTOR = 'main';
+const TAB_CONTENT_SELECTOR = '.ms2-border-layout-content';
const TEST_LAYER = {
id: "TEST_WMS",
type: "wms",
diff --git a/web/client/plugins/containers/ToolsContainer.jsx b/web/client/plugins/containers/ToolsContainer.jsx
index c46a515582..808b127ad2 100644
--- a/web/client/plugins/containers/ToolsContainer.jsx
+++ b/web/client/plugins/containers/ToolsContainer.jsx
@@ -66,7 +66,8 @@ class ToolsContainer extends React.Component {
panelStyle: PropTypes.object,
panelClassName: PropTypes.string,
activePanel: PropTypes.string,
- toolCfg: PropTypes.object
+ toolCfg: PropTypes.object,
+ toolComponent: PropTypes.any
};
static contextTypes = {
@@ -143,7 +144,7 @@ class ToolsContainer extends React.Component {
return this.addTooltip(
+ {...tool.cfg} items={tool.items || []} component={this.props.toolComponent}>
{tool.cfg && tool.cfg.glyph ? : tool.icon}{help} {tool.text}
{toolChildren.length > 0 && ({
onError: loginFail
})(LoginModalComp);
-export const LoginNav = connect((state) => ({
+export const LoginNav = connect((state, props) => ({
currentProvider: authProviderSelector(state),
user: userSelector(state),
nav: false,
@@ -78,7 +78,7 @@ export const LoginNav = connect((state) => ({
renderButtonText: false,
renderButtonContent: () => {return ; },
- className: "square-button",
+ className: props.className || "square-button",
renderUnsavedMapChangesDialog: ConfigUtils.getConfigProp('unsavedMapChangesDialog'),
displayUnsavedDialog: unsavedMapSelector(state)
&& unsavedMapSourceSelector(state) === 'logout'
diff --git a/web/client/plugins/manager/ManagerMenu.jsx b/web/client/plugins/manager/ManagerMenu.jsx
index b57e3d9244..4d08d439f7 100644
--- a/web/client/plugins/manager/ManagerMenu.jsx
+++ b/web/client/plugins/manager/ManagerMenu.jsx
@@ -43,8 +43,7 @@ class ManagerMenu extends React.Component {
panelStyle: PropTypes.object,
panelClassName: PropTypes.string,
enableRulesManager: PropTypes.bool,
- enableImporter: PropTypes.bool,
- enableContextManager: PropTypes.bool
+ enableImporter: PropTypes.bool
};
static contextTypes = {
@@ -59,11 +58,6 @@ class ManagerMenu extends React.Component {
"glyph": "1-group-mod",
"path": "/manager/usermanager"
},
- {
- "msgId": "contextManager.title",
- "glyph": "wrench",
- "path": "/context-manager"
- },
{
"msgId": "rulesmanager.menutitle",
"glyph": "admin-geofence",
@@ -86,8 +80,7 @@ class ManagerMenu extends React.Component {
position: "absolute",
overflow: "auto"
},
- panelClassName: "toolbar-panel",
- enableContextManager: false
+ panelClassName: "toolbar-panel"
};
getTools = () => {
@@ -96,7 +89,6 @@ class ManagerMenu extends React.Component {
...this.props.entries
.filter(e => this.props.enableRulesManager || e.path !== "/rules-manager")
.filter(e => this.props.enableImporter || e.path !== "/importer")
- .filter(e => this.props.enableContextManager || e.path !== "/context-manager")
.sort((a, b) => a.position - b.position).map((entry) => {
return {
action: (context) => {
@@ -121,7 +113,7 @@ class ManagerMenu extends React.Component {
render() {
if (this.props.role === "ADMIN") {
return (
- ({
@@ -161,6 +152,11 @@ export default {
position: 1,
tool: true,
priority: 1
+ },
+ BrandNavbar: {
+ target: 'right-menu',
+ position: 7,
+ priority: 3
}
}),
reducers: {}
diff --git a/web/client/product/pages/Maps.jsx b/web/client/product/pages/Maps.jsx
index d5a93f5e6d..9d36ae5c9d 100644
--- a/web/client/product/pages/Maps.jsx
+++ b/web/client/product/pages/Maps.jsx
@@ -17,8 +17,6 @@ import {loadMaps} from '../../actions/maps';
import Page from '../../containers/Page';
import ConfigUtils from '../../utils/ConfigUtils';
-import("../assets/css/maps.css");
-
const urlQuery = url.parse(window.location.href, true).query;
/**
diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js
index edf1d4ac5e..a31e013367 100644
--- a/web/client/product/plugins.js
+++ b/web/client/product/plugins.js
@@ -8,13 +8,10 @@
import Context from "../plugins/Context";
import ContextCreator from "../plugins/ContextCreator";
import Dashboard from "../plugins/Dashboard";
-import Dashboards from "../plugins/Dashboards";
import FeedbackMask from '../plugins/FeedbackMask';
-import GeoStories from "../plugins/GeoStories";
import GeoStory from "../plugins/GeoStory";
import Identify from '../plugins/Identify';
import Login from '../plugins/Login';
-import Maps from "../plugins/Maps";
import Print from "../plugins/Print";
import RulesDataGrid from "../plugins/RulesDataGrid";
import RulesEditor from "../plugins/RulesEditor";
@@ -23,6 +20,7 @@ import UserSession from "../plugins/UserSession";
import FeatureEditor from '../plugins/FeatureEditor';
import MetadataInfo from '../plugins/MetadataInfo';
import TOC from '../plugins/TOC';
+import * as resourcesCatalogPlugins from '../plugins/ResourcesCatalog';
import SearchServicesConfig from "../plugins/SearchServicesConfig";
import {toModulePlugin} from "../utils/ModulePluginsUtils";
@@ -32,16 +30,15 @@ import {toModulePlugin} from "../utils/ModulePluginsUtils";
*/
export const plugins = {
// ### STATIC PLUGINS ### //
+ ...resourcesCatalogPlugins,
+
ContextCreatorPlugin: ContextCreator,
ContextPlugin: Context,
Dashboard: Dashboard,
- DashboardsPlugin: Dashboards,
FeedbackMaskPlugin: FeedbackMask,
- GeoStoriesPlugin: GeoStories,
GeoStoryPlugin: GeoStory,
IdentifyPlugin: Identify,
LoginPlugin: Login,
- MapsPlugin: Maps,
PrintPlugin: Print,
RulesDataGridPlugin: RulesDataGrid,
RulesEditorPlugin: RulesEditor,
@@ -55,24 +52,16 @@ export const plugins = {
// ### DYNAMIC PLUGINS ### //
// product plugins
AboutPlugin: toModulePlugin('About', () => import(/* webpackChunkName: 'plugins/about' */ './plugins/About')),
- AttributionPlugin: toModulePlugin('Attribution', () => import(/* webpackChunkName: 'plugins/attribution' */ './plugins/Attribution')),
- FooterPlugin: toModulePlugin('Footer', () => import(/* webpackChunkName: 'plugins/footer' */ './plugins/Footer'), {}, 'FooterPlugin'),
- ForkPlugin: toModulePlugin('Fork', () => import(/* webpackChunkName: 'plugins/fork' */ './plugins/Fork')),
HeaderPlugin: toModulePlugin('Header', () => import(/* webpackChunkName: 'plugins/header' */ './plugins/Header')),
- HomeDescriptionPlugin: toModulePlugin('HomeDescription', () => import(/* webpackChunkName: 'plugins/HomeDescription' */ './plugins/HomeDescription')),
- MadeWithLovePlugin: toModulePlugin('MadeWithLove', () => import(/* webpackChunkName: 'plugins/madeWithLove' */ './plugins/MadeWithLove')),
// framework plugins
MapTypePlugin: toModulePlugin('MapType', () => import(/* webpackChunkName: 'plugins/mapType' */ './plugins/MapType')),
- NavMenuPlugin: toModulePlugin('NavMenu', () => import(/* webpackChunkName: 'plugins/navMenu' */ './plugins/NavMenu')),
AddGroupPlugin: toModulePlugin('AddGroup', () => import(/* webpackChunkName: 'plugins/about' */'../plugins/AddGroup')),
AnnotationsPlugin: toModulePlugin('Annotations', () => import(/* webpackChunkName: 'plugins/annotations' */ '../plugins/Annotations')),
AutoMapUpdatePlugin: toModulePlugin('AutoMapUpdate', () => import(/* webpackChunkName: 'plugins/autoMapUpdate' */ '../plugins/AutoMapUpdate')),
BackgroundSelectorPlugin: toModulePlugin('BackgroundSelector', () => import(/* webpackChunkName: 'plugins/backgroundSelector' */ '../plugins/BackgroundSelector')),
BurgerMenuPlugin: toModulePlugin('BurgerMenu', () => import(/* webpackChunkName: 'plugins/burgerMenu' */ '../plugins/BurgerMenu')),
CRSSelectorPlugin: toModulePlugin('CRSSelector', () => import(/* webpackChunkName: 'plugins/CRSSelector' */ '../plugins/CRSSelector')),
- ContentTabs: toModulePlugin('ContentTabs', () => import(/* webpackChunkName: 'plugins/contentTabs' */ '../plugins/ContentTabs')),
ContextManagerPlugin: toModulePlugin('ContextManager', () => import(/* webpackChunkName: 'plugins/contextManager' */ '../plugins/contextmanager/ContextManager')),
- ContextsPlugin: toModulePlugin('Contexts', () => import(/* webpackChunkName: 'plugins/contexts' */ '../plugins/Contexts')),
ContextImportPlugin: toModulePlugin('ContextImport', () => import(/* webpackChunkName: 'plugins/contextImport' */ '../plugins/ContextImport')),
ContextExportPlugin: toModulePlugin('ContextExport', () => import(/* webpackChunkName: 'plugins/contextExport' */ '../plugins/ContextExport')),
CookiePlugin: toModulePlugin('Cookie', () => import(/* webpackChunkName: 'plugins/cookie' */ '../plugins/Cookie')),
@@ -80,22 +69,14 @@ export const plugins = {
DashboardEditor: toModulePlugin('DashboardEditor', () => import(/* webpackChunkName: 'plugins/dashboardEditor' */ '../plugins/DashboardEditor')),
DashboardExport: toModulePlugin('DashboardExport', () => import(/* webpackChunkName: 'plugins/dashboardExport' */ '../plugins/DashboardExport')),
DashboardImport: toModulePlugin('DashboardImport', () => import( /* webpackChunkName: 'plugins/dashboardImport' */'../plugins/DashboardImport')),
- DeleteMapPlugin: toModulePlugin('DeleteMap', () => import(/* webpackChunkName: 'plugins/deleteMap' */ '../plugins/DeleteMap')),
- DeleteGeoStoryPlugin: toModulePlugin('DeleteGeoStory', () => import(/* webpackChunkName: 'plugins/deleteGeoStory' */ '../plugins/DeleteGeoStory')),
- DeleteDashboardPlugin: toModulePlugin('DeleteDashboard', () => import(/* webpackChunkName: 'plugins/deleteDashboard' */ '../plugins/DeleteDashboard')),
DetailsPlugin: toModulePlugin('Details', () => import(/* webpackChunkName: 'plugins/details' */ '../plugins/Details')),
DrawerMenuPlugin: toModulePlugin('DrawerMenu', () => import(/* webpackChunkName: 'plugins/drawerMenu' */ '../plugins/DrawerMenu')),
ExpanderPlugin: toModulePlugin('Expander', () => import(/* webpackChunkName: 'plugins/expander' */ '../plugins/Expander')),
- FeaturedMaps: toModulePlugin('FeaturedMaps', () => import(/* webpackChunkName: 'plugins/featuredMaps' */ '../plugins/FeaturedMaps')),
FilterLayerPlugin: toModulePlugin('FilterLayer', () => import(/* webpackChunkName: 'plugins/filterLayer' */ '../plugins/FilterLayer')),
FullScreenPlugin: toModulePlugin('FullScreen', () => import(/* webpackChunkName: 'plugins/fullScreen' */ '../plugins/FullScreen')),
GeoStoryEditorPlugin: toModulePlugin('GeoStoryEditor', () => import(/* webpackChunkName: 'plugins/geoStoryEditor' */ '../plugins/GeoStoryEditor')),
- GeoStorySavePlugin: toModulePlugin('GeoStorySave', () => import(/* webpackChunkName: 'plugins/geoStorySave' */ '../plugins/GeoStorySave'), { exportedName: 'GeoStorySave'}),
- GeoStorySaveAsPlugin: toModulePlugin('GeoStorySaveAs', () => import(/* webpackChunkName: 'plugins/geoStorySave' */ '../plugins/GeoStorySave'), { exportedName: 'GeoStorySaveAs'}),
GeoStoryExport: toModulePlugin('GeoStoryExport', () => import(/* webpackChunkName: 'plugins/geoStoryExport' */ '../plugins/GeoStoryExport')),
GeoStoryImport: toModulePlugin('GeoStoryImport', () => import(/* webpackChunkName: 'plugins/geoStoryImport' */ '../plugins/GeoStoryImport')),
- DashboardSavePlugin: toModulePlugin('DashboardSave', () => import(/* webpackChunkName: 'plugins/dashboardSave' */ '../plugins/DashboardSave'), { exportedName: 'DashboardSave'}),
- DashboardSaveAsPlugin: toModulePlugin('DashboardSaveAs', () => import(/* webpackChunkName: 'plugins/dashboardSave' */ '../plugins/DashboardSave'), { exportedName: 'DashboardSaveAs'}),
GeoProcessing: toModulePlugin('GeoProcessing', () => import(/* webpackChunkName: 'plugins/GeoProcessing' */ '../plugins/GeoProcessing')),
GeoStoryNavigationPlugin: toModulePlugin('GeoStoryNavigation', () => import(/* webpackChunkName: 'plugins/geoStoryNavigation' */ '../plugins/GeoStoryNavigation')),
GroupManagerPlugin: toModulePlugin('GroupManager', () => import(/* webpackChunkName: 'plugins/groupManager' */ '../plugins/manager/GroupManager')),
@@ -132,9 +113,6 @@ export const plugins = {
QueryPanelPlugin: toModulePlugin('QueryPanel', () => import(/* webpackChunkName: 'plugins/queryPanel' */ '../plugins/QueryPanel')),
RedirectPlugin: toModulePlugin('Redirect', () => import(/* webpackChunkName: 'plugins/redirect' */ '../plugins/Redirect')),
RedoPlugin: toModulePlugin('Redo', () => import(/* webpackChunkName: 'plugins/history' */ '../plugins/History')),
- SavePlugin: toModulePlugin('Save', () => import(/* webpackChunkName: 'plugins/save' */ '../plugins/Save')),
- SaveAsPlugin: toModulePlugin('SaveAs', () => import(/* webpackChunkName: 'plugins/saveAs' */ '../plugins/SaveAs')),
- SaveStoryPlugin: toModulePlugin('SaveStory', () => import(/* webpackChunkName: 'plugins/saveStory' */ '../plugins/GeoStorySave')),
ScaleBoxPlugin: toModulePlugin('ScaleBox', () => import(/* webpackChunkName: 'plugins/scaleBox' */ '../plugins/ScaleBox')),
ScrollTopPlugin: toModulePlugin('ScrollTop', () => import(/* webpackChunkName: 'plugins/scrollTop' */ '../plugins/ScrollTop')),
SearchPlugin: toModulePlugin('Search', () => import(/* webpackChunkName: 'plugins/search' */ '../plugins/Search')),
diff --git a/web/client/product/plugins/Attribution.jsx b/web/client/product/plugins/Attribution.jsx
index d8072b0ef7..3f5ea0457f 100644
--- a/web/client/product/plugins/Attribution.jsx
+++ b/web/client/product/plugins/Attribution.jsx
@@ -41,6 +41,7 @@ class Attribution extends React.Component {
/**
* Renders the logo of GeoSolutions in the {@link #plugins.NavMenu|NavMenu}
* @name Attribution
+ * @deprecated
* @class
* @memberof plugins
* @prop {string} [label='GeoSolutions'] the tooltip for the logo
diff --git a/web/client/product/plugins/Footer.jsx b/web/client/product/plugins/Footer.jsx
index 7bb9f5e204..43cc03b039 100644
--- a/web/client/product/plugins/Footer.jsx
+++ b/web/client/product/plugins/Footer.jsx
@@ -17,6 +17,7 @@ import {createPlugin} from "../../utils/PluginsUtils";
* Footer plugin, section of the homepage.
* description of footer can be overridden by
* `home.footerDescription` message id in the translations
+ * @deprecated
* @prop {boolean} cfg.customFooter params that can be used to render a custom html to be used instead of the default one
* @prop {object} cfg.logo logo data to change image and href, set to null to hide the logo
* @prop {string} cfg.logo.src source of the logo
diff --git a/web/client/product/plugins/Fork.jsx b/web/client/product/plugins/Fork.jsx
index 3568433a3f..758f8f8982 100644
--- a/web/client/product/plugins/Fork.jsx
+++ b/web/client/product/plugins/Fork.jsx
@@ -10,6 +10,7 @@ import PropTypes from 'prop-types';
/**
* Fork Ribbon that links to MapStore GitHub repository
+ * @deprecated
* @memberof plugins
* @prop {string} src image source
* @class
diff --git a/web/client/product/plugins/HomeDescription.jsx b/web/client/product/plugins/HomeDescription.jsx
index 6573e23cb0..b263f6b4a1 100644
--- a/web/client/product/plugins/HomeDescription.jsx
+++ b/web/client/product/plugins/HomeDescription.jsx
@@ -15,6 +15,7 @@ import HTML from '../../components/I18N/HTML';
* Description of MapStore rendered in the home page.
* Renders the HTML in localization files identified by
* the path `home.shortDescription`.
+ * @deprecated
* @name HomeDescription
* @class
* @memberof plugins
diff --git a/web/client/product/plugins/NavMenu.jsx b/web/client/product/plugins/NavMenu.jsx
index 2a45b8a7ba..4cc042138a 100644
--- a/web/client/product/plugins/NavMenu.jsx
+++ b/web/client/product/plugins/NavMenu.jsx
@@ -44,7 +44,7 @@ import { scrollIntoViewId } from '../../utils/DOMUtil';
* }
* }
* ```
- *
+ * @deprecated
* @memberof plugins
* @name NavMenu
* @class
diff --git a/web/client/reducers/__tests__/context-test.js b/web/client/reducers/__tests__/context-test.js
index 766e137b3e..ca71213e1e 100644
--- a/web/client/reducers/__tests__/context-test.js
+++ b/web/client/reducers/__tests__/context-test.js
@@ -28,7 +28,7 @@ describe('context reducer', () => {
it('loadContext', () => {
const CONTEXT = {};
const state = stateMocker(setContext(CONTEXT));
- expect(currentContextSelector(state)).toBe(CONTEXT);
+ expect(currentContextSelector(state)).toEqual(CONTEXT);
});
it('setResource', () => {
const RESOURCE = {};
diff --git a/web/client/reducers/config.js b/web/client/reducers/config.js
index 45a0a6a44e..c0bb32b0b0 100644
--- a/web/client/reducers/config.js
+++ b/web/client/reducers/config.js
@@ -35,6 +35,9 @@ function mapConfig(state = null, action) {
let map;
switch (action.type) {
case LOCATION_CHANGE: {
+ if (action?.payload?.action === 'REPLACE') {
+ return state;
+ }
return {
...state,
mapInitialConfig: {}
diff --git a/web/client/reducers/context.js b/web/client/reducers/context.js
index 17213f8f6a..16f2d679ad 100644
--- a/web/client/reducers/context.js
+++ b/web/client/reducers/context.js
@@ -8,6 +8,7 @@
import { SET_CURRENT_CONTEXT, LOADING, SET_RESOURCE, CLEAR_CONTEXT, UPDATE_USER_PLUGIN } from "../actions/context";
import { find, get } from 'lodash';
import {set, arrayUpdate} from '../utils/ImmutableUtils';
+import { migrateContextConfiguration } from '../utils/ContextCreatorUtils';
/**
* Reducers for context page and configs.
@@ -32,7 +33,7 @@ import {set, arrayUpdate} from '../utils/ImmutableUtils';
export default (state = {}, action) => {
switch (action.type) {
case SET_CURRENT_CONTEXT: {
- return set('currentContext', action.context, state);
+ return set('currentContext', migrateContextConfiguration(action.context), state);
}
case SET_RESOURCE: {
return set('resource', action.resource, state);
diff --git a/web/client/reducers/contextcreator.js b/web/client/reducers/contextcreator.js
index d55bcda354..331acc1f12 100644
--- a/web/client/reducers/contextcreator.js
+++ b/web/client/reducers/contextcreator.js
@@ -17,6 +17,7 @@ import {INIT, SET_CREATION_STEP, SET_WAS_TUTORIAL_SHOWN, SET_TUTORIAL_STEP, MAP_
REMOVE_PLUGIN_TO_UPLOAD, PLUGIN_UPLOADED, UNINSTALLING_PLUGIN, UNINSTALL_PLUGIN_ERROR, PLUGIN_UNINSTALLED,
BACK_TO_PAGE_SHOW_CONFIRMATION, SET_SELECTED_THEME, ON_TOGGLE_CUSTOM_VARIABLES, LOAD_CONTEXT} from "../actions/contextcreator";
import {set} from '../utils/ImmutableUtils';
+import { migrateContextConfiguration } from '../utils/ContextCreatorUtils';
const defaultPlugins = [
@@ -205,7 +206,10 @@ export default (state = {}, action) => {
}
case SET_RESOURCE: {
const {data = {plugins: {desktop: []}}, ...resource} = action.resource || {};
- const {plugins = {desktop: []}, userPlugins = [], templates = [], theme, customVariablesEnabled, ...otherData} = data;
+
+ const migratedData = migrateContextConfiguration(data);
+
+ const {plugins = {desktop: []}, userPlugins = [], templates = [], theme, customVariablesEnabled, ...otherData} = migratedData;
const contextPlugins = get(plugins, 'desktop', []);
const allPlugins = makePluginTree(get(action.pluginsConfig, 'plugins'), ConfigUtils.getConfigProp('plugins'));
diff --git a/web/client/reducers/map.js b/web/client/reducers/map.js
index 5098cbe59a..9551d954b2 100644
--- a/web/client/reducers/map.js
+++ b/web/client/reducers/map.js
@@ -43,6 +43,9 @@ function mapConfig(state = {eventListeners: {}}, action) {
mousePointer: action.pointer
});
case LOCATION_CHANGE:
+ if (action?.payload?.action === 'REPLACE') {
+ return state;
+ }
return assign({}, {eventListeners: {}});
case CHANGE_ZOOM_LVL:
return assign({}, state, {
diff --git a/web/client/selectors/__tests__/context-test.js b/web/client/selectors/__tests__/context-test.js
index f442486505..f7b704d358 100644
--- a/web/client/selectors/__tests__/context-test.js
+++ b/web/client/selectors/__tests__/context-test.js
@@ -29,7 +29,7 @@ import CONTEXT_SHORT_RESOURCE from '../../test-resources/geostore/resources/reso
const stateMocker = createStateMocker({context});
describe('context selectors', () => {
it('currentContextSelector', () => {
- expect(currentContextSelector(stateMocker(setContext(CONTEXT_DATA)))).toBe(CONTEXT_DATA);
+ expect(currentContextSelector(stateMocker(setContext(CONTEXT_DATA)))).toEqual(CONTEXT_DATA);
});
it('contextMonitoredStateSelector', () => {
expect(contextMonitoredStateSelector(stateMocker())).toBe('{}');
diff --git a/web/client/selectors/__tests__/dashboardsave-test.js b/web/client/selectors/__tests__/dashboardsave-test.js
index e4059e6285..163bbbf711 100644
--- a/web/client/selectors/__tests__/dashboardsave-test.js
+++ b/web/client/selectors/__tests__/dashboardsave-test.js
@@ -11,254 +11,6 @@ import expect from 'expect';
import { dashboardHasPendingChangesSelector } from '../dashboardsave';
describe('dashboardsave selectors', () => {
- it('dashboardHasPendingChanges selector with no resource', () => {
- const state = {
- dashboard: {
- originalData: {}
- }
- };
- expect(dashboardHasPendingChangesSelector(state)).toBe(true);
- });
- it('dashboardHasPendingChanges selector with non editable resource', () => {
- const state = {
- dashboard: {
- resource: {
- canEdit: false
- },
- originalData: {
- layouts: {
- lg: {
- w: 1,
- x: 0,
- y: 0,
- h: 1,
- i: "252bb010-49f7-11e8-9f59-630c9298622e"
- }
- },
- widgets: [{
- id: 'widget1',
- dataGrid: {y: 0, x: 0, w: 1, h: 1},
- layer: false,
- legend: false,
- mapSync: false,
- text: "Dashboard",
- title: "Dashboard",
- url: false,
- widgetType: "text"
- }, {
- id: 'widget2',
- dataGrid: {y: 0, x: 0, w: 1, h: 1},
- layer: false,
- legend: false,
- map: {
- bbox: {
- bounds: {minx: -16505099.28586463, miny: 1173433.3176468946, maxx: -5194865.084563671, maxy: 8041758.9312396925},
- crs: "EPSG:3857",
- rotation: 0
- },
- groups: {id: "Default", expanded: true},
- layers: [{
- group: "background",
- id: "mapnik__0",
- name: "mapnik",
- source: "osm",
- title: "Open Street Map",
- type: "osm",
- visibility: true
- }, {
- apiKey: "__API_KEY_MAPQUEST__",
- group: "background",
- id: "osm__2",
- name: "osm",
- source: "mapquest",
- title: "MapQuest OSM",
- type: "mapquest",
- visibility: false
- }, {
- group: "background",
- id: "Night2012__3",
- name: "Night2012",
- provider: "NASAGIBS.ViirsEarthAtNight2012",
- source: "nasagibs",
- title: "NASAGIBS Night 2012",
- type: "tileprovider",
- visibility: false
- }, {
- group: "background",
- id: "OpenTopoMap__4",
- name: "OpenTopoMap",
- provider: "OpenTopoMap",
- source: "OpenTopoMap",
- title: "OpenTopoMap",
- type: "tileprovider",
- visibility: false
- }, {
- group: "background",
- id: "undefined__5",
- source: "ol",
- title: "Empty Background",
- type: "empty",
- visibility: false
- }, {
- allowedSRS: {},
- bbox: {crs: "EPSG:4326", bounds: {
- maxx: -66.969849,
- maxy: 49.371735,
- minx: -124.73142200000001,
- miny: 24.955967
- }},
- description: "This is some census data on the states.",
- dimensions: [],
- id: "topp:states__xbiwklaqww",
- links: [],
- name: "topp:states",
- params: {},
- search: {type: "wfs", url: "https://demo.geo-solutions.it:443/geoserver/wfs"},
- title: "USA Population",
- type: "wms",
- url: "https://demo.geo-solutions.it:443/geoserver/wms",
- visibility: true
- }],
- maxExtent: [-20037508.34, -20037508.34, 20037508.34, 20037508.34],
- size: {width: 578, height: 351},
- projection: "EPSG:900913",
- units: "m",
- center: {x: -97.46704829402395, y: 38.19914131811602, crs: "EPSG:4326"},
- zoom: 3
- },
- mapStateSource: "__base_map__",
- mapSync: false,
- title: "Map of united states",
- url: false,
- widgetType: "map"
- }, {
- id: 'widget3',
- dataGrid: {y: 0, x: 0, w: 1, h: 1},
- dependenciesMap: {layers: "widgets[widget3].map.layers", zoom: "widgets[widget3].map.zoom", viewport: "widgets[widget3].map.viewport"},
- layer: false,
- legend: false,
- mapSync: true,
- title: "Legend",
- url: false,
- widgetType: "legend"
- }]
- }
- },
- widgets: {
- containers: {
- floating: {
- layouts: {
- lg: {
- w: 1,
- x: 0,
- y: 0,
- h: 1,
- i: "252bb010-49f7-11e8-9f59-630c9298622e"
- }
- },
- widgets: [{
- id: 'widget1',
- dataGrid: {y: 0, x: 0, w: 1, h: 1},
- layer: false,
- legend: false,
- mapSync: false,
- text: "Dashboard",
- title: "Dashboard",
- url: false,
- widgetType: "text"
- }, {
- id: 'widget2',
- dataGrid: {y: 0, x: 0, w: 1, h: 1},
- layer: false,
- legend: false,
- map: {
- bbox: {
- bounds: {minx: -26505099.28586463, miny: 3173433.3176468946, maxx: -6194865.084563671, maxy: 8041758.9312396925},
- crs: "EPSG:3857",
- rotation: 0
- },
- groups: {id: "Default", expanded: true},
- layers: [{
- group: "background",
- id: "mapnik__0",
- name: "mapnik",
- source: "osm",
- title: "Open Street Map",
- type: "osm",
- visibility: true
- }, {
- apiKey: "__API_KEY_MAPQUEST__",
- group: "background",
- id: "osm__2",
- name: "osm",
- source: "mapquest",
- title: "MapQuest OSM",
- type: "mapquest",
- visibility: false
- }, {
- group: "background",
- id: "Night2012__3",
- name: "Night2012",
- provider: "NASAGIBS.ViirsEarthAtNight2012",
- source: "nasagibs",
- title: "NASAGIBS Night 2012",
- type: "tileprovider",
- visibility: false
- }, {
- group: "background",
- id: "OpenTopoMap__4",
- name: "OpenTopoMap",
- provider: "OpenTopoMap",
- source: "OpenTopoMap",
- title: "OpenTopoMap",
- type: "tileprovider",
- visibility: false
- }, {
- group: "background",
- id: "undefined__5",
- source: "ol",
- title: "Empty Background",
- type: "empty",
- visibility: false
- }, {
- allowedSRS: {},
- bbox: {crs: "EPSG:4326", bounds: {
- maxx: -66.969849,
- maxy: 49.371735,
- minx: -124.73142200000001,
- miny: 24.955967
- }},
- description: "This is some census data on the states.",
- dimensions: [],
- id: "topp:states__xbiwklaqww",
- links: [],
- name: "topp:states",
- params: {},
- search: {type: "wfs", url: "https://demo.geo-solutions.it:443/geoserver/wfs"},
- title: "USA Population",
- type: "wms",
- url: "https://demo.geo-solutions.it:443/geoserver/wms",
- visibility: true
- }],
- maxExtent: [-20037508.34, -20037508.34, 20037508.34, 20037508.34],
- size: {width: 578, height: 351},
- projection: "EPSG:900913",
- units: "m",
- center: {x: -97.46704829402395, y: 38.19914131811602, crs: "EPSG:4326"},
- zoom: 3
- },
- mapStateSource: "__base_map__",
- mapSync: false,
- title: "Map of united states",
- url: false,
- widgetType: "map"
- }]
- }
- }
- }
- };
- expect(dashboardHasPendingChangesSelector(state)).toBe(false);
- });
it('dashboardHasPendingChanges selector with no changes', () => {
const state = {
dashboard: {
@@ -908,7 +660,7 @@ describe('dashboardsave selectors', () => {
layer: false,
legend: false,
mapSync: false,
- text: "Dashboard",
+ text: "Dashboard Change",
title: "Dashboard",
url: false,
widgetType: "text"
diff --git a/web/client/selectors/__tests__/localConfig-test.js b/web/client/selectors/__tests__/localConfig-test.js
index b208ee4ae6..94a7d5575f 100644
--- a/web/client/selectors/__tests__/localConfig-test.js
+++ b/web/client/selectors/__tests__/localConfig-test.js
@@ -18,7 +18,7 @@ import {
localConfigLoaded
} from '../../actions/localConfig';
import LOCAL_CONFIG from '../../configs/localConfig';
-import {find, includes} from 'lodash';
+import {find} from 'lodash';
const stateMocker = createStateMocker({localConfig});
const TEST_CONFIG = {
@@ -42,8 +42,8 @@ describe('localConfig selectors', () => {
it('pluginSelectorCreator for dashboard', ()=>{
const loadedConfig = (pluginsSelectorCreator('dashboard')(stateMocker(localConfigLoaded(LOCAL_CONFIG))));
- expect(includes(loadedConfig, 'DashboardSave')).toBe(true);
- expect(includes(loadedConfig, 'DashboardSaveAs')).toBe(true);
+ expect(!!find(loadedConfig, { "name": "Save" })).toBe(true);
+ expect(!!find(loadedConfig, { "name": "SaveAs" })).toBe(true);
expect(find(loadedConfig, { "name": "Share"})).toContain({ "name": "Share"});
expect(find(loadedConfig, { "name": "Share"}).cfg).toContain({ "showAPI": false});
expect(find(loadedConfig, { "name": "Share"}).cfg).toContain({ "advancedSettings": false});
diff --git a/web/client/selectors/dashboardsave.js b/web/client/selectors/dashboardsave.js
index 2a3dfdcc58..73d269a9df 100644
--- a/web/client/selectors/dashboardsave.js
+++ b/web/client/selectors/dashboardsave.js
@@ -6,26 +6,67 @@
* LICENSE file in the root directory of this source tree.
*/
-import { isEqual, every, find, omit } from 'lodash';
+import { isEqual, some, find, omit, isArray, isObject} from 'lodash';
import { createSelector } from 'reselect';
import { getDashboardWidgets, getDashboardWidgetsLayout } from './widgets';
-import { dashboardResource, originalDataSelector } from './dashboard';
+import { originalDataSelector } from './dashboard';
-export const dashboardHasPendingChangesSelector = createSelector(dashboardResource, originalDataSelector, getDashboardWidgets, getDashboardWidgetsLayout, (resource, originalData, widgets, layout) => {
+const recursiveIsChanged = (a, b) => {
+ if (!isObject(a)) {
+ return !isEqual(a, b);
+ }
+ if (isArray(a)) {
+ return a.some((v, idx) => {
+ return recursiveIsChanged(a[idx], b?.[idx]);
+ });
+ }
+ return Object.keys(a).some((key) => {
+ return recursiveIsChanged(a[key], b?.[key]);
+ }, {});
+};
+
+export const dashboardHasPendingChangesSelector = createSelector(originalDataSelector, getDashboardWidgets, getDashboardWidgetsLayout, (originalData, widgets, layout) => {
const originalWidgets = originalData?.widgets || [];
const originalLayouts = originalData?.layouts || {};
+ if (recursiveIsChanged(originalLayouts, layout || {})) {
+ return true;
+ }
+ if (originalWidgets.length !== (widgets?.length || 0)) {
+ return true;
+ }
- return !resource || resource.canEdit && (!isEqual(originalLayouts, layout) || originalWidgets.length !== widgets.length || !every(widgets, widget => {
+ return some(widgets || [], widget => {
const originalWidget = find(originalWidgets, {id: widget.id});
- const originalMap = originalWidget?.map;
- const widgetMap = widget.map;
- const originalCenter = originalMap?.center;
- const widgetCenter = widgetMap?.center;
- const CENTER_EPS = 1e-12;
- return !!originalWidget && isEqual(omit(widget, 'dependenciesMap', 'map'), omit(originalWidget, 'dependenciesMap', 'map')) &&
- (!widgetMap && !originalMap || isEqual(omit(widgetMap, 'center', 'bbox', 'size'), omit(originalMap, 'center', 'bbox', 'size'))) &&
- (!widgetMap && !originalMap || !originalCenter && !widgetCenter || !!originalCenter && !!widgetCenter &&
- originalCenter.crs === widgetCenter.crs && Math.abs(originalCenter.x - widgetCenter.x) < CENTER_EPS && Math.abs(originalCenter.y - widgetCenter.y) < CENTER_EPS);
- }));
+ if (!originalWidget
+ || recursiveIsChanged(omit(widget, 'dependenciesMap', 'map', 'maps'), omit(originalWidget, 'dependenciesMap', 'map', 'maps'))
+ ) {
+ return true;
+ }
+ const originalMaps = originalWidget?.map ? [originalWidget.map] : originalWidget?.maps || [];
+ const widgetMaps = widget.map ? [widget.map] : widget.maps || [];
+ if (!widgetMaps?.length && !originalMaps?.length) {
+ return false;
+ }
+
+ return widgetMaps.some((widgetMap, idx) => {
+ const originalMap = originalMaps[idx] || {};
+ if (recursiveIsChanged(omit(widgetMap, 'center', 'bbox', 'size'), omit(originalMap, 'center', 'bbox', 'size'))) {
+ return true;
+ }
+ const originalCenter = originalMap?.center;
+ const widgetCenter = widgetMap?.center;
+ if (!originalCenter && !widgetCenter) {
+ return false;
+ }
+ const CENTER_EPS = 1e-12;
+ return !(
+ !!originalCenter
+ && !!widgetCenter
+ && originalCenter.crs === widgetCenter.crs
+ && Math.abs(originalCenter.x - widgetCenter.x) < CENTER_EPS
+ && Math.abs(originalCenter.y - widgetCenter.y) < CENTER_EPS
+ );
+ });
+ });
});
diff --git a/web/client/selectors/mapsave.js b/web/client/selectors/mapsave.js
index 237b4b3f4b..1fd16d3401 100644
--- a/web/client/selectors/mapsave.js
+++ b/web/client/selectors/mapsave.js
@@ -84,7 +84,7 @@ export const mapSaveSelector = state => {
const textSearchConfig = textSearchConfigSelector(state);
const bookmarkSearchConfig = bookmarkSearchConfigSelector(state);
const additionalOptions = mapOptionsToSaveSelector(state);
- return MapUtils.saveMapConfiguration(map, layers, groups, backgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions);
+ return MapUtils.saveMapConfiguration(map || {}, layers, groups, backgrounds, textSearchConfig, bookmarkSearchConfig, additionalOptions);
};
/**
* Selector to identify pending changes.
@@ -97,6 +97,6 @@ export const mapHasPendingChangesSelector = state => {
const currentMap = mapSelector(state) || {};
const { canEdit } = currentMap.info || {};
const { mapConfigRawData } = state;
- return (canEdit || !currentMap.mapId) && !MapUtils.compareMapChanges(mapConfigRawData, updatedMap);
+ return mapConfigRawData && (canEdit || !currentMap.mapId) && !MapUtils.compareMapChanges(mapConfigRawData, updatedMap);
};
diff --git a/web/client/themes/default/icons/icons.eot b/web/client/themes/default/icons/icons.eot
index ea22c7dc56..1d7a62a4a3 100644
Binary files a/web/client/themes/default/icons/icons.eot and b/web/client/themes/default/icons/icons.eot differ
diff --git a/web/client/themes/default/icons/icons.ttf b/web/client/themes/default/icons/icons.ttf
index b41aa94a8e..a24378d242 100644
Binary files a/web/client/themes/default/icons/icons.ttf and b/web/client/themes/default/icons/icons.ttf differ
diff --git a/web/client/themes/default/icons/icons.woff b/web/client/themes/default/icons/icons.woff
index b2f3cac446..5df5b19057 100644
Binary files a/web/client/themes/default/icons/icons.woff and b/web/client/themes/default/icons/icons.woff differ
diff --git a/web/client/themes/default/icons/icons.woff2 b/web/client/themes/default/icons/icons.woff2
index 9230c406f4..721257a2eb 100644
Binary files a/web/client/themes/default/icons/icons.woff2 and b/web/client/themes/default/icons/icons.woff2 differ
diff --git a/web/client/themes/default/less/common.less b/web/client/themes/default/less/common.less
index 064d7efb3f..23ad0a45cf 100644
--- a/web/client/themes/default/less/common.less
+++ b/web/client/themes/default/less/common.less
@@ -93,7 +93,6 @@
}
}
}
-
}
// **************
@@ -158,10 +157,6 @@
text-shadow: 2px 0 0 @color, -2px 0 0 @color, 0 2px 0 @color, 0 -2px 0 @color, 1px 1px @color, -1px -1px 0 @color, 1px -1px 0 @color, -1px 1px 0 @color;
}
-.btn {
- outline: none !important;
-}
-
textarea {
outline: none !important;
}
diff --git a/web/client/themes/default/less/dropdown-menu.less b/web/client/themes/default/less/dropdown-menu.less
index 92c696d2b2..b08baf77d0 100644
--- a/web/client/themes/default/less/dropdown-menu.less
+++ b/web/client/themes/default/less/dropdown-menu.less
@@ -25,7 +25,8 @@
padding: 0;
}
-.dropdown-menu .glyphicon {
+.dropdown-menu .glyphicon,
+.dropdown-menu .fa {
font-size: @icon-size-md;
margin-right: 15px;
vertical-align: middle;
diff --git a/web/client/themes/default/less/export.less b/web/client/themes/default/less/export.less
index 6962a93aa7..f05948dc4a 100644
--- a/web/client/themes/default/less/export.less
+++ b/web/client/themes/default/less/export.less
@@ -30,7 +30,7 @@
left: 0;
background: rgba(0; 0; 0; 0.75);
color: #fff;
- z-index: 2000;
+ z-index: 4000;
display: flex;
text-align: center;
diff --git a/web/client/themes/default/less/home.less b/web/client/themes/default/less/home.less
index 3a7090641c..c763f6036b 100644
--- a/web/client/themes/default/less/home.less
+++ b/web/client/themes/default/less/home.less
@@ -36,46 +36,6 @@
// **************
// Layout
// **************
-.mapstore-langselector {
- height: @square-btn-size;
- float: right;
-
- .dropdown {
- height: @square-btn-size;
-
- .dropdown-toggle {
- border: none !important;
- padding: 0;
- height: @square-btn-size - 2;
-
- .btn {
- height: @square-btn-size - 2;
- border: none;
- }
-
- .caret {
- margin-left: 4px;
- margin-right: 4px;
- }
- }
-
- .dropdown-menu {
- >li {
- >a {
- padding: 4px !important;
- }
- }
-
- .btn {
- border: none;
- }
-
- border: none;
- .shadow-far;
- }
- }
-}
-
.page-maps {
.MapSearchBar {
@@ -107,7 +67,7 @@
.ms-home-description {
padding: @square-btn-size 0 0 0;
background-position: center;
-
+ margin-bottom: 0;
.container {
width: 100%;
}
@@ -136,9 +96,6 @@
}
}
}
-.lang-button{
- margin-left: 10px;
-}
@media screen and (min-width: 1200px) {
.page-maps {
diff --git a/web/client/themes/default/less/mapstore.less b/web/client/themes/default/less/mapstore.less
index 2bf2c0d5e0..24e340df42 100644
--- a/web/client/themes/default/less/mapstore.less
+++ b/web/client/themes/default/less/mapstore.less
@@ -60,6 +60,7 @@
@import "react-data-grid.less";
@import "react-select.less";
@import "react-widgets.less";
+@import "resources-catalog/index.less";
@import "rulesmanager.less";
@import "searchbar.less";
@import "select.less";
diff --git a/web/client/themes/default/less/mixins/bootstrap.less b/web/client/themes/default/less/mixins/bootstrap.less
index 098e1a19eb..a3346feed0 100644
--- a/web/client/themes/default/less/mixins/bootstrap.less
+++ b/web/client/themes/default/less/mixins/bootstrap.less
@@ -78,7 +78,7 @@
a {
.color-var(@theme-vars[link-color]);
text-decoration: none;
-
+ &:focus,
&:hover {
.color-var(@theme-vars[link-hover-color]);
text-decoration: none;
diff --git a/web/client/themes/default/less/mixins/css-properties.less b/web/client/themes/default/less/mixins/css-properties.less
index 7b94db1335..261925d65e 100644
--- a/web/client/themes/default/less/mixins/css-properties.less
+++ b/web/client/themes/default/less/mixins/css-properties.less
@@ -108,3 +108,10 @@
font-family: @fallback if(@important, ~'!important', );
font-family: var(@value, @fallback) if(@important, ~'!important', );
}
+
+.text-decoration-color-var(@var; @important: false;) {
+ @value: extract(@var, 1);
+ @fallback: extract(@var, 2);
+ text-decoration-color: @fallback if(@important, ~'!important', );
+ text-decoration-color: var(@value, @fallback) if(@important, ~'!important', );
+}
\ No newline at end of file
diff --git a/web/client/themes/default/less/resources-catalog/_base.less b/web/client/themes/default/less/resources-catalog/_base.less
new file mode 100644
index 0000000000..192eb329a9
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_base.less
@@ -0,0 +1,75 @@
+
+// **************
+// Theme
+// **************
+
+#ms-components-theme(@theme-vars) {
+ .pagination.custom {
+ &> li > a,
+ &> li > span {
+ background-color: transparent;
+ }
+ &> .disabled > span,
+ &> .disabled > span:hover,
+ &> .disabled > span:focus,
+ &> .disabled > a,
+ &> .disabled > a:hover,
+ &> .disabled > a:focus {
+ background-color: transparent;
+ }
+ &> .active > a,
+ &> .active > span,
+ &> .active > a:hover,
+ &> .active > span:hover,
+ &> .active > span:focus {
+ .color-var(@theme-vars[primary-contrast]);
+ .background-color-var(@theme-vars[primary]);
+ }
+ }
+ .tabs-underline {
+ > .nav > li {
+ > a {
+ .color-var(@theme-vars[main-color], true);
+ .background-color-var(@theme-vars[main-bg], true);
+ }
+ > a:hover {
+ .border-bottom-color-var(@theme-vars[main-border-color]);
+ }
+ &.active {
+ > a {
+ .border-bottom-color-var(@theme-vars[selected-color]);
+ }
+ }
+ }
+ }
+}
+
+// **************
+// Layout
+// **************
+
+.pagination.custom {
+ &> li > a,
+ &> li > span {
+ height: 30px;
+ min-width: 30px;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 0.25rem;
+ border: none;
+ }
+}
+
+.tabs-underline {
+ > .nav > li {
+ + li {
+ margin-left: 1rem;
+ }
+ > a {
+ padding: 0;
+ border-bottom: 0.2rem solid transparent;
+ border-radius: 0;
+ }
+ }
+}
diff --git a/web/client/themes/default/less/resources-catalog/_details-panel.less b/web/client/themes/default/less/resources-catalog/_details-panel.less
new file mode 100644
index 0000000000..df7dddb6c3
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_details-panel.less
@@ -0,0 +1,35 @@
+// **************
+// Layout
+// **************
+
+.ms-details-panel {
+ &:has(.ms-details-message) {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ .ms-details-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ .tab-content {
+ flex: 1;
+ position: relative;
+ .ms-details-message {
+ position: absolute;
+ padding: 0;
+ width: 100%;
+ height: calc(100% - 40px);
+ }
+ }
+ }
+ }
+}
+
+.ms-details-thumbnail {
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+}
diff --git a/web/client/themes/default/less/resources-catalog/_permissions.less b/web/client/themes/default/less/resources-catalog/_permissions.less
new file mode 100644
index 0000000000..e951c4aaf5
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_permissions.less
@@ -0,0 +1,7 @@
+// **************
+// Layout
+// **************
+
+.ms-permissions-column {
+ width: 30%;
+}
diff --git a/web/client/themes/default/less/resources-catalog/_resource-card.less b/web/client/themes/default/less/resources-catalog/_resource-card.less
new file mode 100644
index 0000000000..0091f805e2
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_resource-card.less
@@ -0,0 +1,85 @@
+
+// **************
+// Theme
+// **************
+
+#ms-components-theme(@theme-vars) {
+ .ms-resources-list-header-divider {
+ .background-color-var(@theme-vars[main-border-color]);
+ &:hover,
+ &.selected {
+ .background-color-var(@theme-vars[main-color]);
+ }
+ }
+}
+
+// **************
+// Layout
+// **************
+
+.ms-resource-card {
+ > div a {
+ position: relative;
+ }
+ .fa, .glyphicon {
+ cursor: default;
+ }
+ button {
+ .fa, .glyphicon {
+ cursor: pointer;
+ }
+ }
+}
+
+.ms-resource-card-type-grid {
+ &:hover {
+ .ms-resource-card-action-buttons {
+ .dropdown:not(.open) {
+ opacity: 1;
+ }
+ }
+ }
+ .ms-resource-card-action-buttons {
+ .dropdown {
+ transition: all .2s ease-in-out;
+ border-radius: 0.25rem;
+ &:not(.open) {
+ opacity: 0;
+ }
+ &.open {
+ opacity: 1;
+ }
+ }
+ }
+}
+
+.ms-resource-card-img {
+ width: 100%;
+ height: 130px;
+ object-fit: cover;
+}
+
+.ms-resource-card-limit {
+ width: 1.5rem;
+ min-height: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.ms-resources-list-header {
+ &:has(.selected) {
+ cursor: ew-resize;
+ }
+}
+
+.ms-resources-list-header-divider {
+ position: absolute;
+ height: 100%;
+ width: 1px;
+ &.selected,
+ &:hover {
+ width: 2px;
+ cursor: ew-resize;
+ }
+}
\ No newline at end of file
diff --git a/web/client/themes/default/less/resources-catalog/_resources-container.less b/web/client/themes/default/less/resources-catalog/_resources-container.less
new file mode 100644
index 0000000000..ebac4de7d8
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_resources-container.less
@@ -0,0 +1,32 @@
+// **************
+// Layout
+// **************
+
+.ms-resources-container {
+ container-name: resources-container;
+ container-type: inline-size;
+ > div {
+ max-width: @ms-page-max-width;
+ }
+}
+
+.ms-resources-container-grid {
+ > li {
+ width: calc(25% - 0.75rem);
+ }
+}
+
+@container resources-container (width < 1100px) {
+ .ms-resources-container-grid {
+ > li {
+ width: calc(50% - 0.5rem);
+ }
+ }
+}
+@container resources-container (width < 600px) {
+ .ms-resources-container-grid {
+ > li {
+ width: 100%;
+ }
+ }
+}
diff --git a/web/client/themes/default/less/resources-catalog/_resources-panel-wrapper.less b/web/client/themes/default/less/resources-catalog/_resources-panel-wrapper.less
new file mode 100644
index 0000000000..cccbaa0838
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_resources-panel-wrapper.less
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2020, 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.
+ */
+
+// **************
+// Theme
+// **************
+
+
+// **************
+// Layout
+// **************
+
+:root {
+ --ms-resources-filter-width: 400px;
+ --ms-resource-detail-width: 640px;
+}
+
+#ms-brand-navbar {
+ z-index: 3000;
+}
+.ms-resources-panel-wrapper {
+ z-index: 2000;
+}
+
+.ms-resources-filter {
+ max-width: var(--ms-resources-filter-width);
+ width: 100%;
+}
+
+.ms-resource-detail {
+ max-width: var(--ms-resource-detail-width);
+ width: 100%;
+ margin-right: 0;
+ margin-left: auto;
+}
+
+:has(.ms-resources-filter._visible) {
+ .ms-resources-grid:not(._panel) {
+ margin-left: var(--ms-resources-filter-width);
+ }
+}
+
+:has(.ms-resource-detail._visible) {
+ .ms-resources-grid:not(._panel) {
+ width: calc(100% - var(--ms-resource-detail-width));
+ }
+ &:has(.ms-resources-filter._visible) {
+ .ms-resources-grid:not(._panel) {
+ width: calc(100% - var(--ms-resource-detail-width) - var(--ms-resources-filter-width));
+ }
+ }
+}
+
+@media (max-width: 1440px) {
+ :root {
+ --ms-resources-filter-width: 300px;
+ --ms-resource-detail-width: 500px;
+ }
+}
+
+@media (max-width: 968px) {
+ :root {
+ --ms-resources-filter-width: 100%;
+ --ms-resource-detail-width: 100%;
+ }
+}
\ No newline at end of file
diff --git a/web/client/themes/default/less/resources-catalog/_spinner.less b/web/client/themes/default/less/resources-catalog/_spinner.less
new file mode 100644
index 0000000000..02cd062fe5
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_spinner.less
@@ -0,0 +1,54 @@
+// **************
+// Layout
+// **************
+
+.get-ms-spinner(@border-size: 0.1em, @size: 1em) {
+
+ @-webkit-keyframes load {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg);
+ }
+ }
+
+ @keyframes load {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ -webkit-transform: rotate(360deg);
+ transform: rotate(360deg);
+ }
+ }
+
+ text-indent: -9999em;
+
+ --ms-spinner-fade-color: color-mix(in srgb, currentColor 20%, transparent);
+
+ border-top: @border-size solid var(--ms-spinner-fade-color);
+ border-right: @border-size solid var(--ms-spinner-fade-color);
+ border-bottom: @border-size solid var(--ms-spinner-fade-color);
+ border-left: @border-size solid currentColor;
+ -webkit-transform: translateZ(0);
+ -ms-transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-animation: load 1.1s infinite linear;
+ animation: load 1.1s infinite linear;
+ border-radius: 50%;
+ width: @size;
+ height: @size;
+}
+
+.ms-spinner {
+ display: inline-flex;
+ >div {
+ .get-ms-spinner(0.1em, 1em);
+ }
+}
diff --git a/web/client/themes/default/less/resources-catalog/_utility.less b/web/client/themes/default/less/resources-catalog/_utility.less
new file mode 100644
index 0000000000..3c99a01ee4
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/_utility.less
@@ -0,0 +1,401 @@
+// **************
+// Theme
+// **************
+
+#ms-components-theme(@theme-vars) {
+ .ms-main-colors {
+ .color-var(@theme-vars[main-color]);
+ .background-color-var(@theme-vars[main-bg]);
+ .border-color-var(@theme-vars[main-border-color]);
+ }
+ .ms-secondary-colors {
+ .color-var(@theme-vars[main-variant-color]);
+ .background-color-var(@theme-vars[main-variant-bg]);
+ .border-color-var(@theme-vars[main-border-color]);
+ }
+ .ms-warning-colors {
+ .background-color-var(@theme-vars[warning]);
+ .color-var(@theme-vars[warning-contrast]);
+ }
+ .ms-danger-colors {
+ .background-color-var(@theme-vars[danger]);
+ .color-var(@theme-vars[danger-contrast]);
+ }
+ .ms-primary-colors {
+ .background-color-var(@theme-vars[primary]);
+ .color-var(@theme-vars[primary-contrast]);
+ }
+ .ms-success-colors {
+ .background-color-var(@theme-vars[success]);
+ .color-var(@theme-vars[success-contrast]);
+ }
+ .ms-image-colors {
+ .color-var(@theme-vars[image-color]);
+ .background-color-var(@theme-vars[image-bg]);
+ }
+ .ms-selected-colors {
+ .color-var(@theme-vars[main-color]);
+ .background-color-var(@theme-vars[selected-bg]);
+ }
+ .ms-warning-text {
+ .color-var(@theme-vars[warning]);
+ }
+ .ms-danger-text {
+ .color-var(@theme-vars[danger]);
+ }
+ .ms-primary-text {
+ .color-var(@theme-vars[primary]);
+ }
+ .ms-success-text {
+ .color-var(@theme-vars[success]);
+ }
+ ._overlay {
+ .background-color-var(@theme-vars[mask-bg]);
+ }
+ ._interactive {
+ &:hover {
+ .border-color-var(@theme-vars[focus-color]);
+ }
+ &._active {
+ .border-color-var(@theme-vars[focus-color]);
+ .outline-color-var(@theme-vars[focus-color]);
+ }
+ }
+ ._row {
+ .border-bottom-color-var(@theme-vars[main-border-color]);
+ }
+ *:focus {
+ .outline-color-var(@theme-vars[focus-color], true);
+ outline-offset: 0 !important;
+ }
+ .ms-notification-circle {
+ &.warning::after {
+ .background-color-var(@theme-vars[warning]);
+ }
+ &.danger::after {
+ .background-color-var(@theme-vars[danger]);
+ }
+ &.success::after {
+ .background-color-var(@theme-vars[success]);
+ }
+ }
+ .ms-tag {
+ .color-var(@theme-vars[main-color]);
+ &.Select-option,
+ &.Select-value {
+ .color-var(@theme-vars[main-color], true);
+ }
+ &:focus,
+ &:hover {
+ .color-var(@theme-vars[main-color]);
+ }
+ }
+}
+
+// **************
+// Layout
+// **************
+
+.shadow-md {
+ -webkit-box-shadow: 0 3px 6px rgba(0, 0, 0, 0.06), 0 4px 8px rgba(0, 0, 0, 0.12);
+ -moz-box-shadow: 0 3px 6px rgba(0, 0, 0, 0.06), 0 4px 8px rgba(0, 0, 0, 0.12);
+ box-shadow: 0 3px 6px rgba(0, 0, 0, 0.06), 0 4px 8px rgba(0, 0, 0, 0.12);
+}
+
+.shadow-sm {
+ -webkit-box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%);
+ -moz-box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%);
+ box-shadow: 0 0.125rem 0.25rem rgb(0 0 0 / 8%);
+}
+
+.ms-flex-box {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ &._flex {
+ display: flex;
+ min-width: 0;
+ }
+ &._inline-flex {
+ display: inline-flex;
+ min-width: 0;
+ }
+ &._flex-column {
+ flex-direction: column;
+ }
+ &._flex-center-h {
+ justify-content: center;
+ }
+ &._flex-center-v {
+ align-items: center;
+ }
+ &._flex-wrap {
+ flex-wrap: wrap;
+ }
+ &._flex-gap-xs { gap: 0.25rem; }
+ &._flex-gap-sm { gap: 0.5rem; }
+ &._flex-gap-md { gap: 0.75rem; }
+ &._flex-gap-lg { gap: 1rem; }
+
+ &:not(._flex-gap-xs):not(._flex-gap-sm):not(._flex-gap-md):not(._flex-gap-lg) {
+ &:not(._flex-column) {
+ &:has(> .btn) {
+ .btn + .btn {
+ border-left: none;
+ }
+ .btn {
+ border-radius: 0;
+ }
+ .btn:first-child {
+ border-top-left-radius: 4px;
+ border-bottom-left-radius: 4px;
+ }
+ .btn:last-child {
+ border-top-right-radius: 4px;
+ border-bottom-right-radius: 4px;
+ }
+ }
+ }
+ &._flex-column {
+ &::has(> .btn) {
+ .btn + .btn {
+ border-top: none;
+ }
+ }
+ }
+ }
+
+ // fix form inputs
+ .form-group,
+ .pagination,
+ .checkbox {
+ margin: 0;
+ }
+}
+
+.ms-flex-fill {
+ flex: 1;
+ min-width: 0;
+}
+
+.ms-tag {
+ border: 1px solid var(--tag-color);
+ border-radius: 10rem;
+ padding: 0 0.4rem;
+ display: inline-block;
+ position: relative;
+ background-color: rgb(from var(--tag-color) r g b / 1%);
+ &:hover:has(a),
+ &:hover:is(a) {
+ background-color: rgb(from var(--tag-color) r g b / 20%);
+ }
+ &.active {
+ background-color: rgb(from var(--tag-color) r g b / 30%);
+ }
+ &.Select-value {
+ border: 1px solid var(--tag-color) !important;
+ margin-right: 0.25rem;
+ border-radius: 10rem;
+ padding: 0;
+ display: inline-block;
+ position: relative;
+ background-color: rgb(from var(--tag-color) r g b / 30%) !important;
+ .Select-value-icon {
+ border-top-left-radius: 10rem;
+ border-bottom-left-radius: 10rem;
+ padding: 0 0.4rem;
+ border-color: var(--tag-color);
+ }
+ .Select-value-label {
+ border-top-right-radius: 10rem;
+ border-bottom-right-radius: 10rem;
+ padding: 0 0.4rem;
+ }
+ }
+ &.Select-option {
+ background-color: rgb(from var(--tag-color) r g b / 1%);
+ display: inline-block;
+ width: fit-content;
+ margin: 0.25rem 0 0 0.25rem;
+ &:last-child {
+ border-radius: 10rem;
+ margin-bottom: 0.25rem;
+ }
+ &.is-selected {
+ background-color: rgb(from var(--tag-color) r g b / 30%) !important;
+ }
+ }
+ img {
+ width: 1em;
+ height: 1em;
+ border-radius: 50%;
+ object-fit: cover;
+ }
+}
+
+.ms-text {
+ white-space: normal;
+ font-size: inherit;
+ word-break: break-all;
+ .ms-tag + .ms-tag {
+ margin-left: 0.25rem;
+ }
+ &._ellipsis {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ &._strong {
+ font-weight: bold;
+ }
+ &:not(._ellipsis):has(.ms-tag) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ align-items: flex-start;
+ .ms-tag {
+ margin-left: 0;
+ }
+ }
+ &._font-size-sm { font-size: @font-size-small; }
+ &._font-size-md { font-size: @font-size-medium; }
+ &._font-size-lg { font-size: @font-size-large; }
+ &._font-size-xl { font-size: 2rem; }
+ &._font-size-xxl { font-size: 3rem; }
+ &._left { text-align: left; }
+ &._center { text-align: center; }
+ &._right { text-align: right; }
+}
+
+.ms-notification-circle {
+ position: relative;
+ &::after {
+ content: '';
+ display: block;
+ width: 0.5rem;
+ height: 0.5rem;
+ position: absolute;
+ right: 2px;
+ top: 2px;
+ border-radius: 50%;
+ .shadow-md();
+ z-index: 10;
+ }
+}
+
+._border-transparent {
+ --ms-button-border-color: transparent;
+ --ms-button-focus-border-color: transparent;
+}
+
+._relative {
+ position: relative;
+}
+
+._absolute {
+ position: absolute;
+}
+
+._fixed {
+ position: fixed;
+}
+
+._sticky {
+ position: sticky;
+ z-index: 10;
+}
+
+._corner-tl {
+ top: 0;
+ left: 0;
+}
+
+._corner-tr {
+ top: 0;
+ right: 0;
+}
+
+._corner-bl {
+ bottom: 0;
+ left: 0;
+}
+
+._corner-br {
+ bottom: 0;
+ right: 0;
+}
+
+._fill {
+ width: 100%;
+ height: 100%;
+}
+
+._pointer-events-auto {
+ pointer-events: auto;
+}
+
+._pointer-events-none {
+ pointer-events: none;
+}
+
+._pointer {
+ cursor: pointer;
+}
+
+._interactive {
+ cursor: pointer;
+ transition: box-shadow, border 0.3s;
+ border-width: 1px;
+ border-style: solid;
+ &._active {
+ outline-width: 0.25rem;
+ outline-style: solid;
+ }
+ .shadow-sm();
+ &:hover {
+ .shadow();
+ }
+}
+
+._overflow-auto {
+ overflow: auto;
+}
+
+._row {
+ border-bottom-width: 1px;
+ border-bottom-style: solid;
+ ._label {
+ width: 250px;
+ flex-shrink: 0;
+ }
+}
+
+@ms-sizes-vars: {
+ xs: xs, 0.25rem;
+ sm: sm, 0.5rem;
+ md: md, 0.75rem;
+ lg: lg, 1rem;
+};
+
+._margin-auto {
+ margin: auto;
+}
+
+each(@ms-sizes-vars, {
+ @size: extract(@value, 1);
+ @val: extract(@value, 2);
+ ._padding-@{size} { padding: @val; }
+ ._padding-lr-@{size} { padding-left: @val; padding-right: @val; }
+ ._padding-tb-@{size} { padding-top: @val; padding-bottom: @val; }
+ ._padding-l-@{size} { padding-left: @val; }
+ ._padding-r-@{size} { padding-right: @val; }
+ ._padding-t-@{size} { padding-top: @val; }
+ ._padding-b-@{size} { padding-bottom: @val; }
+ ._margin-@{size} { margin: @val; }
+ ._margin-lr-@{size} { margin-left: @val; margin-right: @val; }
+ ._margin-tb-@{size} { margin-top: @val; margin-bottom: @val; }
+ ._margin-l-@{size} { margin-left: @val; }
+ ._margin-r-@{size} { margin-right: @val; }
+ ._margin-t-@{size} { margin-top: @val; }
+ ._margin-b-@{size} { margin-bottom: @val; }
+});
diff --git a/web/client/themes/default/less/resources-catalog/index.less b/web/client/themes/default/less/resources-catalog/index.less
new file mode 100644
index 0000000000..e75b633d9c
--- /dev/null
+++ b/web/client/themes/default/less/resources-catalog/index.less
@@ -0,0 +1,10 @@
+
+@import "_utility.less";
+
+@import "_base.less";
+@import "_details-panel.less";
+@import "_permissions.less";
+@import "_resource-card.less";
+@import "_resources-container.less";
+@import "_resources-panel-wrapper.less";
+@import "_spinner.less";
diff --git a/web/client/themes/default/less/select.less b/web/client/themes/default/less/select.less
index 74b0f8d06c..4b77138fc3 100644
--- a/web/client/themes/default/less/select.less
+++ b/web/client/themes/default/less/select.less
@@ -12,7 +12,7 @@
#ms-components-theme(@theme-vars) {
.rw-widget {
- .color-var(@theme-vars[primary], true);
+ .color-var(@theme-vars[main-color], true);
.background-color-var(@theme-vars[main-bg], true);
.border-color-var(@theme-vars[main-border-color], true);
&.rw-state-disabled {
@@ -31,12 +31,12 @@
.color-var(@theme-vars[primary-contrast], true);
}
.rw-state-focus {
- .color-var(@theme-vars[primary], true);
+ .color-var(@theme-vars[main-color], true);
.border-color-var(@theme-vars[focus-color], true);
}
.rw-i {
- .color-var(@theme-vars[primary], true);
+ .color-var(@theme-vars[main-color], true);
}
.rw-placeholder {
@@ -182,6 +182,6 @@ select {
.form-control {
height: 25px;
padding: 0 0 0 4px;
- margin-bottom: 17px;
+ margin-bottom: 0;
}
}
\ No newline at end of file
diff --git a/web/client/themes/default/less/square-button.less b/web/client/themes/default/less/square-button.less
index e4cc6d6486..a064ffef07 100644
--- a/web/client/themes/default/less/square-button.less
+++ b/web/client/themes/default/less/square-button.less
@@ -55,7 +55,7 @@
padding: 0;
outline: 0;
}
-
+ .fa,
.glyphicon {
margin: auto;
font-size: @icon-size-md;
@@ -69,6 +69,12 @@
.mapstore-circle-loader-with-css-variables(@icon-size-md/10, @icon-size-md);
}
}
+
+ // TODO: check and remove all the above rules
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ font-size: @icon-size-md;
}
.square-button-sm {
@@ -78,6 +84,7 @@
padding: 0;
outline: 0 ;
}
+ .fa,
.glyphicon {
margin: auto;
font-size: @icon-size-sm;
diff --git a/web/client/themes/default/ms-variables.less b/web/client/themes/default/ms-variables.less
index f73fc063fb..8ce8abc0e5 100644
--- a/web/client/themes/default/ms-variables.less
+++ b/web/client/themes/default/ms-variables.less
@@ -20,10 +20,10 @@
@ms2-color-text-disabled: #999;
@ms2-color-primary: #078aa3;
-@ms2-color-info: #5a9aab;
-@ms2-color-success: #398439;
+@ms2-color-info: #e3e3e3;
+@ms2-color-success: #3aba6f;
@ms2-color-warning: #ebbc35;
-@ms2-color-danger: #bb4940;
+@ms2-color-danger: #b31e13;
@ms2-color-shade-lighter: #dddddd;
@@ -31,13 +31,15 @@
// MapStore Theme Variables
// ******************************************
+@ms-page-max-width: 1440px;
+
// hints:
// bg -> background color
// color -> text color
@ms-main-color: @ms2-color-text;
@ms-main-bg: @ms2-color-background;
-@ms-mask-bg: #00000088;
+@ms-mask-bg: rgba(@ms-main-bg, 50%);
@ms-main-border-color: @ms2-color-shade-lighter;
@ms-main-variant-color: @ms-main-color;
@@ -67,19 +69,22 @@
@ms-disabled-color: @ms2-color-text-disabled;
@ms-disabled-bg: @ms2-color-disabled;
-@ms-link-color: @ms-primary;
-@ms-link-hover-color: darken(@ms-primary, 8%);
+@ms-link-color: #005dc7;
+@ms-link-hover-color: #0075ff;
-@ms-primary-contrast: @ms2-color-text-primary;
+@ms-primary-contrast: #ffffff;
@ms-info-contrast: @ms-primary-contrast;
@ms-success-contrast: @ms-primary-contrast;
@ms-warning-contrast: @ms-primary-contrast;
-@ms-danger-contrast: @ms-primary-contrast;
+@ms-danger-contrast: #ffffff;
@ms-tray-bg: @ms-main-bg;
@ms-tray-color: lighten(@ms-main-color, 10%);
@ms-tray-border: lighten(@ms-primary, 10%);
+@ms-image-color: #dddddd;
+@ms-image-bg: #f2f2f2;
+
@ms-font-family-sans-serif: 'Helvetica Neue', Helvetica, Arial, sans-serif;
@ms-font-family-serif: Georgia, 'Times New Roman', Times, serif;
@ms-font-family-monospace: Menlo, Monaco, Consolas, 'Courier New', monospace;
@@ -137,6 +142,11 @@
link-color: --ms-link-color, @ms-link-color;
link-hover-color: --ms-link-hover-color, @ms-link-hover-color;
+ // **************
+ // colors for images background (eg. thumbnail placeholder)
+ image-color: --ms-image-color, @ms-image-color;
+ image-bg: --ms-image-bg, @ms-image-bg;
+
// **************
// colors variant based on bootstrap categorization
// used mainly for buttons
@@ -163,25 +173,25 @@
// **************
// buttons states colors variants
@button-default: {
- button-color: --ms-button-color, @ms-primary;
+ button-color: --ms-button-color, @ms-main-color;
button-bg: --ms-button-bg, @ms-main-bg;
- button-border-color: --ms-button-border-color, @ms-primary;
- button-focus-color: --ms-button-focus-color, @ms-primary;
+ button-border-color: --ms-button-border-color, @ms-main-border-color;
+ button-focus-color: --ms-button-focus-color, @ms-main-color;
button-focus-bg: --ms-button-focus-bg, @ms-main-bg;
- button-focus-border-color: --ms-button-focus-border-color, @ms-primary;
- button-hover-color: --ms-button-hover-color, lighten(@ms-primary, 10%);
+ button-focus-border-color: --ms-button-focus-border-color, @ms-main-border-color;
+ button-hover-color: --ms-button-hover-color, lighten(@ms-main-color, 10%);
button-hover-bg: --ms-button-hover-bg, darken(@ms-main-bg, 10%);
- button-hover-border-color: --ms-button-hover-border-color, @ms-primary;
- button-active-color: --ms-button-active-color, lighten(@ms-primary, 10%);
+ button-hover-border-color: --ms-button-hover-border-color, @ms-main-border-color;
+ button-active-color: --ms-button-active-color, lighten(@ms-main-color, 10%);
button-active-bg: --ms-button-active-bg, darken(@ms-main-bg, 10%);
- button-active-border-color: --ms-button-active-border-color, @ms-primary;
- button-active-hover-color: --ms-button-active-hover-color, lighten(@ms-primary, 10%);
+ button-active-border-color: --ms-button-active-border-color, @ms-main-border-color;
+ button-active-hover-color: --ms-button-active-hover-color, lighten(@ms-main-color, 10%);
button-active-hover-bg: --ms-button-active-hover-bg, darken(@ms-main-bg, 17%);
- button-active-hover-border-color: --ms-button-active-hover-border-color, @ms-primary;
+ button-active-hover-border-color: --ms-button-active-hover-border-color, @ms-main-border-color;
button-disabled-color: --ms-button-disabled-color, @ms-disabled-color;
button-disabled-bg: --ms-button-disabled-bg, @ms-disabled-bg;
button-disabled-border-color: --ms-button-disabled-border-color, @ms-disabled-bg;
- button-badge-color: --ms-button-badge-color, @ms-primary;
+ button-badge-color: --ms-button-badge-color, @ms-main-color;
button-badge-bg: --ms-button-badge-bg, @ms-main-bg;
}
@@ -201,9 +211,9 @@
button-active-hover-color: --ms-button-primary-active-hover-color, @ms-primary-contrast;
button-active-hover-bg: --ms-button-primary-active-hover-bg, darken(@ms-primary, 17%);
button-active-hover-border-color: --ms-button-primary-active-hover-border-color, darken(@ms-primary, 25%);
- button-disabled-color: --ms-button-primary-disabled-color, @ms-primary-contrast;
- button-disabled-bg: --ms-button-primary-disabled-bg, lighten(desaturate(@ms-primary, 50%), 20%);
- button-disabled-border-color: --ms-button-primary-disabled-border-color, lighten(desaturate(@ms-primary, 50%), 20%);
+ button-disabled-color: --ms-button-primary-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-primary-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-primary-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-primary-badge-color, @ms-primary;
button-badge-bg: --ms-button-primary-badge-bg, @ms-primary-contrast;
}
@@ -224,9 +234,9 @@
button-active-hover-color: --ms-button-info-active-hover-color, @ms-info-contrast;
button-active-hover-bg: --ms-button-info-active-hover-bg, darken(@ms-info, 17%);
button-active-hover-border-color: --ms-button-info-active-hover-border-color, darken(@ms-info, 25%);
- button-disabled-color: --ms-button-info-disabled-color, @ms-info-contrast;
- button-disabled-bg: --ms-button-info-disabled-bg, lighten(desaturate(@ms-info, 50%), 20%);
- button-disabled-border-color: --ms-button-info-disabled-border-color, lighten(desaturate(@ms-info, 50%), 20%);
+ button-disabled-color: --ms-button-info-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-info-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-info-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-info-badge-color, @ms-info;
button-badge-bg: --ms-button-info-badge-bg, @ms-info-contrast;
}
@@ -247,9 +257,9 @@
button-active-hover-color: --ms-button-success-active-hover-color, @ms-success-contrast;
button-active-hover-bg: --ms-button-success-active-hover-bg, darken(@ms-success, 17%);
button-active-hover-border-color: --ms-button-success-active-hover-border-color, darken(@ms-success, 25%);
- button-disabled-color: --ms-button-success-disabled-color, @ms-success-contrast;
- button-disabled-bg: --ms-button-success-disabled-bg, lighten(desaturate(@ms-success, 50%), 20%);
- button-disabled-border-color: --ms-button-success-disabled-border-color, lighten(desaturate(@ms-success, 50%), 20%);
+ button-disabled-color: --ms-button-success-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-success-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-success-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-success-badge-color, @ms-success;
button-badge-bg: --ms-button-success-badge-bg, @ms-success-contrast;
}
@@ -270,9 +280,9 @@
button-active-hover-color: --ms-button-warning-active-hover-color, @ms-warning-contrast;
button-active-hover-bg: --ms-button-warning-active-hover-bg, darken(@ms-warning, 17%);
button-active-hover-border-color: --ms-button-warning-active-hover-border-color, darken(@ms-warning, 25%);
- button-disabled-color: --ms-button-warning-disabled-color, @ms-warning-contrast;
- button-disabled-bg: --ms-button-warning-disabled-bg, lighten(desaturate(@ms-warning, 50%), 20%);
- button-disabled-border-color: --ms-button-warning-disabled-border-color, lighten(desaturate(@ms-warning, 50%), 20%);
+ button-disabled-color: --ms-button-warning-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-warning-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-warning-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-warning-badge-color, @ms-warning;
button-badge-bg: --ms-button-warning-badge-bg, @ms-warning-contrast;
}
@@ -293,9 +303,9 @@
button-active-hover-color: --ms-button-danger-active-hover-color, @ms-danger-contrast;
button-active-hover-bg: --ms-button-danger-active-hover-bg, darken(@ms-danger, 17%);
button-active-hover-border-color: --ms-button-danger-active-hover-border-color, darken(@ms-danger, 25%);
- button-disabled-color: --ms-button-danger-disabled-color, @ms-danger-contrast;
- button-disabled-bg: --ms-button-danger-disabled-bg, lighten(desaturate(@ms-danger, 50%), 20%);
- button-disabled-border-color: --ms-button-danger-disabled-border-color, lighten(desaturate(@ms-danger, 50%), 20%);
+ button-disabled-color: --ms-button-danger-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-danger-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-danger-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-danger-badge-color, @ms-danger;
button-badge-bg: --ms-button-danger-badge-bg, @ms-danger-contrast;
}
@@ -316,9 +326,9 @@
button-active-hover-color: --ms-button-tray-active-hover-color, @ms-tray-color;
button-active-hover-bg: --ms-button-tray-active-hover-bg, darken(@ms-tray-bg, 17%);
button-active-hover-border-color: --ms-button-tray-active-hover-border-color, darken(@ms-tray-border, 25%);
- button-disabled-color: --ms-button-tray-disabled-color, @ms-tray-color;
- button-disabled-bg: --ms-button-tray-disabled-bg, lighten(desaturate(@ms-tray-bg, 50%), 20%);
- button-disabled-border-color: --ms-button-tray-disabled-border-color, lighten(desaturate(@ms-tray-bg, 50%), 20%);
+ button-disabled-color: --ms-button-tray-disabled-color, @ms-disabled-color;
+ button-disabled-bg: --ms-button-tray-disabled-bg, @ms-disabled-bg;
+ button-disabled-border-color: --ms-button-tray-disabled-border-color, @ms-disabled-bg;
button-badge-color: --ms-button-tray-badge-color, @ms-tray-bg;
button-badge-bg: --ms-button-tray-badge-bg, @ms-tray-color;
}
@@ -424,3 +434,9 @@
}
+// inline variables
+// default values
+
+:root {
+ --tag-color: @ms-link-color;
+}
diff --git a/web/client/themes/default/variables.less b/web/client/themes/default/variables.less
index 9b874454e5..161d83961d 100644
--- a/web/client/themes/default/variables.less
+++ b/web/client/themes/default/variables.less
@@ -6,3 +6,10 @@
// ******************************************
@import 'bootstrap-variables.less';
+
+// ******************************************
+// Alias
+// ******************************************
+
+@font-size-lg: @font-size-large;
+@font-size-sm: @font-size-small;
\ No newline at end of file
diff --git a/web/client/translations/data.da-DK.json b/web/client/translations/data.da-DK.json
index b30947ab5d..3d6e46e2b8 100644
--- a/web/client/translations/data.da-DK.json
+++ b/web/client/translations/data.da-DK.json
@@ -330,7 +330,7 @@
},
"home": {
"open": "Open",
- "shortDescription": "Modern webmapping with OpenLayers, Leaflet and ReactJS. Visit the documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore has been developed to create, save and share in a simple and intuitive way maps and mashups created selecting contents coming from well-known sources like OpenStreetMap, Google Maps or from services provided by organizations using open protocols like OGC WMS, WFS, WMTS or TMS and so on... Visit the home page for more details.",
"Applications": "Applications",
@@ -3811,6 +3811,122 @@
"infoSupported": "Supported file types: GeoJSON, DXF, Shapefiles
",
"dxfGeometryNotSupported": "Only LWPOLYLINE is supported"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.de-DE.json b/web/client/translations/data.de-DE.json
index 81e2e66238..6f819c20b7 100644
--- a/web/client/translations/data.de-DE.json
+++ b/web/client/translations/data.de-DE.json
@@ -379,7 +379,7 @@
},
"home":{
"open": "Öffnen",
- "shortDescription": "Modern webmapping mit OpenLayers, Leaflet und Reactbesuchen sie die dokumentationsseite ",
+ "shortDescription": "Willkommen bei MapStore Modernes Webmapping mit OpenLayers, Cesium, Leaflet und ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore wurde entwickelt, um auf einfache und intuitive Weise Karten und Mashups zu erstellen, zu speichern und zu teilen die auf Inhalten von bekannten Quellen wie Google Maps und OpenStreetMap oder von Diensten die auf offenen Protokollen wie OGC WMS, WFS, WMTS or TMS und so weiter basieren. Besuche die Homepage für mehr Details.",
"Applications": "Anwendungen",
@@ -4221,6 +4221,122 @@
"validLayer": "Diese Ebene ist gültig und kann in diesem Prozess verwendet werden",
"invalidLayer": "Dieser Layer unterstützt weder die erforderlichen WPS-Prozesse \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\noch handelt es sich um einen Raster-Layer."
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filter",
+ "view": "Anzeigen",
+ "resourcesFound": "{count, plural, =0 {0 Ressourcen gefunden} =1 {1 Ressource gefunden} other {# Ressourcen gefunden}}",
+ "unadvertised": "Ressource wird nicht beworben. Sie ist aus dem Katalog und den Suchergebnissen ausgeblendet",
+ "mapUsesContext": "Diese Karte verwendet den Kontext: {contextName}",
+ "orderBy": "Sortieren nach",
+ "mostRecent": "Neueste",
+ "lessRecent": "Ältereste",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Beliebteste",
+ "clearFilters": "Filter löschen",
+ "search": "Suchen...",
+ "customFiltersTitle": "Ressourcen",
+ "noResultsWithFilterTitle": "Keine Ergebnisse",
+ "noResultsWithFilterContent": "Für die ausgewählten Filter liegen keine Ergebnisse vor. Löschen Sie alle Filter und versuchen Sie es mit einer neuen Anfrage.",
+ "errorResourcePageTitle": "Fehler beim Laden der Seite",
+ "errorResourcePageContent": "Die ausgewählte Ressourcenseite ist nicht verfügbar",
+ "catalogSection": {
+ "noContentYetTitle": "Ressourcenkatalog",
+ "noContentYetContent": "Dieser Katalog hat noch keine veröffentlichten Inhalte. Wir arbeiten daran, ihn mit tollen Ressourcen zu füllen. Bleiben Sie dran!",
+ "noPublicContentTitle": "Ressourcenkatalog",
+ "noPublicContentContent": "Dieser Katalog hat keine öffentlichen Ressourcen. Bitte melden Sie sich an, um die Inhalte zu durchsuchen."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Ausgewählte Ressourcen",
+ "noContentYetContent": "Dieser Katalog hat noch keine veröffentlichten ausgewählten Inhalte. Wir arbeiten daran, ihn mit tollen Ressourcen zu füllen. Bleiben Sie dran!",
+ "noPublicContentTitle": "Ausgewählte Ressourcen",
+ "noPublicContentContent": "Dieser Katalog enthält keine ausgewählten Ressourcen."
+ },
+ "mapsFilter": "Karten",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Kontexte",
+ "columnName": "Name",
+ "columnDescription": "Beschreibung",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Geändert von",
+ "columnLastModified": "Geändert",
+ "columnCreatedBy": "Erstellt von",
+ "columnCreated": "Erstellt",
+ "columnAdvertised": "Beworben",
+ "columnFeatured": "Ausgewählt",
+ "contactDetails": "Kontaktdaten",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unbekannt",
+ "info": "Info",
+ "filterByNameOrPermissions": "Nach Name oder Berechtigungen filtern",
+ "permissionsName": "Name",
+ "permissions": "Berechtigungen",
+ "permissionsEntriesNoResults": "Keine Ergebnisse...",
+ "addPermissionsEntry": "Berechtigung hinzufügen",
+ "viewPermission": "Anzeigen",
+ "editPermission": "Bearbeiten",
+ "ownerPermission": "Eigentümer",
+ "groups": "Gruppen",
+ "filterBy": "Filtern...",
+ "about": "Über",
+ "readMore": "Mehr lesen",
+ "readLess": "Weniger lesen",
+ "noPermissionsAvailable": "Keine Berechtigung verfügbar",
+ "noAbout": "Keine zusätzlichen Informationen zur Ressource",
+ "addResource": "Ressource hinzufügen",
+ "createMap": "Karte erstellen",
+ "createDashboard": "Dashboard erstellen",
+ "createGeoStory": "Geostory erstellen",
+ "createContext": "Kontext erstellen",
+ "createMapFromContext": "Karte aus diesem Kontext erstellen",
+ "viewResourceProperties": "Eigenschaften öffnen",
+ "editResourceProperties": "Eigenschaften bearbeiten",
+ "uploadImage": "Bild hochladen",
+ "removeThumbnail": "Miniaturansicht entfernen",
+ "apply": "Anwenden",
+ "detailsPendingChangesTitle": "Möchten Sie die Seite wirklich verlassen, ohne Ihre Änderungen anzuwenden?",
+ "detailsPendingChangesDescription": "Wenn Sie die Seite verlassen, gehen Ihre ausstehenden Änderungen verloren",
+ "detailsPendingChangesConfirm": "Verlassen",
+ "detailsPendingChangesCancel": "Zurück zur Bearbeitung",
+ "filterMapsByContext": "Karten nach Kontext",
+ "tags": "Tags",
+ "deleteResource": "Löschen",
+ "deleteResourceTitle": "Möchten Sie diese Ressource wirklich löschen?",
+ "deleteResourceDescription": "Diese Ressource und alle verknüpften Ressourcen werden gelöscht",
+ "deleteResourceConfirm": "Löschen",
+ "deleteResourceCancel": "Behalten",
+
+ "copyResourceTitle": "Eine Kopie der aktuellen Ressource erstellen",
+ "copyResourceDescription": "Geben Sie einen gültigen Namen für die neue Ressource ein. Der Name muss eindeutig sein.",
+ "copyResourceCancel": "Zurück zur Bearbeitung",
+ "copyResourceConfirm": "Erstellen",
+
+ "createNewResourceTitle": "Neue Ressource erstellen",
+ "createNewResourceDescription": "Geben Sie einen gültigen Namen für die neue Ressource ein. Der Name muss eindeutig sein.",
+ "createNewResourceCancel": "Zurück zur Bearbeitung",
+ "createNewResourceConfirm": "Erstellen",
+ "resourceError": {
+ "errorTitle": "Die aktuelle Ressource kann nicht gespeichert werden",
+ "error403": "Sie dürfen die Ressource nicht aktualisieren",
+ "error404": "Beim Erstellen der Ressource auf dem Server ist ein Fehler aufgetreten",
+ "error409": "Eine Ressource mit diesem Namen existiert bereits",
+ "error500": "Interner Serverfehler. Überprüfen Sie, ob die Größe der Ressourcenkonfigurationsdatei das festgelegte Limit überschreitet",
+ "errorDefault": "Netzwerkfehler"
+ },
+ "deleteError": {
+ "error403": "Sie dürfen die Ressource nicht löschen",
+ "error404": "Beim Löschen der Ressource auf dem Server ist ein Fehler aufgetreten",
+ "error500": "Interner Server Fehler",
+ "errorDefault": "Netzwerkfehler"
+ },
+ "myResources": "Meine Ressourcen",
+ "creationFilter": {
+ "from": "Erstellungsdatum von",
+ "to": "Erstellungsdatum bis"
+ }
}
}
}
diff --git a/web/client/translations/data.en-US.json b/web/client/translations/data.en-US.json
index ecc3594b09..beb4f802eb 100644
--- a/web/client/translations/data.en-US.json
+++ b/web/client/translations/data.en-US.json
@@ -379,7 +379,7 @@
},
"home":{
"open": "Open",
- "shortDescription": "Modern webmapping with OpenLayers, Leaflet and ReactJS. Visit the documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore has been developed to create, save and share in a simple and intuitive way maps and mashups created selecting contents coming from well-known sources like OpenStreetMap, Google Maps or from services provided by organizations using open protocols like OGC WMS, WFS, WMTS or TMS and so on... Visit the home page for more details.",
"Applications": "Applications",
@@ -4194,6 +4194,122 @@
"validLayer": "This layer is valid and can be used in this process",
"invalidLayer": "This layer does not support the needed wps processes \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\nor it is a Raster layer"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.es-ES.json b/web/client/translations/data.es-ES.json
index fa24cb51a7..a700e52c4e 100644
--- a/web/client/translations/data.es-ES.json
+++ b/web/client/translations/data.es-ES.json
@@ -379,7 +379,7 @@
},
"home":{
"open": "Abrir",
- "shortDescription": "Webmapping moderno con OpenLayers, Leaflet y Reactvisite la página de documentación ",
+ "shortDescription": "Bienvenido a MapStore Mapas web modernos con OpenLayers, Cesium, Leaflet y ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore está hecho para crear, guardar y compartir de forma simple e intuitiva mapas y composiciones creados a partir de contenidos de servidores tales como OpenStreetMap, Google Maps, MapQuest o cualquier otro servidor que proporcione protocolos estándar tales como OGC WMS, WFS, WMTS o TMS y otros. Visite nuestra página principal para más detalles.",
"Applications": "Aplicaciones",
@@ -4183,6 +4183,122 @@
"validLayer": "Esta capa es válida y se puede utilizar en este proceso.",
"invalidLayer": "Esta capa no soporta los procesos wps necesarios \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\ni es una capa Raster"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filtrar",
+ "filters": "Filtros",
+ "view": "Ver",
+ "resourcesFound": "{count, plural, =0 {0 Recursos encontrados} =1 {1 Recurso encontrado} other {# Recursos encontrados}}",
+ "unadvertised": "El recurso no está anunciado. Está oculto en el catálogo y en los resultados de búsqueda",
+ "mapUsesContext": "Este mapa utiliza el contexto: {contextName}",
+ "orderBy": "Ordenar por",
+ "mostRecent": "Más reciente",
+ "lessRecent": "Menos reciente",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Más popular",
+ "clearFilters": "Borrar filtros",
+ "search": "Buscar...",
+ "customFiltersTitle": "Recursos",
+ "noResultsWithFilterTitle": "Sin resultados",
+ "noResultsWithFilterContent": "No hay resultados para los filtros seleccionados. Borre todos los filtros e intente con una nueva solicitud.",
+ "errorResourcePageTitle": "Página Error de carga",
+ "errorResourcePageContent": "La página de recursos seleccionada no está disponible",
+ "catalogSection": {
+ "noContentYetTitle": "Catálogo de recursos",
+ "noContentYetContent": "Este catálogo aún no tiene contenidos publicados. Estamos trabajando para llenarlo con excelentes recursos. ¡Esté atento!",
+ "noPublicContentTitle": "Catálogo de recursos",
+ "noPublicContentContent": "Este catálogo no tiene recursos públicos. Inicie sesión para explorar los contenidos."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Recursos destacados",
+ "noContentYetContent": "Este catálogo aún no tiene contenidos destacados publicados. Estamos trabajando para llenarlo con excelentes recursos. ¡Esté atento!",
+ "noPublicContentTitle": "Recursos destacados",
+ "noPublicContentContent": "Este catálogo no tiene recursos destacados."
+ },
+ "mapsFilter": "Mapas",
+ "dashboardsFilter": "Paneles de control",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contextos",
+ "columnName": "Nombre",
+ "columnDescription": "Descripción",
+ "columnTags": "Etiquetas",
+ "columnLastModifiedBy": "Modificado por",
+ "columnLastModified": "Modificado",
+ "columnCreatedBy": "Creado por",
+ "columnCreated": "Creado",
+ "columnAdvertised": "Anunciado",
+ "columnFeatured": "Destacado",
+ "contactDetails": "Contacto detalles",
+ "emptyNA": "N/A",
+ "emptyUnknown": "N/A",
+ "info": "Información",
+ "filterByNameOrPermissions": "Filtrar por nombre o permisos",
+ "permissionsName": "Nombre",
+ "permissions": "Permisos",
+ "permissionsEntriesNoResults": "Sin resultados...",
+ "addPermissionsEntry": "Agregar permiso",
+ "viewPermission": "Ver",
+ "editPermission": "Editar",
+ "ownerPermission": "Propietario",
+ "groups": "Grupos",
+ "filterBy": "Filtrar...",
+ "about": "Acerca de",
+ "readMore": "Leer más",
+ "readLess": "Leer menos",
+ "noPermissionsAvailable": "No hay permisos disponibles",
+ "noAbout": "No hay información adicional sobre el recurso",
+ "addResource": "Agregar recurso",
+ "createMap": "Crear mapa",
+ "createDashboard": "Crear panel",
+ "createGeoStory": "Crear geohistoria",
+ "createContext": "Crear contexto",
+ "createMapFromContext": "Crear mapa a partir de este contexto",
+ "viewResourceProperties": "Abrir propiedades",
+ "editResourceProperties": "Editar propiedades",
+ "uploadImage": "Subir imagen",
+ "removeThumbnail": "Eliminar miniatura",
+ "apply": "Aplicar",
+ "detailsPendingChangesTitle": "¿Está seguro de salir sin aplicar sus cambios?",
+ "detailsPendingChangesDescription": "Si sale, perderá sus cambios pendientes",
+ "detailsPendingChangesConfirm": "Salir",
+ "detailsPendingChangesCancel": "Volver a editar",
+ "filterMapsByContext": "Mapas por contexto",
+ "tags": "Etiquetas",
+ "deleteResource": "Eliminar",
+ "deleteResourceTitle": "¿Está seguro de que desea eliminar este recurso?",
+ "deleteResourceDescription": "Este recurso y todos los recursos vinculados se eliminarán eliminado",
+ "deleteResourceConfirm": "Eliminar",
+ "deleteResourceCancel": "Conservarlo",
+
+ "copyResourceTitle": "Crear una copia del recurso actual",
+ "copyResourceDescription": "Ingrese un nombre válido para el nuevo recurso. El nombre debe ser único.",
+ "copyResourceCancel": "Volver a editar",
+ "copyResourceConfirm": "Crear",
+
+ "createNewResourceTitle": "Crear un nuevo recurso",
+ "createNewResourceDescription": "Ingrese un nombre válido para el nuevo recurso. El nombre debe ser único.",
+ "createNewResourceCancel": "Volver a la edición",
+ "createNewResourceConfirm": "Crear",
+ "resourceError": {
+ "errorTitle": "No se puede guardar el recurso actual",
+ "error403": "No tiene permiso para actualizar el recurso",
+ "error404": "Se produjo un error al crear el recurso en el servidor",
+ "error409": "Ya existe un recurso con este nombre",
+ "error500": "Error interno del servidor. Verifique si el tamaño del archivo de configuración del recurso supera el límite fijo",
+ "errorDefault": "Error de red"
+ },
+ "deleteError": {
+ "error403": "No tiene permiso para eliminar el recurso",
+ "error404": "Se produjo un error al eliminar el recurso en el servidor",
+ "error500": "Error interno del servidor",
+ "errorDefault": "Error de red"
+ },
+ "myResources": "Mis recursos",
+ "creationFilter": {
+ "from": "Fecha de creación desde",
+ "to": "Fecha de creación hasta"
+ }
}
}
}
diff --git a/web/client/translations/data.fi-FI.json b/web/client/translations/data.fi-FI.json
index 22933299c4..7594d39813 100644
--- a/web/client/translations/data.fi-FI.json
+++ b/web/client/translations/data.fi-FI.json
@@ -187,7 +187,7 @@
},
"home": {
"open": "Avoin",
- "shortDescription": "Nykyaikaista web-karttojen kehittämistä OpenLayersin, Leafletin and Reactin kanssakäväise lukemassa dokumentaatiota ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Forkkaa minut GitHubissa",
"description": "MapStore on kehitetty luomaan, tallentamaan ja jakamaan yksinkertaisella ja intuitiivisella tavalla karttoja ja yhdistelmiä, jotka on luotu valitsemalla sisältö tunnetuista lähteistä, kuten Google Maps ja OpenStreetMap, tai palveluista, joita tarjoavat organisaatiot, jotka käyttävät avoimia protokollia, kuten OGC WMS, WFS , WMTS tai TMS ja niin edelleen. Lisätietoja ",
"Applications": "Sovellukset",
@@ -2643,6 +2643,122 @@
"title": "Kartta-vimpain",
"text": "Lisää uusi interaktiivinen kartta ohjauspaneeliin. Voit lisätä useita karttoja ja yhdistää niihin muita vimpaimia. Ensimmäisen kartan tallentamisen jälkeen selite-vimpain lisätään vimpainluetteloon. Selite-vimpain näyttää kartan selitteen.
Vaiheet:
Valitse kartta Paranna karttaa lisäämällä uusia tasoja Tallenna ja lisää ohjauspaneeliin
"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.fr-FR.json b/web/client/translations/data.fr-FR.json
index 3c09fe4c91..b2b3ae9c23 100644
--- a/web/client/translations/data.fr-FR.json
+++ b/web/client/translations/data.fr-FR.json
@@ -379,7 +379,7 @@
},
"home": {
"open": "Ouvrir",
- "shortDescription": "Application cartographique en ligne moderne Modern propulsée par OpenLayers, Leaflet et ReactJS. Consultez la page de documentation small>",
+ "shortDescription": "Bienvenue sur MapStore Cartographie Web moderne avec OpenLayers, Cesium, Leaflet et ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore a été développé pour créer, sauvegarder et partager de façon simple et intuitive des cartes simple ou élaboréess créés à partir de services comme OpenStreetMap, Google Maps ou tout autres services fournis par des organisations utilisant des protocoles ouverts comme OGC WMS, WFS, WMTS ou TMS et bien d'autres... Visitez notre page d'accueil pour plus de détails.",
"Applications": "Applications",
@@ -4183,6 +4183,122 @@
"validLayer": "Cette couche est valide et peut être utilisée dans ce processus",
"invalidLayer": "Cette couche ne prend pas en charge les processus wps nécessaires \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\nou est une couche raster"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filtrer",
+ "filters": "Filtres",
+ "view": "Afficher",
+ "resourcesFound": "{count, plural, =0 {0 Ressources trouvées} =1 {1 Ressource trouvée} other {# Ressources trouvées}}",
+ "unadvertised": "La ressource n'est pas annoncée. Elle est masquée du catalogue et des résultats de recherche",
+ "mapUsesContext": "Cette carte utilise le contexte: {contextName}",
+ "orderBy": "Trier par",
+ "mostRecent": "Le plus récent",
+ "lessRecent": "Moins récent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Le plus populaire",
+ "clearFilters": "Effacer les filtres",
+ "search": "Rechercher...",
+ "customFiltersTitle": "Ressources",
+ "noResultsWithFilterTitle": "Aucun résultat",
+ "noResultsWithFilterContent": "Aucun résultat n'est disponible pour les filtres sélectionnés. Effacez tous les filtres et réessayez avec une nouvelle demande.",
+ "errorResourcePageTitle": "Erreur de chargement de la page",
+ "errorResourcePageContent": "La page des ressources sélectionnées n'est pas disponible",
+ "catalogSection": {
+ "noContentYetTitle": "Catalogue de ressources",
+ "noContentYetContent": "Ce catalogue n'a pas encore de contenu publié. Nous travaillons à le remplir avec de bonnes ressources. Restez à l'écoute!",
+ "noPublicContentTitle": "Catalogue de ressources",
+ "noPublicContentContent": "Ce catalogue n'a pas de ressources publiques. Veuillez vous connecter pour parcourir le contenu."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Ressources en vedette",
+ "noContentYetContent": "Ce catalogue n'a pas encore de contenu en vedette publié. Nous travaillons à le remplir avec de bonnes ressources. Restez à l'écoute!",
+ "noPublicContentTitle": "Ressources en vedette",
+ "noPublicContentContent": "Ce catalogue n'a pas de ressources en vedette."
+ },
+ "mapsFilter": "Cartes",
+ "dashboardsFilter": "Tableaux de bord",
+ "geostoriesFilter": "Géostories",
+ "contextsFilter": "Contextes",
+ "columnName": "Nom",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modifié par",
+ "columnLastModified": "Modifié",
+ "columnCreatedBy": "Créé par",
+ "columnCreated": "Créé",
+ "columnAdvertised": "Annoncé",
+ "columnFeatured": "En vedette",
+ "contactDetails": "Coordonnées",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Inconnu",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filtrer par nom ou autorisations",
+ "permissionsName": "Nom",
+ "permissions": "Autorisations",
+ "permissionsEntriesNoResults": "Aucun résultat...",
+ "addPermissionsEntry": "Ajouter une autorisation",
+ "viewPermission": "Afficher",
+ "editPermission": "Modifier",
+ "ownerPermission": "Propriétaire",
+ "groups": "Groupes",
+ "filterBy": "Filtrer...",
+ "about": "À propos",
+ "readMore": "Lire la suite",
+ "readLess": "Lire moins",
+ "noPermissionsAvailable": "Aucune autorisation disponible",
+ "noAbout": "Aucune information supplémentaire sur la ressource",
+ "addResource": "Ajouter une ressource",
+ "createMap": "Créer une carte",
+ "createDashboard": "Créer un tableau de bord",
+ "createGeoStory": "Créer un géo-histoire",
+ "createContext": "Créer un contexte",
+ "createMapFromContext": "Créer une carte à partir de ce contexte",
+ "viewResourceProperties": "Ouvrir les propriétés",
+ "editResourceProperties": "Modifier les propriétés",
+ "uploadImage": "Télécharger une image",
+ "removeThumbnail": "Supprimer la miniature",
+ "apply": "Appliquer",
+ "detailsPendingChangesTitle": "Êtes-vous sûr de vouloir quitter sans appliquer vos modifications?",
+ "detailsPendingChangesDescription": "Si vous quittez, vous perdrez vos modifications en attente",
+ "detailsPendingChangesConfirm": "Quitter",
+ "detailsPendingChangesCancel": "Retour à l'édition",
+ "filterMapsByContext": "Cartes par contexte",
+ "tags": "Tags",
+ "deleteResource": "Supprimer",
+ "deleteResourceTitle": "Êtes-vous sûr de vouloir supprimer cette ressource?",
+ "deleteResourceDescription": "Cette ressource et toutes les ressources liées seront supprimées",
+ "deleteResourceConfirm": "Supprimer",
+ "deleteResourceCancel": "Conserver",
+
+ "copyResourceTitle": "Créer une copie de la ressource actuelle",
+ "copyResourceDescription": "Entrez un nom valide pour la nouvelle ressource. Le nom doit être unique.",
+ "copyResourceCancel": "Retour à l'édition",
+ "copyResourceConfirm": "Créer",
+
+ "createNewResourceTitle": "Créer une nouvelle ressource",
+ "createNewResourceDescription": "Entrez un nom valide pour la nouvelle ressource. Le nom doit être unique.",
+ "createNewResourceCancel": "Retour à l'édition",
+ "createNewResourceConfirm": "Créer",
+ "resourceError": {
+ "errorTitle": "Impossible d'enregistrer la ressource actuelle",
+ "error403": "Vous n'êtes pas autorisé à mettre à jour la ressource",
+ "error404": "Une erreur s'est produite lors de la création de la ressource sur le serveur",
+ "error409": "Une ressource portant ce nom existe déjà",
+ "error500": "Erreur interne du serveur. Vérifiez si la taille du fichier de configuration de la ressource dépasse la limite fixée",
+ "errorDefault": "Erreur réseau"
+ },
+ "deleteError": {
+ "error403": "Vous n'êtes pas autorisé à supprimer la ressource",
+ "error404": "Une erreur s'est produite lors de la suppression de la ressource sur le serveur",
+ "error500": "Erreur interne du serveur",
+ "errorDefault": "Erreur réseau"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.hr-HR.json b/web/client/translations/data.hr-HR.json
index 546c987c39..4875c67ade 100644
--- a/web/client/translations/data.hr-HR.json
+++ b/web/client/translations/data.hr-HR.json
@@ -186,7 +186,7 @@
},
"home":{
"open": "Otvori",
- "shortDescription": "Moderan webmapping pomoću OpenLayers, Leaflet i Reactpregledaj stranicu sa dokumentacijom ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Forkaj me na GitHub-u",
"description": "MapStore je razvijen za jednostavno i intuitivno kreiranje, spremanje i dijeljenje karata i kombinacija podataka dobivenih odabirom sadržaja koji dolaze iz standardnih izvora kao što su Google Maps i OpenStreetMap ili putem servisa koje pružaju organizacije koristeći otvorene protokole kao što su OGC WMS, WFS, WMTS ili TMS itd. Posjetite početnu stranicu za više informacija.",
"Applications": "Aplikacije",
@@ -2041,6 +2041,122 @@
"title": "Karta",
"text": "Dodaj novu interaktivnu kartu na ploču. Možete dodati više od jedne karte sa mogućnošću povezivanja sa ostalim widgetima. Nakon spremanja prve karte, widget legende će biti pridodan listi. Legenda će prikazivati podatke povezane sa prikazanom kartom.
Koraci:
Odaberi kartu Poboljšaj kartu dodavanjem novih slojeva Spremi i dodaj na ploču "
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.is-IS.json b/web/client/translations/data.is-IS.json
index 956264102d..646a9d0fc8 100644
--- a/web/client/translations/data.is-IS.json
+++ b/web/client/translations/data.is-IS.json
@@ -334,7 +334,7 @@
},
"home": {
"open": "Open",
- "shortDescription": "Modern webmapping with OpenLayers, Leaflet and ReactJS. Visit the documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore has been developed to create, save and share in a simple and intuitive way maps and mashups created selecting contents coming from well-known sources like OpenStreetMap, Google Maps or from services provided by organizations using open protocols like OGC WMS, WFS, WMTS or TMS and so on... Visit the home page for more details.",
"Applications": "Applications",
@@ -3835,6 +3835,122 @@
"infoSupported": "Supported file types: GeoJSON, DXF, Shapefiles
",
"dxfGeometryNotSupported": "Only LWPOLYLINE is supported"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.it-IT.json b/web/client/translations/data.it-IT.json
index e7b02b2e0b..55fbdd65e3 100644
--- a/web/client/translations/data.it-IT.json
+++ b/web/client/translations/data.it-IT.json
@@ -377,7 +377,7 @@
},
"home":{
"open": "Apri",
- "shortDescription": "Modern webmapping con OpenLayers, Leaflet e Reactvisita la pagina di documentazione ",
+ "shortDescription": "Benvenuti su MapStore Modern webmapping con OpenLayers, Cesium, Leaflet e ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore è sviluppato per creare, salvare e condividere in modo semplice ed intuitivo mappe e mashup creati selezionando contenuti da server come Google Maps, OpenStreetMap, MapQuest o da server specifici forniti dalla propria organizzazione o da terzi. Visita la home page per maggiori dettagli.",
"Applications": "Applicazioni",
@@ -4181,6 +4181,122 @@
"validLayer": "Questo livello è valido e può essere utilizzato in questo processo",
"invalidLayer": "Questo livello non supporta i processi wps necessari \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\no è un livello raster"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filtro",
+ "filters": "Filtri",
+ "view": "Visualizza",
+ "resourcesFound": "{count, plural, =0 {0 Risorse trovate} =1 {1 Risorsa trovata} other {# Risorse trovate}}",
+ "unadvertised": "La risorsa non è pubblicizzata. È nascosta dal catalogo e dai risultati di ricerca",
+ "mapUsesContext": "Questa mappa usa il contesto: {contextName}",
+ "orderBy": "Ordina per",
+ "mostRecent": "Più recenti",
+ "lessRecent": "Meno recenti",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Più popolari",
+ "clearFilters": "Cancella filtri",
+ "search": "Cerca...",
+ "customFiltersTitle": "Risorse",
+ "noResultsWithFilterTitle": "Nessun risultato",
+ "noResultsWithFilterContent": "Non ci sono risultati per i filtri selezionati. Cancella tutti i filtri e prova con una nuova richiesta.",
+ "errorResourcePageTitle": "Errore di caricamento della pagina",
+ "errorResourcePageContent": "La pagina delle risorse selezionate non è disponibile",
+ "catalogSection": {
+ "noContentYetTitle": "Catalogo delle risorse",
+ "noContentYetContent": "Questo catalogo non ha ancora contenuti pubblicati. Stiamo lavorando per popolarlo con nuove risorse.",
+ "noPublicContentTitle": "Catalogo delle risorse",
+ "noPublicContentContent": "Questo catalogo non ha risorse pubbliche. Effettua il login per esplorare i contenuti."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Risorse in evidenza",
+ "noContentYetContent": "Questo catalogo non ha ancora contenuti in evidenza pubblicati. Stiamo lavorando per popolarlo con nuove risorse.",
+ "noPublicContentTitle": "Risorse in evidenza",
+ "noPublicContentContent": "Questo catalogo non ha risorse in evidenza."
+ },
+ "mapsFilter": "Mappe",
+ "dashboardsFilter": "Dashboard",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contesti",
+ "columnName": "Nome",
+ "columnDescription": "Descrizione",
+ "columnTags": "Tag",
+ "columnLastModifiedBy": "Modificato da",
+ "columnLastModified": "Modificato",
+ "columnCreatedBy": "Creato da",
+ "columnCreated": "Creato",
+ "columnAdvertised": "Pubblicizzato",
+ "columnFeatured": "In evidenza",
+ "contactDetails": "Dettagli di contatto",
+ "emptyNA": "N/A",
+ "emptyUnknown": "N/A",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filtra per nome o permessi",
+ "permissionsName": "Nome",
+ "permissions": "Permessi",
+ "permissionsEntriesNoResults": "Nessun risultato...",
+ "addPermissionsEntry": "Aggiungi permesso",
+ "viewPermission": "Visualizza",
+ "editPermission": "Modifica",
+ "ownerPermission": "Proprietario",
+ "groups": "Gruppi",
+ "filterBy": "Filtra...",
+ "about": "Informazioni",
+ "readMore": "Leggi di più",
+ "readLess": "Leggi di meno",
+ "noPermissionsAvailable": "Nessun permesso disponibile",
+ "noAbout": "Nessuna informazione aggiuntiva sulla risorsa",
+ "addResource": "Aggiungi risorsa",
+ "createMap": "Crea mappa",
+ "createDashboard": "Crea dashboard",
+ "createGeoStory": "Crea geostoria",
+ "createContext": "Crea contesto",
+ "createMapFromContext": "Crea mappa da questo contesto",
+ "viewResourceProperties": "Visualizza proprietà",
+ "editResourceProperties": "Modifica proprietà",
+ "uploadImage": "Carica immagine",
+ "removeThumbnail": "Rimuovi thumbanil",
+ "apply": "Applica",
+ "detailsPendingChangesTitle": "Vuoi uscire senza applicare le modifiche?",
+ "detailsPendingChangesDescription": "Se esci perderai le modifiche",
+ "detailsPendingChangesConfirm": "Esci",
+ "detailsPendingChangesCancel": "Torna alla modifica",
+ "filterMapsByContext": "Mappe per contesto",
+ "tags": "Tag",
+ "deleteResource": "Elimina",
+ "deleteResourceTitle": "Vuoi davvero eliminare questa risorsa?",
+ "deleteResourceDescription": "Questa risorsa e tutte le risorse collegate verranno eliminate",
+ "deleteResourceConfirm": "Elimina",
+ "deleteResourceCancel": "Conserva",
+
+ "copyResourceTitle": "Crea una copia della risorsa corrente",
+ "copyResourceDescription": "Inserisci un nome valido per la nuova risorsa. Il nome deve essere univoco.",
+ "copyResourceCancel": "Torna alla modifica",
+ "copyResourceConfirm": "Crea",
+
+ "createNewResourceTitle": "Crea una nuova risorsa",
+ "createNewResourceDescription": "Inserisci un nome valido per la nuova risorsa. Il nome deve essere univoco.",
+ "createNewResourceCancel": "Torna alla modifica",
+ "createNewResourceConfirm": "Crea",
+ "resourceError": {
+ "errorTitle": "Impossibile salvare la risorsa corrente",
+ "error403": "Non sei autorizzato ad aggiornare la risorsa",
+ "error404": "Si è verificato un errore durante la creazione della risorsa sul server",
+ "error409": "Esiste già una risorsa con questo nome",
+ "error500": "Errore interno del server. Verifica se la dimensione del file di configurazione della risorsa supera il limite configurato",
+ "errorDefault": "Errore di rete"
+ },
+ "deleteError": {
+ "error403": "Non sei autorizzato a eliminare la risorsa",
+ "error404": "Si è verificato un errore durante l'eliminazione della risorsa sul server",
+ "error500": "Errore interno del server",
+ "errorDefault": "Errore di rete"
+ },
+ "myResources": "Le mie risorse",
+ "creationFilter": {
+ "from": "Dalla data di creazione",
+ "to": "Alla data di creazione"
+ }
}
}
}
diff --git a/web/client/translations/data.nl-NL.json b/web/client/translations/data.nl-NL.json
index 79610ea350..7bf7c0b0b7 100644
--- a/web/client/translations/data.nl-NL.json
+++ b/web/client/translations/data.nl-NL.json
@@ -367,7 +367,7 @@
},
"home":{
"open": "Open",
- "shortDescription": "Moderne webmapping applicatie met OpenLayers, Leaflet en Reactvisit the documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Vork mij op GitHub",
"description": "MapStore is ontwikkeld om op een eenvoudige en intuïtieve manier kaarten en mashups te maken, op te slaan en te delen die zijn gemaakt door inhoud te selecteren die afkomstig is van bekende bronnen zoals OpenStreetMap, Google Maps of van services die worden aangeboden door organisaties die open protocollen gebruiken zoals OGC WMS, WFS, WMTS of TMS enzovoort ... Bezoek onze homepage voor meer detail.",
"Applications": "Toepassingen",
@@ -4178,6 +4178,122 @@
"validLayer": "Dit is een geldige laag die gebruikt kan worden in dit proces",
"invalidLayer": "Deze laag ondersteunt niet de vereiste WPS-processen \ngeo:buffer\ngs:IntersectionFeatureCollection\ngs:CollectGeometries\n\nof het is een rasterlaag"
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.pt-PT.json b/web/client/translations/data.pt-PT.json
index 6348e8e117..1ef5b28b40 100644
--- a/web/client/translations/data.pt-PT.json
+++ b/web/client/translations/data.pt-PT.json
@@ -188,7 +188,7 @@
},
"home":{
"open": "Abrir",
- "shortDescription": "Modern webmapping com OpenLayers, Leaflet e Reactvisite a página de documentação ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore 2 foi desenvolvido para criar, guardar e partilhar de uma maneira simples e intuitiva mapas e mashups criados seleccionando conteudos de fontes populares como o Google Maps e OpenStreetMap ou de serviços fornecidos por organisações utilizando protocolos livre como OGC WMS, WFS, WMTS ou TMS, etc. Visite home page para mais detalhes.",
"Applications": "Aplicações",
@@ -1998,6 +1998,122 @@
"title": "Map Widget",
"text": "Add a new interactive map to the dashboard. You can add more than one map with the ability to connect other widgets to them. After saving the first map, the legend widget will be added to the list. Legend Widget will show a legend related to the connected map.
Steps:
Select a map Improve map by adding new layers Save and add to dashboard "
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.sk-SK.json b/web/client/translations/data.sk-SK.json
index 66949e87a5..53d5fb936c 100644
--- a/web/client/translations/data.sk-SK.json
+++ b/web/client/translations/data.sk-SK.json
@@ -277,7 +277,7 @@
},
"home":{
"open": "Otvoriť",
- "shortDescription": "Moderné webové mapovanie s využitím OpenLayers, Leaflet a ReactJS. Pre viac informácií navštív documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore has been developed to create, save and share in a simple and intuitive way maps and mashups created selecting contents coming from well-known sources like OpenStreetMap, Google Maps or from services provided by organizations using open protocols like OGC WMS, WFS, WMTS or TMS and so on... Visit the home page for more details.",
"Applications": "Aplikácie",
@@ -3358,6 +3358,122 @@
"successRemoved": "Relácia používateľa bola odstránená",
"remove": "Obnoviť reláciu používateľa",
"confirmRemove": "Naozaj chceš obnoviť predvolenú konfiguráciu?"
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.sv-SE.json b/web/client/translations/data.sv-SE.json
index 41e8505b9f..590e3899ab 100644
--- a/web/client/translations/data.sv-SE.json
+++ b/web/client/translations/data.sv-SE.json
@@ -285,7 +285,7 @@
},
"home":{
"open": "Öppna",
- "shortDescription": "En modern webbkarta som bygger på OpenLayers, Leaflet och ReactJS. Besök MapStores dokumentation ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Skapa en fork på GitHub",
"description": "MapStore har utvecklats för att på ett enkelt och intuitivt sätt skapa, spara och dela kartor och mashups som skapats genom att välja innehåll från välkända källor som OpenStreetMap, Google Maps eller från tjänster som tillhandahålls av organisationer som använder öppna protokoll som OGC WMS , WFS, WMTS eller TMS och så vidare ... Besök startsidan för mer information.",
"Applications": "Applikationer",
@@ -3397,6 +3397,122 @@
"successRemoved": "Användarsession borttagen",
"remove": "Återställ användarsession",
"confirmRemove": "Är du säker på att du vill återställa standardkonfigurationen?"
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.vi-VN.json b/web/client/translations/data.vi-VN.json
index 3f7ac1edf9..87767607d7 100644
--- a/web/client/translations/data.vi-VN.json
+++ b/web/client/translations/data.vi-VN.json
@@ -570,7 +570,7 @@
"forkMeOnGitHub": "Chia đôi tôi trên GitHub",
"open": "Mở",
"scrollTop": "Cuộn lên đầu trang",
- "shortDescription": "Lập bản đồ web hiện đại với OpenLayers, Tờ rơi và Phản ứngtruy cập tài liệu trang "
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
"
},
"htmlTest": "{name} {surname}",
"identifyHideCoordinateEditor": "Ẩn trình soạn thảo tọa độ",
@@ -2027,6 +2027,122 @@
"zoomAllTooltip": "Thu phóng đến mức tối đa",
"zoomInTooltip": "Tăng thu phóng",
"zoomOutTooltip": "Giảm thu phóng"
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/translations/data.zh-ZH.json b/web/client/translations/data.zh-ZH.json
index 2ae401434d..bb3c10f3e6 100644
--- a/web/client/translations/data.zh-ZH.json
+++ b/web/client/translations/data.zh-ZH.json
@@ -187,7 +187,7 @@
},
"home":{
"open": "打开",
- "shortDescription": "Modern webmapping with OpenLayers, Leaflet and Reactvisit the documentation page ",
+ "shortDescription": "Welcome to MapStore Modern webmapping with OpenLayers, Cesium, Leaflet and ReactJS.
",
"forkMeOnGitHub": "Fork me on GitHub",
"description": "MapStore的开发旨在以简单直观的方式创建,保存和共享地图和混搭,这些地图和混搭创建的内容来自知名来源,如Google Maps和OpenStreetMap,或者由使用开放协议的组织提供的服务,如OGC WMS,WFS,WMTS 或TMS等等。 有关更多详细信息,请访问主页 。",
"Applications": "应用",
@@ -1974,6 +1974,122 @@
"title": "Map Widget",
"text": "Add a new interactive map to the dashboard. You can add more than one map with the ability to connect other widgets to them. After saving the first map, the legend widget will be added to the list. Legend Widget will show a legend related to the connected map.
Steps:
Select a map Improve map by adding new layers Save and add to dashboard "
}
+ },
+ "resourcesCatalog": {
+ "filter": "Filter",
+ "filters": "Filters",
+ "view": "View",
+ "resourcesFound": "{count, plural, =0 {0 Resources found} =1 {1 Resource found} other {# Resources found}}",
+ "unadvertised": "Resource is not advertised. It is hidden from the catalog and search results",
+ "mapUsesContext": "This map uses the context: {contextName}",
+ "orderBy": "Order by",
+ "mostRecent": "Most recent",
+ "lessRecent": "Less recent",
+ "aZ": "A Z",
+ "zA": "Z A",
+ "mostPopular": "Most popular",
+ "clearFilters": "Clear filters",
+ "search": "Search...",
+ "customFiltersTitle": "Resources",
+ "noResultsWithFilterTitle": "No results",
+ "noResultsWithFilterContent": "There are not results for the selected filters. Clear all filters and try with a new request.",
+ "errorResourcePageTitle": "Page Loading Error",
+ "errorResourcePageContent": "The selected resources page is not available",
+ "catalogSection": {
+ "noContentYetTitle": "Resources Catalog",
+ "noContentYetContent": "This catalog doesn't have published contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Resources Catalog",
+ "noPublicContentContent": "This catalog doesn't have public resources. Please login to browse the contents."
+ },
+ "featuredSection": {
+ "noContentYetTitle": "Featured Resources",
+ "noContentYetContent": "This catalog doesn't have published featured contents yet. We're working to populate it with great resources. Stay tuned!",
+ "noPublicContentTitle": "Featured Resources",
+ "noPublicContentContent": "This catalog doesn't have featured resources."
+ },
+ "mapsFilter": "Maps",
+ "dashboardsFilter": "Dashboards",
+ "geostoriesFilter": "Geostories",
+ "contextsFilter": "Contexts",
+ "columnName": "Name",
+ "columnDescription": "Description",
+ "columnTags": "Tags",
+ "columnLastModifiedBy": "Modified by",
+ "columnLastModified": "Modified",
+ "columnCreatedBy": "Created by",
+ "columnCreated": "Created",
+ "columnAdvertised": "Advertised",
+ "columnFeatured": "Featured",
+ "contactDetails": "Contact details",
+ "emptyNA": "N/A",
+ "emptyUnknown": "Unknown",
+ "info": "Info",
+ "filterByNameOrPermissions": "Filter by name or permissions",
+ "permissionsName": "Name",
+ "permissions": "Permissions",
+ "permissionsEntriesNoResults": "No Results...",
+ "addPermissionsEntry": "Add Permission",
+ "viewPermission": "View",
+ "editPermission": "Edit",
+ "ownerPermission": "Owner",
+ "groups": "Groups",
+ "filterBy": "Filter...",
+ "about": "About",
+ "readMore": "Read more",
+ "readLess": "Read less",
+ "noPermissionsAvailable": "No permission available",
+ "noAbout": "No additional information about the resource",
+ "addResource": "Add Resource",
+ "createMap": "Create map",
+ "createDashboard": "Create dashboard",
+ "createGeoStory": "Create geostory",
+ "createContext": "Create context",
+ "createMapFromContext": "Create map from this context",
+ "viewResourceProperties": "Open properties",
+ "editResourceProperties": "Edit properties",
+ "uploadImage": "Upload image",
+ "removeThumbnail": "Remove thumbnail",
+ "apply": "Apply",
+ "detailsPendingChangesTitle": "Are you sure to leave without apply your changes?",
+ "detailsPendingChangesDescription": "If you leave you will lose your pending changes",
+ "detailsPendingChangesConfirm": "Leave",
+ "detailsPendingChangesCancel": "Back to editing",
+ "filterMapsByContext": "Maps by context",
+ "tags": "Tags",
+ "deleteResource": "Delete",
+ "deleteResourceTitle": "Are you sure you want to delete this resource?",
+ "deleteResourceDescription": "This resource and all linked resources will be deleted",
+ "deleteResourceConfirm": "Delete",
+ "deleteResourceCancel": "Keep it",
+
+ "copyResourceTitle": "Create a copy of the current resource",
+ "copyResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "copyResourceCancel": "Back to editing",
+ "copyResourceConfirm": "Create",
+
+ "createNewResourceTitle": "Create a new resource",
+ "createNewResourceDescription": "Enter a valid name for the new resource. The name must be unique.",
+ "createNewResourceCancel": "Back to editing",
+ "createNewResourceConfirm": "Create",
+ "resourceError": {
+ "errorTitle": "Cannot save the current resource",
+ "error403": "You are not allowed to update the resource",
+ "error404": "An error occurred while creating the resource on the server",
+ "error409": "A resource with this name already exists",
+ "error500": "Internal Server Error. Verify if resource configuration file size exceeds fixed limit",
+ "errorDefault": "Network error"
+ },
+ "deleteError": {
+ "error403": "You are not allowed to delete the resource",
+ "error404": "An error occurred while deleting the resource on the server",
+ "error500": "Internal Server Error",
+ "errorDefault": "Network error"
+ },
+ "myResources": "My resources",
+ "creationFilter": {
+ "from": "Creation date from",
+ "to": "Creation date to"
+ }
}
}
}
diff --git a/web/client/utils/ContextCreatorUtils.js b/web/client/utils/ContextCreatorUtils.js
index efa1605c98..c512b31be7 100644
--- a/web/client/utils/ContextCreatorUtils.js
+++ b/web/client/utils/ContextCreatorUtils.js
@@ -20,3 +20,30 @@ export const makePlugins = (plugins = []) =>
*/
export const flattenPluginTree = (plugins = []) =>
flatten(plugins.map(plugin => [omit(plugin, 'children')].concat(plugin.enabled ? flattenPluginTree(plugin.children) : [])));
+/**
+ * @param {object} context context configuration
+ * @returns update context configuration based on plugins updates or changes (eg. rename of plugins)
+ */
+export const migrateContextConfiguration = (context) => {
+ const changedPluginsNames = {
+ 'DeleteMap': 'DeleteResource'
+ };
+ return {
+ ...context,
+ ...(context?.plugins && {
+ plugins: Object.fromEntries(Object.keys(context.plugins)
+ .map((key) => {
+ const plugins = context.plugins[key];
+ return [key, plugins.map((plugin) => {
+ if (changedPluginsNames[plugin.name]) {
+ return {
+ ...plugin,
+ name: changedPluginsNames[plugin.name]
+ };
+ }
+ return plugin;
+ })];
+ }))
+ })
+ };
+};
diff --git a/web/client/utils/FontUtils.js b/web/client/utils/FontUtils.js
new file mode 100644
index 0000000000..a3e709abfe
--- /dev/null
+++ b/web/client/utils/FontUtils.js
@@ -0,0 +1,20 @@
+
+let fontAwesomeLoaded = false;
+
+export const isFontAwesomeReady = () => fontAwesomeLoaded;
+
+export const loadFontAwesome = () => {
+ if (fontAwesomeLoaded) {
+ return Promise.resolve();
+ }
+ // async load of font awesome
+ return import('font-awesome/css/font-awesome.min.css')
+ .then(() => {
+ // ensure the font is loaded
+ return document.fonts.load('1rem FontAwesome')
+ .then(() => {
+ fontAwesomeLoaded = true;
+ return fontAwesomeLoaded;
+ });
+ });
+};
diff --git a/web/client/utils/PluginsUtils.js b/web/client/utils/PluginsUtils.js
index 97ba9bac24..dd4e4ea130 100644
--- a/web/client/utils/PluginsUtils.js
+++ b/web/client/utils/PluginsUtils.js
@@ -8,7 +8,7 @@
import React from 'react';
import assign from 'object-assign';
-import {endsWith, get, head, isArray, isFunction, isObject, isString, memoize, omit, size} from 'lodash';
+import {endsWith, get, head, isArray, isFunction, isObject, isString, memoize, omit, size, maxBy } from 'lodash';
import {connect as originalConnect} from 'react-redux';
import url from 'url';
import curry from 'lodash/curry';
@@ -269,15 +269,24 @@ export const getMorePrioritizedContainer = (plugin, override = {}, plugins, prio
const pluginImpl = plugin.impl;
return plugins.reduce((previous, current) => {
const containerName = current.name || current;
- const pluginPriority = getPriority(plugin, override, containerName);
- return pluginPriority > previous.priority ? {
+ const currentPlugin = !isArray(pluginImpl[containerName])
+ ? { priority: getPriority(plugin, override, containerName), impl: pluginImpl[containerName] }
+ : maxBy(pluginImpl[containerName]
+ .map((containerConfig) => {
+ return {
+ priority: getPriority({ impl: { [containerName]: containerConfig } }, override, containerName),
+ impl: containerConfig
+ };
+ }), 'priority')
+ ;
+ return currentPlugin.priority > previous.priority ? {
plugin: {
name: containerName,
impl: {
- ...(isFunction(pluginImpl[containerName]) ? pluginImpl[containerName](plugin.config) : pluginImpl[containerName]),
+ ...(isFunction(currentPlugin.impl) ? currentPlugin.impl(plugin.config) : currentPlugin.impl),
...(override[containerName] ?? {})}
},
- priority: pluginPriority} : previous;
+ priority: currentPlugin.priority} : previous;
}, {plugin: null, priority: priority});
};
diff --git a/web/client/utils/__tests__/ContextCreatorUtils-test.js b/web/client/utils/__tests__/ContextCreatorUtils-test.js
index 0c6869c223..9d849a36db 100644
--- a/web/client/utils/__tests__/ContextCreatorUtils-test.js
+++ b/web/client/utils/__tests__/ContextCreatorUtils-test.js
@@ -9,7 +9,8 @@ import expect from 'expect';
import {
makePlugins,
- flattenPluginTree
+ flattenPluginTree,
+ migrateContextConfiguration
} from '../ContextCreatorUtils';
describe('Test the ContextCreatorUtils', () => {
@@ -21,4 +22,16 @@ describe('Test the ContextCreatorUtils', () => {
const plugins = flattenPluginTree([{ name: 'Map', enabled: true, children: [ { name: 'MapSupport' } ] }]);
expect(plugins).toEqual([ { name: 'Map', enabled: true }, { name: 'MapSupport' } ]);
});
+ it('migrateContextConfiguration', () => {
+ const newContext = migrateContextConfiguration({
+ plugins: {
+ desktop: [{ name: 'Map' }, { name: 'DeleteMap' }]
+ }
+ });
+ expect(newContext).toEqual({
+ plugins: {
+ desktop: [{ name: 'Map' }, { name: 'DeleteResource' }]
+ }
+ });
+ });
});
diff --git a/web/client/utils/styleparser/StyleParserUtils.js b/web/client/utils/styleparser/StyleParserUtils.js
index 498173598e..dd023733c7 100644
--- a/web/client/utils/styleparser/StyleParserUtils.js
+++ b/web/client/utils/styleparser/StyleParserUtils.js
@@ -40,7 +40,7 @@ import isObject from 'lodash/isObject';
import MarkerUtils from '../MarkerUtils';
import {randomInt} from '../RandomUtils';
import { getConfigProp } from '../ConfigUtils';
-
+import { loadFontAwesome } from '../FontUtils';
export const isGeoStylerBooleanFunction = (got) => [
'between',
@@ -860,23 +860,6 @@ export const parseSymbolizerExpressions = (symbolizer, feature) => {
}), {});
};
-let fontAwesomeLoaded = false;
-const loadFontAwesome = () => {
- if (fontAwesomeLoaded) {
- return Promise.resolve();
- }
- // async load of font awesome
- return import('font-awesome/css/font-awesome.min.css')
- .then(() => {
- // ensure the font is loaded
- return document.fonts.load('1rem FontAwesome')
- .then(() => {
- fontAwesomeLoaded = true;
- return fontAwesomeLoaded;
- });
- });
-};
-
/**
* prefetch all image or mark symbol in a geostyler style
* @param {object} geoStylerStyle geostyler style