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/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/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..20a2b4c96c 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,138 @@ "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" + } + ] + } + ] } - }, { - "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 +851,9 @@ "FeedbackMask" ], "dashboard": [ + { "name": "ResourceDetails", "cfg": { "resourceType": "DASHBOARD" } }, + { "name": "Save", "cfg": { "resourceType": "DASHBOARD" }}, + { "name": "SaveAs", "cfg": { "resourceType": "DASHBOARD" }}, "Details", "AddWidgetDashboard", "MapConnectionDashboard", @@ -769,10 +874,6 @@ } }, "Language", - "NavMenu", - "DashboardSave", - "DashboardSaveAs", - "Attribution", { "name": "Home", "override": { @@ -789,13 +890,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 +1001,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 +1027,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 +1078,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/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..8034b3dced 100644 --- a/web/client/plugins/BurgerMenu.jsx +++ b/web/client/plugins/BurgerMenu.jsx @@ -56,7 +56,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 +67,7 @@ class BurgerMenu extends React.Component { static defaultProps = { id: "mapstore-burger-menu", + className: 'square-button', items: [], onItemClick: () => {}, title: , @@ -157,7 +159,7 @@ class BurgerMenu extends React.Component { render() { return ( - ({ + 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 +205,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: 4, + priority: 2, + target: 'right-menu', + Component: connect(() => ({ id: 'ms-burger-menu', className: 'square-button-md' }))(BurgerMenuPlugin) } } } diff --git a/web/client/plugins/Home.jsx b/web/client/plugins/Home.jsx index 9bb9583a52..d51fad3dcb 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: 1, + priority: 5 } }), reducers: {}, diff --git a/web/client/plugins/Language.jsx b/web/client/plugins/Language.jsx index f9a23a8221..6e9c955bbc 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: 0, + priority: 2 } }), reducers: {} diff --git a/web/client/plugins/Login.jsx b/web/client/plugins/Login.jsx index aff7e10c2e..d9d5ade940 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: 2, + priority: 3, + Component: (props) => { + return ( + <> + + + + + + ); + } + }, SidebarMenu: { name: "login", position: 2, diff --git a/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx b/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx new file mode 100644 index 0000000000..b308795a34 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/BrandNavbar.jsx @@ -0,0 +1,72 @@ +/* + * 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'; + +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..5c4cbb24d5 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/DeleteResource.jsx @@ -0,0 +1,148 @@ +/* + * 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 { userSelector } from '../../selectors/security'; +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'; +import { MenuItem, Glyphicon } from 'react-bootstrap'; +import Message from '../../components/I18N/Message'; + +function DeleteResource({ + user, + 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); + })); + } + } + // TODO: use resource.canDelete instead of user + if (!user || resource?.id === undefined) { + return null; + } + return ( + <> + {Component ? setShowModal(true)} + /> : null} + + + ); +} + +const deleteResourcesConnect = connect( + createStructuredSelector({ + user: userSelector, + 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); + +const BurgerMenuMenuItem = ({ + active, + onClick, + glyph, + labelId +}) => { + return ( + onClick(!active)} + > + + + ); +}; + +export default createPlugin('DeleteResource', { + component: DeleteResourcePlugin, + containers: { + ResourcesGrid: { + target: 'card-options', + position: 1, + priority: 2 + }, + SidebarMenu: { + position: 300, + tool: DeleteResourcePlugin, + priority: 2 + }, + BurgerMenu: { + position: 5, + tool: (props) => , + priority: 1 + } + } +}); diff --git a/web/client/plugins/ResourcesCatalog/EditContext.jsx b/web/client/plugins/ResourcesCatalog/EditContext.jsx new file mode 100644 index 0000000000..e5a22d9873 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/EditContext.jsx @@ -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 React from 'react'; +import { createPlugin } from "../../utils/PluginsUtils"; +import { connect } from 'react-redux'; +import { createStructuredSelector } from 'reselect'; +import { userSelector } from '../../selectors/security'; + +function EditContext({ + user, + resource, + component +}) { + const Component = component; + // TODO: use resource.canEdit instead of user + if (user && 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..1e8c760922 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/ResourceDetails.jsx @@ -0,0 +1,355 @@ +/* + * 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'; + +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('resourceCanEdit')}", + "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); + return ( + + + {title} + + + + ); +} + +export default createPlugin('ResourceDetails', { + component: resourceDetailsConnect(withResizeDetector(ResourceDetails)), + containers: { + BrandNavbar: { + priority: 1, + target: 'right-menu', + Component: resourceDetailsConnect(BrandNavbarDetailsButton), + doNotHide: true, + position: -3 + }, + 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..6f7cb11f8b --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/ResourcesFiltersForm.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 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'; + +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: '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: "context" + } + ], + monitoredState, + customFilters, + location, + show, + targetSelector, + headerNodeSelector = '#ms-brand-navbar', + navbarNodeSelector = '', + footerNodeSelector = '', + width, + height +}) { + + 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 + }); + + return ( + + + onSearch({ params }, resourcesGridId)} + onClear={() => onSearch({ clear: true }, resourcesGridId)} + onClose={() => onClose(resourcesGridId)} + /> + + + ); +} + +const ResourcesGridPlugin = connect( + createStructuredSelector({ + 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..9c9f75b1c6 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/ResourcesGrid.jsx @@ -0,0 +1,212 @@ +/* + * 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: '-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' + } + ] + }, + metadata = { + list: [ + { + path: 'name', + target: 'header', + width: 30, + labelId: 'resourcesCatalog.columnName' + }, + { + path: 'description', + width: 20, + labelId: 'resourcesCatalog.columnDescription' + }, + { + path: 'tags', + type: 'tag', + itemValue: 'value', + itemColor: 'color', + filter: 'filter{tag.in}', + width: 30, + labelId: 'resourcesCatalog.columnTags', + showFullContent: true + }, + { + path: 'lastUpdate', + type: 'date', + format: 'MMM Do YY, h:mm:ss a', + width: 10, + icon: { glyph: 'clock-o' }, + labelId: 'resourcesCatalog.columnLastModified' + }, + { + path: 'creator', + target: 'footer', + filter: 'filter{creator.in}', + icon: { glyph: 'user', type: 'glyphicon' }, + width: 10, + labelId: 'resourcesCatalog.columnCreatedBy' + } + ], + grid: [ + { + path: 'name', + target: 'header' + }, + { + path: 'description', + width: 20, + labelId: 'resourcesCatalog.columnDescription' + }, + { + path: 'tags', + type: 'tag', + itemValue: 'value', + itemColor: 'color', + filter: 'filter{tag.in}', + showFullContent: true + }, + { + path: 'creator', + target: 'footer', + filter: 'filter{creator.in}', + icon: { glyph: 'user', type: 'glyphicon' }, + width: 10, + labelId: 'resourcesCatalog.columnCreator' + } + ] + }, + ...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..609eb78935 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/Save.jsx @@ -0,0 +1,171 @@ +/* + * 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 Button from './components/Button'; +import Icon from './components/Icon'; +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 Spinner from './components/Spinner'; +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 +}) { + 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; + } + 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' +}; + +export default createPlugin('Save', { + component: SavePlugin, + containers: { + BrandNavbar: { + target: 'right-menu', + position: -2, + priority: 1 + } + } +}); diff --git a/web/client/plugins/ResourcesCatalog/SaveAs.jsx b/web/client/plugins/ResourcesCatalog/SaveAs.jsx new file mode 100644 index 0000000000..3d91f8ebcd --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/SaveAs.jsx @@ -0,0 +1,226 @@ +/* + * 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 Button from './components/Button'; +import Icon from './components/Icon'; +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 Spinner from './components/Spinner'; +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 +}) { + + 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 messagePrefix = pendingChanges?.initialResource?.id === undefined + ? 'createNewResource' + : 'copyResource'; + + return ( + <> + + + + + + ); +} + +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: SaveAsPlugin, + containers: { + BrandNavbar: { + target: 'right-menu', + position: -1, + 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..e3c08fdedc --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/api/__tests__/resources-test.js @@ -0,0 +1,208 @@ +/* + * 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 }); + let json; + xml2js.parseString(config.data, { explicitArray: false }, (ignore, result) => { + json = result; + }); + expect(json).toEqual({ + "AND": { + "OR": [ + { + "CATEGORY": [ + { "operator": "EQUAL_TO", "name": "MAP" }, + { "operator": "EQUAL_TO", "name": "DASHBOARD" }, + { "operator": "EQUAL_TO", "name": "GEOSTORY" }, + { "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 }); + let json; + xml2js.parseString(config.data, { explicitArray: false }, (ignore, result) => { + json = result; + }); + expect(json).toEqual({ + "AND": { + "FIELD": { "field": "NAME", "operator": "ILIKE", "value": "%A%" }, + "ATTRIBUTE": { "name": "featured", "operator": "EQUAL_TO", "type": "STRING", "value": "true" }, + "OR": [ + { + "CATEGORY": { "operator": "EQUAL_TO", "name": "MAP" } + }, + { + "ATTRIBUTE": { "name": "context", "operator": "EQUAL_TO", "type": "STRING", "value": "contextName" } + } + ] + } + }); + } catch (e) { + done(e); + } + return [200, { + ExtResourceList: { + Resource: [], + ResourceCount: 0 + } + }]; + }); + requestResources({ + params: { + page: 2, + pageSize: 24, + f: ['map', 'featured'], + q: 'A', + 'filter{ctx}': ['contextName'] + } + }) + .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 }); + } 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..351de4c104 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/api/resources.js @@ -0,0 +1,248 @@ +/* + * 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'; + +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}'] || []); + const categories = ['MAP', 'DASHBOARD', 'GEOSTORY', 'CONTEXT']; + const categoriesFilters = categories.filter(category => f.includes(category.toLocaleLowerCase())); + return { + AND: { + FIELD: [ + ...(q ? [{ + field: ['NAME'], + operator: ['ILIKE'], + value: ['%' + q + '%'] + }] : []) + ], + ATTRIBUTE: [ + ...(f.includes('featured') ? [{ + name: ['featured'], + operator: ['EQUAL_TO'], + type: ['STRING'], + value: [true] + }] : []) + ], + OR: [ + { + CATEGORY: categories + .map(name => { + return (!categoriesFilters.length || categoriesFilters.includes(name)) + ? { + operator: ['EQUAL_TO'], + name: [name] + } + : null; + }).filter(value => value) + }, + { + ATTRIBUTE: [ + ...(ctx.map((ctxValue) => { + const { value } = splitFilterValue(ctxValue); + return { + name: ['context'], + operator: ['EQUAL_TO'], + type: ['STRING'], + value: [value] + }; + })) + ] + } + ] + } + }; +}; + +export const requestResources = ({ + params +} = {}, { user } = {}) => { + + const { + page = 1, + pageSize = 12, + sort, + customFilters, + q, + ...query + } = params || {}; + return searchListByAttributes(getFilter({ + q, + user, + query + }), + { + params: { + includeAttributes: true, + start: parseFloat(page - 1) * pageSize, + limit: pageSize + } + }) + .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}', + 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) + }; + }); + } + } +]; + + +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} + + + + + + + + ); +} + +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..52c59325d4 --- /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}} + + ); + } + 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..0dc4e82474 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/components/FilterAccordion.jsx @@ -0,0 +1,134 @@ +/* + * 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} + + + + + + +
+ ); +} + +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..68d0268fa4 --- /dev/null +++ b/web/client/plugins/ResourcesCatalog/components/Menu.jsx @@ -0,0 +1,95 @@ +/* + * 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, + ...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..86b8e78492 --- /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..83acce5e06 --- /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 }) => { + + 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 ( + + ); + } + + 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..4d8ecef0ef --- /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 && } + } + {editing ? + + + {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 ? + + + +
    + +
    +
    : 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..56ab8540ec --- /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) && } + {loading && } + + + {entries.map((entry, idx) => { + return ( +
  • + + {entry.permissions + ? + : + } + +
  • + ); + })} + {(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..86e2561b05 --- /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 + ? ( +