diff --git a/docma-config.json b/docma-config.json
index 9085a54d7e..8b000814ba 100644
--- a/docma-config.json
+++ b/docma-config.json
@@ -218,7 +218,7 @@
"web/client/plugins/WFSDownload.jsx",
"web/client/plugins/ZoomIn.jsx",
"web/client/plugins/ZoomOut.jsx"
- ]
+ ]
},
"./docs/**/*md",
{
diff --git a/package.json b/package.json
index 7fedde7b54..c1cb57b80b 100644
--- a/package.json
+++ b/package.json
@@ -141,6 +141,7 @@
"react-container-dimensions": "1.3.2",
"react-copy-to-clipboard": "5.0.0",
"react-data-grid": "2.0.59",
+ "react-data-grid-addons": "3.0.11",
"react-dnd": "2.4.0",
"react-dnd-html5-backend": "2.4.1",
"react-dock": "0.2.4",
diff --git a/web/client/actions/__tests__/rulesmanager-test.js b/web/client/actions/__tests__/rulesmanager-test.js
index c3e3b7c12c..3fba228be8 100644
--- a/web/client/actions/__tests__/rulesmanager-test.js
+++ b/web/client/actions/__tests__/rulesmanager-test.js
@@ -10,11 +10,44 @@ const expect = require('expect');
const { RULES_SELECTED, RULES_LOADED, UPDATE_ACTIVE_RULE,
ACTION_ERROR, OPTIONS_LOADED, UPDATE_FILTERS_VALUES,
rulesSelected, rulesLoaded, updateActiveRule,
- actionError, optionsLoaded, updateFiltersValues} = require('../rulesmanager');
+ actionError, optionsLoaded, updateFiltersValues,
+ SET_FILTER, setFilter,
+ SAVE_RULE, saveRule, cleanEditing, CLEAN_EDITING,
+ onEditRule, EDIT_RULE, delRules, DELETE_RULES} = require('../rulesmanager');
describe('test rules manager actions', () => {
-
- it('rules slected', () => {
+ it('save rule', () => {
+ const rule = {};
+ const action = saveRule(rule);
+ expect(action).toExist();
+ expect(action.type).toBe(SAVE_RULE);
+ expect(action.rule).toBe(rule);
+ });
+ it('clean editing', () => {
+ const action = cleanEditing();
+ expect(action).toExist();
+ expect(action.type).toBe(CLEAN_EDITING);
+ });
+ it('on edit rule', () => {
+ const action = onEditRule();
+ expect(action).toExist();
+ expect(action.type).toBe(EDIT_RULE);
+ expect(action.createNew).toBe(false);
+ expect(action.targetPriority).toBe(0);
+ });
+ it('delete rules', () => {
+ const action = delRules();
+ expect(action).toExist();
+ expect(action.type).toBe(DELETE_RULES);
+ });
+ it('set Filter', () => {
+ const action = setFilter("key", "value");
+ expect(action).toExist();
+ expect(action.type).toBe(SET_FILTER);
+ expect(action.key).toBe("key");
+ expect(action.value).toBe("value");
+ });
+ it('rules selected', () => {
const rules = [
{ id: "rules1" },
{ id: "rules2" }
diff --git a/web/client/actions/rulesmanager.js b/web/client/actions/rulesmanager.js
index 2c2e975675..e0b85bc043 100644
--- a/web/client/actions/rulesmanager.js
+++ b/web/client/actions/rulesmanager.js
@@ -18,13 +18,57 @@ const UPDATE_ACTIVE_RULE = 'UPDATE_ACTIVE_RULE';
const UPDATE_FILTERS_VALUES = 'UPDATE_FILTERS_VALUES';
const ACTION_ERROR = 'ACTION_ERROR';
const OPTIONS_LOADED = 'OPTIONS_LOADED';
+const LOADING = 'RULES_MANAGER:LOADING';
+const SET_FILTER = "RULES_MANAGER:SET_FILTER";
+const EDIT_RULE = "RULES_MANAGER:EDIT_RULE";
+const CLEAN_EDITING = "RULES_MANAGER:CLEAN_EDITING";
+const SAVE_RULE = "RULES_MANAGER:SAVE_RULE";
+const RULE_SAVED = "RULES_MANAGER:RULE_SAVED";
+const DELETE_RULES = "RULES_MANAGER: DELETE_RULES";
-function rulesSelected(rules, merge, unselect) {
+function delRules(ids) {
+ return {
+ type: DELETE_RULES,
+ ids
+ };
+}
+
+function setFilter(key, value) {
+ return {
+ type: SET_FILTER,
+ key,
+ value
+ };
+}
+
+function onEditRule(targetPriority = 0, createNew = false) {
+ return {
+ type: EDIT_RULE,
+ createNew,
+ targetPriority
+ };
+}
+
+function cleanEditing() {
+ return {
+ type: CLEAN_EDITING
+ };
+}
+
+function setLoading(loading) {
+ return {
+ type: LOADING,
+ loading
+ };
+}
+
+function rulesSelected(rules, merge, unselect, targetPosition) {
return {
type: RULES_SELECTED,
- rules: rules,
- merge: merge,
- unselect: unselect
+ rules,
+ merge,
+ unselect,
+ targetPosition
};
}
@@ -203,6 +247,8 @@ function updateRule() {
};
}
+const saveRule = (rule) => ({type: SAVE_RULE, rule});
+
module.exports = {
RULES_SELECTED,
RULES_LOADED,
@@ -225,5 +271,11 @@ module.exports = {
loadWorkspaces,
loadLayers,
actionError,
- optionsLoaded
+ optionsLoaded,
+ LOADING, setLoading,
+ SET_FILTER, setFilter,
+ EDIT_RULE, onEditRule,
+ CLEAN_EDITING, cleanEditing,
+ SAVE_RULE, saveRule, RULE_SAVED,
+ DELETE_RULES, delRules
};
diff --git a/web/client/api/geoserver/GeoFence.js b/web/client/api/geoserver/GeoFence.js
index b6e985ef83..6ea166969e 100644
--- a/web/client/api/geoserver/GeoFence.js
+++ b/web/client/api/geoserver/GeoFence.js
@@ -10,17 +10,27 @@ const axios = require('../../libs/ajax');
const assign = require('object-assign');
const ConfigUtils = require('../../utils/ConfigUtils');
-
+const EMPTY_RULE = {
+ constraints: {},
+ ipaddress: "",
+ layer: "",
+ request: "",
+ rolename: "",
+ service: "",
+ username: "",
+ workspace: ""
+ };
var Api = {
- loadRules: function(rulesPage, rulesFiltersValues) {
- const options = {
- 'params': {
- 'page': rulesPage - 1,
- 'entries': 10
- }
+ loadRules: function(page, rulesFiltersValues, entries = 10) {
+ const params = {
+ page,
+ entries,
+ ...this.assignFiltersValue(rulesFiltersValues)
};
- this.assignFiltersValue(rulesFiltersValues, options);
+ const options = {params, 'headers': {
+ 'Content': 'application/json'
+ }};
return axios.get('geofence/rest/rules', this.addBaseUrl(options))
.then(function(response) {
return response.data;
@@ -30,9 +40,8 @@ var Api = {
getRulesCount: function(rulesFiltersValues) {
const options = {
- 'params': {}
+ 'params': this.assignFiltersValue(rulesFiltersValues)
};
- this.assignFiltersValue(rulesFiltersValues, options);
return axios.get('geofence/rest/rules/count', this.addBaseUrl(options)).then(function(response) {
return response.data;
});
@@ -55,10 +64,15 @@ var Api = {
},
addRule: function(rule) {
- if (!rule.access) {
- rule.access = "ALLOW";
+ const newRule = {...rule};
+ if (!newRule.instance) {
+ const {id: instanceId} = ConfigUtils.getDefaults().geoFenceGeoServerInstance;
+ newRule.instance = {id: instanceId};
+ }
+ if (!newRule.grant) {
+ newRule.grant = "ALLOW";
}
- return axios.post('geofence/rest/rules', rule, this.addBaseUrl({
+ return axios.post('geofence/rest/rules', newRule, this.addBaseUrl({
'headers': {
'Content': 'application/json'
}
@@ -66,29 +80,34 @@ var Api = {
},
updateRule: function(rule) {
- return axios.post('geofence/rest/rules/id/' + rule.id, rule, this.addBaseUrl({
+ // id, priority and grant aren't updatable
+ const {id, priority, grant, position, ...others} = rule;
+ const newRule = {...EMPTY_RULE, ...others};
+ return axios.put(`geofence/rest/rules/id/${id}`, newRule, this.addBaseUrl({
'headers': {
'Content': 'application/json'
}
}));
},
- assignFiltersValue: function(rulesFiltersValues, options) {
- if (rulesFiltersValues) {
- assign(options.params, {"userName": this.normalizeFilterValue(rulesFiltersValues.userName)});
- assign(options.params, {"roleName": this.normalizeFilterValue(rulesFiltersValues.roleName)});
- assign(options.params, {"service": this.normalizeFilterValue(rulesFiltersValues.service)});
- assign(options.params, {"request": this.normalizeFilterValue(rulesFiltersValues.request)});
- assign(options.params, {"workspace": this.normalizeFilterValue(rulesFiltersValues.workspace)});
- assign(options.params, {"layer": this.normalizeFilterValue(rulesFiltersValues.layer)});
- }
- return options;
+ assignFiltersValue: function(rulesFiltersValues = {}) {
+ return Object.keys(rulesFiltersValues).map(key => ({key, normKey: this.normalizeKey(key)}))
+ .reduce((params, {key, normKey}) => ({...params, [normKey]: this.normalizeFilterValue(rulesFiltersValues[key])}), {});
},
normalizeFilterValue(value) {
return value === "*" ? undefined : value;
},
-
+ normalizeKey(key) {
+ switch (key) {
+ case 'username':
+ return 'userName';
+ case 'rolename':
+ return 'groupName';
+ default:
+ return key;
+ }
+ },
assignFilterValue: function(queryParameters, filterName, filterAny, filterValue) {
if (!filterValue) {
return;
@@ -99,29 +118,52 @@ var Api = {
assign(queryParameters, {[filterName]: filterValue});
}
},
-
- getGroups: function() {
- return axios.get('security/rest/roles', this.addBaseUrl({
+ getGroupsCount: function(filter = " ") {
+ const encodedFilter = encodeURIComponent(`%${filter}%`);
+ return axios.get(`geofence/rest/groups/count/${encodedFilter}`, this.addBaseUrl({
'headers': {
- 'Accept': 'application/json'
+ 'Accept': 'text/plain'
}
})).then(function(response) {
return response.data;
});
},
-
- getUsers: function() {
- return axios.get('security/rest/usergroup/users', this.addBaseUrl({
+ getGroups: function(filter, page, entries = 10) {
+ const params = {
+ page,
+ entries,
+ nameLike: `%${filter}%`
+ };
+ const options = {params};
+ return axios.get(`geofence/rest/groups`, this.addBaseUrl(options)).then(function(response) {
+ return response.data;
+ });
+ },
+ getUsersCount: function(filter = " ") {
+ const encodedFilter = encodeURIComponent(`%${filter}%`);
+ return axios.get(`geofence/rest/users/count/${encodedFilter}`, this.addBaseUrl({
'headers': {
- 'Accept': 'application/json'
+ 'Accept': 'text/plain'
}
})).then(function(response) {
return response.data;
});
},
+ getUsers: function(filter, page, entries = 10) {
+ const params = {
+ page,
+ entries,
+ nameLike: `%${filter}%`
+ };
+ const options = {params};
+ return axios.get(`geofence/rest/users`, this.addBaseUrl(options)).then(function(response) {
+ return response.data;
+ });
+ },
+
getWorkspaces: function() {
- return axios.get('rest/workspaces', this.addBaseUrl({
+ return axios.get('rest/workspaces', this.addBaseUrlGS({
'headers': {
'Accept': 'application/json'
}
@@ -134,8 +176,12 @@ var Api = {
return !value ? '*' : value;
},
- addBaseUrl: function(options) {
- return assign(options, {baseURL: ConfigUtils.getDefaults().geoServerUrl});
+ addBaseUrl: function(options = {}) {
+ return assign(options, {baseURL: ConfigUtils.getDefaults().geoFenceUrl});
+ },
+ addBaseUrlGS: function(options = {}) {
+ const {url: baseURL} = ConfigUtils.getDefaults().geoFenceGeoServerInstance || {};
+ return assign(options, {baseURL});
}
};
diff --git a/web/client/components/data/grid/DataGrid.jsx b/web/client/components/data/grid/DataGrid.jsx
index 0467709b5d..913923bd59 100644
--- a/web/client/components/data/grid/DataGrid.jsx
+++ b/web/client/components/data/grid/DataGrid.jsx
@@ -27,6 +27,7 @@ class DataGrid extends Grid {
componentWillUnmount() {
if (this.canvas) {
this.canvas.removeEventListener('scroll', this.scrollListener);
+ this.canvas = null;
}
if (this.props.displayFilters) {
this.onToggleFilter();
diff --git a/web/client/components/manager/rulesmanager/enhancers/__tests__/autoComplete-test.js b/web/client/components/manager/rulesmanager/enhancers/__tests__/autoComplete-test.js
new file mode 100644
index 0000000000..e9ec8551f1
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/enhancers/__tests__/autoComplete-test.js
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const React = require('react');
+const ReactDOM = require('react-dom');
+const { createSink, setObservableConfig} = require('recompose');
+const expect = require('expect');
+const autoComplete = require('../autoComplete');
+
+const rxjsConfig = require('recompose/rxjsObservableConfig').default;
+setObservableConfig(rxjsConfig);
+
+
+describe('autoComplete enhancer', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '
';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('it calls load function', (done) => {
+ const Sink = autoComplete(createSink( props => {
+ expect(props).toExist();
+ expect(props.onChange).toExist();
+ expect(props.onSelect).toExist();
+ expect(props.onToggle).toExist();
+ props.onChange("%");
+ }));
+ const loadData = (search, page, size, parentsFilter, count) => {
+ expect(search).toBe("%");
+ expect(page).toBe(0);
+ expect(size).toBe(5);
+ expect(parentsFilter.workspaces).toBe("cite");
+ expect(count).toBe(true);
+ done();
+ };
+ ReactDOM.render(, document.getElementById("container"));
+ });
+ it('it emits selected val', (done) => {
+ const Sink = autoComplete(createSink( props => {
+ expect(props).toExist();
+ expect(props.onValueSelected).toExist();
+ expect(props.onSelect).toExist();
+ props.onSelect("%");
+ }));
+ const onValueSelected = (val) => {
+ expect(val).toBe("%");
+ done();
+ };
+ ReactDOM.render(, document.getElementById("container"));
+ });
+ it('on toggle with empty val it cleans selected', (done) => {
+ const Sink = autoComplete(createSink( props => {
+ expect(props).toExist();
+ expect(props.selectedValue).toBe("%");
+ expect(props.onValueSelected).toExist();
+ expect(props.onSelect).toExist();
+ props.onChange("");
+ props.onToggle(false);
+ }));
+ const onValueSelected = (val) => {
+ expect(val).toNotExist();
+ done();
+ };
+ ReactDOM.render(, document.getElementById("container"));
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/enhancers/autoComplete.js b/web/client/components/manager/rulesmanager/enhancers/autoComplete.js
new file mode 100644
index 0000000000..845e23dc9a
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/enhancers/autoComplete.js
@@ -0,0 +1,159 @@
+const Rx = require("rxjs");
+const { compose, withStateHandlers, defaultProps, createEventHandler, renameProp} = require('recompose');
+const propsStreamFactory = require('../../../misc/enhancers/propsStreamFactory');
+const {isObject} = require("lodash");
+const stop = stream$ => stream$.filter(() => false);
+
+
+const sameParentFilter = ({parentsFilter: f1}, {parentsFilter: f2}) => f1 === f2;
+const sameFilter = ({val: f1}, {val: f2}) => f1 === f2;
+const sameEmptyRequest = ({emptyReq: f1}, {emptyReq: f2}) => f1 === f2;
+
+// ParentFilter change so resets the data
+const resetStream = prop$ => prop$.distinctUntilChanged((oP, nP) => sameParentFilter(oP, nP)).skip(1).do(({resetCombo}) => resetCombo()).let(stop);
+
+// Trigger first loading when value change
+const triggerEmptyLoadDataStream = prop$ => prop$.distinctUntilChanged((oP, nP) => sameEmptyRequest(oP, nP))
+ .skip(1)
+ .debounceTime(300)
+ .switchMap(({emptySearch, loadingErrorMsg, nextPage, prevPage, onError, parentsFilter = {}, size = 10, pagination, loadData, setData = () => {}}) => {
+ return loadData(emptySearch, 0, size, parentsFilter, true)
+ .do(({count, data}) => {
+ setData({
+ pagination: {...pagination, loadNextPage: nextPage, loadPrevPage: prevPage, firstPage: true, lastPage: Math.ceil(count / size) <= 1},
+ data,
+ page: 0,
+ count
+ });
+ }).let(stop).startWith({busy: true}).catch((e) => Rx.Observable.of(e).do(() => {
+ onError(loadingErrorMsg);
+ }).mapTo({busy: false})).concat(Rx.Observable.of({busy: false}));
+ });
+
+// Trigger first loading when value change
+const triggerLoadDataStream = prop$ => prop$.distinctUntilChanged((oP, nP) => sameFilter(oP, nP))
+ .debounceTime(300)
+ .filter(({val}) => val && val.length > 0)
+ .switchMap(({loadingErrorMsg, nextPage, prevPage, onError, parentsFilter = {}, val = "", size = 10, pagination, loadData, setData = () => {}}) => {
+ return loadData(val, 0, size, parentsFilter, true)
+ .do(({count, data}) => {
+ setData({
+ pagination: {...pagination, loadNextPage: nextPage, loadPrevPage: prevPage, firstPage: true, lastPage: Math.ceil(count / size) <= 1},
+ data,
+ page: 0,
+ count
+ });
+ }).let(stop).startWith({busy: true}).catch((e) => Rx.Observable.of(e).do(() => {
+ onError(loadingErrorMsg);
+ }).mapTo({busy: false})).concat(Rx.Observable.of({busy: false}));
+ });
+
+const loadPageStream = page$ => page$
+ .switchMap(({pageStep, page, parentsFilter, count, val, size,
+ pagination, setData, loadData, onError, loadingErrorMsg}) => {
+ const newPage = page + pageStep;
+ return loadData(val, newPage, size, parentsFilter)
+ .do(({data}) => {
+ setData({
+ pagination: {...pagination, firstPage: newPage === 0, lastPage: Math.ceil(count / size) <= newPage + 1},
+ data,
+ page: newPage
+ });
+ }).let(stop).startWith({busy: true}).catch((e) => Rx.Observable.of(e).do(() => {
+ onError(loadingErrorMsg);
+ }).mapTo({busy: false})).concat(Rx.Observable.of({busy: false}));
+ });
+const dataStreamFactory = prop$ => {
+ const {handler: nextPage, stream: nextPage$ } = createEventHandler();
+ const {handler: prevPage, stream: prevPage$ } = createEventHandler();
+ const page$ = Rx.Observable
+ .merge(nextPage$.mapTo(1), prevPage$.mapTo(-1))
+ .withLatestFrom(prop$.map(({onError, loadData, page, parentsFilter, count,
+ val, size, pagination, setData, loadingErrorMsg}) => ({
+ loadData, onError, page, parentsFilter, count, val, size, pagination,
+ setData, loadingErrorMsg})), (pageStep, other) => ({ pageStep, ...other}));
+
+ const $p = prop$.map(o => ({ ...o, nextPage, prevPage, nextPage$, prevPage$}));
+ return triggerLoadDataStream($p).merge(triggerEmptyLoadDataStream($p), loadPageStream(page$), resetStream($p)).startWith({busy: false});
+};
+
+
+/**
+ * Add remote loading to PaginatedCombo components. The data are managed in the enhanced state.
+ * Pass as a prop an onLoad function that returns a stream$. onLoad will be called with:
+ * search text, page (0 first page), size, count (if true should return the number of elements)
+ * The stream has to return and object with data (array of loaded elements) and [count] the number of
+ * total elements if required.
+ * Also pass onValueSelected and selected props. onValueSelected is called whe the users select a value
+ * from the downloaded data.
+ * @name autoComplete
+ * @memberof manager.rulesmanager.enhancers
+ * @param {PaginatedCombo} Component The PaginatedCombo to enhance with remote loading
+ * @return {HOC} An HOC that replaces the prop string with localized string.
+ */
+
+module.exports = compose(
+ defaultProps({
+ emptySearch: "%",
+ stopPropagation: true,
+ paginated: true,
+ size: 5,
+ loadData: () => Rx.Observable.of({data: [], count: 0}),
+ parentsFilter: {},
+ filter: false,
+ dataStreamFactory,
+ onValueSelected: () => {},
+ onError: () => {},
+ loadingErrorMsg: {
+ title: "",
+ message: ""
+ }
+ }),
+ withStateHandlers(({paginated, selected, initialData = []}) => ({
+ val: selected,
+ page: 0,
+ data: initialData,
+ emptyReq: 0,
+ pagination: {
+ paginated: paginated,
+ firstPage: false,
+ lastPage: false,
+ loadPrevPage: () => {},
+ loadNextPage: () => {}
+ }}), {
+ resetCombo: ({onValueSelected}, { initialData = []}) => () => {
+ return {
+ data: initialData,
+ page: 0,
+ val: undefined,
+ select: undefined
+ }; },
+ setData: ({count: oldCount}) => ({pagination, data, page = 0, count = oldCount} = {}) => ({
+ pagination,
+ data,
+ page,
+ count
+ }),
+ onChange: (state, {initialData = [], valueField}) => (val = "") => {
+ const currentVal = isObject(val) && val[valueField] || val;
+ return currentVal.length === 0 && {val: currentVal, data: initialData} || {val: currentVal};
+ },
+ onToggle: ({ val = "", data, emptyReq}, {selected, onValueSelected, initialData = []}) => (open) => {
+ if (!open && val === "" && selected) {
+ onValueSelected();
+ }else if (!open && val !== selected) {
+ return {val: selected, data: initialData};
+ }else if (open && val.length === 0 && data.length === 0) {
+ return {emptyReq: emptyReq + 1};
+ }
+ },
+ onSelect: (state, {onValueSelected, selected, valueField}) => (select) => {
+ const selectedVal = isObject(select) && select[valueField] || select;
+ if (selectedVal !== selected) {
+ onValueSelected(selectedVal);
+ }
+ }
+ }),
+ propsStreamFactory,
+ renameProp("val", "selectedValue")
+);
diff --git a/web/client/components/manager/rulesmanager/enhancers/fixedOptions.js b/web/client/components/manager/rulesmanager/enhancers/fixedOptions.js
new file mode 100644
index 0000000000..cf2f21492e
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/enhancers/fixedOptions.js
@@ -0,0 +1,65 @@
+const { compose, withStateHandlers, defaultProps, renameProp} = require('recompose');
+const propsStreamFactory = require('../../../misc/enhancers/propsStreamFactory');
+const {isObject} = require("lodash");
+const stop = stream$ => stream$.filter(() => false);
+
+const sameParentFilter = ({parentsFilter: f1}, {parentsFilter: f2}) => f1 === f2;
+
+// ParentsFilter change so resets the data
+const resetStream = prop$ => prop$.distinctUntilChanged((oP, nP) => sameParentFilter(oP, nP)).skip(1).do(({resetCombo}) => resetCombo()).let(stop);
+
+
+const dataStreamFactory = prop$ => {
+ return resetStream(prop$).startWith({busy: false});
+};
+
+module.exports = compose(
+ defaultProps({
+ stopPropagation: true,
+ emitOnReset: false,
+ paginated: false,
+ parentsFilter: {},
+ filter: "startsWith",
+ dataStreamFactory,
+ onValueSelected: () => {},
+ onError: () => {},
+ loadingErroMsg: "",
+ data: []
+ }),
+ withStateHandlers(({paginated, selected}) => ({
+ val: selected,
+ pagination: {
+ paginated: paginated,
+ firstPage: false,
+ lastPage: false,
+ loadPrevPage: () => {},
+ loadNextPage: () => {}
+ }}), {
+ resetCombo: (state, {emitOnReset, onValueSelected}) => () => {
+ if (emitOnReset) {
+ onValueSelected();
+ }
+ return {
+ val: undefined,
+ select: undefined
+ }; },
+ onChange: () => (val = "") => {
+ return {val};
+ },
+ onToggle: ({ val = ""}, {selected, onValueSelected}) => (open) => {
+ if (!open && val === "" && selected) {
+ onValueSelected();
+ }else if (!open && val !== selected) {
+ return {val: selected};
+ }
+ },
+ onSelect: (state, {onValueSelected, selected, valueField}) => (select) => {
+ const selectedVal = isObject(select) && select[valueField] || select;
+ if (selectedVal !== selected) {
+ onValueSelected(selectedVal);
+ }
+ }
+ }),
+ propsStreamFactory,
+ renameProp("val", "selectedValue")
+);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/EditMain.jsx b/web/client/components/manager/rulesmanager/ruleseditor/EditMain.jsx
new file mode 100644
index 0000000000..48529728db
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/EditMain.jsx
@@ -0,0 +1,21 @@
+const React = require('react');
+const {Grid} = require('react-bootstrap');
+const Selectors = require("./attributeselectors");
+
+module.exports = ({rule = {}, setOption= () => {}, active = true}) => {
+ return (
+
+ { !!rule.id && () }
+
+
+
+
+
+
+
+
+
+ );
+};
+
+
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/Header.jsx b/web/client/components/manager/rulesmanager/ruleseditor/Header.jsx
new file mode 100644
index 0000000000..7fc8fe0bb0
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/Header.jsx
@@ -0,0 +1,31 @@
+const React = require('react');
+const Toolbar = require('../../../misc/toolbar/Toolbar');
+const {NavItem, Nav} = require('react-bootstrap');
+const Message = require('../../../I18N/Message');
+
+module.exports = ({onNavChange = () => {}, onExit = () => {}, disableSave = true, onSave = () => {}, activeTab = "1", detailsActive = false}) => {
+ const buttons = [{
+ glyph: '1-close',
+ tooltipId: 'rulesmanager.tooltip.close',
+ onClick: onExit
+ },
+ {
+ glyph: 'floppy-disk',
+ tooltipId: 'rulesmanager.tooltip.save',
+ onClick: onSave,
+ disabled: disableSave
+ }];
+ return ();
+};
+
+
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/__tests__/EditMain-test.jsx b/web/client/components/manager/rulesmanager/ruleseditor/__tests__/EditMain-test.jsx
new file mode 100644
index 0000000000..650363a738
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/__tests__/EditMain-test.jsx
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const rxjsConfig = require('recompose/rxjsObservableConfig').default;
+
+const React = require('react');
+const ReactDOM = require('react-dom');
+const expect = require('expect');
+const Provider = require('react-redux').Provider;
+const Editor = require('../EditMain.jsx');
+
+const configureMockStore = require('redux-mock-store').default;
+const { setObservableConfig } = require('recompose');
+setObservableConfig(rxjsConfig);
+const mockStore = configureMockStore();
+
+const renderComp = (props, store) => {
+ return ReactDOM.render(
+
+
+ , document.getElementById("container"));
+};
+
+
+describe('Rules Editor Main Editor component', () => {
+ let store;
+ beforeEach((done) => {
+ store = mockStore();
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('render nothing if not active', () => {
+ store = mockStore({
+ rulesmanager: {}
+ });
+ renderComp({active: false}, store);
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-rule-editor');
+ expect(el).toExist();
+ expect(el.style.display).toBe("none");
+ });
+ it('render default when active', () => {
+ store = mockStore({
+ rulesmanager: {}
+ });
+ renderComp({active: true}, store);
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-rule-editor');
+ expect(el).toExist();
+ const rows = el.querySelectorAll('.row');
+ expect(rows).toExist();
+ expect(rows.length).toBe(8);
+ const disabledRows = el.querySelectorAll('.ms-disabled.row');
+ expect(disabledRows).toExist();
+ expect(disabledRows.length).toBe(2);
+ });
+ it('render priority selector', () => {
+ store = mockStore({
+ rulesmanager: {}
+ });
+ renderComp({active: true, rule: {id: 10}}, store);
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-rule-editor');
+ expect(el).toExist();
+ const rows = el.querySelectorAll('.row');
+ expect(rows).toExist();
+ expect(rows.length).toBe(9);
+ const disabledRows = el.querySelectorAll('.ms-disabled.row');
+ expect(disabledRows).toExist();
+ expect(disabledRows.length).toBe(2);
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/__tests__/Header-test.jsx b/web/client/components/manager/rulesmanager/ruleseditor/__tests__/Header-test.jsx
new file mode 100644
index 0000000000..d0e3969a49
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/__tests__/Header-test.jsx
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const React = require('react');
+const ReactDOM = require('react-dom');
+const expect = require('expect');
+const Header = require('../Header.jsx');
+describe('Rules Editor Header component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('render default buttons and navigation items', () => {
+ ReactDOM.render(, document.getElementById("container"));
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-panel-header-container');
+ expect(el).toExist();
+ const btns = el.querySelectorAll('button');
+ expect(btns).toExist();
+ expect(btns.length).toBe(2);
+ const navItems = el.querySelectorAll('.nav.nav-tabs li');
+ expect(navItems).toExist();
+ expect(navItems.length).toBe(4);
+ const navItemsDisabled = el.querySelectorAll('.nav.nav-tabs li.disabled');
+ expect(navItemsDisabled).toExist();
+ expect(navItemsDisabled.length).toBe(3);
+
+ });
+ it('render navigation items with details active', () => {
+ ReactDOM.render(, document.getElementById("container"));
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-panel-header-container');
+ expect(el).toExist();
+ const btns = el.querySelectorAll('button');
+ expect(btns).toExist();
+ expect(btns.length).toBe(2);
+ const navItems = el.querySelectorAll('.nav.nav-tabs li');
+ expect(navItems).toExist();
+ expect(navItems.length).toBe(4);
+ const navItemsDisabled = el.querySelectorAll('.nav.nav-tabs li.disabled');
+ expect(navItemsDisabled).toExist();
+ expect(navItemsDisabled.length).toBe(0);
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Access.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Access.jsx
new file mode 100644
index 0000000000..e0f0875ab5
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Access.jsx
@@ -0,0 +1,47 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const {Row, Col} = require('react-bootstrap');
+const fixedOptions = require("../../enhancers/fixedOptions");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+const Message = require('../../../../I18N/Message');
+
+const AccessSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ defaultProps({
+ size: 5,
+ textField: "label",
+ valueField: "value",
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.access",
+ data: [
+ {value: "ALLOW", label: "ALLOW"},
+ {value: "DENY", label: "DENY"}
+ ]
+ }),
+ withPropsOnChange(["services"], ({services, data}) => ({data: services || data})),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "grant", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ fixedOptions
+)(AccessSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/IpAddress.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/IpAddress.jsx
new file mode 100644
index 0000000000..a54b806faa
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/IpAddress.jsx
@@ -0,0 +1,63 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const PropTypes = require("prop-types");
+const {FormControl, FormGroup, Row, Col} = require("react-bootstrap");
+const Message = require('../../../../I18N/Message');
+const {checkIp} = require('../../../../../utils/RulesGridUtils');
+const withLocalized = require("../../../../misc/enhancers/localizedProps");
+const {compose, defaultProps} = require("recompose");
+
+class IpAddress extends React.Component {
+ static propTypes = {
+ selected: PropTypes.string,
+ setOption: PropTypes.func,
+ placeholder: PropTypes.string,
+ disabled: PropTypes.bool
+ }
+ static defaultProps = {
+ setOption: () => {},
+ disabled: false
+ }
+ getValidationState() {
+ if (this.props.selected && this.props.selected.length > 0) {
+ return this.props.selected.match(checkIp) && "success" || "error";
+ }
+ return null;
+ }
+ render() {
+ const {disabled, selected, placeholder} = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ handleChange = (e) => {
+ this.props.setOption({key: "ipaddress", value: e.target.value});
+ }
+}
+
+module.exports = compose(
+ defaultProps({
+ placeholder: "rulesmanager.placeholders.ip"
+ }),
+ withLocalized(["placeholder"]))(IpAddress);
+
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Layer.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Layer.jsx
new file mode 100644
index 0000000000..45a6459ba0
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Layer.jsx
@@ -0,0 +1,58 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const {Row, Col} = require('react-bootstrap');
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+const {error} = require('../../../../../actions/notifications');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {loadLayers} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const Message = require('../../../../I18N/Message');
+
+const LayerSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(() => ({}), {onError: error}),
+ defaultProps({
+ paginated: false,
+ emitOnReset: true,
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: loadLayers,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.layer",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingLayers"
+ }
+ }),
+ withPropsOnChange(["workspace"], ({workspace}) => {
+ return {
+ parentsFilter: {workspace},
+ disabled: !workspace
+ }; }),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "layer", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ autoComplete
+)(LayerSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Priority.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Priority.jsx
new file mode 100644
index 0000000000..ac2a6052f3
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Priority.jsx
@@ -0,0 +1,64 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const PropTypes = require("prop-types");
+const {FormControl, FormGroup, Row, Col} = require("react-bootstrap");
+const Message = require('../../../../I18N/Message');
+const {toNumber, isNumber} = require("lodash");
+const withLocalized = require("../../../../misc/enhancers/localizedProps");
+const {compose, defaultProps} = require("recompose");
+
+class Priority extends React.Component {
+ static propTypes = {
+ selected: PropTypes.string,
+ setOption: PropTypes.func,
+ placeholder: PropTypes.string,
+ disabled: PropTypes.bool
+ }
+ static defaultProps = {
+ setOption: () => {},
+ disabled: false
+ }
+ getValidationState() {
+ if (this.props.selected && this.props.selected.length > 0) {
+ return isNumber(toNumber(this.props.selected)) && "success" || "error";
+ }
+ return null;
+ }
+ render() {
+ const {disabled, selected, placeholder} = this.props;
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ handleChange = (e) => {
+ this.props.setOption({key: "priority", value: e.target.value});
+ }
+}
+
+module.exports = compose(
+ defaultProps({
+ placeholder: "rulesmanager.placeholders.priority"
+ }),
+ withLocalized(["placeholder"]))(Priority);
+
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Request.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Request.jsx
new file mode 100644
index 0000000000..bd3fed49f8
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Request.jsx
@@ -0,0 +1,74 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const {Row, Col} = require('react-bootstrap');
+const fixedOptions = require("../../enhancers/fixedOptions");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+const Message = require('../../../../I18N/Message');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {servicesConfigSel} = require("../../../../../selectors/rulesmanager");
+const selector = createSelector(servicesConfigSel, services => ({
+ services
+}));
+
+const RequestSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(selector),
+ defaultProps({
+ size: 5,
+ emitOnReset: true,
+ textField: "label",
+ valueField: "value",
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.request",
+ services: {
+ "WFS": [
+ "DescribeFeatureType",
+ "GetCapabilities",
+ "GetFeature",
+ "GetFeatureWithLock",
+ "LockFeature",
+ "Transaction"
+ ],
+ "WMS": [
+ "DescribeLayer",
+ "GetCapabilities",
+ "GetFeatureInfo",
+ "GetLegendGraphic",
+ "GetMap",
+ "GetStyles"
+ ]
+ }
+ }),
+ withPropsOnChange(["service", "services"], ({services = {}, service}) => {
+ return {
+ data: service && (services[service] || []).map(req => ({label: req, value: req.toUpperCase()})),
+ parentsFilter: {service},
+ disabled: !service
+ }; }),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "request", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ fixedOptions
+)(RequestSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Role.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Role.jsx
new file mode 100644
index 0000000000..1d8ce3deb3
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Role.jsx
@@ -0,0 +1,52 @@
+/**
+* Copyright 2018, 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.
+*/
+
+const React = require("react");
+const {Row, Col} = require('react-bootstrap');
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getRoles} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const {error} = require('../../../../../actions/notifications');
+const Message = require('../../../../I18N/Message');
+
+const RoleSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(() => ({}), {onError: error}),
+ defaultProps({
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: getRoles,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.role",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingRoles"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "rolename", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder", "loadingErroMsg"]),
+ autoComplete
+)(RoleSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Service.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Service.jsx
new file mode 100644
index 0000000000..7b3dc851db
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Service.jsx
@@ -0,0 +1,56 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const {Row, Col} = require('react-bootstrap');
+const fixedOptions = require("../../enhancers/fixedOptions");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+const Message = require('../../../../I18N/Message');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {servicesSelector} = require("../../../../../selectors/rulesmanager");
+
+const selector = createSelector(servicesSelector, ( services) => ({
+ services
+}));
+
+const WorkspaceSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(selector),
+ defaultProps({
+ size: 5,
+ textField: "label",
+ valueField: "value",
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.service",
+ data: [
+ {value: "WMS", label: "WMS"},
+ {value: "WFS", label: "WFS"},
+ {value: "WCS", label: "WCS"}
+ ]
+ }),
+ withPropsOnChange(["services"], ({services, data}) => ({data: services || data})),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "service", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ fixedOptions
+)(WorkspaceSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/User.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/User.jsx
new file mode 100644
index 0000000000..47e0cfa532
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/User.jsx
@@ -0,0 +1,52 @@
+/**
+* Copyright 2018, 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.
+*/
+
+const React = require("react");
+const {Row, Col} = require('react-bootstrap');
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getUsers} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const {error} = require('../../../../../actions/notifications');
+const Message = require('../../../../I18N/Message');
+
+const UserSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(() => ({}), {onError: error}),
+ defaultProps({
+ size: 5,
+ textField: "userName",
+ valueField: "userName",
+ loadData: getUsers,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.user",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingRoles"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "username", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder", "loadingErroMsg"]),
+ autoComplete
+)(UserSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Workspace.jsx b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Workspace.jsx
new file mode 100644
index 0000000000..e1f6aae423
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/Workspace.jsx
@@ -0,0 +1,52 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require("react");
+const {Row, Col} = require('react-bootstrap');
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const {error} = require('../../../../../actions/notifications');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getWorkspaces} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const Message = require('../../../../I18N/Message');
+
+const WorkspaceSelector = (props) => (
+
+
+
+
+
+
+
+
);
+
+module.exports = compose(
+ connect(() => ({}), {onError: error}),
+ defaultProps({
+ paginated: false,
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: getWorkspaces,
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.workspace",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingWorkspaces"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({setOption = () => {}}) => filterTerm => {
+ setOption({key: "workspace", value: filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ autoComplete
+)(WorkspaceSelector);
diff --git a/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/index.js b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/index.js
new file mode 100644
index 0000000000..4972b2f764
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/ruleseditor/attributeselectors/index.js
@@ -0,0 +1,12 @@
+module.exports = {
+ Role: require("./Role"),
+ User: require("./User"),
+ Service: require("./Service"),
+ Request: require("./Request"),
+ Workspace: require("./Workspace"),
+ Layer: require("./Layer"),
+ Access: require("./Access"),
+ Ip: require("./IpAddress"),
+ Priority: require("./Priority")
+};
+
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/RulesGrid.jsx b/web/client/components/manager/rulesmanager/rulesgrid/RulesGrid.jsx
new file mode 100644
index 0000000000..04ccd9fd64
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/RulesGrid.jsx
@@ -0,0 +1,123 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require('react');
+const PropTypes = require('prop-types');
+const RuleRenderer = require('./renderers/RuleRenderer');
+
+const { Draggable} = require('react-data-grid-addons');
+
+const DataGrid = require('../../../data/grid/DataGrid');
+const { Container: DraggableContainer, DropTargetRowContainer: dropTargetRowContainer } = Draggable;
+const PriorityActionCell = require("./renderers/PriorityActionCell");
+const RowRenderer = dropTargetRowContainer(RuleRenderer);
+
+class RulesGrid extends React.Component {
+ static propTypes = {
+ rowKey: PropTypes.string.isRequired,
+ width: PropTypes.number,
+ height: PropTypes.number,
+ rows: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
+ rowsCount: PropTypes.number,
+ columns: PropTypes.array,
+ onSelect: PropTypes.func,
+ selectedIds: PropTypes.array,
+ rowGetter: PropTypes.func,
+ onGridScroll: PropTypes.func,
+ onAddFilter: PropTypes.func,
+ onReorderRows: PropTypes.func,
+ isEditing: PropTypes.bool
+ };
+ static contextTypes = {
+ messages: PropTypes.object
+ };
+
+ static defaultProps = {
+ rowKey: 'id',
+ rows: [],
+ onSort: () => {},
+ onSelect: () => {},
+ selectedIds: [],
+ columns: [],
+ isEditing: false
+ };
+
+ componentDidMount() {
+ if (this.props.rowsCount > 0) {
+ this.grid.scrollListener();
+ }
+ }
+ componentDidUpdate = ({rowsCount, isEditing}) => {
+ if (this.props.rowsCount > 0 && rowsCount !== this.props.rowsCount || (isEditing && !this.props.isEditing)) {
+ this.grid.scrollListener();
+ }
+ }
+ componentWillUnmount() {
+ this.grid = null;
+ }
+ onRowsSelected = (rows) => {
+ const selectedRules = this._getSelectedRow(this.props.rowKey, this.props.selectedIds, this.props.rows).concat(rows.map(({row}) => row).filter(({id}) => id !== "empty_row"));
+ if (selectedRules.length > 0) {
+ this.props.onSelect(selectedRules);
+ }
+ }
+ onRowsDeselected = (rows) => {
+ let rowIds = rows.map(r => r.row[this.props.rowKey]);
+ const selectedIds = this.props.selectedIds.filter(i => rowIds.indexOf(i) === -1);
+ this.props.onSelect(this._getSelectedRow(this.props.rowKey, selectedIds, this.props.rows));
+ }
+ render() {
+ return (
+
+ { this.grid = grid; }}
+ enableCellSelection={false}
+ rowActionsCell={PriorityActionCell}
+ columns={this.props.columns}
+ rowGetter={this.props.rowGetter}
+ rowsCount={this.props.rowsCount}
+ rows={this.props.rows}
+ minHeight={this.props.height}
+ minWidth={this.props.width}
+ rowRenderer={}
+ virtualScroll
+ onGridScroll={this.props.onGridScroll}
+ onAddFilter={this.props.onAddFilter}
+ rowSelection={{
+ showCheckbox: true,
+ enableShiftSelect: true,
+ onRowsSelected: this.onRowsSelected,
+ onRowsDeselected: this.onRowsDeselected,
+ selectBy: {
+ keys: {rowKey: this.props.rowKey, values: this.props.selectedIds}
+ }}}/>
+ );
+ }
+
+ isDraggedRowSelected = (selectedRows, rowDragSource) => {
+ if (selectedRows && selectedRows.length > 0) {
+ let key = this.props.rowKey;
+ return selectedRows.filter(r => r[key] === rowDragSource.data[key]).length > 0;
+ }
+ return false;
+ };
+ _getSelectedRow = ( rowKey = [], selectedKeys = [], rows = {}) => Object.keys(rows).reduce((sel, key) => sel.concat(rows[key].filter(row => selectedKeys.indexOf(row[rowKey]) !== -1)), [])
+
+ reorderRows = (e) => {
+ if (e.rowSource.data[this.props.rowKey] === "empty_row" || e.rowTarget.data[this.props.rowKey] === "empty_row") {
+ return;
+ }else if (e.rowSource.idx === e.rowTarget.idx) {
+ return;
+ }
+ let selectedRows = this._getSelectedRow(this.props.rowKey, this.props.selectedIds, this.props.rows);
+ let draggedRows = this.isDraggedRowSelected(selectedRows, e.rowSource) ? selectedRows : [e.rowSource.data];
+ this.props.onReorderRows({rules: draggedRows, targetPriority: e.rowTarget.data.priority});
+ };
+}
+
+module.exports = RulesGrid;
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/__tests__/RuleGrid-test.jsx b/web/client/components/manager/rulesmanager/rulesgrid/__tests__/RuleGrid-test.jsx
new file mode 100644
index 0000000000..a64585b403
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/__tests__/RuleGrid-test.jsx
@@ -0,0 +1,32 @@
+/**
+ * Copyright 2018, 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.
+ */
+const React = require('react');
+const ReactDOM = require('react-dom');
+
+const expect = require('expect');
+const RulesGrid = require('../RulesGrid');
+
+
+describe('Test RulesGrid component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('render with defaults', () => {
+ ReactDOM.render( {}} rowsCount={0} width={100} height={100}/>, document.getElementById("container"));
+ const container = document.getElementById('container');
+ expect(container).toExist();
+ const grid = container.querySelector(".react-grid-Container");
+ expect(grid).toExist();
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/filtersStream-test.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/filtersStream-test.js
new file mode 100644
index 0000000000..a31db5b333
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/filtersStream-test.js
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const expect = require('expect');
+const filtersStream = require('../filtersStream');
+const Rx = require("rxjs");
+describe('rulegrid filterStream', () => {
+ it('debounce addFilter$', (done) => {
+ const setFilters = (filter) => {
+ expect(filter).toExist();
+ expect(filter).toBe("WFS");
+ done();
+ };
+ const addFilter$ = Rx.Observable.from(["WF", "WFS"]);
+ const prop$ = Rx.Observable.of({setFilters, addFilter$});
+ filtersStream(prop$).subscribe(() => {});
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/scrollStream-test.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/scrollStream-test.js
new file mode 100644
index 0000000000..5973f29a54
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/scrollStream-test.js
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const expect = require('expect');
+const scrollStream = require('../scrollStream');
+const Rx = require("rxjs");
+describe('rulegrid scrollStream', () => {
+ it('generate pages request', (done) => {
+ const moreRules = (pagesRequest) => {
+ expect(pagesRequest).toExist();
+ expect(pagesRequest.pagesToLoad).toExist();
+ expect(pagesRequest.pagesToLoad).toEqual([0, 1]);
+ expect(pagesRequest.startPage).toBe(0);
+ expect(pagesRequest.endPage).toBe(1);
+ expect(pagesRequest.pages).toEqual({});
+ done();
+ };
+ const onGridScroll$ = Rx.Observable.of({ firstRowIdx: 0, lastRowIdx: 10});
+ const prop$ = Rx.Observable.of({ size: 10, moreRules, pages: {}, rowsCount: 50, vsOverScan: 5, scrollDebounce: 50, onGridScroll$});
+ scrollStream(prop$).subscribe(() => {});
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/triggerFetch-test.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/triggerFetch-test.js
new file mode 100644
index 0000000000..3d86502456
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/triggerFetch-test.js
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const expect = require('expect');
+const triggerFetch = require('../triggerFetch');
+const Rx = require("rxjs");
+const axios = require('../../../../../../libs/ajax');
+const triggerInterceptors = (config) => {
+ if (config.url.indexOf("geofence/rest/rules/count") !== -1) {
+ config.url = "base/web/client/test-resources/geofence/rest/rules/count";
+ }
+ return config;
+};
+
+describe('rulegrid triggerFetch', () => {
+ it('get count', (done) => {
+ const inter = axios.interceptors.request.use(triggerInterceptors);
+ const onLoad = ({pages, rowsCount}) => {
+ expect(pages).toEqual({});
+ expect(rowsCount).toBe(10);
+ done();
+ };
+ const onLoadError = () => {};
+ const prop$ = Rx.Observable.of({version: 0, filters: {}, setLoading: () => {}, onLoad, onLoadError});
+ triggerFetch(prop$).subscribe({
+ next: () => {},
+ error: () => {},
+ complete: () => {
+ axios.interceptors.request.eject(inter);
+ }
+ });
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/virtualScrollFetch-test.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/virtualScrollFetch-test.js
new file mode 100644
index 0000000000..dfb07d348a
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/__tests__/virtualScrollFetch-test.js
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const expect = require('expect');
+const virtualScrollFetch = require('../virtualScrollFetch');
+const Rx = require("rxjs");
+
+const axios = require('../../../../../../libs/ajax');
+const rulesInterceptor = (config) => {
+ if (config.url.indexOf("geofence/rest/rules") !== -1) {
+ config.url = "base/web/client/test-resources/geofence/rest/rules/rules.xml";
+ }
+ return config;
+};
+
+describe('rulegrid virtulaScrollFetch', () => {
+ it('generate pages request', (done) => {
+ const inter = axios.interceptors.request.use(rulesInterceptor);
+ const onLoad = ({pages}) => {
+ expect(pages).toExist();
+ expect(pages[0]).toExist();
+ expect(pages[0].length).toBe(5);
+ done();
+ };
+ const onLoadError = () => {};
+ const pages$ = Rx.Observable.of({ pagesToLoad: [0], startPage: 0, endPage: 0, pages: {}});
+ const prop$ = Rx.Observable.of({size: 5,
+ maxStoredPages: 5,
+ filters: {},
+ onLoad,
+ moreRules: () => {},
+ setLoading: () => {},
+ onLoadError
+ });
+ virtualScrollFetch(pages$)(prop$).subscribe({
+ next: () => {},
+ error: () => {},
+ complete: () => {
+ axios.interceptors.request.eject(inter);
+ }
+ });
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/filtersStream.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/filtersStream.js
new file mode 100644
index 0000000000..5ea7af423e
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/filtersStream.js
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+/**
+ * Function that debaounce filters change
+ * @param {Observable} Stream of props.
+ * @return {Observable} Stream of props to trigger the data fetch
+ */
+module.exports = (props$) => props$
+ .distinctUntilChanged(({setFilters}, newProps) => setFilters === newProps.setFilters)
+ .switchMap(({setFilters, addFilter$}) => addFilter$
+ .debounceTime(500)
+ .do((f) => setFilters(f))
+ );
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/reorderRules.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/reorderRules.js
new file mode 100644
index 0000000000..757febeb40
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/reorderRules.js
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2018, 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.
+ */
+const Rx = require("rxjs");
+const samePages = ({pages: oP}, {pages: nP}) => oP === nP;
+const { moveRules } = require('../../../../../observables/rulesmanager');
+
+/**
+ * Function that converts stream of a reorder rules into action that updates geofence
+ * @param {Observable} Stream of props.
+ * @return {Observable} Stream of props to trigger the data fetch
+ */
+module.exports = () => (prop$) =>
+ prop$.distinctUntilChanged((oP, nP) => samePages(oP, nP))
+ .switchMap(({ orderRule$, setLoading, setData, onLoadError, pages}) =>
+ orderRule$
+ .switchMap(({rules, targetPriority}) => {
+ setData({pages, editing: true});
+ setLoading(true);
+ return moveRules(targetPriority, rules)
+ .catch((e) => Rx.Observable.of({
+ error: e
+ }).do(() => onLoadError({
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorMovingRules"
+ })).do(() => setLoading(false)))
+ .do(() => setData({pages: []}));
+ }
+));
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js
new file mode 100644
index 0000000000..129ca9dcc7
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid.js
@@ -0,0 +1,110 @@
+const React = require("react");
+const { compose, withPropsOnChange, withHandlers, withStateHandlers, defaultProps, createEventHandler } = require('recompose');
+const propsStreamFactory = require('../../../../misc/enhancers/propsStreamFactory');
+const triggerFetch = require("./triggerFetch");
+const virtualScrollFetch = require("./virtualScrollFetch");
+const reorderRules = require("./reorderRules");
+const scrollStream = require("./scrollStream");
+const filtersStream = require("./filtersStream");
+const FilterRenderers = require("../filterRenderers");
+const Message = require('../../../../I18N/Message');
+const AccessFormatter = require('../formatters/AccessFormatter');
+const {getRow, flattenPages, getOffsetFromTop} = require('../../../../../utils/RulesGridUtils');
+
+const emitStop = stream$ => stream$.filter(() => false).startWith({});
+const triggerLoadStream = prop$ => prop$.distinctUntilChanged(({triggerLoad}, nP) => triggerLoad === nP.triggerLoad)
+ .skip(1)
+ .do(({incrementVersion}) => incrementVersion());
+
+const dataStreamFactory = $props => {
+ const {handler: onGridScroll, stream: onGridScroll$ } = createEventHandler();
+ const {handler: moreRules, stream: page$ } = createEventHandler();
+ const {handler: onAddFilter, stream: addFilter$ } = createEventHandler();
+ const {handler: onReorderRows, stream: orderRule$ } = createEventHandler();
+ const $p = $props.map(o => ({ ...o, onGridScroll$, addFilter$, orderRule$, moreRules}));
+ return triggerFetch($p).let(emitStop)
+ .combineLatest([
+ virtualScrollFetch(page$)($p).let(emitStop),
+ reorderRules(page$)($p).let(emitStop),
+ filtersStream($p).let(emitStop),
+ scrollStream($p).let(emitStop),
+ triggerLoadStream($props).let(emitStop)
+ ])
+ .mapTo({onGridScroll,
+ onAddFilter,
+ onReorderRows});
+
+};
+
+module.exports = compose(
+ defaultProps({
+ sortable: false,
+ size: 20,
+ onSelect: () => {},
+ onLoadError: () => {},
+ setLoading: () => {},
+ dataStreamFactory,
+ virtualScroll: true,
+ setFilters: () => {},
+ columns: [
+ { key: 'rolename', name: , filterable: true, filterRenderer: FilterRenderers.RolesFilter},
+ { key: 'username', name: , filterable: true, filterRenderer: FilterRenderers.UsersFilter},
+ { key: 'ipaddress', name: , filterable: false},
+ { key: 'service', name: , filterable: true, filterRenderer: FilterRenderers.ServicesFilter},
+ { key: 'request', name: , filterable: true, filterRenderer: FilterRenderers.RequestsFilter },
+ { key: 'workspace', name: , filterable: true, filterRenderer: FilterRenderers.WorkspacesFilter},
+ { key: 'layer', name: , filterable: true, filterRenderer: FilterRenderers.LayersFilter},
+ { key: 'grant', name: , formatter: AccessFormatter, filterable: false }
+ ]
+ }),
+ withStateHandlers({
+ pages: {},
+ rowsCount: 0,
+ version: 0,
+ isEditing: false
+ }, {
+ setData: ({rowsCount: oldRowsCount}) => ({pages, rowsCount = oldRowsCount, editing = false} = {}) => ({
+ pages,
+ rowsCount,
+ error: undefined,
+ isEditing: editing
+ }),
+ setFilters: (state, {filters = {}, setFilters}) => ({column, filterTerm}) => {
+ // Can add some logic here to clean related filters
+ if (column.key === "workspace" && filters.layer) {
+ setFilters("layer");
+ }else if (column.key === "service" && filters.request) {
+ setFilters("request");
+ }
+ setFilters(column.key, filterTerm);
+ return {rowsCount: 0, pages: {}};
+ },
+ incrementVersion: ({ version }) => () => ({
+ version: version + 1,
+ isEditing: true
+ })
+ }),
+ withHandlers({
+ onLoad: ({ setData = () => {}, onLoad = () => {}} = {}) => (...args) => {
+ setData(...args);
+ onLoad(...args);
+ },
+ onSelect: ({onSelect: select, pages, rowsCount}) => (selected) => {
+ if ( selected.length === 1) {
+ const offsetFromTop = getOffsetFromTop(selected[0], flattenPages(pages));
+ select(selected, false, false, {offsetFromTop, rowsCount});
+ }else {
+ select(selected);
+ }
+ }
+ }),
+ withPropsOnChange(
+ ["enableColumnFilters", "pages"],
+ props => ({
+ displayFilters: props.enableColumnFilters,
+ rows: props.pages
+ })
+ ),
+ withHandlers({ rowGetter: props => i => getRow(i, props.rows, props.size) }),
+ propsStreamFactory
+);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/scrollStream.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/scrollStream.js
new file mode 100644
index 0000000000..975f69ef60
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/scrollStream.js
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+/**
+ * Function that converts a stream of a scrollEvent into page requestes
+ * @param {Observable} Stream of props.
+ * @return {Observable} Stream of props to trigger the data fetch
+ */
+
+const {getPagesToLoad} = require('../../../../../utils/RulesGridUtils');
+
+const sameRowsCount = ({rowsCount: oR}, {rowsCount: nR}) => oR === nR;
+const samePages = ({pages: oP}, {pages: nP}) => oP === nP;
+
+module.exports = ($props) => {
+ return $props.distinctUntilChanged((oP, nP) => sameRowsCount(oP, nP) && samePages(oP, nP))
+ .switchMap(({ size, moreRules, pages, rowsCount, vsOverScan = 5, scrollDebounce = 50, onGridScroll$ }) => {
+ return onGridScroll$.filter(() => rowsCount !== 0)
+ .debounceTime(scrollDebounce)
+ .map(({ firstRowIdx, lastRowIdx }) => {
+ const fr = firstRowIdx - vsOverScan < 0 ? 0 : firstRowIdx - vsOverScan;
+ const lr = lastRowIdx + vsOverScan > rowsCount - 1 ? rowsCount - 1 : lastRowIdx + vsOverScan;
+ const startPage = Math.floor(fr / size);
+ const endPage = Math.floor(lr / size);
+ return {pagesToLoad: getPagesToLoad(startPage, endPage, pages), startPage, endPage, pages};
+ })
+ .filter(({pagesToLoad}) => pagesToLoad.length > 0)
+ .do((p) => moreRules(p));
+ });
+};
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/triggerFetch.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/triggerFetch.js
new file mode 100644
index 0000000000..0c5e1df1c9
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/triggerFetch.js
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const Rx = require('rxjs');
+
+const sameFilter = (f1, {filters: f2}) => f1 === f2;
+const sameVersion = (f1, {version: f2}) => f1 === f2;
+const { getCount } = require('../../../../../observables/rulesmanager');
+/**
+ * Function that converts stream of a RulesGrid props to trigger data fetch events, It gets the rules count
+ * @param {Observable} Stream of props.
+ * @return {Observable} Stream of props to trigger the data fetch
+ */
+module.exports = ($props) => {
+ return $props.distinctUntilChanged(
+ ({filters, version}, newProps) => sameVersion(version, newProps) && sameFilter(filters, newProps))
+ .switchMap(({filters, setLoading, onLoad, onLoadError = () => { }}) => {
+ setLoading(true);
+ return getCount(filters)
+ .do(() => setLoading(false))
+ .do((rowsCount) => onLoad({pages: {}, rowsCount}))
+ .catch((e) => Rx.Observable.of({
+ error: e
+ }).do(() => onLoadError({
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingRules"
+ })).do(() => setLoading(false)));
+ }
+ );
+};
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/enhancers/virtualScrollFetch.js b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/virtualScrollFetch.js
new file mode 100644
index 0000000000..bad7940295
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/enhancers/virtualScrollFetch.js
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const Rx = require('rxjs');
+const { loadRules } = require('../../../../../observables/rulesmanager');
+const sameFilter = ({filters: f1}, {filters: f2}) => f1 === f2;
+const {updatePages, getPagesToLoad} = require('../../../../../utils/RulesGridUtils');
+/**
+ * Create an operator that responds to page$ request stream.
+ * While loading the pages request are stored and the last request is emitted on load end
+ * @param {Observable} page$ the stream of virtual scroll pages requests
+ * @returns a function that can be merged with stream of
+ * props to retrieve data using virtual scroll. This stearm doesn't emit
+ */
+module.exports = page$ => props$ => props$.distinctUntilChanged((oProps, nProps) => sameFilter(oProps, nProps))
+ .switchMap(({size = 5, maxStoredPages = 5, filters = {},
+ onLoad = () => { }, moreRules, setLoading, onLoadError = () => { }
+ }) => page$.delay(1).exhaustMap((pagesRequest) => {
+ // First request
+ setLoading(true);
+ return loadRules(pagesRequest.pagesToLoad, filters, size)
+ .do(newPages => onLoad({
+ ...updatePages(newPages.pages, pagesRequest, maxStoredPages)
+ }))
+ .do(() => setLoading(false))
+ .catch((e) => Rx.Observable.of({
+ error: e
+ }).do(() => onLoadError({
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingRules"
+ })).do(() => setLoading(false))) // Store pages requests and emit on first request end
+ .withLatestFrom(page$, ({pages: nPages}, lastRequest) => ({
+ lastRequest,
+ nPages
+ }))
+ .filter(({error}) => !error)
+ .map(({lastRequest, nPages}) => {
+ // Prepare the new request merging last loaded pages and last pages request
+ const {pages: tPages} = updatePages(nPages, pagesRequest, maxStoredPages);
+ const pagesToLoad = getPagesToLoad(lastRequest.startPage, lastRequest.endPage, tPages);
+ return {...lastRequest,
+ pages: tPages,
+ pagesToLoad};
+ })
+ .filter(({pagesToLoad}) => pagesToLoad.length > 0)
+ .do((newRequest) => moreRules(newRequest));
+ })
+);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/LayersFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/LayersFilter.jsx
new file mode 100644
index 0000000000..a5beabcfb2
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/LayersFilter.jsx
@@ -0,0 +1,49 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const {loadLayers} = require('../../../../../observables/rulesmanager');
+const {error} = require('../../../../../actions/notifications');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {filterSelector} = require("../../../../../selectors/rulesmanager");
+const workspaceSelector = createSelector(filterSelector, (filter) => filter.workspace);
+const parentFiltersSel = createSelector(workspaceSelector, (workspace) => ({
+ workspace
+}));
+const selector = createSelector([filterSelector, parentFiltersSel], (filter, parentsFilter) => ({
+ selected: filter.layer,
+ disabled: !filter.workspace,
+ parentsFilter
+}));
+
+module.exports = compose(
+ connect(selector, {onError: error}),
+ defaultProps({
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: loadLayers,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.filter",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingLayers"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ autoComplete
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RequestsFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RequestsFilter.jsx
new file mode 100644
index 0000000000..e8bc4fbe4d
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RequestsFilter.jsx
@@ -0,0 +1,68 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const fixedOptions = require("../../enhancers/fixedOptions");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {filterSelector, servicesConfigSel} = require("../../../../../selectors/rulesmanager");
+const serviceSelector = createSelector(filterSelector, (filter) => filter.service);
+const parentFiltersSel = createSelector(serviceSelector, (service) => ({
+ parentFilters: {service}
+}));
+const selector = createSelector([filterSelector, parentFiltersSel, servicesConfigSel], (filter, parentsFilter, services) => ({
+ selected: filter.request,
+ disabled: !filter.service,
+ service: filter.service,
+ parentsFilter,
+ services
+}));
+
+
+module.exports = compose(
+ connect(selector),
+ defaultProps({
+ size: 5,
+ textField: "label",
+ valueField: "value",
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.filter",
+ services: {
+ "WFS": [
+ "DescribeFeatureType",
+ "GetCapabilities",
+ "GetFeature",
+ "GetFeatureWithLock",
+ "LockFeature",
+ "Transaction"
+ ],
+ "WMS": [
+ "DescribeLayer",
+ "GetCapabilities",
+ "GetFeatureInfo",
+ "GetLegendGraphic",
+ "GetMap",
+ "GetStyles"
+ ]
+ }
+ }),
+ withPropsOnChange(["service", "services"], ({services = {}, service}) => {
+ return {
+ data: service && (services[service] || []).map(req => ({label: req, value: req.toUpperCase()}))
+ }; }),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ fixedOptions
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RolesFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RolesFilter.jsx
new file mode 100644
index 0000000000..f727879e52
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/RolesFilter.jsx
@@ -0,0 +1,43 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getRoles} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {error} = require('../../../../../actions/notifications');
+const {filterSelector} = require("../../../../../selectors/rulesmanager");
+const selector = createSelector(filterSelector, (filter) => ({
+ selected: filter.rolename
+}));
+
+module.exports = compose(
+ connect(selector, {onError: error}),
+ defaultProps({
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: getRoles,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.filter",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingRoles"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder", "loadingErroMsg"]),
+ autoComplete
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/ServicesFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/ServicesFilter.jsx
new file mode 100644
index 0000000000..2dd2e6bc17
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/ServicesFilter.jsx
@@ -0,0 +1,45 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const fixedOptions = require("../../enhancers/fixedOptions");
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const { compose, defaultProps, withHandlers, withPropsOnChange} = require('recompose');
+
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {filterSelector, servicesSelector} = require("../../../../../selectors/rulesmanager");
+
+const selector = createSelector(filterSelector, servicesSelector, (filter, services) => ({
+ selected: filter.service,
+ services
+}));
+
+module.exports = compose(
+ connect(selector),
+ defaultProps({
+ size: 5,
+ textField: "label",
+ valueField: "value",
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.filter",
+ data: [
+ {value: "WMS", label: "WMS"},
+ {value: "WFS", label: "WFS"},
+ {value: "WCS", label: "WCS"}
+ ]
+ }),
+ withPropsOnChange(["services"], ({services, data}) => ({data: services || data})),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ fixedOptions
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/UsersFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/UsersFilter.jsx
new file mode 100644
index 0000000000..96b5f02782
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/UsersFilter.jsx
@@ -0,0 +1,43 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getUsers} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {filterSelector} = require("../../../../../selectors/rulesmanager");
+const {error} = require('../../../../../actions/notifications');
+const selector = createSelector(filterSelector, (filter) => ({
+ selected: filter.username
+}));
+
+module.exports = compose(
+ connect(selector, {onError: error}),
+ defaultProps({
+ size: 5,
+ textField: "userName",
+ valueField: "userName",
+ loadData: getUsers,
+ parentsFilter: {},
+ filter: false,
+ placeholder: "rulesmanager.placeholders.filter",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingUsers"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ autoComplete
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/WorkspacesFilter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/WorkspacesFilter.jsx
new file mode 100644
index 0000000000..0b9f0571f2
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/WorkspacesFilter.jsx
@@ -0,0 +1,46 @@
+/**
+* Copyright 2018, 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.
+*/
+const PagedCombo = require('../../../../misc/combobox/PagedCombobox');
+const autoComplete = require("../../enhancers/autoComplete");
+const { compose, defaultProps, withHandlers} = require('recompose');
+const {error} = require('../../../../../actions/notifications');
+const localizedProps = require("../../../../misc/enhancers/localizedProps");
+const {getWorkspaces} = require('../../../../../observables/rulesmanager');
+const {connect} = require("react-redux");
+const {createSelector} = require("reselect");
+const {filterSelector} = require("../../../../../selectors/rulesmanager");
+
+const selector = createSelector(filterSelector, (filter) => ({
+ selected: filter.workspace
+}));
+
+
+module.exports = compose(
+ connect(selector, {onError: error}),
+ defaultProps({
+ paginated: false,
+ size: 5,
+ textField: "name",
+ valueField: "name",
+ loadData: getWorkspaces,
+ parentsFilter: {},
+ filter: "startsWith",
+ placeholder: "rulesmanager.placeholders.filter",
+ loadingErrorMsg: {
+ title: "rulesmanager.errorTitle",
+ message: "rulesmanager.errorLoadingWorkspaces"
+ }
+ }),
+ withHandlers({
+ onValueSelected: ({column = {}, onFilterChange = () => {}}) => filterTerm => {
+ onFilterChange({column, filterTerm});
+ }
+ }),
+ localizedProps(["placeholder"]),
+ autoComplete
+)(PagedCombo);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/__tests__/WorkspacesFilter-test.jsx b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/__tests__/WorkspacesFilter-test.jsx
new file mode 100644
index 0000000000..4454b8212d
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/__tests__/WorkspacesFilter-test.jsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2018, 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.
+ */
+const React = require('react');
+const ReactDOM = require('react-dom');
+const expect = require('expect');
+const PropTypes = require('prop-types');
+
+const { withContext } = require('recompose');
+const mockStore = withContext({
+ store: PropTypes.any
+}, ({store = {}} = {}) => ({
+ store: {
+ dispatch: () => { },
+ subscribe: () => { },
+ getState: () => ({}),
+ ...store
+ }
+}));
+
+const WorkspacesFilter = mockStore(require('../WorkspacesFilter'));
+
+
+describe('it render components', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('Load Workspaces', () => {
+ ReactDOM.render(, document.getElementById("container"));
+ const container = document.getElementById('container');
+ const input = container.querySelector('input');
+ expect(input).toExist();
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/index.js b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/index.js
new file mode 100644
index 0000000000..cc24299dcb
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/filterRenderers/index.js
@@ -0,0 +1,8 @@
+module.exports = {
+ RolesFilter: require('./RolesFilter'),
+ UsersFilter: require('./UsersFilter'),
+ WorkspacesFilter: require('./WorkspacesFilter'),
+ LayersFilter: require('./LayersFilter'),
+ ServicesFilter: require('./ServicesFilter'),
+ RequestsFilter: require('./RequestsFilter')
+};
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/formatters/AccessFormatter.jsx b/web/client/components/manager/rulesmanager/rulesgrid/formatters/AccessFormatter.jsx
new file mode 100644
index 0000000000..e918ab195f
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/formatters/AccessFormatter.jsx
@@ -0,0 +1,24 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require('react');
+const accessField = {
+ ALLOW: {
+ className: 'ms-allow-cell'
+ },
+ DENY: {
+ className: 'ms-deny-cell'
+ }
+};
+
+module.exports = ({value = 'DENY', msClasses = accessField}) => (
+
+
+ {value.toUpperCase()}
+
+
+ );
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/formatters/__tests__/AccessFormatter-test.jsx b/web/client/components/manager/rulesmanager/rulesgrid/formatters/__tests__/AccessFormatter-test.jsx
new file mode 100644
index 0000000000..bfb027a58a
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/formatters/__tests__/AccessFormatter-test.jsx
@@ -0,0 +1,35 @@
+/**
+ * Copyright 2018, 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.
+ */
+const React = require('react');
+const ReactDOM = require('react-dom');
+const AccessFormatter = require('../AccessFormatter');
+const expect = require('expect');
+
+describe('Test AccessFormatter component', () => {
+ beforeEach((done) => {
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ afterEach((done) => {
+ ReactDOM.unmountComponentAtNode(document.getElementById("container"));
+ document.body.innerHTML = '';
+ setTimeout(done);
+ });
+ it('render with defaults', () => {
+ ReactDOM.render(, document.getElementById("container"));
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-deny-cell');
+ expect(el).toExist();
+ });
+ it('render with ALLOW', () => {
+ ReactDOM.render(, document.getElementById("container"));
+ const container = document.getElementById('container');
+ const el = container.querySelector('.ms-allow-cell');
+ expect(el).toExist();
+ });
+});
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/renderers/PriorityActionCell.jsx b/web/client/components/manager/rulesmanager/rulesgrid/renderers/PriorityActionCell.jsx
new file mode 100644
index 0000000000..e3aa99e6e7
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/renderers/PriorityActionCell.jsx
@@ -0,0 +1,65 @@
+const React = require('react');
+const PropTypes = require('prop-types');
+const { DragSource: dragSource } = require('react-dnd');
+const { editors } = require('react-data-grid');
+const { CheckboxEditor } = editors;
+
+class PriorityActionsCell extends React.Component {
+
+ renderRowIndex() {
+ return (
+ { this.props.dependentValues && this.props.dependentValues.priority || "" }
+
);
+ }
+
+ render() {
+ const {connectDragSource, rowSelection} = this.props;
+ let rowHandleStyle = rowSelection !== null ? {position: 'absolute', marginTop: '5px'} : {};
+ let isSelected = this.props.value;
+ let editorClass = isSelected ? 'rdg-actions-checkbox selected' : 'rdg-actions-checkbox';
+
+ return connectDragSource(
+
+
+ {!isSelected ? this.renderRowIndex() : null}
+ {rowSelection !== null &&
+
+
}
+
);
+ }
+}
+
+PriorityActionsCell.propTypes = {
+ rowIdx: PropTypes.number.isRequired,
+ connectDragSource: PropTypes.func.isRequired,
+ connectDragPreview: PropTypes.func.isRequired,
+ isDragging: PropTypes.bool.isRequired,
+ isRowHovered: PropTypes.bool,
+ column: PropTypes.object,
+ dependentValues: PropTypes.object,
+ value: PropTypes.bool,
+ rowSelection: PropTypes.object.isRequired
+};
+
+PriorityActionsCell.defaultProps = {
+ rowIdx: 0
+};
+
+function collect(connect, monitor) {
+ return {
+ connectDragSource: connect.dragSource(),
+ isDragging: monitor.isDragging(),
+ connectDragPreview: connect.dragPreview()
+ };
+}
+
+const rowIndexSource = {
+ beginDrag(props) {
+ return { idx: props.rowIdx, data: props.dependentValues };
+ },
+ endDrag(props) {
+ return { idx: props.rowIdx, data: props.dependentValues };
+ }
+};
+
+module.exports = dragSource('Row', rowIndexSource, collect)(PriorityActionsCell);
diff --git a/web/client/components/manager/rulesmanager/rulesgrid/renderers/RuleRenderer.jsx b/web/client/components/manager/rulesmanager/rulesgrid/renderers/RuleRenderer.jsx
new file mode 100644
index 0000000000..7d2678d9b1
--- /dev/null
+++ b/web/client/components/manager/rulesmanager/rulesgrid/renderers/RuleRenderer.jsx
@@ -0,0 +1,47 @@
+/**
+* Copyright 2018, 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.
+*/
+const React = require('react');
+const PropTypes = require('prop-types');
+const { Row: Rule } = require('react-data-grid');
+
+const accessField = {
+ ALLOW: {
+ classNameRow: 'ms-allow-row'
+ },
+ DENY: {
+ classNameRow: 'ms-deny-row'
+ }
+};
+
+class RuleRenderer extends React.Component {
+ static propTypes = {
+ idx: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number]),
+ row: PropTypes.object,
+ isSelected: PropTypes.bool
+ };
+ static defaultProps = {
+ selected: []
+ }
+ constructor(props) {
+ super(props);
+ this.setScrollLeft = (scrollBy) => this.row.setScrollLeft(scrollBy);
+ }
+ componentWillUnmount() {
+ this.setScrollLeft = null;
+ }
+ render() {
+ const {row = {}, isSelected} = this.props;
+ const extraClasses = (isSelected && ' ms-row-select ' || '') + ((accessField[row.grant] || {}).classNameRow || ' ');
+ return ( this.row = node } extraClasses={extraClasses} {...this.props} />);
+ }
+
+}
+
+module.exports = RuleRenderer;
diff --git a/web/client/components/misc/combobox/PagedCombobox.jsx b/web/client/components/misc/combobox/PagedCombobox.jsx
index 610b65e9ad..80ed8533fa 100644
--- a/web/client/components/misc/combobox/PagedCombobox.jsx
+++ b/web/client/components/misc/combobox/PagedCombobox.jsx
@@ -48,7 +48,9 @@ class PagedCombobox extends React.Component {
selectedValue: PropTypes.string,
textField: PropTypes.string,
tooltip: PropTypes.object,
- valueField: PropTypes.string
+ valueField: PropTypes.string,
+ placeholder: PropTypes.string,
+ stopPropagation: PropTypes.bool
};
static contextTypes = {
@@ -56,6 +58,7 @@ class PagedCombobox extends React.Component {
};
static defaultProps = {
+ stopPropagation: false,
dropUp: false,
itemComponent: AutocompleteListItem,
loading: false,
@@ -106,10 +109,20 @@ class PagedCombobox extends React.Component {
return (
{ !firstPage &&
- this.props.pagination.loadPrevPage() }/>
+ {
+ if (this.props.stopPropagation) {
+ e.stopPropagation();
+ }
+ this.props.pagination.loadPrevPage();
+ }}/>
}
{ !lastPage &&
- this.props.pagination.loadNextPage()}/>
+ {
+ if (this.props.stopPropagation) {
+ e.stopPropagation();
+ }
+ this.props.pagination.loadNextPage();
+ }}/>
}
);
@@ -130,6 +143,7 @@ class PagedCombobox extends React.Component {
}
const data = this.props.loading ? [] : options;
const field = ( this.props.onChange(val)}
onFocus={() => this.props.onFocus(this.props.data)}
onSelect={(v) => this.props.onSelect(v)}
- onToggle={() => this.props.onToggle()}
+ onToggle={(stato) => this.props.onToggle(stato)}
textField={this.props.textField}
valueField={this.props.valueField}
value={this.props.selectedValue}
diff --git a/web/client/epics/rulesmanager.js b/web/client/epics/rulesmanager.js
new file mode 100644
index 0000000000..e2276634c5
--- /dev/null
+++ b/web/client/epics/rulesmanager.js
@@ -0,0 +1,25 @@
+const Rx = require("rxjs");
+
+const {SAVE_RULE, setLoading, RULE_SAVED, DELETE_RULES} = require("../actions/rulesmanager");
+const {error} = require("../actions/notifications");
+const {updateRule, createRule, deleteRule} = require("../observables/rulesmanager");
+// To do add Error management
+const {get} = require("lodash");
+const saveRule = stream$ => stream$
+ .mapTo({type: RULE_SAVED})
+ .catch(() => {
+ return Rx.Observable.of(error({title: "rulesmanager.errorTitle", message: "rulesmanager.errorUpdatingRule"}));
+ })
+ .startWith(setLoading(true))
+ .concat(Rx.Observable.of(setLoading(false)));
+module.exports = {
+ onSave: (action$, {getState}) => action$.ofType(SAVE_RULE)
+ .exhaustMap(({rule}) =>
+ rule.id ? updateRule(rule, get(getState(), "rulesmanager.activeRule", {})).let(saveRule) : createRule(rule).let(saveRule)
+ ),
+ onDelete: (action$, {getState}) => action$.ofType(DELETE_RULES)
+ .switchMap(({ids = get(getState(), "rulesmanager.selectedRules", []).map(row => row.id)}) => {
+ return Rx.Observable.combineLatest(ids.map(id => deleteRule(id))).let(saveRule);
+ })
+};
+
diff --git a/web/client/observables/rulesmanager.js b/web/client/observables/rulesmanager.js
new file mode 100644
index 0000000000..5ef6f567b2
--- /dev/null
+++ b/web/client/observables/rulesmanager.js
@@ -0,0 +1,105 @@
+const Rx = require('rxjs');
+
+const {parseString} = require('xml2js');
+const {stripPrefix} = require('xml2js/lib/processors');
+const CatalogAPI = require('../api/CSW');
+const GeoFence = require('../api/geoserver/GeoFence');
+const ConfigUtils = require('../utils/ConfigUtils');
+
+const xmlToJson$ = response => Rx.Observable.bindNodeCallback( (data, callback) => parseString(data, {
+ tagNameProcessors: [stripPrefix],
+ explicitArray: false,
+ mergeAttrs: true
+}, callback))(response);
+
+
+const loadSinglePage = (page = 0, filters = {}, size = 10) => Rx.Observable.defer(() => GeoFence.loadRules(page, filters, size))
+ .switchMap( response => xmlToJson$(response)
+ .map(({RuleList = {}}) => ({ page, rules: [].concat(RuleList.rule || [])}))
+ );
+const countUsers = (filter = "") => Rx.Observable.defer(() => GeoFence.getUsersCount(filter));
+const loadUsers = (filter = "", page = 0, size = 10) => Rx.Observable.defer(() => GeoFence.getUsers(filter, page, size))
+.switchMap( response => xmlToJson$(response).
+ map(({UserList = {}}) => ({users: [].concat(UserList.User || [])}))
+);
+
+const countRoles = (filter = "") => Rx.Observable.defer(() => GeoFence.getGroupsCount(filter));
+
+const loadRoles = (filter = "", page = 0, size = 10) => Rx.Observable.defer(() => GeoFence.getGroups(filter, page, size))
+.switchMap( response => xmlToJson$(response).
+ map(({UserGroupList = {}}) => ({users: [].concat(UserGroupList.UserGroup || [])}))
+);
+const deleteRule = (id) => Rx.Observable.defer(() => GeoFence.deleteRule(id));
+// Full update we need to delete, save and move
+const fullUpdate = (update$) => update$.filter(({rule: r, origRule: oR}) => r.priority !== oR.priority)
+ .switchMap(({rule, origRule}) => deleteRule(rule.id)
+ .switchMap(() => {
+ const {priority, id, ...newRule} = rule;
+ return Rx.Observable.defer(() => GeoFence.addRule(newRule))
+ .catch((e) => {
+ const {priority: p, id: omit, ...oldRule} = origRule;
+ oldRule.position = {value: p, position: "fixedPriority"};
+ // We have to restore original rule and to throw the exception!!
+ return Rx.Observable.defer(() => GeoFence.addRule(oldRule)).do(() => { throw (e); });
+ });
+ })
+ .switchMap(({data: id}) => {
+ return Rx.Observable.defer(() => GeoFence.moveRules(rule.priority, [{id}]));
+ }
+));
+const grantUpdate = (update$) => update$.filter(({rule: r, origRule: oR}) => r.priority === oR.priority && (r.grant !== oR.grant || r.ipaddress !== oR.ipaddress))
+ .switchMap(({rule, origRule}) => deleteRule(rule.id)
+ .switchMap(() => {
+ const {priority, id, ...newRule} = rule;
+ newRule.position = {value: priority, position: "fixedPriority"};
+ return Rx.Observable.defer(() => GeoFence.addRule(newRule))
+ .catch((e) => {
+ const {priority: p, id: omit, ...oldRule} = origRule;
+ oldRule.position = {value: p, position: "fixedPriority"};
+ // We have to restore original rule and to throw the exception!!
+ return Rx.Observable.defer(() => GeoFence.addRule(oldRule)).do(() => { throw (e); });
+ });
+ })
+ );
+// if priority and grant are the same we just need to update new rule
+const justUpdate = (update$) => update$.filter(({rule: r, origRule: oR}) => r.priority === oR.priority && r.grant === oR.grant && r.ipaddress === oR.ipaddress)
+ .switchMap(({rule}) => Rx.Observable.defer(() => GeoFence.updateRule(rule)));
+module.exports = {
+ loadRules: (pages = [], filters = {}, size) =>
+ Rx.Observable.combineLatest(pages.map(p => loadSinglePage(p, filters, size)))
+ .map(results => results.reduce( (acc, {page, rules}) => ({...acc, [page]: rules}), {}))
+ .map(p => ({pages: p})),
+ getCount: (filters = {}) => Rx.Observable.defer(() => GeoFence.getRulesCount(filters)),
+ moveRules: (targetPriority, rulesIds) => Rx.Observable.defer(() => GeoFence.moveRules(targetPriority, rulesIds)),
+ getUsers: (userFilter = "", page = 0, size = 10, parentsFilter = {}, countEl = false) => {
+ return countEl && Rx.Observable.combineLatest([countUsers(userFilter), loadUsers(userFilter, page, size)], (count, {users}) => ({
+ count,
+ data: users
+ })) || loadUsers(userFilter, page, size).map(({users}) => ({data: users}));
+ },
+ getRoles: (roleFilter = "", page = 0, size = 10, parentsFilter = {}, countEl = false) => {
+ return countEl && Rx.Observable.combineLatest([countRoles(roleFilter), loadRoles(roleFilter, page, size)], (count, {users}) => ({
+ count,
+ data: users
+ })) || loadRoles(roleFilter, page, size).map(({users}) => ({data: users}));
+ },
+ getWorkspaces: ({size}) => Rx.Observable.defer(() => GeoFence.getWorkspaces())
+ .map(({workspaces = {}}) => ({count: size, data: [].concat(workspaces.workspace)})),
+ loadLayers: (layerFilter = "", page = 0, size = 10, parentsFilter = {}) => {
+ const {url: baseURL} = ConfigUtils.getDefaults().geoFenceGeoServerInstance || {};
+ const catalogUrl = baseURL + 'csw';
+ const {workspace = ""} = parentsFilter;
+ return Rx.Observable.defer(() =>
+ CatalogAPI.workspaceSearch(catalogUrl, (page) * size + 1, size, layerFilter, workspace))
+ .map((layers) => ({data: layers.records.map(layer => ({name: layer.dc.identifier.replace(/^.*?:/g, '')})), count: layers.numberOfRecordsMatched}));
+ },
+ updateRule: (rule, origRule) => {
+ const fullUp = Rx.Observable.of({rule, origRule}).let(fullUpdate);
+ const simpleUpdate = Rx.Observable.of({rule, origRule}).let(justUpdate);
+ const grant = Rx.Observable.of({rule, origRule}).let(grantUpdate);
+ return fullUp.merge(simpleUpdate, grant);
+ },
+ createRule: (rule) => Rx.Observable.defer(() => GeoFence.addRule(rule)),
+ deleteRule
+
+};
diff --git a/web/client/plugins/RulesDataGrid.jsx b/web/client/plugins/RulesDataGrid.jsx
new file mode 100644
index 0000000000..507eb080fe
--- /dev/null
+++ b/web/client/plugins/RulesDataGrid.jsx
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2017, 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.
+ */
+
+const React = require('react');
+const {connect} = require('react-redux');
+const {compose} = require("recompose");
+
+const {createSelector} = require('reselect');
+const {selectedRules, filterSelector, isEditorActive, triggerLoadSel} = require('../selectors/rulesmanager');
+
+const ContainerDimensions = require('react-container-dimensions').default;
+const PropTypes = require('prop-types');
+const {rulesSelected, setLoading, setFilter} = require("../actions/rulesmanager");
+const {error} = require('../actions/notifications');
+
+const ruelsSelector = createSelector([selectedRules, filterSelector, triggerLoadSel], (rules, filters, triggerLoad) => {
+ return {
+ selectedIds: rules.map(r => r.id),
+ filters,
+ triggerLoad
+}; });
+const rulesGridEnhancer = compose(
+ connect( ruelsSelector, {onSelect: rulesSelected, onLoadError: error, setLoading, setFilters: setFilter}),
+ require('../components/manager/rulesmanager/rulesgrid/enhancers/rulesgrid'));
+
+const RulesGrid = rulesGridEnhancer(require('../components/manager/rulesmanager/rulesgrid/RulesGrid'));
+
+/**
+ * @name RulesGrid
+ * @memberof plugins
+ * @class
+ * @prop {boolean} cfg.virtualScroll default true. Activates virtualScroll. When false the grid uses normal pagination
+ * @prop {number} cfg.maxStoredPages default 5. In virtual Scroll mode determines the size of the loaded pages cache
+ * @prop {number} cfg.vsOverScan default 20. Number of rows to load above/below the visible slice of the grid
+ * @prop {number} cfg.scrollDebounce default 50. milliseconds of debounce interval between two scroll event
+ * @classdesc
+ * Rules-grid it's part of rules-manager page. It loads goefence's rules from configured geofence instance.
+ * It uses virtualScroll to manage rules loading. It allows to order geofence's rules by drag and drop.
+ * Rules can be filtered selecting values form columns' header.
+*/
+
+class RulesDataGrid extends React.Component {
+ static propTypes = {
+ enabled: PropTypes.bool
+ };
+ static defaultProps = {
+ enabled: true
+ };
+ render() {
+ return ({({width, height}) =>
+ (
+ {!this.props.enabled && (
)}
+
+
)
+ }
+ );
+ }
+}
+const RulesDataGridPlugin = connect(
+ createSelector(
+ isEditorActive,
+ editing => ({enabled: !editing})
+ ), {
+ // setEditing,
+ // onMount: () => setEditorAvailable(true),
+ // onUnmount: () => setEditorAvailable(false)
+ }
+)(RulesDataGrid);
+module.exports = {
+ RulesDataGridPlugin,
+ reducers: {rulesmanager: require('../reducers/rulesmanager')}
+};
diff --git a/web/client/plugins/RulesEditor.jsx b/web/client/plugins/RulesEditor.jsx
new file mode 100644
index 0000000000..60cd6b371e
--- /dev/null
+++ b/web/client/plugins/RulesEditor.jsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const React = require('react');
+
+const {createSelector} = require('reselect');
+const {connect} = require('react-redux');
+const PropTypes = require('prop-types');
+
+const { isEditorActive} = require('../selectors/rulesmanager');
+
+const Editor = require('./manager/RulesEditor');
+const Toolbar = require('./manager/RulesToolbar');
+
+/**
+ * @name RulesEditor
+ * @memberof plugins
+ * @class
+ * @classdesc
+ * Rules-editor it's part of rules-manager page. It allow a admin user to add, modify and delete geofence rules
+*/
+class RulesEditorComponent extends React.Component {
+ static propTypes = {
+ id: PropTypes.string,
+ editing: PropTypes.bool,
+ limitDockHeight: PropTypes.bool,
+ fluid: PropTypes.bool,
+ zIndex: PropTypes.number,
+ dockSize: PropTypes.number,
+ position: PropTypes.string,
+ onMount: PropTypes.func,
+ onUnmount: PropTypes.func,
+ setEditing: PropTypes.func,
+ dimMode: PropTypes.string,
+ src: PropTypes.string,
+ style: PropTypes.object
+ };
+ static defaultProps = {
+ id: "rules-editor",
+ editing: false,
+ dockSize: 500,
+ limitDockHeight: true,
+ zIndex: 10000,
+ fluid: false,
+ dimMode: "none",
+ position: "left",
+ onMount: () => {},
+ onUnmount: () => {},
+ setEditing: () => {}
+ };
+ componentDidMount() {
+ this.props.onMount();
+ }
+
+ componentWillUnmount() {
+ this.props.onUnmount();
+ }
+ render() {
+
+
+ return this.props.editing
+ ? this.props.setEditing(false)} catalog={this.props.catalog}/>
+ : (
+
+
);
+ }
+}
+
+const Plugin = connect(
+ createSelector(
+ isEditorActive,
+ editing => ({editing})
+ ), {
+ // setEditing,
+ // onMount: () => setEditorAvailable(true),
+ // onUnmount: () => setEditorAvailable(false)
+ }
+)(RulesEditorComponent);
+module.exports = {
+ RulesEditorPlugin: Plugin,
+ reducers: {
+ rulesmanager: require('../reducers/rulesmanager')
+ },
+ epics: require("../epics/rulesmanager")
+};
diff --git a/web/client/plugins/RulesManagerFooter.jsx b/web/client/plugins/RulesManagerFooter.jsx
new file mode 100644
index 0000000000..da43e9a6c1
--- /dev/null
+++ b/web/client/plugins/RulesManagerFooter.jsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2018, 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.
+ */
+
+const React = require('react');
+const PropTypes = require('prop-types');
+const src = require("../product/plugins/attribution/geosolutions-brand.png");
+const {connect} = require('react-redux');
+
+class RulesManagerFooter extends React.Component {
+
+ static propTypes = {
+ loading: PropTypes.bool,
+ containerPosition: PropTypes.string
+ };
+
+ static defaultProps = {
+ loading: false,
+ containerPosition: "footer"
+ };
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+module.exports = {
+ RulesManagerFooterPlugin: connect(({rulesmanager}) => ({loading: rulesmanager.loading}))(RulesManagerFooter),
+ reducers: {}
+};
diff --git a/web/client/plugins/manager/EditorEnhancer.js b/web/client/plugins/manager/EditorEnhancer.js
new file mode 100644
index 0000000000..6ad9924f4e
--- /dev/null
+++ b/web/client/plugins/manager/EditorEnhancer.js
@@ -0,0 +1,31 @@
+const {compose, withStateHandlers} = require('recompose');
+
+
+module.exports = compose(
+ // defaultProps({dataStreamFactory}),
+ withStateHandlers(({activeRule: initRule}) => ({
+ activeRule: initRule,
+ initRule,
+ activeEditor: "1"
+ }), {
+ setOption: ({activeRule}) => ({key, value}) => {
+ // Add some reference logic here
+ if (key === "workspace" && !value) {
+ const {layer, workspace, ...newActive} = activeRule;
+ return {activeRule: newActive};
+ }else if (key === "service" && !value) {
+ const {request, service, ...newActive} = activeRule;
+ return {activeRule: newActive};
+ }else if (!value) {
+ const {[key]: omit, ...newActive} = activeRule;
+ return {activeRule: newActive};
+ }
+ return {
+ activeRule: {...activeRule, [key]: value}
+ };
+ },
+ onNavChange: () => activeEditor => ({
+ activeEditor
+ })
+ })
+);
diff --git a/web/client/plugins/manager/ManagerMenu.jsx b/web/client/plugins/manager/ManagerMenu.jsx
index 8812c58b5e..142afd3426 100644
--- a/web/client/plugins/manager/ManagerMenu.jsx
+++ b/web/client/plugins/manager/ManagerMenu.jsx
@@ -52,6 +52,11 @@ class ManagerMenu extends React.Component {
"msgId": "users.title",
"glyph": "1-group-mod",
"path": "/manager/usermanager"
+ },
+ {
+ "msgId": "rulesmanager.menutitle",
+ "glyph": "lock",
+ "path": "/rules-manager"
}],
role: "",
onItemClick: () => {},
diff --git a/web/client/plugins/manager/ModalDialog.jsx b/web/client/plugins/manager/ModalDialog.jsx
new file mode 100644
index 0000000000..abc3c25b02
--- /dev/null
+++ b/web/client/plugins/manager/ModalDialog.jsx
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2018, 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.
+ */
+const React = require('react');
+
+const Message = require('../../components/I18N/Message');
+const Portal = require('../../components/misc/Portal');
+const ResizableModal = require('../../components/misc/ResizableModal');
+
+module.exports = ({title = "", showDialog = false, buttons = [], closeAction = () => {}, msg = "Missing message"}) => {
+ return (
+
+ }
+ size="xs"
+ show={showDialog}
+ onClose={closeAction}
+ buttons={buttons}>
+
+
+
+ );
+};
diff --git a/web/client/plugins/manager/RulesEditor.jsx b/web/client/plugins/manager/RulesEditor.jsx
new file mode 100644
index 0000000000..b351c2c182
--- /dev/null
+++ b/web/client/plugins/manager/RulesEditor.jsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2018, 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.
+ */
+const React = require('react');
+const PropTypes = require('prop-types');
+
+const {connect} = require('react-redux');
+const {createSelector} = require("reselect");
+const {compose} = require('recompose');
+const enhancer = require("./EditorEnhancer");
+const {cleanEditing, saveRule} = require("../../actions/rulesmanager");
+const {activeRuleSelector} = require("../../selectors/rulesmanager");
+
+const {isEqual} = require("lodash");
+
+const Message = require('../../components/I18N/Message');
+const BorderLayout = require("../../components/layout/BorderLayout");
+const Header = require("../../components/manager/rulesmanager/ruleseditor/Header");
+const MainEditor = require("../../components/manager/rulesmanager/ruleseditor/EditMain");
+const ModalDialog = require("./ModalDialog");
+const {isRuleValid} = require("../../utils/RulesGridUtils");
+
+class RuleEditor extends React.Component {
+ static propTypes = {
+ initRule: PropTypes.object,
+ activeRule: PropTypes.object,
+ activeEditor: PropTypes.string,
+ onNavChange: PropTypes.func,
+ setOption: PropTypes.func,
+ onExit: PropTypes.func,
+ onSave: PropTypes.func,
+ onDelete: PropTypes.func
+ }
+ static defaultProps = {
+ activeEditor: "1",
+ onNavChange: () => {},
+ setOption: () => {},
+ onExit: () => {},
+ onSave: () => {},
+ onDelete: () => {}
+ }
+ render() {
+ const {activeRule, activeEditor, onNavChange, setOption, initRule} = this.props;
+ const {modalProps} = this.state || {};
+ return (
+ }>
+
+
+ );
+ }
+ cancelEditing = () => {
+ const {activeRule, initRule, onExit} = this.props;
+ if (!isEqual(activeRule, initRule)) {
+ this.setState( () => ({modalProps: {title: "featuregrid.toolbar.saveChanges",
+ showDialog: true, buttons: [{
+ text: ,
+ bsStyle: 'primary',
+ onClick: this.cancel
+ },
+ {
+ text: ,
+ bsStyle: 'primary',
+ onClick: onExit
+ }
+ ], closeAction: this.cancel, msg: "map.details.sureToClose"}}));
+ } else {
+ onExit();
+ }
+ }
+ cancel = () => {
+ this.setState( () => ({modalProps: {showDialog: false}}));
+ }
+ save = () => {
+ const {activeRule, onSave} = this.props;
+ if (isRuleValid(activeRule)) {
+ onSave(activeRule);
+ }else {
+ this.setState( () => ({modalProps: {title: "featuregrid.toolbar.saveChanges",
+ showDialog: true, buttons: [
+ {
+ text: 'Ok',
+ bsStyle: 'primary',
+ onClick: this.cancel
+ }
+ ], closeAction: this.cancel, msg: "rulesmanager.invalidForm"}}));
+ }
+ }
+}
+
+module.exports = compose(
+ connect(createSelector(activeRuleSelector, activeRule => ({activeRule})), {
+ onExit: cleanEditing,
+ onSave: saveRule
+ }),
+ enhancer)(RuleEditor);
diff --git a/web/client/plugins/manager/RulesToolbar.jsx b/web/client/plugins/manager/RulesToolbar.jsx
new file mode 100644
index 0000000000..7e4148d7b0
--- /dev/null
+++ b/web/client/plugins/manager/RulesToolbar.jsx
@@ -0,0 +1,142 @@
+import { withPropsOnChange } from "recompose";
+
+/*
+ * Copyright 2018, 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.
+ */
+const React = require("react");
+const {compose, withProps, withStateHandlers} = require("recompose");
+const {connect} = require("react-redux");
+const { onEditRule, delRules} = require('../../actions/rulesmanager');
+const {rulesEditorToolbarSelector} = require('../../selectors/rulesmanager');
+const Toolbar = require('../../components/misc/toolbar/Toolbar');
+const Modal = require("./ModalDialog");
+const Message = require("../../components/I18N/Message");
+
+const ToolbarWithModal = ({modalsProps, ...props}) => {
+ return (
+
+
+
+
+ );
+};
+
+const EditorToolbar = compose(
+ connect(
+ rulesEditorToolbarSelector,
+ {
+ deleteRules: delRules,
+ editOrCreate: onEditRule
+ }
+ ),
+ withStateHandlers(() => ({
+ modal: "none"
+ }), {
+ cancelModal: () => () => ({
+ modal: "none"
+ }),
+ showModal: () => (modal) => ({
+ modal
+ })
+ }),
+ withProps(({
+ showAdd, showEdit, showModal, showInsertBefore, showInsertAfter, showDel, showCache,
+ editOrCreate = () => {}
+ }) => ({
+ buttons: [{
+ glyph: 'plus',
+ tooltipId: 'rulesmanager.tooltip.addT',
+ visible: showAdd,
+ onClick: editOrCreate.bind(null, 0, true)
+ }, {
+ glyph: 'pencil',
+ tooltipId: 'rulesmanager.tooltip.editT',
+ visible: showEdit,
+ onClick: editOrCreate.bind(null, 0, false)
+ }, {
+ glyph: 'add-row-before',
+ tooltipId: 'rulesmanager.tooltip.addBeT',
+ visible: showInsertBefore,
+ onClick: editOrCreate.bind(null, -1, true)
+ }, {
+ glyph: 'add-row-after',
+ tooltipId: 'rulesmanager.tooltip.addAfT',
+ visible: showInsertAfter,
+ onClick: editOrCreate.bind(null, 1, true)
+ }, {
+ glyph: 'trash',
+ tooltipId: 'rulesmanager.tooltip.deleteT',
+ visible: showDel,
+ onClick: () => {
+ showModal("delete");
+ }
+ }, {
+ glyph: 'clear-brush',
+ tooltipId: 'rulesmanager.tooltip.cacheT',
+ visible: showCache,
+ onClick: () => {
+ showModal("cache");
+ }
+ }]
+ })),
+ withPropsOnChange(["modal"], ({modal, cancelModal, deleteRules}) => {
+ switch (modal) {
+ case "delete":
+ return {
+ modalsProps: {
+ showDialog: true,
+ title: "rulesmanager.deltitle",
+ buttons: [{
+ text: ,
+ bsStyle: 'primary',
+ onClick: cancelModal
+ },
+ {
+ text: ,
+ bsStyle: 'primary',
+ onClick: () => { cancelModal(); deleteRules(); }
+ }
+ ],
+ closeAction: cancelModal,
+ msg: "rulesmanager.delmsg"
+ }
+ };
+ case "cache":
+ return {
+ modalsProps: {
+ showDialog: true,
+ title: "rulesmanager.cachetitle",
+ buttons: [{
+ text: ,
+ bsStyle: 'primary',
+ onClick: cancelModal
+ },
+ {
+ text: ,
+ bsStyle: 'primary',
+ onClick: () => { cancelModal(); }
+ }
+ ],
+ closeAction: cancelModal,
+ msg: "rulesmanager.cachemsg"
+ }
+ };
+ default:
+ return {
+ modalsProps: {showDialog: false,
+ title: "",
+ buttons: [],
+ closeAction: cancelModal,
+ msg: ""
+ }
+ };
+ }
+
+ })
+)( ToolbarWithModal);
+
+module.exports = EditorToolbar;
diff --git a/web/client/product/appConfig.js b/web/client/product/appConfig.js
index 892159d9f9..43098b35bf 100644
--- a/web/client/product/appConfig.js
+++ b/web/client/product/appConfig.js
@@ -35,11 +35,15 @@ module.exports = {
name: "dashboard",
path: "/dashboard",
component: require('./pages/Dashboard')
- }, {
- name: "dashboard",
- path: "/dashboard/:did",
- component: require('./pages/Dashboard')
- }],
+ }, {
+ name: "dashboard",
+ path: "/dashboard/:did",
+ component: require('./pages/Dashboard')
+ }, {
+ name: "rulesmanager",
+ path: "/rules-manager",
+ component: require('./pages/RulesManager')
+ }],
initialState: {
defaultState: {
mousePosition: {enabled: false},
diff --git a/web/client/product/pages/RulesManager.jsx b/web/client/product/pages/RulesManager.jsx
new file mode 100644
index 0000000000..ca8eb44df0
--- /dev/null
+++ b/web/client/product/pages/RulesManager.jsx
@@ -0,0 +1,131 @@
+const PropTypes = require('prop-types');
+/**
+ * Copyright 2016, 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.
+ */
+const React = require('react');
+const {connect} = require('react-redux');
+
+const url = require('url');
+const urlQuery = url.parse(window.location.href, true).query;
+
+const ConfigUtils = require('../../utils/ConfigUtils');
+
+const {loadMapConfig} = require('../../actions/config');
+const {resetControls} = require('../../actions/controls');
+
+const HolyGrail = require('../../containers/HolyGrail');
+/**
+ * @name RulesManagerPage
+ * @memberof pages
+ * @class
+ * @classdesc
+ * Rules Manager allow a user with admin permissions to easly manage geofence's rules.
+ * Configure geoFenceUrl and geoFenceGeoServerInstance params in localConfig.
+ * Add services configuration to overwrite the default values used by the service and the request
+ * selectors. See the page's plugins configuration in the following example.
+ * The app is available at http://localhos:8081/#/rules-manager.
+ *
+ * @example
+ * // localConfig configuration example
+ * "geoFenceUrl": "http://localhost:8081/",
+ * "geoFenceGeoServerInstance": {
+ * "url": "geoserver/",
+ * "id" : 1
+ * }
+ * "initialState": {
+ * "defaultState": {
+ * "rulesmanager": {
+ * "services": {
+ * "WFS": [
+ * "DescribeFeatureType",
+ * "GetCapabilities",
+ * "GetFeature",
+ * "GetFeatureWithLock",
+ * "LockFeature",
+ * "Transaction"
+ * ],
+ * "WMS": [
+ * "DescribeLayer",
+ * "GetCapabilities",
+ * "GetFeatureInfo",
+ * "GetLegendGraphic",
+ * "GetMap",
+ * "GetStyles"
+ * ]
+ * }
+ * }
+ * }
+ * },....
+ * "plugins": {
+ * "rulesmanager": [{
+ * "name": "OmniBar",
+ * "cfg": {
+ * "containerPosition": "header",
+ * "className": "navbar shadow navbar-home"
+ * }
+ * }, {
+ * "name": "Home",
+ * "override": {
+ * "OmniBar": {
+ * "position": 1,
+ * "priority": 1
+ * }
+ * }
+ * },"Language", "Login", "Attribution", "RulesDataGrid", "Notifications", {
+ * "name": "RulesManagerFooter" , "cfg": { "containerPosition": "footer"} },{
+ * "name": "RulesEditor",
+ * "containerPosition": "columns"
+ * }}]
+ * }
+*/
+
+class RulesManagerPage extends React.Component {
+ static propTypes = {
+ name: PropTypes.string,
+ mode: PropTypes.string,
+ match: PropTypes.object,
+ loadMaps: PropTypes.func,
+ reset: PropTypes.func,
+ plugins: PropTypes.object
+ };
+
+ static defaultProps = {
+ name: "rulesmanager",
+ mode: 'desktop',
+ loadMaps: () => {},
+ reset: () => {}
+ };
+
+ render() {
+ let plugins = ConfigUtils.getConfigProp("plugins") || {};
+ let pagePlugins = {
+ "desktop": [], // TODO mesh page plugins with other plugins
+ "mobile": []
+ };
+ let pluginsConfig = {
+ "desktop": plugins[this.props.name] || [], // TODO mesh page plugins with other plugins
+ "mobile": plugins[this.props.name] || []
+ };
+
+ return pluginsConfig.desktop.length > 0 && () || ;
+ }
+}
+
+module.exports = connect((state) => ({
+ mode: urlQuery.mobile || state.browser && state.browser.mobile ? 'mobile' : 'desktop'
+}),
+ {
+ loadMapConfig,
+ reset: resetControls
+ })(RulesManagerPage);
diff --git a/web/client/product/plugins.js b/web/client/product/plugins.js
index 89f5a858e8..98689a1f9b 100644
--- a/web/client/product/plugins.js
+++ b/web/client/product/plugins.js
@@ -84,8 +84,11 @@ module.exports = {
WidgetsPlugin: require('../plugins/Widgets'),
WidgetsBuilderPlugin: require('../plugins/WidgetsBuilder'),
TOCItemsSettingsPlugin: require('../plugins/TOCItemsSettings'),
+ RulesDataGridPlugin: require('../plugins/RulesDataGrid'),
+ RulesManagerFooter: require('../plugins/RulesManagerFooter'),
FeaturedMaps: require('../plugins/FeaturedMaps'),
- NavMenu: require('./plugins/NavMenu')
+ NavMenu: require('./plugins/NavMenu'),
+ RulesEditorPlugin: require('../plugins/RulesEditor')
},
requires: {
ReactSwipe: require('react-swipeable-views').default,
diff --git a/web/client/reducers/rulesmanager.js b/web/client/reducers/rulesmanager.js
index dcfe84073c..1e3be061bf 100644
--- a/web/client/reducers/rulesmanager.js
+++ b/web/client/reducers/rulesmanager.js
@@ -9,15 +9,49 @@
const assign = require('object-assign');
const { RULES_SELECTED, RULES_LOADED, UPDATE_ACTIVE_RULE,
- ACTION_ERROR, OPTIONS_LOADED, UPDATE_FILTERS_VALUES } = require('../actions/rulesmanager');
+ ACTION_ERROR, OPTIONS_LOADED, UPDATE_FILTERS_VALUES,
+ LOADING, EDIT_RULE, SET_FILTER, CLEAN_EDITING, RULE_SAVED} = require('../actions/rulesmanager');
const _ = require('lodash');
+const defaultState = {
+ services: {
+ WFS: [
+ "DescribeFeatureType",
+ "GetCapabilities",
+ "GetFeature",
+ "GetFeatureWithLock",
+ "LockFeature",
+ "Transaction"
+ ],
+ WMS: [
+ "DescribeLayer",
+ "GetCapabilities",
+ "GetFeatureInfo",
+ "GetLegendGraphic",
+ "GetMap",
+ "GetStyles"
+ ]
+ },
+ triggerLoad: 0
+};
-function rulesmanager(state = {}, action) {
+const getPosition = ({targetPosition = {}}, priority) => {
+ switch (priority) {
+ case -1:
+ return targetPosition.offsetFromTop;
+ case +1:
+ return targetPosition.offsetFromTop + 1;
+ default:
+ return 0;
+ }
+};
+
+function rulesmanager(state = defaultState, action) {
switch (action.type) {
case RULES_SELECTED: {
if (!action.merge) {
return assign({}, state, {
- selectedRules: action.rules
+ selectedRules: action.rules,
+ targetPosition: action.targetPosition
});
}
const newRules = action.rules || [];
@@ -29,8 +63,7 @@ function rulesmanager(state = {}, action) {
});
}
return assign({}, state, {
- selectedRules: _(existingRules).concat(newRules).uniq(rule => rule.id).value()
- });
+ selectedRules: _(existingRules).concat(newRules).uniq(rule => rule.id).value()});
}
case RULES_LOADED: {
return assign({}, state, {
@@ -84,6 +117,29 @@ function rulesmanager(state = {}, action) {
})
});
}
+ case LOADING:
+ return assign({}, state, {loading: action.loading});
+ case SET_FILTER: {
+ const {key, value} = action;
+ if (value) {
+ return assign({}, state, {filters: {...state.filters, [key]: value}});
+ }
+ const {[key]: omit, ...newFilters} = state.filters;
+ return assign({}, state, {filters: newFilters});
+ }
+ case EDIT_RULE: {
+ const {createNew, targetPriority} = action;
+ if (createNew) {
+ return assign({}, state, {activeRule: {position: {value: getPosition(state, targetPriority), position: "offsetFromTop"}}});
+ }
+ return assign({}, state, {activeRule: {...(state.selectedRules[0] || {}), position: {value: state.targetPosition.offsetFromTop, position: "offsetFromTop"}}});
+ }
+ case RULE_SAVED: {
+ return assign({}, state, {triggerLoad: (state.triggerLoad || 0) + 1, activeRule: undefined, selectedRules: [], targetPosition: undefined });
+ }
+ case CLEAN_EDITING: {
+ return assign({}, state, {activeRule: undefined});
+ }
default:
return state;
}
diff --git a/web/client/selectors/rulesmanager.js b/web/client/selectors/rulesmanager.js
index 5a6c3ed4d5..34f4c93be4 100644
--- a/web/client/selectors/rulesmanager.js
+++ b/web/client/selectors/rulesmanager.js
@@ -8,6 +8,7 @@
const assign = require('object-assign');
const _ = require('lodash');
+const {createSelector} = require('reselect');
const rulesSelector = (state) => {
if (!state.rulesmanager || !state.rulesmanager.rules) {
@@ -42,8 +43,36 @@ const optionsSelector = (state) => {
options.layersCount = stateOptions.layersCount || 0;
return options;
};
+const EMPTY_FILTERS = {};
+const filterSelector = (state) => state.rulesmanager && state.rulesmanager.filters || EMPTY_FILTERS;
+const selectedRules = (state) => state.rulesmanager && state.rulesmanager.selectedRules || [];
+const activeRuleSelector = (state) => state.rulesmanager && state.rulesmanager.activeRule;
+const servicesConfigSel = (state) => state.rulesmanager && state.rulesmanager.services;
+const servicesSelector = createSelector(servicesConfigSel, (services) => ( services && Object.keys(services).map(service => ({value: service, label: service}))
+));
+const targetPositionSelector = (state) => state.rulesmanager && state.rulesmanager.targetPosition || EMPTY_FILTERS;
+const rulesEditorToolbarSelector = createSelector(selectedRules, targetPositionSelector, (sel, {offsetFromTop}) => {
+ return {
+ showAdd: sel.length === 0,
+ showEdit: sel.length === 1,
+ showInsertBefore: sel.length === 1 && offsetFromTop !== 0,
+ showInsertAfter: sel.length === 1,
+ showDel: sel.length > 0,
+ showCache: sel.length === 0
+ };
+});
+const isEditorActive = state => state.rulesmanager && !!state.rulesmanager.activeRule;
+const triggerLoadSel = state => state.rulesmanager && state.rulesmanager.triggerLoad;
module.exports = {
rulesSelector,
- optionsSelector
+ optionsSelector,
+ selectedRules,
+ filterSelector,
+ servicesSelector,
+ rulesEditorToolbarSelector,
+ isEditorActive,
+ activeRuleSelector,
+ servicesConfigSel,
+ triggerLoadSel
};
diff --git a/web/client/test-resources/geofence/rest/rules/count b/web/client/test-resources/geofence/rest/rules/count
new file mode 100644
index 0000000000..9a037142aa
--- /dev/null
+++ b/web/client/test-resources/geofence/rest/rules/count
@@ -0,0 +1 @@
+10
\ No newline at end of file
diff --git a/web/client/test-resources/geofence/rest/rules/rules.xml b/web/client/test-resources/geofence/rest/rules/rules.xml
new file mode 100644
index 0000000000..510fc9888f
--- /dev/null
+++ b/web/client/test-resources/geofence/rest/rules/rules.xml
@@ -0,0 +1,62 @@
+
+
+21
+25
+
+1
+default-gs
+
+WFS
+GETFEATURE
+
+tasmania
+
+
+4
+26
+
+1
+default-gs
+
+WMS
+GETMAP
+tiger
+poi
+
+
+25
+27
+
+1
+default-gs
+
+WFS
+GETFEATURE
+tiger
+poly_landmarks
+
+
+23
+28
+
+1
+default-gs
+
+WMS
+GETFEATURE
+tiger
+giant_polygon
+
+
+16
+33
+
+1
+default-gs
+
+WMS
+GETFEATURE
+sf
+streams
+
+
\ No newline at end of file
diff --git a/web/client/themes/default/less/rulesmanager.less b/web/client/themes/default/less/rulesmanager.less
new file mode 100644
index 0000000000..8ae317fd1c
--- /dev/null
+++ b/web/client/themes/default/less/rulesmanager.less
@@ -0,0 +1,340 @@
+.rules-manager{
+ position: absolute;
+ .hide-locked-cell .react-grid-Cell--locked:focus {
+ z-index: 9;
+ }
+ .nav-tabs.nav-justified > li {
+ display: table-cell;
+ width: 1%;
+ }
+
+ .ms-vertical-toolbar.rules-editor.re-toolbar{
+ order: -1;
+ width: 52px;
+ height: 100%;
+ padding: 10px;
+ border-left: 1px solid #dddddd;
+ }
+ #rules-editor {
+ border-right: 1px solid #dddddd;
+ border-left: none;
+ padding: 10px 8px;
+ }
+ .rules-manager .ms-vertical-toolbar.rules-editor.re-toolbar
+
+ .autocomplete-toolbar, .autocomplete-toolbar span:focus, .rw-popup.rw-widget li:focus{
+ outline: none;
+ }
+
+ .mapstore-footer {
+ position: fixed;
+ width: 100%;
+ height: 52px;
+ display: flex;
+ -webkit-box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.06), 0 -4px 8px rgba(0, 0, 0, 0.12);
+ -moz-box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.06), 0 -4px 8px rgba(0, 0, 0, 0.12);
+ box-shadow: 0 -3px 6px rgba(0, 0, 0, 0.06), 0 -4px 8px rgba(0, 0, 0, 0.12);
+ z-index: 10;
+ background-color: #ffffff;
+ bottom: 0;
+ justify-content: space-between;
+ }
+ .mapstore-footer .ms-circle-loader-md {
+ margin: 13px 18px;
+ }
+ .mapstore-footer .ms-circle-loader-md {
+ text-indent: -9999em;
+ border-top: 3.25px solid rgba(7, 138, 163, 0.2);
+ border-right: 3.25px solid rgba(7, 138, 163, 0.2);
+ border-bottom: 3.25px solid rgba(7, 138, 163, 0.2);
+ border-left: 3.25px solid #078aa3;
+ -webkit-transform: translateZ(0);
+ -ms-transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-animation: mapstore-circle-load8 1.1s infinite linear;
+ animation: mapstore-circle-load8 1.1s infinite linear;
+ border-radius: 50%;
+ width: 26px;
+ height: 26px;
+ }
+
+
+ .mapstore-footer .ms-logo-geosolutions {
+ height: 52px;
+ width: 156px;
+ overflow: hidden;
+ margin-right: 26px;
+ display: flex;
+ }
+ .ms-logo-geosolutions img{
+ width: 100%;
+ height: auto;
+ margin: auto;
+ }
+
+
+ #mapstore-navbar-container {
+ margin-bottom: 0;
+ z-index: 100;
+ }
+
+ #home-button {
+ float: right;
+ }
+ .shadow-soft-inset {
+ -webkit-box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.06), inset 0 4px 8px rgba(0, 0, 0, 0.12);
+ -moz-box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.06), inset 0 4px 8px rgba(0, 0, 0, 0.12);
+ box-shadow: inset 0 3px 6px rgba(0, 0, 0, 0.06), inset 0 4px 8px rgba(0, 0, 0, 0.12);
+ }
+
+
+ .ms-header {
+ height: @square-btn-size;
+ border-bottom: 1px solid @ms2-color-shade-lighter;
+ display: flex;
+ .btn-group {
+ margin: auto;
+ }
+ }
+ .react-grid-Main {
+ outline: none;
+ .react-grid-Grid {
+ border: none;
+ }
+ }
+
+.ms-rules-side {
+ width: 500px;
+ .shadow-soft-inset;
+ .ms-wizard {
+ position: absolute;
+ width: 500px;
+
+ height: 100%;
+ }
+}
+.react-grid-Cell {
+
+ &:focus {
+ outline: none;
+ }
+}
+.ms-grid-card {
+ color: @ms2-color-text;
+ background-color: @ms2-color-background;
+ .shadow-soft;
+ flex: 1;
+ width: 100%;
+ padding: 0 8px;
+ box-sizing: border-box;
+ overflow: hidden;
+ margin-top: 8px;
+ transition: 0.3s;
+ & > * {
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ padding: 0px 0 2px 0;
+ }
+ &:hover {
+ cursor: pointer;
+ z-index: 10;
+ transform: scale(1.1);
+ .shadow-far;
+ }
+}
+.ms-row-select {
+
+ box-sizing: border-box;
+ .react-grid-Cell {
+ font-weight: bold;
+
+ background-color: darken(@ms2-color-background, 10%);
+ color: @ms2-color-primary;
+ &:focus {
+ outline: none;
+ }
+ }
+}
+.ms-card-success {
+ border-bottom: 4px solid @ms2-color-success;
+
+}
+.ms-card-danger {
+ border-bottom: 4px solid @ms2-color-danger;
+}
+.ms-allow-cell {
+ color: @ms2-color-text-primary;
+ background-color: @ms2-color-success;
+ text-align: center;
+ padding: 4px;
+}
+
+.ms-deny-cell {
+ color: @ms2-color-text-primary;
+ background-color: @ms2-color-danger;
+ text-align: center;
+ padding: 4px;
+}
+.ms-check-cell {
+ text-align: center;
+}
+
+.ms-grab-cell {
+ span {
+ display: block;
+ width: 8px;
+ overflow: hidden;
+ margin: 0 auto;
+ }
+}
+.ms-allow-row {
+ .react-grid-Cell {
+ border-bottom: 2px solid @ms2-color-success;
+ }
+}
+
+.ms-deny-row {
+ .react-grid-Cell {
+ border-bottom: 2px solid @ms2-color-danger;
+ }
+}
+
+.floating-btn {
+ position: absolute;
+ bottom: @square-btn-size + @square-btn-size / 2;
+ right: @square-btn-size / 2;
+ .shadow-far;
+}
+
+.ms-overlay {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 10;
+}
+
+.ms2-border-layout-content {
+ .ms-rule-editor {
+ &.container-fluid {
+ padding: 0 30px;
+ }
+ .mapstore-switch-panel {
+ padding-left: 10px;
+ padding-right: 10px;
+ width: 420px;
+ margin-left: auto;
+ margin-right: auto;
+ .shadow-soft;
+ &:first-child {
+ margin-top: 20px;
+ }
+ .ReactCodeMirror {
+ margin-bottom: 10px;
+ height: @square-btn-size * 3;
+ }
+ .row {
+ &:last-child {
+ margin-bottom: 10px;
+ }
+ }
+ }
+ .row {
+ margin-top: 15px;
+ display: flex;
+ .col-sm-4 {
+
+ display: flex;
+ align-items: center;
+ * {
+ flex: 1;
+ }
+ pre {
+ margin: 0;
+ padding: 6px;
+ }
+
+ }
+ .col-xs-12 {
+ &:first-child {
+ display: flex;
+ align-items: center;
+ * {
+ flex: 1;
+ }
+ }
+
+ .rw-state-disabled {
+ opacity: 0.3;
+ }
+ }
+ }
+ .form-group {
+ margin-bottom: 0;
+ }
+ }
+}
+
+.ms-panel-header-container {
+ .nav-tabs {
+ .disabled {
+ a {
+ color: fade(@ms2-color-text, 20%);
+ }
+ }
+ }
+ .ms-toolbar-container {
+ height: @square-btn-size;
+ width: 100%;
+ display: flex;
+ .btn-group {
+ margin: auto;
+ }
+ }
+}
+
+ .mapstore-side-card {
+ &.ms-no-select {
+ &:hover {
+ cursor: default;
+ transform: none;
+ .shadow;
+ }
+ }
+ }
+
+ .ms-style-modal {
+ & > div {
+ & > span {
+ .btn-group {
+ margin-top: 15px;
+ }
+ &:first-child {
+ padding: 15px;
+ }
+ }
+ }
+ .ms2-border-layout-content {
+ padding: 0 15px;
+ & > div:last-child {
+ margin-bottom: 15px;
+ }
+ }
+ }
+
+ .ms-add-style {
+ padding: 0 15px;
+ display: flex;
+ & > div:first-child {
+ flex: 1;
+ }
+ .glyphicon {
+
+ &:hover {
+
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/web/client/themes/default/ms2-theme.less b/web/client/themes/default/ms2-theme.less
index 2bd5222b35..3e0eecdd90 100644
--- a/web/client/themes/default/ms2-theme.less
+++ b/web/client/themes/default/ms2-theme.less
@@ -31,6 +31,7 @@
@import "./less/print.less";
@import "./less/query-panel.less";
@import "./less/react-data-grid.less";
+@import "./less/rulesmanager.less";
@import "./less/select.less";
@import "./less/settings.less";
@import "./less/sliders.less";
diff --git a/web/client/translations/data.de-DE b/web/client/translations/data.de-DE
index dbf0aca7a4..f5ac70e79c 100644
--- a/web/client/translations/data.de-DE
+++ b/web/client/translations/data.de-DE
@@ -1326,9 +1326,46 @@
"layerlabel": "Ebene"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Suche...",
+ "role": "Nach Rollen suchen",
+ "user": "Benutzer suchen",
+ "ip": "Wählen Sie IP",
+ "service": "Suchdienste",
+ "request": "Type to serach Requests",
+ "workspace": "Suchanfragen",
+ "layer": "Suche Ebenen",
+ "access": "Suchzugriff",
+ "ip": "###.###.###.###/##",
+ "priority": "Wählen Sie Priorität"
+ },
+ "menutitle": "GeoFence-Regeln Verwalten",
+ "tooltip": {
+ "addT": "Fügen Sie eine regel hinzu",
+ "editT": "Bearbeiten Sie die ausgewählte regel",
+ "addBeT": "Fügen Sie eine neue Regel hinzu, bevor Sie ausgewählt werden",
+ "addAfT": "Fügen Sie nach der Auswahl eine neue Regel hinzu",
+ "deleteT": "Entferne ausgewählte Regeln",
+ "cacheT": "Cache leeren",
+ "save": "Aktuelle Regel speichern",
+ "close": "Beenden Sie die Regel erstellen"
+ },
+ "navItems": {
+ "main": "Allgemeine Regel",
+ "style": "Stil",
+ "filter": "Filter",
+ "attribute": "Attribute Regel"
+ },
+ "cachetitle": "Cache leeren",
+ "cachemsg": "Möchten Sie den GeoFence-Cache wirklich löschen?",
+ "deltitle": "Regel Löschen",
+ "delmsg": "Möchten Sie diese Regel wirklich löschen?",
+ "invalidForm": "Das Formular ist ungültig. Überprüfen Sie die Felderwerte",
+ "ip": "IP",
"title": "Zugangs Regeln",
"role": "Rolle",
"user": "Benutzer",
+ "priority": "Priorität",
"service": "Service",
"request": "Anfrage",
"workspace": "Workspace",
@@ -1343,6 +1380,7 @@
"close": "Schließen",
"previous": "vorheriger",
"next": "nächster",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Fehler beim Laden der Rollen.",
"errorLoadingUsers": "Fehler beim Laden der Benutzer.",
"errorLoadingWorkspaces": "Fehler beim Laden der Workspaces.",
diff --git a/web/client/translations/data.en-US b/web/client/translations/data.en-US
index 8ebf89311e..272ab91163 100644
--- a/web/client/translations/data.en-US
+++ b/web/client/translations/data.en-US
@@ -1327,9 +1327,46 @@
"layerlabel": "Layer"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Search...",
+ "role": "Type to search Roles",
+ "user": "Type to search Users",
+ "ip": "Select IP",
+ "service": "Type to search Services",
+ "request": "Type to search Requests",
+ "workspace": "Type to search Workspaces",
+ "layer": "Type to search Layers",
+ "access": "Type to search Access",
+ "ip": "###.###.###.###/##",
+ "priority": "Select Priority"
+ },
+ "menutitle": "Manage GeoFence Rules",
+ "tooltip": {
+ "addT": "Add a rule",
+ "editT": "Edit selected rule",
+ "addBeT": "Add new rule before selected",
+ "addAfT": "Add new rule after selected",
+ "deleteT": "Remove selected rules",
+ "cacheT": "Clear cache",
+ "save": "Save current rule",
+ "close": "Exit from create rule"
+ },
+ "navItems": {
+ "main": "General Rule",
+ "style": "Style",
+ "filter": "Filters",
+ "attribute": "Attributes Rule"
+ },
+ "cachetitle": "Clear Cache",
+ "cachemsg": "Are you sure to clear the GeoFence cache?",
+ "deltitle": "Delete Rule",
+ "delmsg": "Do you really want to delete this rule?",
+ "invalidForm": "The form is invalid check fields values",
+ "ip": "IP",
"title": "Access Rules",
"role": "Role",
"user": "User",
+ "priority": "Priority",
"service": "Service",
"request": "Request",
"workspace": "Workspace",
@@ -1344,6 +1381,7 @@
"close": "Close",
"previous": "previous",
"next": "next",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Error loading roles.",
"errorLoadingUsers": "Error loading users.",
"errorLoadingWorkspaces": "Error loading workspaces.",
diff --git a/web/client/translations/data.es-ES b/web/client/translations/data.es-ES
index a123ccd2b1..890f3c9c26 100644
--- a/web/client/translations/data.es-ES
+++ b/web/client/translations/data.es-ES
@@ -1326,9 +1326,46 @@
"layerlabel": "Capa"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Buscar...",
+ "role": "Escriba para buscar Roles",
+ "user": "Escriba para buscar usuarios",
+ "ip": "Seleccionar IP",
+ "service": "Escriba para buscar Servicios",
+ "request": "Escriba para buscar Solicitudes",
+ "workspace": "Escriba para buscar espacios de trabajo",
+ "layer": "Escriba para buscar Capas",
+ "access": "Escriba para buscar Acceso",
+ "ip": "###.###.###.###/##",
+ "priority": "Seleccionar prioridad"
+ },
+ "menutitle": "Administrar reglas de GeoFence",
+ "tooltip": {
+ "addT": "Agregar una regla",
+ "editT": "Editar la regla seleccionada",
+ "addBeT": "Agregar nueva regla antes de seleccionar",
+ "addAfT": "Agregar nueva regla después de seleccionar",
+ "deleteT": "Eliminar las reglas seleccionadas",
+ "cacheT": "Limpiar cache",
+ "save": "Salir de crear regla",
+ "close": "Exit from create rule"
+ },
+ "navItems": {
+ "main": "Regla general",
+ "style": "Estilo",
+ "filter": "Filtros",
+ "attribute": "Regla de atributos"
+ },
+ "cachetitle": "Limpiar Cache",
+ "cachemsg": "¿Estás seguro de limpiar el caché GeoFence?",
+ "deltitle": "Eliminar Regla",
+ "delmsg": "¿De verdad quieres eliminar esta regla?",
+ "invalidForm": "El formulario no es válido para verificar los valores de los campos",
+ "ip": "IP",
"title": "Reglas de acceso",
"role": "Rol",
"user": "Ususario",
+ "priority": "Prioridad",
"service": "Servicio",
"request": "Peticion",
"workspace": "Espacio de trabajo",
@@ -1343,6 +1380,7 @@
"close": "Cerrar",
"previous": "previo",
"next": "próximo",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Error al cargar los roles.",
"errorLoadingUsers": "Error al cargar los usuarios.",
"errorLoadingWorkspaces": "Error al cargar los espacios de trabajo.",
diff --git a/web/client/translations/data.fr-FR b/web/client/translations/data.fr-FR
index c46eebd3d7..1c596947d2 100644
--- a/web/client/translations/data.fr-FR
+++ b/web/client/translations/data.fr-FR
@@ -1326,9 +1326,46 @@
"layerlabel": "Couche"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Chercher...",
+ "role": "Tapez pour rechercher des rôles",
+ "user": "Tapez pour rechercher des utilisateurs",
+ "ip": "Sélectionnez IP",
+ "service": "Tapez pour rechercher des services",
+ "request": "Tapez pour rechercher des demandes",
+ "workspace": "Tapez pour rechercher des espaces de travail",
+ "layer": "Tapez pour rechercher des calques",
+ "access": "Tapez pour rechercher Access",
+ "ip": "###.###.###.###/##",
+ "priority": "Sélectionnez Priorité"
+ },
+ "menutitle": "Gérer les règles de GeoFence",
+ "tooltip": {
+ "addT": "Ajouter une règle",
+ "editT": "Modifier la règle sélectionnée",
+ "addBeT": "Ajouter une nouvelle règle avant de sélectionner",
+ "addAfT": "Ajouter une nouvelle règle après la sélection",
+ "deleteT": "Supprimer les règles sélectionnées",
+ "cacheT": "Vider le cache",
+ "save": "Enregistrer la règle actuelle",
+ "close": "Quitter de la règle de création"
+ },
+ "navItems": {
+ "main": "Règle générale",
+ "style": "Style",
+ "filter": "Filters",
+ "attribute": "Règle des attributs"
+ },
+ "cachetitle": "Vider le Cache",
+ "cachemsg": "Etes-vous sûr d'effacer le cache de GeoFence?",
+ "deltitle": "Supprimer la règle",
+ "delmsg": "Voulez-vous vraiment supprimer cette règle?",
+ "invalidForm": "Le formulaire est invalide vérifier les valeurs des champs",
+ "ip": "IP",
"title": "Règles d'accès",
"role": "Rôle",
"user": "Utilisateur",
+ "priority": "Priorité",
"service": "Service",
"request": "Requête",
"workspace": "Espace de travail",
@@ -1343,6 +1380,7 @@
"close": "Fermer",
"previous": "précédent",
"next": "prochain",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Erreur lors du chargement des rôles.",
"errorLoadingUsers": "Erreur lors du chargement des utilisateurs.",
"errorLoadingWorkspaces": "Erreur lors du chargement des workspaces.",
diff --git a/web/client/translations/data.hr-HR b/web/client/translations/data.hr-HR
index 70a2d0e6ca..3f36e58fb5 100644
--- a/web/client/translations/data.hr-HR
+++ b/web/client/translations/data.hr-HR
@@ -1279,6 +1279,10 @@
"layerlabel": "Sloj"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Traži..."
+ },
+ "menutitle": "Upravljanje pravilima GeoFence",
"title": "Pravila pristupa",
"role": "Rola",
"user": "Korisnik",
@@ -1296,6 +1300,7 @@
"close": "Zatvori",
"previous": "prethodno",
"next": "slijedeće",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Greška prilikom učitavanja rola.",
"errorLoadingUsers": "Greška prilikom učitavanja korisnika.",
"errorLoadingWorkspaces": "Greška prilikom učitavanja radnih okolina.",
diff --git a/web/client/translations/data.it-IT b/web/client/translations/data.it-IT
index 2c8f04d1ab..8cae34b019 100644
--- a/web/client/translations/data.it-IT
+++ b/web/client/translations/data.it-IT
@@ -1325,38 +1325,76 @@
"paneltitle": "Styler",
"layerlabel": "Layer"
},
- "rulesmanager": {
- "title": "Regole Di Accesso",
- "role": "Ruolo",
- "user": "Utente",
- "service": "Servizio",
- "request": "Richiesta",
- "workspace": "Workspace",
- "layer": "Layer",
- "filters": "Filtri",
- "rules": "Regole",
- "access": "Accesso",
- "newModal": "Nuova Regola",
- "editModal": "Modifica Regola",
- "newButton": "Crea",
- "editButton": "Salva",
- "close": "Chiudi",
- "previous": "precedente",
- "next": "prossima",
- "errorLoadingRoles": "Errore durante il caricamento dei ruoli",
- "errorLoadingUsers": "Errore durante il caricamento degli utenti.",
- "errorLoadingWorkspaces": "Errore durante il caricamento dei workspaces.",
- "errorLoadingLayers": "Errore durante il caricamento dei layers.",
- "errorLoadingRules": "Errore durante il caricamento delle regole.",
- "errorMovingRules": "Errore durante il caricamento delle regole.",
- "errorDeletingRules": "Errore durante la cancellazione della regola.",
- "errorAddingRule": "Errore durante la creazione della regola.",
- "errorUpdatingRule": "Errore durante l'aggiornamento della regola.",
- "deleteModal": "Elimina Regola",
- "selectedRulesDelete": "Vuoi eliminare le regole selezionate?",
- "deleteButton": "Elimina",
- "cancelButton": "Annulla"
+ "rulesmanager": {
+ "placeholders": {
+ "filter": "Ricerca...",
+ "role": "Cerca Gruppo",
+ "user": "Cerca Utenti",
+ "ip": "Seleziona IP",
+ "service": "Cerca Servizi",
+ "request": "Cerca Richieste",
+ "workspace": "Cerca Workspacs",
+ "layer": "Cerca Layers",
+ "access": "Cerca Permesso",
+ "ip": "###.###.###.###/##",
+ "priority": "Seleziona Priorità"
+ },
+ "menutitle": "Configura GeoFence Rules",
+ "tooltip": {
+ "addT": "Crea regola",
+ "editT": "Modifica regola selezionata",
+ "addBeT": "Crea regola prima di quella selezionata",
+ "addAfT": "Crea regola dopo quella selezionata",
+ "deleteT": "Rimuovi regole selezionate",
+ "cacheT": "Pulisci la cache",
+ "save": "Salva regola corrente",
+ "close": "Esci da modifica regola"
},
+ "navItems": {
+ "main": "Regola Generica",
+ "style": "Stile",
+ "filter": "Filtri",
+ "attribute": "Attributi"
+ },
+ "cachetitle": "Pulisci Cache",
+ "cachemsg": "Sei sicuro di voler rimuovere la cache di Geofance?",
+ "deltitle": "Rimuovi Regola",
+ "delmsg": "Sei sicuro di voler rimuovere la regola?",
+ "invalidForm": "I campi inseriti non sono validi, controlla la form",
+ "ip": "IP",
+ "title": "Regole Di Accesso",
+ "role": "Ruolo",
+ "user": "Utente",
+ "priority": "Priorità",
+ "service": "Servizio",
+ "request": "Richiesta",
+ "workspace": "Workspace",
+ "layer": "Layer",
+ "filters": "Filtri",
+ "rules": "Regole",
+ "access": "Accesso",
+ "newModal": "Nuova Regola",
+ "editModal": "Modifica Regola",
+ "newButton": "Crea",
+ "editButton": "Salva",
+ "close": "Chiudi",
+ "previous": "precedente",
+ "next": "prossima",
+ "errorTitle": "Geofence",
+ "errorLoadingRoles": "Errore durante il caricamento dei ruoli",
+ "errorLoadingUsers": "Errore durante il caricamento degli utenti.",
+ "errorLoadingWorkspaces": "Errore durante il caricamento dei workspaces.",
+ "errorLoadingLayers": "Errore durante il caricamento dei layers.",
+ "errorLoadingRules": "Errore durante il caricamento delle regole.",
+ "errorMovingRules": "Errore durante il caricamento delle regole.",
+ "errorDeletingRules": "Errore durante la cancellazione della regola.",
+ "errorAddingRule": "Errore durante la creazione della regola.",
+ "errorUpdatingRule": "Errore durante l'aggiornamento della regola.",
+ "deleteModal": "Elimina Regola",
+ "selectedRulesDelete": "Vuoi eliminare le regole selezionate?",
+ "deleteButton": "Elimina",
+ "cancelButton": "Annulla"
+ },
"tutorial": {
"title": "Tutorial",
"back": "Indietro",
diff --git a/web/client/translations/data.nl-NL b/web/client/translations/data.nl-NL
index e63013c242..e2a1f31fd0 100644
--- a/web/client/translations/data.nl-NL
+++ b/web/client/translations/data.nl-NL
@@ -1059,6 +1059,10 @@
"layerlabel": "Laag"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Zoeken..."
+ },
+ "menutitle": "Beheer GeoFence Rules",
"title": "Toegangsregels",
"role": "Rol",
"user": "Gebruiker",
@@ -1076,6 +1080,7 @@
"close": "Sluiten",
"previous": "vorige",
"next": "volgende",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Fout bij laden van de rollen.",
"errorLoadingUsers": "Fout bij laden van de gebruikers.",
"errorLoadingWorkspaces": "Fout bij laden van workspaces.",
diff --git a/web/client/translations/data.zh-ZH b/web/client/translations/data.zh-ZH
index 92e38e4999..62b6134f62 100644
--- a/web/client/translations/data.zh-ZH
+++ b/web/client/translations/data.zh-ZH
@@ -1122,6 +1122,10 @@
"layerlabel": "Layer"
},
"rulesmanager": {
+ "placeholders": {
+ "filter": "Search..."
+ },
+ "menutitle": "Manage GeoFence Rules",
"title": "Access Rules",
"role": "Role",
"user": "User",
@@ -1139,6 +1143,7 @@
"close": "Close",
"previous": "previous",
"next": "next",
+ "errorTitle": "Geofence",
"errorLoadingRoles": "Error loading roles.",
"errorLoadingUsers": "Error loading users.",
"errorLoadingWorkspaces": "Error loading workspaces.",
diff --git a/web/client/utils/RulesGridUtils.js b/web/client/utils/RulesGridUtils.js
new file mode 100644
index 0000000000..5b70d37225
--- /dev/null
+++ b/web/client/utils/RulesGridUtils.js
@@ -0,0 +1,82 @@
+ /**
+ * Copyright 2018, 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.
+ */
+
+
+const EMPTY_ROW = {id: "empty_row", get: () => undefined};
+const getPageIdx = (i, size) => Math.floor(i / size);
+const getRow = (i, pages = {}, size) => {
+ const pIdx = getPageIdx(i, size);
+ return pages[pIdx] && pages[pIdx][i - (pIdx * size)] || EMPTY_ROW;
+};
+
+/**
+ * Sort old pages by index distance from the average needed pages
+ * @param {numeric} avgIdx The average page index
+ * @param {array} [pages=[]] old pages indexes
+ * @param {numeric} firstIdx index of the first needed page
+ * @param {numeric} lastIdx index of the latest needed page
+ * @returns {array} Array of old page indexes ordered by distance from requested pages
+ */
+const getIdxFarthestEl = (avgIdx, pages = [], firstIdx, lastIdx) => {
+ return pages.map(val => firstIdx <= val && val <= lastIdx ? 0 : Math.abs(val - avgIdx)).map((distance, idx) => ({idx: pages[idx], distance})).sort((a, b) => a.distance - b.distance).reverse().map(({idx}) => idx);
+};
+
+const checkIp = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.)){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?(\/)(?:3[0-2]|[1-2]?[0-9]))\b/g;
+module.exports = {
+ getPageIdx,
+ getRow,
+ getPagesToLoad: (startPage, endPage, pages) => {
+ let needPages = [];
+ for (let i = startPage; i <= endPage; i++) {
+ if (!pages[i]) {
+ needPages.push(i);
+ }
+ }
+ return needPages;
+ },
+ /**
+ * updates the pages and the rules of the request to support virtual scroll.
+ * This is virtual scroll with support for
+ * @param {object} newPages An object with page index as key and an array of rules ad value
+ * @param {object} requestOptions contains startPage and endPage needed.
+ * @param {object} oldPages pages previously loaded
+ * @param {numeric} maxStoredPages. the max number of page to cache, .
+ *
+ */
+ updatePages: (newPages = {}, {startPage, endPage, pages: oldPages}, maxStoredPages = 5) => {
+ const newPageLength = Object.keys(newPages).length;
+ const oldPageLength = Object.keys(oldPages).length;
+ // Cached page should be less than the max of maxStoredPages or the number of page needed to fill the visible are of the grid
+ const nSpaces = oldPageLength + newPageLength - Math.max(maxStoredPages, (endPage - startPage + 1));
+ let tempPages = {...oldPages};
+ if (nSpaces > 0) {
+ // remove farhest pages
+ const pages = Object.keys(oldPages);
+ // Remove the farthest page from last loaded pages
+ const averageIdx = startPage + endPage / 2;
+ const farthestElindexes = getIdxFarthestEl(averageIdx, pages, startPage, endPage);
+ for (let i = 0; i < nSpaces; i++) {
+ delete tempPages[farthestElindexes[i]];
+ }
+ }
+ return { pages: {...tempPages, ...newPages}};
+ },
+ flattenPages: (pages = {}) => Object.keys(pages).reduce((rows, key) => rows.concat((pages[key] || [])), []),
+ checkIp,
+ isRuleValid: (rule = {}) => {
+ if (rule.ipaddress && rule.ipaddress.length > 0 ) {
+ return !!rule.ipaddress.match(checkIp);
+ }
+ return true;
+ },
+ getOffsetFromTop: (row, rows) => rows.indexOf(row),
+ getClosestRows: (row, rows) => {
+ const idx = rows.indexOf(row);
+ return {prev: rows[idx - 1], next: rows[idx + 1]};
+ }
+};