diff --git a/TableWidget.jsx b/TableWidget.jsx new file mode 100644 index 0000000000..5a74058ff0 --- /dev/null +++ b/TableWidget.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 Message = require('../../I18N/Message'); +const loadingState = require('../../misc/enhancers/loadingState'); +const FeatureGrid = loadingState(({ describeFeatureType }) => !describeFeatureType)(require('../../data/featuregrid/FeatureGrid')); +const InfoPopover = require('./InfoPopover'); + +const WidgetContainer = require('./WidgetContainer'); +const { + Glyphicon, + ButtonToolbar, + DropdownButton, + MenuItem +} = require('react-bootstrap'); + +const renderHeaderLeftTopItem = ({ title, description }) => { + return title || description ? : null; +}; + + +module.exports = ({ + id, + title, + description, + loading, + confirmDelete = false, + toggleTableView = () => { }, + toggleDeleteConfirm = () => { }, + exportCSV = () => { }, + onEdit = () => { }, + onDelete = () => { }, + pageEvents = { + moreFeatures: () => {} + }, + describeFeatureType, + features, + size, + pages, + pagination = {}, + virtualScroll = true +}) => + ( + } noCaret id="dropdown-no-caret"> + toggleTableView()} eventKey="1">  + onEdit()} eventKey="3">  + toggleDeleteConfirm(true)} eventKey="2">  + exportCSV({ title })} eventKey="4">  + + }> + + + + ); diff --git a/web/client/actions/__tests__/widgets-test.js b/web/client/actions/__tests__/widgets-test.js index 04e353d0f3..bf3eaac557 100644 --- a/web/client/actions/__tests__/widgets-test.js +++ b/web/client/actions/__tests__/widgets-test.js @@ -11,6 +11,7 @@ const { NEW, INSERT, UPDATE, + UPDATE_PROPERTY, DELETE, CHANGE_LAYOUT, EDIT, @@ -27,6 +28,7 @@ const { createWidget, insertWidget, updateWidget, + updateWidgetProperty, deleteWidget, changeLayout, editWidget, @@ -84,6 +86,15 @@ describe('Test correctness of the widgets actions', () => { expect(retval.widget).toBe(widget); expect(retval.target).toBe(DEFAULT_TARGET); }); + it('updateWidgetProperty', () => { + const retval = updateWidgetProperty("id", "key", "value"); + expect(retval).toExist(); + expect(retval.type).toBe(UPDATE_PROPERTY); + expect(retval.id).toBe("id"); + expect(retval.key).toBe("key"); + expect(retval.value).toBe("value"); + expect(retval.target).toBe(DEFAULT_TARGET); + }); it('deleteWidget', () => { const widget = {}; const retval = deleteWidget(widget); diff --git a/web/client/actions/widgets.js b/web/client/actions/widgets.js index 72180a4b94..f7bd49e3bf 100644 --- a/web/client/actions/widgets.js +++ b/web/client/actions/widgets.js @@ -13,6 +13,7 @@ const EDIT_NEW = "WIDGETS:EDIT_NEW"; const EDITOR_CHANGE = "WIDGETS:EDITOR_CHANGE"; const EDITOR_SETTING_CHANGE = "WIGETS:EDITOR_SETTING_CHANGE"; const UPDATE = "WIDGETS:UPDATE"; +const UPDATE_PROPERTY = "WIDGETS:UPDATE_PROPERTY"; const CHANGE_LAYOUT = "WIDGETS:CHANGE_LAYOUT"; const DELETE = "WIDGETS:DELETE"; const CLEAR_WIDGETS = "WIDGETS:CLEAR_WIDGETS"; @@ -56,6 +57,20 @@ const updateWidget = (widget, target = DEFAULT_TARGET) => ({ target, widget }); +/** + * Update a widget property in the provided target + * @param {string} id The widget id to update + * @param {string} key The widget property name or path to update + * @param {any} value the widget value to update + * @return {object} action with type `WIDGETS:UPDATE_PROPERTY`, the widget and the target + */ +const updateWidgetProperty = (id, key, value, target = DEFAULT_TARGET) => ({ + type: UPDATE_PROPERTY, + id, + target, + key, + value +}); /** * Deletes a widget from the passed target * @param {object} widget The widget template to start with @@ -100,7 +115,7 @@ const editWidget = (widget) => ({ * Edit new widget. Initializes the widget builder properly * @param {object} widget The widget template * @param {object} settings The settings for the template - * @return {object} the action of type `WIGETS:EDIT_NEW` + * @return {object} the action of type `WIGETS:EDIT_NEW` */ const editNewWidget = (widget, settings) => ({ type: EDIT_NEW, @@ -157,6 +172,7 @@ module.exports = { NEW, INSERT, UPDATE, + UPDATE_PROPERTY, DELETE, CLEAR_WIDGETS, CHANGE_LAYOUT, @@ -174,6 +190,7 @@ module.exports = { createWidget, insertWidget, updateWidget, + updateWidgetProperty, deleteWidget, clearWidgets, changeLayout, diff --git a/web/client/components/data/featuregrid/AttributeTable.jsx b/web/client/components/data/featuregrid/AttributeTable.jsx new file mode 100644 index 0000000000..b27e8c0510 --- /dev/null +++ b/web/client/components/data/featuregrid/AttributeTable.jsx @@ -0,0 +1,39 @@ +/* + * 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 ReactDataGrid = require('react-data-grid'); +const Message = require('../../I18N/Message'); + +module.exports = ({ + style = {}, + titleMsg = "featuregrid.columns", + onChange = () => { }, + attributes = [] +} = {}) => ( +
+

+ attributes[idx]} + rowsCount={attributes.length} + rowSelection={{ + showCheckbox: true, + enableShiftSelect: true, + onRowsSelected: rows => onChange(rows.map(row => attributes[row.rowIdx].name), false), + onRowsDeselected: rows => onChange(rows.map(row => attributes[row.rowIdx].name), true), + selectBy: { + indexes: attributes.reduce( (acc, a, idx) => [...acc, ...(a.hide ? [] : [idx] )], []) + } + }} /> +
+ ); diff --git a/web/client/components/data/featuregrid/FeatureGrid.jsx b/web/client/components/data/featuregrid/FeatureGrid.jsx index 05799b5969..27d569ae81 100644 --- a/web/client/components/data/featuregrid/FeatureGrid.jsx +++ b/web/client/components/data/featuregrid/FeatureGrid.jsx @@ -57,6 +57,7 @@ class FeatureGrid extends React.PureComponent { }; static defaultProps = { editingAllowedRoles: ["ADMIN"], + initPlugin: () => {}, autocompleteEnabled: false, gridComponent: AdaptiveGrid, changes: {}, @@ -73,6 +74,7 @@ class FeatureGrid extends React.PureComponent { constructor(props) { super(props); } + // TODO: externalize initPlugin componentDidMount() { this.props.initPlugin({virtualScroll: this.props.virtualScroll, editingAllowedRoles: this.props.editingAllowedRoles, maxStoredPages: this.props.maxStoredPages}); } diff --git a/web/client/components/data/featuregrid/__tests__/AttributeTable-test.jsx b/web/client/components/data/featuregrid/__tests__/AttributeTable-test.jsx new file mode 100644 index 0000000000..4a85bdcef5 --- /dev/null +++ b/web/client/components/data/featuregrid/__tests__/AttributeTable-test.jsx @@ -0,0 +1,56 @@ +/* + * 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. + */ +var React = require('react'); +var ReactDOM = require('react-dom'); +var AttributeSelector = require('../AttributeTable'); +var expect = require('expect'); +const spyOn = expect.spyOn; + + +describe('Test for AttributeTable 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 el = document.getElementsByClassName("data-attribute-selector")[0]; + expect(el).toExist(); + }); + it('render with attributes, checked by default', () => { + ReactDOM.render(, document.getElementById("container")); + const check = document.getElementsByTagName("input")[1]; + expect(check).toExist(); + expect(check.checked).toBe(true); + }); + it('check hide is not selected', () => { + ReactDOM.render(, document.getElementById("container")); + const checks = document.getElementsByTagName("input"); + expect(checks.length).toBe(2); + expect(checks[0].checked).toBe(false); + }); + it('click event', () => { + const events = { + onChange: () => {} + }; + spyOn(events, "onChange"); + ReactDOM.render(, document.getElementById("container")); + const checks = document.getElementsByTagName("input"); + expect(checks.length).toBe(2); + checks[0].click(); + expect(events.onChange).toHaveBeenCalled(); + + }); + +}); diff --git a/web/client/components/data/featuregrid/enhancers/editor.js b/web/client/components/data/featuregrid/enhancers/editor.js index f0ffd32072..266e6888f8 100644 --- a/web/client/components/data/featuregrid/enhancers/editor.js +++ b/web/client/components/data/featuregrid/enhancers/editor.js @@ -44,6 +44,7 @@ const dataStreamFactory = $props => { const featuresToGrid = compose( defaultProps({ + sortable: true, autocompleteEnabled: false, initPlugin: () => {}, url: "", @@ -88,7 +89,7 @@ const featuresToGrid = compose( withPropsOnChange( ["features", "newFeatures", "changes"], props => ({ - rows: ( [...props.newFeatures, ...props.features] : props.features) + rows: (props.newFeatures ? [...props.newFeatures, ...props.features] : props.features) .filter(props.focusOnEdit ? createNewAndEditingFilter(props.changes && Object.keys(props.changes).length > 0, props.newFeatures, props.changes) : () => true) .map(orig => applyAllChanges(orig, props.changes)).map(result => ({...result, @@ -112,12 +113,13 @@ const featuresToGrid = compose( ), withHandlers({rowGetter: props => props.virtualScroll && (i => getRowVirtual(i, props.rows, props.pages, props.size)) || (i => getRow(i, props.rows))}), withPropsOnChange( - ["describeFeatureType", "columnSettings", "tools", "actionOpts", "mode", "isFocused"], + ["describeFeatureType", "columnSettings", "tools", "actionOpts", "mode", "isFocused", "sortable"], props => ({ columns: getToolColumns(props.tools, props.rowGetter, props.describeFeatureType, props.actionOpts) .concat(featureTypeToGridColumns(props.describeFeatureType, props.columnSettings, { editable: props.mode === "EDIT", - sortable: !props.isFocused + sortable: props.sortable && !props.isFocused, + defaultSize: props.defaultSize }, { getEditor: (desc) => { const generalProps = { @@ -147,7 +149,7 @@ const featuresToGrid = compose( }) ), withPropsOnChange( - ["gridOpts", "describeFeatureType", "actionOpts", "mode", "select"], + ["gridOpts", "describeFeatureType", "actionOpts", "mode", "select", "columns"], props => { // bind proper events and setup the colums array // bind and get proper grid events from gridEvents object @@ -156,7 +158,7 @@ const featuresToGrid = compose( onRowsDeselected = () => {}, onRowsToggled = () => {}, hasTemporaryChanges = () => {}, - ...gridEvents} = getGridEvents(props.gridEvents, props.rowGetter, props.describeFeatureType, props.actionOpts); + ...gridEvents} = getGridEvents(props.gridEvents, props.rowGetter, props.describeFeatureType, props.actionOpts, props.columns); // setup gridOpts setting app selection events binded let gridOpts = props.gridOpts; diff --git a/web/client/components/data/grid/DataGrid.jsx b/web/client/components/data/grid/DataGrid.jsx index 3dd9282b5d..0467709b5d 100644 --- a/web/client/components/data/grid/DataGrid.jsx +++ b/web/client/components/data/grid/DataGrid.jsx @@ -84,7 +84,9 @@ class DataGrid extends Grid { const visibleRows = Math.ceil(this.canvas.clientHeight / this.props.rowHeight); const firstRowIdx = Math.floor(this.canvas.scrollTop / this.props.rowHeight); const lastRowIdx = firstRowIdx + visibleRows; - this.props.onGridScroll({firstRowIdx, lastRowIdx}); + if (this.props.onGridScroll) { + this.props.onGridScroll({ firstRowIdx, lastRowIdx }); + } } setCanvasListner = () => { this.canvas = ReactDOM.findDOMNode(this).querySelector('.react-grid-Canvas'); diff --git a/web/client/components/misc/wizard/__tests__/enhanchers-test.jsx b/web/client/components/misc/wizard/__tests__/enhanchers-test.jsx index 31341d2d92..583339a3ae 100644 --- a/web/client/components/misc/wizard/__tests__/enhanchers-test.jsx +++ b/web/client/components/misc/wizard/__tests__/enhanchers-test.jsx @@ -5,14 +5,14 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. */ -const {wizardHanlders} = require('../enhancers'); +const {wizardHandlers} = require('../enhancers'); const React = require('react'); const ReactDOM = require('react-dom'); const ReactTestUtils = require('react-dom/test-utils'); const expect = require('expect'); -const WizardContainer = wizardHanlders(require('../WizardContainer')); +const WizardContainer = wizardHandlers(require('../WizardContainer')); describe('wizard enhancers ', () => { beforeEach((done) => { diff --git a/web/client/components/misc/wizard/enhancers.js b/web/client/components/misc/wizard/enhancers.js index b00e50f97d..b3449010e0 100644 --- a/web/client/components/misc/wizard/enhancers.js +++ b/web/client/components/misc/wizard/enhancers.js @@ -8,7 +8,7 @@ const {compose, withState, withPropsOnChange, withHandlers} = require('recompose'); -const wizardHanlders = compose( +const wizardHandlers = compose( withPropsOnChange(["step"], ({skipButtonsOnSteps = [], step, hideButtons} = {}) => { if (skipButtonsOnSteps && skipButtonsOnSteps.indexOf(step) >= 0) { return {hideButtons: true}; @@ -26,18 +26,18 @@ const wizardHanlders = compose( ); module.exports = { /** - * Apply this enhancer to the WizarContainer to make it controlled. - * It controls the step and the hideButtons paroperties + * Apply this enhancer to the WizardContainer to make it controlled. + * It controls the step and the hideButtons properties */ controlledWizard: compose( withState( "step", "setPage", 0 ), - wizardHanlders + wizardHandlers ), /** * Use this enhancer if you want to change step and use setPage as handler */ - wizardHanlders + wizardHandlers }; diff --git a/web/client/components/widgets/builder/WidgetTypeSelector.jsx b/web/client/components/widgets/builder/WidgetTypeSelector.jsx index c95869e8ef..e491898dc7 100644 --- a/web/client/components/widgets/builder/WidgetTypeSelector.jsx +++ b/web/client/components/widgets/builder/WidgetTypeSelector.jsx @@ -21,6 +21,11 @@ const DEFAULT_TYPES = [{ type: "text", glyph: "sheet", caption: +}, { + title: , + type: "table", + glyph: "features-grid", + caption: }]; module.exports = ({widgetTypes = DEFAULT_TYPES, typeFilter = () => true, onSelect= () => {}}) => diff --git a/web/client/components/widgets/builder/wizard/ChartWizard.jsx b/web/client/components/widgets/builder/wizard/ChartWizard.jsx index e71321d2ff..348f5cde67 100644 --- a/web/client/components/widgets/builder/wizard/ChartWizard.jsx +++ b/web/client/components/widgets/builder/wizard/ChartWizard.jsx @@ -7,7 +7,7 @@ */ const React = require('react'); -const {wizardHanlders} = require('../../../misc/wizard/enhancers'); +const {wizardHandlers} = require('../../../misc/wizard/enhancers'); const loadingState = require('../../../misc/enhancers/loadingState')(({loading, data}) => loading || !data, {width: 500, height: 200}); const ChartType = require('./chart/ChartType'); @@ -38,7 +38,7 @@ const sampleProps = { const isChartOptionsValid = (options = {}) => options.aggregateFunction && options.aggregationAttribute && options.groupByAttributes; -const Wizard = wizardHanlders(require('../../../misc/wizard/WizardContainer')); +const Wizard = wizardHandlers(require('../../../misc/wizard/WizardContainer')); const renderPreview = ({data = {}, layer, dependencies={}, setValid = () => {}}) => isChartOptionsValid(data.options) diff --git a/web/client/components/widgets/builder/wizard/TableWizard.jsx b/web/client/components/widgets/builder/wizard/TableWizard.jsx new file mode 100644 index 0000000000..a77a257451 --- /dev/null +++ b/web/client/components/widgets/builder/wizard/TableWizard.jsx @@ -0,0 +1,50 @@ +/** + * 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 { wizardHandlers } = require('../../../misc/wizard/enhancers'); + +const TableOptions = require('./table/TableOptions'); +const WidgetOptions = require('./common/WidgetOptions'); +const isChartOptionsValid = (options = {}) => options.aggregateFunction && options.aggregationAttribute && options.groupByAttributes; + +const Wizard = wizardHandlers(require('../../../misc/wizard/WizardContainer')); + +const { compose, lifecycle } = require('recompose'); + +const triggerValidationReset = compose(lifecycle({ + componentWillReceiveProps: ({ data = {}, valid, setValid = () => { } } = {}) => { + if (valid && !isChartOptionsValid(data.options)) { + setValid(false); + } + } +}) +); + +module.exports = triggerValidationReset(({ onChange = () => { }, onFinish = () => { }, setPage = () => { }, data = {}, layer = {}, step = 0, types, featureTypeProperties, dependencies }) => + ( n === 1 ? isChartOptionsValid(data.options) : true} hideButtons> + + + )); diff --git a/web/client/components/widgets/builder/wizard/TextWizard.jsx b/web/client/components/widgets/builder/wizard/TextWizard.jsx index 02db85f994..1ecb29006d 100644 --- a/web/client/components/widgets/builder/wizard/TextWizard.jsx +++ b/web/client/components/widgets/builder/wizard/TextWizard.jsx @@ -6,9 +6,9 @@ * LICENSE file in the root directory of this source tree. */ const React = require('react'); -const {wizardHanlders} = require('../../../misc/wizard/enhancers'); +const {wizardHandlers} = require('../../../misc/wizard/enhancers'); const TextOptions = require('./text/TextOptions'); -const Wizard = wizardHanlders(require('../../../misc/wizard/WizardContainer')); +const Wizard = wizardHandlers(require('../../../misc/wizard/WizardContainer')); module.exports = ({ diff --git a/web/client/components/widgets/builder/wizard/__tests__/TableWizard-test.jsx b/web/client/components/widgets/builder/wizard/__tests__/TableWizard-test.jsx new file mode 100644 index 0000000000..6bdc1cdc77 --- /dev/null +++ b/web/client/components/widgets/builder/wizard/__tests__/TableWizard-test.jsx @@ -0,0 +1,37 @@ +/* + * 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 ReactDOM = require('react-dom'); + +const expect = require('expect'); +const TableWizard = require('../TableWizard'); +describe('ChartWizard component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('ChartWizard rendering with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.ms-wizard'); + expect(el).toExist(); + expect(container.querySelector('.chart-options-form')).toExist(); + }); + it('ChartWizard rendering options', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.widget-options-form'); + expect(el).toExist(); + }); +}); diff --git a/web/client/components/widgets/builder/wizard/table/TableOptions.jsx b/web/client/components/widgets/builder/wizard/table/TableOptions.jsx new file mode 100644 index 0000000000..ce1f68c5a4 --- /dev/null +++ b/web/client/components/widgets/builder/wizard/table/TableOptions.jsx @@ -0,0 +1,76 @@ + /* + * 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 { isGeometryType } = require('../../../../../utils/ogc/WFS/base'); +const { uniq, castArray, includes } = require('lodash'); +const { Row, Col, Form, FormGroup, ControlLabel, Button } = require('react-bootstrap'); +const Message = require('../../../../I18N/Message'); +const StepHeader = require('../../../../misc/wizard/StepHeader'); +const SwitchButton = require('../../../../misc/switch/SwitchButton'); + +const {withProps, withHandlers, compose} = require('recompose'); +const updatePropertyName = (arr, name, hide) => { + const names = castArray(name); + if (hide) { + return arr.filter(e => !includes(names, e)); + } + return uniq([...arr, ...names]); +}; +const AttributeSelector = compose(withProps( + ({ attributes = [], options = {}} = {}) => ({ // TODO manage hide condition + attributes: attributes + .filter(a => !isGeometryType(a)) + .map( a => ({ + ...a, + label: a.name, + attribute: a.name, + hide: options.propertyName && (options.propertyName.indexOf( a.name ) < 0) + }) + ) + })), + withHandlers({ + onChange: ({ onChange = () => {}, options = {}}) => (name, hide) => onChange("options.propertyName", updatePropertyName(options && options.propertyName || [], name, hide)) + }) +)(require('../../../../data/featuregrid/AttributeTable')); + + +module.exports = ({ data = { options: {} }, onChange = () => { }, dependencies, featureTypeProperties, sampleChart}) => ( + } /> + +
+ {sampleChart} +
+ + +
+ + {dependencies && dependencies.viewport + ? ( + + + + + { + onChange("mapSync", val); + }} + /> + + ) + : null} + {data.options && data.options.columnSettings + ? + : null + } + + +
); diff --git a/web/client/components/widgets/builder/wizard/table/Toolbar.jsx b/web/client/components/widgets/builder/wizard/table/Toolbar.jsx new file mode 100644 index 0000000000..29bf974086 --- /dev/null +++ b/web/client/components/widgets/builder/wizard/table/Toolbar.jsx @@ -0,0 +1,59 @@ +/* + * 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 Toolbar = require('../../../../misc/toolbar/Toolbar'); +const isValidStep1 = editorData => editorData && editorData.options && editorData.options.propertyName && editorData.options.propertyName.length !== 0; + +const getBackTooltipId = step => { + switch (step) { + case 1: + return "widgets.builder.wizard.backToTableOptions"; + case 2: + return "widgets.builder.wizard.backToChartOptions"; + default: + return "back"; + + } +}; + +const getNextTooltipId = (step, valid) => valid ? "widgets.builder.wizard.configureWidgetOptions" : "widget.builder.wizard.errors.checkAtLeastOneAttribute"; + +const getSaveTooltipId = (step, {id} = {}) => { + if (id) { + return "widgets.builder.wizard.updateWidget"; + } + return "widgets.builder.wizard.addToTheMap"; +}; +module.exports = ({openFilterEditor = () => {}, step = 0, editorData = {}, setPage = () => {}, onFinish = () => {}} = {}) => ( setPage(Math.max(0, step - 1)), + visible: step > 0, + glyph: "arrow-left", + tooltipId: getBackTooltipId(step) + }, { + visible: step >= 0, + onClick: openFilterEditor, + glyph: "filter", + tooltipId: "widgets.builder.setupFilter" + }, { + onClick: () => setPage(Math.min(step + 1, 2)), + visible: step === 0, + disabled: step === 0 && !isValidStep1(editorData), + glyph: "arrow-right", + tooltipId: getNextTooltipId(step, isValidStep1(editorData)) + }, { + onClick: () => onFinish(Math.min(step + 1, 1)), + visible: step === 1, + glyph: "floppy-disk", + tooltipId: getSaveTooltipId(step, editorData) + }]} />); diff --git a/web/client/components/widgets/builder/wizard/table/__tests__/TableOptions-test.jsx b/web/client/components/widgets/builder/wizard/table/__tests__/TableOptions-test.jsx new file mode 100644 index 0000000000..1f1700cc5e --- /dev/null +++ b/web/client/components/widgets/builder/wizard/table/__tests__/TableOptions-test.jsx @@ -0,0 +1,68 @@ +/* + * 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 ReactDOM = require('react-dom'); +const { get } = require('lodash'); +const describeStates = require('json-loader!../../../../../../test-resources/wfs/describe-states.json'); +const ReactTestUtils = require('react-dom/test-utils'); +const expect = require('expect'); + +const TableOptions = require('../TableOptions'); +describe('TableOptions component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('TableOptions rendering with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.chart-options-form'); + expect(el).toExist(); + const resetButton = document.querySelector('.btn'); + expect(resetButton).toNotExist(); + }); + it('Test TableOptions tools visibility', () => { + + ReactDOM.render(, document.getElementById("container")); + const switchEl = document.querySelector('.mapstore-switch-btn'); + expect(switchEl).toExist(); + const resetButton = document.querySelector('.btn'); + expect(resetButton).toExist(); + + }); + it('Test TableOptions onChange', () => { + const actions = { + onChange: () => { } + }; + const spyonChange = expect.spyOn(actions, 'onChange'); + ReactDOM.render(, document.getElementById("container")); + const inputs = document.querySelectorAll('input'); + expect(inputs.length).toBe(16); // 15 + 1 mapSync because dependencies is present + ReactTestUtils.Simulate.change(inputs[15]); + expect(spyonChange.calls[0].arguments[0]).toBe("mapSync"); + expect(spyonChange.calls[0].arguments[1]).toBe(true); + const resetButton = document.querySelector('.btn'); + expect(resetButton).toExist(); + ReactTestUtils.Simulate.click(resetButton); + expect(spyonChange.calls[1].arguments[0]).toBe("options.columnSettings"); + expect(spyonChange.calls[1].arguments[1]).toBe(undefined); + }); +}); diff --git a/web/client/components/widgets/enhancers/builderConfiguration.jsx b/web/client/components/widgets/enhancers/builderConfiguration.jsx index 30ea0af563..ba96638507 100644 --- a/web/client/components/widgets/enhancers/builderConfiguration.jsx +++ b/web/client/components/widgets/enhancers/builderConfiguration.jsx @@ -24,7 +24,7 @@ module.exports = compose( .do(([result]) => { const geomProp = get(findGeometryProperty(result.data || {}), "name"); if (geomProp) { - // set the geometry property (needed for syncronization with a map or any other sort of spatial filter) + // set the geometry property (needed for synchronization with a map or any other sort of spatial filter) onEditorChange("geomProp", geomProp); } diff --git a/web/client/components/widgets/enhancers/dependenciesToFilter.js b/web/client/components/widgets/enhancers/dependenciesToFilter.js index 37c7944b8d..1e4f15022f 100644 --- a/web/client/components/widgets/enhancers/dependenciesToFilter.js +++ b/web/client/components/widgets/enhancers/dependenciesToFilter.js @@ -21,7 +21,7 @@ module.exports = compose( const {filter, property, and} = filterBuilder({gmlVersion: "3.1.1"}); if (!mapSync || !dependencies.viewport) { return { - filter: filterObj ? filter(and((FilterUtils.toOGCFilterParts(filterObj, "1.1.0", "ogc") : []))) : undefined + filter: filterObj ? filter(and((FilterUtils.toOGCFilterParts(filterObj, "1.1.0", "ogc") || []))) : undefined }; } diff --git a/web/client/components/widgets/enhancers/tableWidget.js b/web/client/components/widgets/enhancers/tableWidget.js new file mode 100644 index 0000000000..1eaba727d4 --- /dev/null +++ b/web/client/components/widgets/enhancers/tableWidget.js @@ -0,0 +1,25 @@ +/* + * 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 { compose, withPropsOnChange} = require('recompose'); +const {get} = require('lodash'); +/** + * Enhances the table widget to connect to WFS and to update widget column size on resize. + * Moreover enhances it to allow delete. +*/ +module.exports = compose( + require('./wfsTable'), + withPropsOnChange(["gridEvents"], ({gridEvents = {}, updateProperty = () => {}} = {}) => ({ + gridEvents: { + ...gridEvents, + onColumnResize: + (colIdx, width, rg, d, a, columns) => + updateProperty(`options.columnSettings["${get(columns.filter(c => !c.hide)[colIdx], "name")}"].width`, width) + } + })), + require('./deleteWidget') +); diff --git a/web/client/components/widgets/enhancers/wfsTable/__tests__/wfsTable-test.js b/web/client/components/widgets/enhancers/wfsTable/__tests__/wfsTable-test.js new file mode 100644 index 0000000000..91ea26c7e6 --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/__tests__/wfsTable-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 wfsTable = require('../index'); + +const rxjsConfig = require('recompose/rxjsObservableConfig').default; +setObservableConfig(rxjsConfig); + + +describe('index enhancer', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('retrieve WFS describeFeatureType and features', (done) => { + const Sink = wfsTable(createSink( props => { + expect(props).toExist(); + if (props.describeFeatureType) { + expect(props.describeFeatureType.featureTypes).toExist(); + } + if (props.features && props.features.length > 0) { + expect(props.features.length > 0).toBe(true); + done(); + } + })); + ReactDOM.render(, document.getElementById("container")); + }); + it('retrieve WFS describeFeatureType with virtualScroll', (done) => { + let triggered = false; + const Sink = wfsTable(createSink(props => { + expect(props).toExist(); + if (props.describeFeatureType) { + expect(props.describeFeatureType.featureTypes).toExist(); + } + if (props.pages && props.features.length > 0 && props.pages[0] === 0 && !triggered) { + expect(props.pages[1]).toBe(20); + triggered = true; + props.pageEvents.moreFeatures({startPage: 2, endPage: 3}); + } else if (props.pages && props.features.length > 0 && props.pages[0] === 40) { + expect(props.pages[1]).toBe(60); + done(); + + } + })); + ReactDOM.render(, document.getElementById("container")); + }); + +}); diff --git a/web/client/components/widgets/enhancers/wfsTable/describeFetch.js b/web/client/components/widgets/enhancers/wfsTable/describeFetch.js new file mode 100644 index 0000000000..8b6dcedce8 --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/describeFetch.js @@ -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 Rx = require('rxjs'); +const { describeFeatureType } = require('../../../../observables/wfs'); +const { getSearchUrl } = require('../../../../utils/LayersUtils'); +/** + * Retrieves feature types for the layer provideded in props. When the layer changes url, + * @param {Obserbable} props$ props stream + */ +module.exports = props$ => + props$ + .distinctUntilChanged(({ layer: layer1 } = {}, { layer: layer2 } = {}) => getSearchUrl(layer1) === getSearchUrl(layer2)) // this check is not too precise,it may need a refinement + .switchMap(({ layer } = {}) => describeFeatureType({ layer }) + .map(r => ({ describeFeatureType: r.data, loading: false, error: undefined }))) + .catch(error => Rx.Observable.of({ + loading: false, + error + })); diff --git a/web/client/components/widgets/enhancers/wfsTable/index.js b/web/client/components/widgets/enhancers/wfsTable/index.js new file mode 100644 index 0000000000..ec6b8fc14a --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/index.js @@ -0,0 +1,112 @@ +/** + * 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 { includes, merge } = require('lodash'); +const { compose, withProps, createEventHandler, withHandlers, withStateHandlers, defaultProps } = require('recompose'); + + +const { getFeatureTypeProperties } = require('../../../../utils/ogc/WFS/base'); +const propsStreamFactory = require('../../../misc/enhancers/propsStreamFactory'); + +const triggerFetch = require('./triggerFetch'); +const describeFetch = require('./describeFetch'); +const virtualScrollFetch = require('./virtualScrollFetch'); +const noPaginationFetch = require('./noPaginationFetch'); + +const fetchDataStream = (props$, pages$, virtualScroll = true) => + triggerFetch(props$) + .let(virtualScroll + ? virtualScrollFetch( + pages$.withLatestFrom( + props$ + // get latest options needed + .map(({pagination = {}} = {}) => ({ + pagination + })), // pagination is needed to allow workaround of GEOS-7233 + (pagesRange, otherOptions) => ({ + pagesRange, + ...otherOptions + }) + ) + ) + : noPaginationFetch + ) + .startWith({}); + + +const dataStreamFactory = ($props) => { + const {handler, stream: pages$ } = createEventHandler(); + return describeFetch($props) + .combineLatest( + fetchDataStream($props, pages$.startWith({startPage: 0, endPage: 1})), + (p1, p2) => ({ + ...p1, + ...p2, + pageEvents: { + moreFeatures: handler, + onPageChange: () => {} + } + }) + ) + .startWith({loading: true}); +}; + +/** + * Enhancer a FeatureGrid or a TableWidget to connect to WFS services. It's used in a widgets context, but is enough general + * to be used in any other context. TODO: move it in FeatureGrid enhancers when enough stable and general. + * of a layer and use virtualScroll. + * Manages propertyNames to manage columns and support WFS Filters + * @prop {object} layer. The layer with at least layer name and URL. + * @prop {boolean} virtualScroll. If enabled, the FeatureGrid will retrieve data with virtualScroll + * @prop {object} options. Options for WFS. Can contain + * - propertyName: an array of property name to optimize download. + * - columnSettings: will be merged with grid columnSettings to setup column width and so on +*/ +module.exports = compose( + defaultProps({ + virtualScroll: true, + size: 20, + maxStoredPages: 5 + }), + withStateHandlers({ + pages: [], + features: [], + pagination: {} + }, { + setData: () => ({pages, features, pagination} = {}) => ({ + pages, + features, + pagination, + error: undefined + }) + }), + withHandlers({ + onLoad: ({ setData = () => {}, onLoad = () => {}} = {}) => (...args) => { + setData(...args); + onLoad(...args); + } + }), + withProps(() => ({ + dataStreamFactory + })), + propsStreamFactory, + // handle propertyNames and columnOptions + withProps(({ options = {}, describeFeatureType: dft, columnSettings = {} } = {}) => ({ + columnSettings: merge( + dft ? + getFeatureTypeProperties(dft) + .filter(p => !includes(options.propertyName || [], p.name)) + .reduce((acc, p) => ({ + ...acc, + [p.name]: { + hide: true + } + }), {}) : {}, + options.columnSettings || {}, + columnSettings) + })) +); diff --git a/web/client/components/widgets/enhancers/wfsTable/noPaginationFetch.js b/web/client/components/widgets/enhancers/wfsTable/noPaginationFetch.js new file mode 100644 index 0000000000..b4f3b9d98c --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/noPaginationFetch.js @@ -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. + */ + + /** + * Get data all in one request. + * @param {Observable} props$ stream of props + */ +const Rx = require('rxjs'); +const { getLayerJSONFeature } = require('../../../../observables/wfs'); +module.exports = props$ => props$.switchMap( + ({ + layer = {}, + options, + filter, + onLoad = () => { }, + onLoadError = () => { } + }) => + getLayerJSONFeature(layer, filter, { + timeout: 15000, + params: { propertyName: options.propertyName } + // TODO totalFeatures + // TODO sortOptions - default + }) + .map(() => ({ + loading: false, + error: undefined + + })).do(data => onLoad({ + features: data.features, + pagination: { + totalFeatures: data.totalFeatures + } + })) + .catch((e) => Rx.Observable.of({ + loading: false, + error: e, + data: [] + }).do(onLoadError)) +); diff --git a/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js b/web/client/components/widgets/enhancers/wfsTable/triggerFetch.js new file mode 100644 index 0000000000..ef836d918e --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/triggerFetch.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. + */ + +require('rxjs'); +const { getSearchUrl } = require('../../../../utils/LayersUtils'); +const sameFilter = (f1, f2) => f1 === f2; +const sameOptions = (o1 = {}, o2 = {}) => + o1.propertyName === o2.propertyName; +const sameSortOptions = (o1 = {}, o2 = {}) => + o1.sortBy === o2.sortBy + && o1.sortOrder === o2.sortOrder; + +/** + * Function that converts stream of a WFSTable props to trigger data fetch events + * @param {Observable} Stream of props. + * @return {Observable} Stream of props to trigger the data fetch + */ +module.exports = ($props) => + $props.filter(({ layer = {} }) => layer.name ) + .distinctUntilChanged( + ({ layer = {}, options = {}, filter, sortOptions }, newProps) => + getSearchUrl(layer) === getSearchUrl(layer) + && (newProps.layer && layer.name === newProps.layer.name) + && sameOptions(options, newProps.options) + && sameFilter(filter, newProps.filter) + && sameSortOptions(sortOptions, newProps.sortOptions)) + // when one of the items above changed invalidates cache for before the next request + .map((props) => ({ + ...props, + features: [], + pages: [], + pagination: {} + })); diff --git a/web/client/components/widgets/enhancers/wfsTable/virtualScrollFetch.js b/web/client/components/widgets/enhancers/wfsTable/virtualScrollFetch.js new file mode 100644 index 0000000000..1991b05237 --- /dev/null +++ b/web/client/components/widgets/enhancers/wfsTable/virtualScrollFetch.js @@ -0,0 +1,51 @@ +/* + * 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 { getLayerJSONFeature } = require('../../../../observables/wfs'); +const { getCurrentPaginationOptions, updatePages } = require('../../../../utils/FeatureGridUtils'); +/** + * Create an operator that resonds to a fetch data trigger event to retrives data on scroll. + * @param {Observable} pages$ the stream of virtual scroll pages requests + * @returns a function that can be merged with stream of + * props to retrieve data using virtual scroll. + */ +module.exports = pages$ => props$ => props$.switchMap(({ + layer = {}, + size = 20, + maxStoredPages = 5, + filter, + options = {}, + pages, + features = [], + onLoad = () => { }, + onLoadError = () => { } +}) => pages$.switchMap(({ pagesRange, pagination = {} }, { }) => getLayerJSONFeature(layer, filter, { + ...getCurrentPaginationOptions(pagesRange, pages, size), + timeout: 15000, + totalFeatures: pagination.totalFeatures, // this is needed to allow workaround of GEOS-7233 + propertyName: options.propertyName + // TODO: defaultSortOptions - to skip primary-key issues + }) + .do(data => onLoad({ + ...updatePages(data, pagesRange, { pages, features }, { ...getCurrentPaginationOptions(pagesRange, pages, size), size, maxStoredPages }), + pagination: { + totalFeatures: data.totalFeatures + } + })) + .map(() => ({ + loading: false + })) + .catch((e) => Rx.Observable.of({ + loading: false, + error: e + }).do(onLoadError)) + .startWith({ + loading: true + }) + )); diff --git a/web/client/components/widgets/view/WidgetsView.jsx b/web/client/components/widgets/view/WidgetsView.jsx index 003fa8ea42..dd3936e679 100644 --- a/web/client/components/widgets/view/WidgetsView.jsx +++ b/web/client/components/widgets/view/WidgetsView.jsx @@ -28,6 +28,7 @@ module.exports = pure(({ widgets=[], layouts, dependencies, + updateWidgetProperty = () => {}, deleteWidget = () => {}, editWidget = () => {}, onLayoutChange = () => {}, @@ -35,6 +36,7 @@ module.exports = pure(({ }={}) => ( updateWidgetProperty(w.id, ...args)} onDelete={() => deleteWidget(w)} onEdit={() => editWidget(w)} />)) } diff --git a/web/client/components/widgets/widget/DefaultWidget.jsx b/web/client/components/widgets/widget/DefaultWidget.jsx index 83e227d734..ba4937f89c 100644 --- a/web/client/components/widgets/widget/DefaultWidget.jsx +++ b/web/client/components/widgets/widget/DefaultWidget.jsx @@ -8,10 +8,12 @@ const React = require('react'); const enhanceChartWidget = require('../enhancers/chartWidget'); const enhanceTextWidget = require('../enhancers/deleteWidget'); +const enhanceTableWidget = require('../enhancers/tableWidget'); const wpsChart = require('../enhancers/wpsChart'); const dependenciesToFilter = require('../enhancers/dependenciesToFilter'); const ChartWidget = dependenciesToFilter(wpsChart(enhanceChartWidget(require('./ChartWidget')))); const TextWidget = enhanceTextWidget(require('./TextWidget')); +const TableWidget = dependenciesToFilter(enhanceTableWidget(require('./TableWidget'))); module.exports = ({ dependencies, exportCSV = () => {}, @@ -23,6 +25,13 @@ module.exports = ({ ? () + : w.widgetType === "table" + ? : ( !describeFeatureType)(require('../../data/featuregrid/FeatureGrid'))); +const InfoPopover = require('./InfoPopover'); + +const WidgetContainer = require('./WidgetContainer'); +const { + Glyphicon, + ButtonToolbar, + DropdownButton, + MenuItem +} = require('react-bootstrap'); + +const renderHeaderLeftTopItem = ({ title, description }) => { + return title || description ? : null; +}; + + +module.exports = ({ + id, + title, + description, + loading, + confirmDelete = false, + toggleTableView = () => { }, + toggleDeleteConfirm = () => { }, + onEdit = () => { }, + onDelete = () => { }, + gridEvents = () => {}, + pageEvents = { + moreFeatures: () => {} + }, + describeFeatureType, + columnSettings, + features, + size, + pages, + error, + pagination = {}, + virtualScroll = true +}) => + ( + } noCaret id="dropdown-no-caret"> + onEdit()} eventKey="3">  + toggleDeleteConfirm(true)} eventKey="2">  + + }> + + {loading ? : null} + + ) : null} + > + + + + + ); diff --git a/web/client/components/widgets/widget/WidgetContainer.jsx b/web/client/components/widgets/widget/WidgetContainer.jsx index a62e2eb078..3bd653b578 100644 --- a/web/client/components/widgets/widget/WidgetContainer.jsx +++ b/web/client/components/widgets/widget/WidgetContainer.jsx @@ -14,6 +14,7 @@ module.exports = ({ id, title, confirmDelete= false, + handle = "draggableHandle", toggleDeleteConfirm = () => {}, onDelete=() => {}, topLeftItems, @@ -21,7 +22,7 @@ module.exports = ({ children }) => (
- +
{topLeftItems} {title} diff --git a/web/client/components/widgets/widget/__tests__/TableWidget-test.jsx b/web/client/components/widgets/widget/__tests__/TableWidget-test.jsx new file mode 100644 index 0000000000..eeaf8cb386 --- /dev/null +++ b/web/client/components/widgets/widget/__tests__/TableWidget-test.jsx @@ -0,0 +1,50 @@ +/* + * 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. + */ + +/* + * 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 ReactDOM = require('react-dom'); +const ReactTestUtils = require('react-dom/test-utils'); +const expect = require('expect'); +const TableWidget = require('../ChartWidget'); + +describe('TableWidget component', () => { + beforeEach((done) => { + document.body.innerHTML = '
'; + setTimeout(done); + }); + afterEach((done) => { + ReactDOM.unmountComponentAtNode(document.getElementById("container")); + document.body.innerHTML = ''; + setTimeout(done); + }); + it('TableWidget rendering with defaults', () => { + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.mapstore-widget-card'); + expect(el).toExist(); + }); + it('Test TableWidget onEdit callback', () => { + const actions = { + onEdit: () => { } + }; + const spyonEdit = expect.spyOn(actions, 'onEdit'); + ReactDOM.render(, document.getElementById("container")); + const container = document.getElementById('container'); + const el = container.querySelector('.glyphicon-pencil'); + ReactTestUtils.Simulate.click(el); // <-- trigger event callback + expect(spyonEdit).toHaveBeenCalled(); + }); +}); diff --git a/web/client/epics/featuregrid.js b/web/client/epics/featuregrid.js index edfb0d7bc5..7714d7b496 100644 --- a/web/client/epics/featuregrid.js +++ b/web/client/epics/featuregrid.js @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ const Rx = require('rxjs'); -const {get, head, isEmpty, find, fill} = require('lodash'); +const {get, head, isEmpty, find} = require('lodash'); const { LOCATION_CHANGE } = require('react-router-redux'); const Proj4js = require('proj4').default; const proj4 = Proj4js; @@ -53,7 +53,7 @@ const {getLayerFromId} = require('../selectors/layers'); const {interceptOGCError} = require('../utils/ObservableUtils'); -const {gridUpdateToQueryUpdate, getIdxFarthestEl, removePageFeatures, removePage} = require('../utils/FeatureGridUtils'); +const {gridUpdateToQueryUpdate, updatePages} = require('../utils/FeatureGridUtils'); const {queryFormUiStateSelector} = require('../selectors/queryform'); /** @@ -679,9 +679,9 @@ module.exports = { .switchMap( ac => { const state = getState(); const {startPage, endPage} = ac.pages; - const {pages, pagination} = state.featuregrid; + const {pages: oldPages, pagination} = state.featuregrid; const size = get(pagination, "size"); - const nPs = getPagesToLoad(startPage, endPage, pages, size); + const nPs = getPagesToLoad(startPage, endPage, oldPages, size); const needPages = (nPs[1] - nPs[0] + 1 ); return Rx.Observable.of( query(wfsURL(state), addPagination({ @@ -692,38 +692,19 @@ module.exports = { )).filter(() => nPs.length > 0) .merge( action$.ofType(QUERY_RESULT) .filter(() => nPs.length > 0) - .map((ra) => { - let {features = [], maxStoredPages} = (getState()).featuregrid; - let fts = get(ra, "result.features", []); - if (fts.length !== needPages * size) { - fts = fts.concat(fill(Array(needPages * size - fts.length), false)); - } - const startIdx = get(ra, "filterObj.pagination.startIndex"); - let oldPages = pages; - // 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 = oldPages.length + needPages - Math.max(maxStoredPages, (endPage - startPage + 1)); - if ( nSpaces > 0) { - const firstRow = startPage * size; - const lastRow = endPage * size; - // Remove the farthest page from last loaded pages - const averageIdx = firstRow + (lastRow - firstRow) / 2; - for (let i = 0; i < nSpaces; i++) { - const idxFarthestEl = getIdxFarthestEl(averageIdx, pages, firstRow, lastRow); - const idxToRemove = idxFarthestEl * size; - oldPages = removePage(idxFarthestEl, oldPages); - features = removePageFeatures(features, idxToRemove, size); - } - } - let pagesLoaded = []; - for (let i = 0; i < needPages; i++) { - pagesLoaded.push(startIdx + (size * i)); - } - const newPages = oldPages.concat(pagesLoaded); - const newFts = features.concat(fts); - return fatureGridQueryResult( newFts, newPages); + .map(({result = {}, filterObj} = {}) => { + const {features: oldFeatures, maxStoredPages} = (getState()).featuregrid; + const startIndex = get(filterObj, "pagination.startIndex"); + const { pages, features } = updatePages( + result, + { endPage, startPage }, + { pages: oldPages, features: oldFeatures || [] }, + { size, startIndex, maxStoredPages}); + return fatureGridQueryResult(features, pages); }).take(1).takeUntil(action$.ofType(QUERY_ERROR)) - ).merge(action$.ofType(FEATURE_LOADING).filter(() => nPs.length > 0) - // When loading we store the load more features request, on loading end we emit the last + ).merge( + action$.ofType(FEATURE_LOADING).filter(() => nPs.length > 0) + // When loading we store the load more features request, on loading end we emit the last .filter(a => !a.isLoading) .withLatestFrom(action$.ofType(LOAD_MORE_FEATURES)) .map((p) => p[1]) diff --git a/web/client/epics/wfsquery.js b/web/client/epics/wfsquery.js index d71285244d..35e367ef3f 100644 --- a/web/client/epics/wfsquery.js +++ b/web/client/epics/wfsquery.js @@ -8,39 +8,21 @@ const Rx = require('rxjs'); const axios = require('../libs/ajax'); -const Url = require('url'); + const {changeSpatialAttribute, SELECT_VIEWPORT_SPATIAL_METHOD, updateGeometrySpatialField} = require('../actions/queryform'); const {CHANGE_MAP_VIEW} = require('../actions/map'); const {FEATURE_TYPE_SELECTED, QUERY, UPDATE_QUERY, featureLoading, featureTypeLoaded, featureTypeError, querySearchResponse, queryError} = require('../actions/wfsquery'); const {paginationInfo, isDescribeLoaded, layerDescribeSelector} = require('../selectors/query'); const {mapSelector} = require('../selectors/map'); const {authkeyParamNameSelector} = require('../selectors/catalog'); -const FilterUtils = require('../utils/FilterUtils'); + const CoordinatesUtils = require('../utils/CoordinatesUtils'); const ConfigUtils = require('../utils/ConfigUtils'); const assign = require('object-assign'); const {spatialFieldMethodSelector, spatialFieldSelector, spatialFieldGeomTypeSelector, spatialFieldGeomCoordSelector, spatialFieldGeomSelector, spatialFieldGeomProjSelector} = require('../selectors/queryform'); const {changeDrawingStatus} = require('../actions/draw'); const {INIT_QUERY_PANEL} = require('../actions/wfsquery'); - -const {isObject} = require('lodash'); -const {interceptOGCError} = require('../utils/ObservableUtils'); -// this is a workaround for https://osgeo-org.atlassian.net/browse/GEOS-7233. can be removed when fixed -const workaroundGEOS7233 = ({totalFeatures, features, ...rest}, {startIndex, maxFeatures}, originalSize) => { - if (originalSize > totalFeatures && originalSize === startIndex + features.length && totalFeatures === features.length) { - return { - ...rest, - features, - totalFeatures: originalSize - }; - } - return { - ...rest, - features, - totalFeatures - }; - -}; +const {getJSONFeatureWA} = require('../observables/wfs'); const {describeFeatureTypeToAttributes} = require('../utils/FeatureTypeUtils'); const extractInfo = (data) => { return { @@ -62,39 +44,6 @@ const extractInfo = (data) => { }; }; -const getWFSFilterData = (filterObj) => { - let data; - if (typeof filterObj === 'string') { - data = filterObj; - } else { - data = filterObj.filterType === "OGC" ? - FilterUtils.toOGCFilter(filterObj.featureTypeName, filterObj, filterObj.ogcVersion, filterObj.sortOptions, filterObj.hits) : - FilterUtils.toCQLFilter(filterObj); - } - return data; -}; - -const getWFSFeature = (searchUrl, filterObj, state) => { - const data = getWFSFilterData(filterObj); - - const urlParsedObj = Url.parse(ConfigUtils.filterUrlParams(searchUrl, authkeyParamNameSelector(state)), true); - let params = isObject(urlParsedObj.query) ? urlParsedObj.query : {}; - params.service = 'WFS'; - params.outputFormat = 'json'; - const queryString = Url.format({ - protocol: urlParsedObj.protocol, - host: urlParsedObj.host, - pathname: urlParsedObj.pathname, - query: params - }); - - return Rx.Observable.defer( () => - axios.post(queryString, data, { - timeout: 60000, - headers: {'Accept': 'application/json', 'Content-Type': 'application/json'} - })); -}; - const getFirstAttribute = (state)=> { return state.query && state.query.featureTypes && state.query.featureTypes[state.query.typeName] && state.query.featureTypes[state.query.typeName].attributes && state.query.featureTypes[state.query.typeName].attributes[0] && state.query.featureTypes[state.query.typeName].attributes[0].attribute || null; }; @@ -103,22 +52,6 @@ const getDefaultSortOptions = (attribute) => { return attribute ? { sortBy: attribute, sortOrder: 'A'} : {}; }; -const retryWithForcedSortOptions = (action, store) => { - const sortOptions = getDefaultSortOptions(getFirstAttribute(store.getState())); - return getWFSFeature(action.searchUrl, assign(action.filterObj, { - sortOptions - }), store.getState()) - .let(interceptOGCError) - .map((newResponse) => { - const state = store.getState(); - const data = workaroundGEOS7233(newResponse.data, action.filterObj.pagination, paginationInfo.totalFeatures(state)); - return querySearchResponse(data, action.searchUrl, action.filterObj); - }) - .catch((e) => { - return Rx.Observable.of(queryError(e)); - }); -}; - /** * Gets the WFS feature type attributes and geometry when the feature has been selected * @memberof epics.wfsquery @@ -161,25 +94,20 @@ const featureTypeSelectedEpic = (action$, store) => * @param {external:Observable} action$ manages `QUERY` * @return {external:Observable} */ - const wfsQueryEpic = (action$, store) => action$.ofType(QUERY) .switchMap(action => { + const sortOptions = getDefaultSortOptions(getFirstAttribute(store.getState())); + const totalFeatures = paginationInfo.totalFeatures(store.getState()); + const searchUrl = ConfigUtils.filterUrlParams(action.searchUrl, authkeyParamNameSelector(store.getState())); return Rx.Observable.merge( - getWFSFeature(action.searchUrl, action.filterObj, store.getState()) - .let(interceptOGCError) - .switchMap((response) => { - const state = store.getState(); - const data = workaroundGEOS7233(response.data, action.filterObj.pagination, paginationInfo.totalFeatures(state)); - return Rx.Observable.of(querySearchResponse(data, action.searchUrl, action.filterObj)); + getJSONFeatureWA(searchUrl, action.filterObj, { + totalFeatures, + sortOptions }) + .map(data => querySearchResponse(data, action.searchUrl, action.filterObj)) + .catch(error => Rx.Observable.of(queryError(error))) .startWith(featureLoading(true)) - .catch((error) => { - if (error.name === "OGCError" && error.code === 'NoApplicableCode') { - return retryWithForcedSortOptions(action, store); - } - return Rx.Observable.of(queryError(error)); - }) .concat(Rx.Observable.of(featureLoading(false))) ).takeUntil(action$.ofType(UPDATE_QUERY)); }); @@ -237,6 +165,5 @@ module.exports = { featureTypeSelectedEpic, wfsQueryEpic, redrawSpatialFilterEpic, - viewportSelectedEpic, - getWFSFilterData + viewportSelectedEpic }; diff --git a/web/client/observables/wfs.js b/web/client/observables/wfs.js index 36990d4624..f1b807f0c9 100644 --- a/web/client/observables/wfs.js +++ b/web/client/observables/wfs.js @@ -11,14 +11,18 @@ const axios = require('../libs/ajax'); const urlUtil = require('url'); const Rx = require('rxjs'); +const {castArray, isNil} = require('lodash'); const {parseString} = require('xml2js'); const {stripPrefix} = require('xml2js/lib/processors'); const {interceptOGCError} = require('../utils/ObservableUtils'); const {getCapabilitiesUrl} = require('../utils/LayersUtils'); +const FilterUtils = require('../utils/FilterUtils'); +const requestBuilder = require('../utils/ogc/WFS/RequestBuilder'); +const {getFeature, query, sortBy, propertyName} = requestBuilder({ wfsVersion: "1.1.0" }); -const toDescribeURL = ({name, search = {}, url} = {}) => { - const parsed = urlUtil.parse(search.url || url, true); +const toDescribeURL = ({ name, search = {}, url, describeFeatureTypeURL} = {}) => { + const parsed = urlUtil.parse(describeFeatureTypeURL || search.url || url, true); return urlUtil.format( { ...parsed, @@ -49,8 +53,128 @@ const toLayerCapabilitiesURL = ({name, search = {}, url} = {}) => { } }); }; +const Url = require('url'); +const { isObject } = require('lodash'); + +// this is a workaround for https://osgeo-org.atlassian.net/browse/GEOS-7233. can be removed when fixed +const workaroundGEOS7233 = ({ totalFeatures, features, ...rest } = {}, { startIndex, maxFeatures } = {}, originalSize) => { + if (originalSize > totalFeatures && originalSize === startIndex + features.length && totalFeatures === features.length) { + return { + ...rest, + features, + totalFeatures: originalSize + }; + } + return { + ...rest, + features, + totalFeatures + }; + +}; +const getWFSFilterData = (filterObj) => { + let data; + if (typeof filterObj === 'string') { + data = filterObj; + } else { + data = filterObj.filterType === "OGC" + ? FilterUtils.toOGCFilter(filterObj.featureTypeName, filterObj, filterObj.ogcVersion, filterObj.sortOptions, filterObj.hits) + : FilterUtils.toCQLFilter(filterObj); + } + return data; +}; + +const getPagination = (filterObj = {}, options = {}) => + filterObj.pagination + || !isNil(options.startIndex) + && !isNil(options.maxFeatures) + && { + startIndex: options.startIndex, + maxFeatures: options.maxFeatures + }; +/** + * Get Features in json format. Intercepts request with 200 errors and workarounds GEOS-7233 if `totalFeatures` is passed + * @param {string} searchUrl URL of WFS service + * @param {object} filterObj FilterObject + * @param {number} totalFeatures optional number to use in case of a previews request, needed to workaround GEOS-7233. + * @return {Observable} a stream that emits the GeoJSON or an error. + */ +const getJSONFeature = (searchUrl, filterObj, options = {}) => { + const data = getWFSFilterData(filterObj); + + const urlParsedObj = Url.parse(searchUrl, true); + let params = isObject(urlParsedObj.query) ? urlParsedObj.query : {}; + params.service = 'WFS'; + params.outputFormat = 'json'; + const queryString = Url.format({ + protocol: urlParsedObj.protocol, + host: urlParsedObj.host, + pathname: urlParsedObj.pathname, + query: params + }); + + return Rx.Observable.defer(() => + axios.post(queryString, data, { + timeout: 60000, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } + })) + .let(interceptOGCError) + .map((response) => workaroundGEOS7233(response.data, getPagination(filterObj, options), options.totalFeatures)); +}; + +/** + * Same of `getJSONFeature` but auto-retries possible errors due to no-primary-key issues + * (when you are using pagination vendor parameters for GeoServer and the primary-key of the table was not set). + * When this kind of error occurs, auto-retry using the sortOptions passed. + * present. . + * @param {string} searchUrl URL of WFS service + * @param {object} filterObj Filter object + * @param {object} options params that can contain `totalFeatures` and sort options + * @return {Observable} a stream that emits the GeoJSON or an error. + */ +const getJSONFeatureWA = (searchUrl, filterObj, { sortOptions = {}, ...options } = {}) => + getJSONFeature(searchUrl, filterObj, options) + .catch(error => { + if (error.name === "OGCError" && error.code === 'NoApplicableCode') { + return getJSONFeature(searchUrl, { + ...filterObj, + sortOptions + }, {options}); + } + throw error; + }); + +/** + * Same of `getJSONFeatureWA` but accepts the layer as first parameter. + * Accepts filter as a string or object. In case of string filter manages pagination and + * sort options from 3rd parameter. This is a little different from normal getJSONFeature, + * anyway this version is more rational, separating pagination, sorting etc... from filter. + * TODO: make this more flexible to manage also object filter with a clear default rule. + * @param {object} layer the layer to search + * @param {object|string} filter the filter object or string of the filter. To maintain + * retro compatibility the filter object can contain pagination info, typeName and so on. + * @param {object} options the options (pagination, totalFeatures and so on ...) + */ +const getLayerJSONFeature = ({ search = {}, url, name } = {}, filter, {sortOptions, propertyName: pn, ...options} = {}) => + // TODO: Apply sort workaround for no primary keys + getJSONFeature(search.url || url, + filter && typeof filter === 'object' ? { + ...filter, + typeName: name || filter.typeName + } : getFeature( + query(name, + [ + ...( sortOptions ? [sortBy(sortOptions.sortBy, sortOptions.sortOrder)] : []), + ...(pn ? [propertyName(pn)] : []), + ...(filter ? castArray(filter) : []) + ]), + options), // options contains startIndex, maxFeatures and it can be passed as it is + options); module.exports = { + getJSONFeature, + getLayerJSONFeature, + getJSONFeatureWA, describeFeatureType: ({layer}) => Rx.Observable.defer(() => axios.get(toDescribeURL(layer))).let(interceptOGCError), diff --git a/web/client/plugins/Dashboard.jsx b/web/client/plugins/Dashboard.jsx index 35b2e8eefb..24f651bbad 100644 --- a/web/client/plugins/Dashboard.jsx +++ b/web/client/plugins/Dashboard.jsx @@ -11,8 +11,8 @@ const {connect} = require('react-redux'); const {compose, withProps} = require('recompose'); const {createSelector} = require('reselect'); const {mapIdSelector} = require('../selectors/map'); -const {getDashboardWidgets, dashBoardDependenciesSelector, getDashboardWidgetsLayout} = require('../selectors/widgets'); -const {editWidget, deleteWidget, changeLayout, exportCSV, exportImage} = require('../actions/widgets'); +const { getDashboardWidgets, dashBoardDependenciesSelector, getDashboardWidgetsLayout} = require('../selectors/widgets'); +const { editWidget, updateWidgetProperty, deleteWidget, changeLayout, exportCSV, exportImage} = require('../actions/widgets'); const ContainerDimensions = require('react-container-dimensions').default; const PropTypes = require('prop-types'); @@ -31,6 +31,7 @@ const WidgetsView = compose( }) ), { editWidget, + updateWidgetProperty, exportCSV, exportImage, deleteWidget, diff --git a/web/client/plugins/Widgets.jsx b/web/client/plugins/Widgets.jsx index 2ff91d2fb8..7bb5a0981a 100644 --- a/web/client/plugins/Widgets.jsx +++ b/web/client/plugins/Widgets.jsx @@ -12,7 +12,7 @@ const {createSelector} = require('reselect'); const {compose, withProps} = require('recompose'); const {mapIdSelector} = require('../selectors/map'); const {getFloatingWidgets, dependenciesSelector, getFloatingWidgetsLayout} = require('../selectors/widgets'); -const {editWidget, deleteWidget, changeLayout, exportCSV, exportImage} = require('../actions/widgets'); +const { editWidget, updateWidgetProperty, deleteWidget, changeLayout, exportCSV, exportImage} = require('../actions/widgets'); const ContainerDimensions = require('react-container-dimensions').default; const {rightPanelOpenSelector, bottomPanelOpenSelector} = require('../selectors/maplayout'); @@ -33,6 +33,7 @@ compose( }) ), { editWidget, + updateWidgetProperty, exportCSV, exportImage, deleteWidget, diff --git a/web/client/plugins/widgetbuilder/TableBuilder.jsx b/web/client/plugins/widgetbuilder/TableBuilder.jsx new file mode 100644 index 0000000000..e2679a48a1 --- /dev/null +++ b/web/client/plugins/widgetbuilder/TableBuilder.jsx @@ -0,0 +1,91 @@ +import { mapPropsStream } from 'recompose'; + +/* + * 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 {get} = require('lodash'); +const { isGeometryType } = require('../../utils/ogc/WFS/base'); +const { compose, renameProps, branch, renderComponent } = require('recompose'); +const InfoPopover = require('../../components/widgets/widget/InfoPopover'); +const Message = require('../../components/I18N/Message'); +const BorderLayout = require('../../components/layout/BorderLayout'); + +const { insertWidget, onEditorChange, setPage, openFilterEditor, changeEditorSetting } = require('../../actions/widgets'); + +const builderConfiguration = require('../../components/widgets/enhancers/builderConfiguration'); + +const { + wizardStateToProps, + wizardSelector +} = require('./commons'); + +const Builder = connect( + wizardSelector, + { + setPage, + setValid: valid => changeEditorSetting("valid", valid), + onEditorChange, + insertWidget + }, + wizardStateToProps +)(compose( + builderConfiguration, + renameProps({ + editorData: "data", + onEditorChange: "onChange" + }), + mapPropsStream(props$ => props$.merge( + props$ + .distinctUntilChanged(({ featureTypeProperties: oldFT } = {}, { featureTypeProperties: newFT } = {}) => oldFT === newFT) + // set propTypes to all attributes when + .do(({ featureTypeProperties = [], onChange = () => { }, data={} } = {}) => { + // initialize attribute list if empty (first time) + if (onChange && featureTypeProperties.length > 0 && !get(data, "options.propertyName")) { + onChange("options.propertyName", featureTypeProperties.filter(a => !isGeometryType(a)).map(ft => ft.name)); + } + }).ignoreElements() + )) +)(require('../../components/widgets/builder/wizard/TableWizard'))); + +const BuilderHeader = require('./BuilderHeader'); +const Toolbar = connect(wizardSelector, { + openFilterEditor, + setPage, + insertWidget +}, + wizardStateToProps +)(require('../../components/widgets/builder/wizard/table/Toolbar')); + +/* + * in case you don't have a layer selected (e.g. dashboard) the chartbuilder + * prompts a catalog view to allow layer selection + */ +const chooseLayerEhnancer = compose( + connect(wizardSelector), + branch( + ({ layer } = {}) => !layer, + renderComponent(require('./LayerSelector')) + ) +); + +module.exports = chooseLayerEhnancer(({ enabled, onClose = () => { }, editorData = {}, dependencies, ...props } = {}) => + + ( + + {get(editorData, "options.propertyName.length") === 0 ? } /> : null} + } + > + {enabled ? : null} + )); diff --git a/web/client/plugins/widgetbuilder/WidgetTypeBuilder.jsx b/web/client/plugins/widgetbuilder/WidgetTypeBuilder.jsx index 724c92876e..9a56a07cb4 100644 --- a/web/client/plugins/widgetbuilder/WidgetTypeBuilder.jsx +++ b/web/client/plugins/widgetbuilder/WidgetTypeBuilder.jsx @@ -24,7 +24,8 @@ const mapStateToProps = createSelector( const WidgetTypeSelector = require('./WidgetTypeSelector'); const Builders = { chart: require('./ChartBuilder'), - text: require('./TextBuilder') + text: require('./TextBuilder'), + table: require('./TableBuilder') }; /** diff --git a/web/client/reducers/widgets.js b/web/client/reducers/widgets.js index 31f675c1d5..357fff0224 100644 --- a/web/client/reducers/widgets.js +++ b/web/client/reducers/widgets.js @@ -6,12 +6,13 @@ * LICENSE file in the root directory of this source tree. */ -const {EDIT_NEW, INSERT, EDIT, DELETE, EDITOR_CHANGE, EDITOR_SETTING_CHANGE, CHANGE_LAYOUT, CLEAR_WIDGETS, DEFAULT_TARGET} = require('../actions/widgets'); +const { EDIT_NEW, INSERT, EDIT, UPDATE_PROPERTY, DELETE, EDITOR_CHANGE, EDITOR_SETTING_CHANGE, CHANGE_LAYOUT, CLEAR_WIDGETS, DEFAULT_TARGET} = require('../actions/widgets'); const { MAP_CONFIG_LOADED } = require('../actions/config'); const set = require('lodash/fp/set'); +const { get, find} = require('lodash'); const {arrayUpsert, arrayDelete} = require('../utils/ImmutableUtils'); const emptyState = { @@ -81,6 +82,18 @@ function widgetsReducer(state = emptyState, action) { }, state); return tempState; + case UPDATE_PROPERTY: + return arrayUpsert(`containers[${action.target}].widgets`, + // update the widget setting the value to the existing object + set( + action.key, + action.value, + find(get(state, `containers[${action.target}].widgets`), { + id: action.id + }), + ), { + id: action.id + }, state); case DELETE: return arrayDelete(`containers[${action.target}].widgets`, { id: action.widget.id diff --git a/web/client/themes/default/less/widget.less b/web/client/themes/default/less/widget.less index b76363b708..0a44312090 100644 --- a/web/client/themes/default/less/widget.less +++ b/web/client/themes/default/less/widget.less @@ -1,3 +1,10 @@ +.draggableHandle { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: -o-grab; + cursor: -ms-grab; + cursor: grab; +} .widget-container { &.react-grid-layout.on-map { pointer-events: none; @@ -13,7 +20,7 @@ height: 100%; width: 100%; margin: 0; - webkit-box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); + -webkit-box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); -moz-box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22); .mapstore-widget-layer { diff --git a/web/client/translations/data.de-DE b/web/client/translations/data.de-DE index 233c83df8d..3b56da9a4b 100644 --- a/web/client/translations/data.de-DE +++ b/web/client/translations/data.de-DE @@ -1064,7 +1064,6 @@ } }, "widgets": { - "types": { "chart": { "title": "Grafik", @@ -1072,7 +1071,11 @@ }, "text": { "title": "Text", - "caption": "Fügen Sie einen Textbereich hinzu" + "caption": "fügen Sie einen Textbereich hinzu" + }, + "table": { + "title": "Tabelle", + "caption": "füge eine Tabelle hinzu" } }, "selectWidgetType": "Wählen Sie den Widgettyp", @@ -1096,6 +1099,9 @@ "backToChartOptions": "Zurück zu den Diagrammoptionen", "configureChartOptions": "Diagrammoptionen konfigurieren", "configureWidgetOptions": "Widget-Optionen konfigurieren", + "backToTableOptions": "Zurück zu den Tabellenoptionen", + "configureTableOptions": "Konfigurieren Sie die Tabellenoptionen", + "resetColumnsSizes": "Setzen Sie alle Änderungen an den Spaltengrößen zurück", "updateWidget": "Aktualisieren Sie das Widget", "addToTheMap": "Hinzufügen des Widgets zur Karte", "titlePlaceholder": "Gib den Titel ein...", @@ -1103,7 +1109,8 @@ }, "errors": { "noWidgetsAvailableTitle": "Keine Widgets verfügbar", - "noWidgetsAvailableDescription": "

Sie können keine Widgets für die ausgewählte Ebene erstellen. Dies liegt wahrscheinlich daran, dass der Layer nicht für die verfügbaren Widgets geeignet ist oder der Server nicht alle benötigten Services oder Informationen zum Generieren eines Widgets bereitstellt. Mögliche Ursachen sind:

  • Die ausgewählte Ebene ist eine Rasterebene
  • WFS-Dienst ist nicht verfügbar
  • gs: Aggregatprozess ist nicht verfügbar
)

" + "noWidgetsAvailableDescription": "

Sie können keine Widgets für die ausgewählte Ebene erstellen. Dies liegt wahrscheinlich daran, dass der Layer nicht für die verfügbaren Widgets geeignet ist oder der Server nicht alle benötigten Services oder Informationen zum Generieren eines Widgets bereitstellt. Mögliche Ursachen sind:

  • Die ausgewählte Ebene ist eine Rasterebene
  • WFS-Dienst ist nicht verfügbar
  • gs: Aggregatprozess ist nicht verfügbar
)

", + "checkAtLeastOneAttribute": "Sie müssen mindestens eine Spalte auswählen" }, "setupFilter": "Konfigurieren Sie einen Filter für die Widgetdaten" }, @@ -1175,6 +1182,9 @@ }, "dashboard": { + "editor": { + "addACardToTheDashboard": "Agregar un widget al tablero" + }, "emptyTitle": "Das Armaturenbrett ist leer" }, "wizard": { diff --git a/web/client/translations/data.en-US b/web/client/translations/data.en-US index 05e5c86257..89367766d8 100644 --- a/web/client/translations/data.en-US +++ b/web/client/translations/data.en-US @@ -1073,6 +1073,10 @@ "text": { "title": "Text", "caption": "add a text area" + }, + "table": { + "title": "Table", + "caption": "add a table" } }, "selectWidgetType": "Select the widget type", @@ -1096,6 +1100,9 @@ "backToChartOptions": "Back to chart options", "configureChartOptions": "Configure chart options", "configureWidgetOptions": "Configure widget options", + "backToTableOptions": "Back to table options", + "configureTableOptions": "Configure table options", + "resetColumnsSizes": "Reset all changes to the column sizes", "updateWidget": "Update the widget", "addToTheMap": "Add the widget to the map", "titlePlaceholder": "Insert title...", @@ -1103,7 +1110,8 @@ }, "errors": { "noWidgetsAvailableTitle": "No Widgets available", - "noWidgetsAvailableDescription": "

You can't create any widgets for the selected layer. This is probably because the layer is not suitable for the available widgets or the server doesn't expose all the needed services or information to generate widget. Possible causes are:

  • The selected layer is a raster layer
  • WFS service is not available
  • gs:aggregate process is not available

" + "noWidgetsAvailableDescription": "

You can't create any widgets for the selected layer. This is probably because the layer is not suitable for the available widgets or the server doesn't expose all the needed services or information to generate widget. Possible causes are:

  • The selected layer is a raster layer
  • WFS service is not available
  • gs:aggregate process is not available

", + "checkAtLeastOneAttribute": "You must select at least one column" }, "setupFilter": "Configure a filter for the widget data" }, @@ -1175,6 +1183,9 @@ }, "dashboard": { + "editor": { + "addACardToTheDashboard": "Add a widget to the dashboard" + }, "emptyTitle": "The dashboard is empty" }, "wizard": { diff --git a/web/client/translations/data.es-ES b/web/client/translations/data.es-ES index f4ad3a51ba..eb9488e20d 100644 --- a/web/client/translations/data.es-ES +++ b/web/client/translations/data.es-ES @@ -1072,6 +1072,10 @@ "text": { "title": "Texto", "caption": "agregar un área de texto" + }, + "table": { + "title": "Mesa", + "caption": "agregar una mesa" } }, "selectWidgetType": "Seleccione el tipo de widget", @@ -1095,6 +1099,9 @@ "backToChartOptions": "Volver a las opciones de gráfico", "configureChartOptions": "Configurar opciones de gráfico", "configureWidgetOptions": "Configurar las opciones del widget", + "backToTableOptions": "Volver a las opciones de mesa", + "configureTableOptions": "Configurar opciones de tabla", + "resetColumnsSizes": "Restablecer todos los cambios en los tamaños de columna", "updateWidget": "Actualiza el widget", "addToTheMap": "Agrega el widget al mapa", "titlePlaceholder": "Ingrese el título...", @@ -1102,7 +1109,8 @@ }, "errors": { "noWidgetsAvailableTitle": "Sin widgets disponibles", - "noWidgetsAvailableDescription": "

No puede crear ningún widgets para la capa seleccionada. Esto es probablemente porque la capa no es adecuada para los widgets disponibles o el servidor no expone todos los servicios o información necesarios para generar el widget. Las posibles causas son:

  • La capa seleccionada es una capa ráster
  • El servicio WFS no está disponible
  • gs: el proceso agregado no está disponible
)

" + "noWidgetsAvailableDescription": "

No puede crear ningún widgets para la capa seleccionada. Esto es probablemente porque la capa no es adecuada para los widgets disponibles o el servidor no expone todos los servicios o información necesarios para generar el widget. Las posibles causas son:

  • La capa seleccionada es una capa ráster
  • El servicio WFS no está disponible
  • gs: el proceso agregado no está disponible
)

", + "checkAtLeastOneAttribute": "Debes seleccionar al menos una columna" }, "setupFilter": "Configurar un filtro para los datos del widget" }, @@ -1174,6 +1182,9 @@ }, "dashboard": { + "editor": { + "addACardToTheDashboard": "Agregar un widget al tablero" + }, "emptyTitle": "El tablero está vacío" }, "wizard": { diff --git a/web/client/translations/data.fr-FR b/web/client/translations/data.fr-FR index 7a3065c731..7cc6e44cb4 100644 --- a/web/client/translations/data.fr-FR +++ b/web/client/translations/data.fr-FR @@ -1076,6 +1076,10 @@ "text": { "title": "Texte", "caption": "ajouter une zone de texte" + }, + "table": { + "title": "Table", + "caption": "ajouter une table" } }, "selectWidgetType": "Sélectionnez le type de widget", @@ -1099,6 +1103,9 @@ "backToChartOptions": "retour aux options de graphique", "configureChartOptions": "Configurer les options de graphique", "configureWidgetOptions": "Configurer les options du widget", + "backToTableOptions": "Retour à l'option de table", + "configureTableOptions": "Configurer les options de table", + "resetColumnsSizes": "Réinitialiser toutes les modifications aux tailles de colonnes", "updateWidget": "Mettre à jour le widget", "addToTheMap": "Ajouter le widget à la carte", "titlePlaceholder": "Entrez le titre...", @@ -1106,7 +1113,8 @@ }, "errors": { "noWidgetsAvailableTitle": "Aucun widget disponible", - "noWidgetsAvailableDescription": "

Vous ne pouvez pas créer de widget pour le calque sélectionné. C'est probablement parce que la couche ne convient pas aux widgets ou que le serveur n'expose pas tous les services ou informations nécessaires pour générer un widget. Les causes possibles sont:

  • Le calque sélectionné est une couche raster
  • Le service WFS n'est pas disponible
  • gs:aggregate process n'est pas disponible
)

" + "noWidgetsAvailableDescription": "

Vous ne pouvez pas créer de widget pour le calque sélectionné. C'est probablement parce que la couche ne convient pas aux widgets ou que le serveur n'expose pas tous les services ou informations nécessaires pour générer un widget. Les causes possibles sont:

  • Le calque sélectionné est une couche raster
  • Le service WFS n'est pas disponible
  • gs:aggregate process n'est pas disponible
)

", + "checkAtLeastOneAttribute": "Vous devez sélectionner au moins une colonne" }, "setupFilter": "Configurer un filtre pour les données du widget" }, @@ -1177,6 +1185,9 @@ } }, "dashboard": { + "editor": { + "addACardToTheDashboard": "Ajouter un widget au tableau de bord" + }, "emptyTitle": "Le tableau de bord est vide" }, "wizard": { diff --git a/web/client/translations/data.it-IT b/web/client/translations/data.it-IT index 513338bc7f..6bc55aed59 100644 --- a/web/client/translations/data.it-IT +++ b/web/client/translations/data.it-IT @@ -1073,6 +1073,10 @@ "text": { "title": "Testo", "caption": "aggiungi un area di testo" + }, + "table": { + "title": "Tabella", + "caption": "aggiungi una tabella" } }, "selectWidgetType": "Seleziona il tipo di widget", @@ -1096,6 +1100,9 @@ "backToChartOptions": "Torna alle opzioni del grafico", "configureChartOptions": "Configura le opzioni del grafico", "configureWidgetOptions": "Configura le opzioni del widget", + "backToTableOptions": "Torna alle opzioni della tabella", + "configureTableOptions": "Configura le opzioni della tabella", + "resetColumnsSizes": "Reimposta larghezza automatica delle colonne", "updateWidget": "Aggiorna il widget", "addToTheMap": "Aggiungi il widget alla mappa", "titlePlaceholder": "Inserisci il titolo...", @@ -1103,7 +1110,8 @@ }, "errors": { "noWidgetsAvailableTitle": "Nessun widgets disponibile", - "noWidgetsAvailableDescription": "

Non si possono creare widget per il livello selezionato. Questo perchè il livello non è adatto a nessuno degli widget disponibili o perché il server non espone sufficienti servizi per gernerali. Possibili cause:

  • Hai selezionato un livello raster
  • Il servizio WFS non è disponibile per il livello in oggetto
  • Il processo gs:aggregate non è disponibile sul server
)

" + "noWidgetsAvailableDescription": "

Non si possono creare widget per il livello selezionato. Questo perchè il livello non è adatto a nessuno degli widget disponibili o perché il server non espone sufficienti servizi per gernerali. Possibili cause:

  • Hai selezionato un livello raster
  • Il servizio WFS non è disponibile per il livello in oggetto
  • Il processo gs:aggregate non è disponibile sul server
)

", + "checkAtLeastOneAttribute": "seleziona almeno un attributo" }, "setupFilter": "Configura un filtro per il livello selezionato" }, @@ -1175,6 +1183,9 @@ }, "dashboard": { + "editor": { + "addACardToTheDashboard": "Aggiungi un widget alla dashboard" + }, "emptyTitle": "La dashboard è vuota" }, "wizard": { diff --git a/web/client/utils/FeatureGridUtils.js b/web/client/utils/FeatureGridUtils.js index faa01d1397..da913595e6 100644 --- a/web/client/utils/FeatureGridUtils.js +++ b/web/client/utils/FeatureGridUtils.js @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -const {get, findIndex, isNil} = require('lodash'); +const { get, findIndex, isNil, fill} = require('lodash'); const {getFeatureTypeProperties, isGeometryType, isValid, isValidValueForPropertyName, findGeometryProperty, getPropertyDesciptor} = require('./ogc/WFS/base'); const getGeometryName = (describe) => get(findGeometryProperty(describe), "name"); @@ -77,18 +77,45 @@ const upsertFilterField = (filterFields = [], filter, newObject) => { }; const getAttributeFields = (describe) => (getFeatureTypeProperties(describe) || []).filter( e => !isGeometryType(e)); +// virtual scroll utility functions +const getIdxFarthestEl = (startIdx, pages = [], firstRow, lastRow) => { + return pages.map(val => firstRow <= val && val <= lastRow ? 0 : Math.abs(val - startIdx)).reduce((i, distance, idx, vals) => distance > vals[i] && idx || i, 0); +}; +const removePage = (idxFarthestEl, pages) => pages.filter((el, i) => i !== idxFarthestEl); +const removePageFeatures = (features, idxToRemove, size) => features.filter((el, i) => i < idxToRemove || i >= idxToRemove + size); +const getPagesToLoad = (startPage, endPage, pages, size) => { + let firstMissingPage; + let lastMissingPage; + for (let i = startPage; i <= endPage && firstMissingPage === undefined; i++) { + if (getRowIdx(i * size, pages, size) === -1) { + firstMissingPage = i; + } + } + for (let i = endPage; i >= startPage && lastMissingPage === undefined; i--) { + if (getRowIdx(i * size, pages, size) === -1) { + lastMissingPage = i; + } + } + return [firstMissingPage, lastMissingPage].filter(p => p !== undefined); +}; +// return the options for the paged request to get checking the current cached data +const getCurrentPaginationOptions = ({ startPage, endPage }, oldPages, size) => { + const nPs = getPagesToLoad(startPage, endPage, oldPages, size); + const needPages = (nPs[1] - nPs[0] + 1); + return { startIndex: nPs[0] * size, maxFeatures: needPages * size }; +}; module.exports = { getAttributeFields, featureTypeToGridColumns: ( describe, columnSettings = {}, - {editable=false, sortable=true, resizable=true, filterable = true} = {}, + {editable=false, sortable=true, resizable=true, filterable = true, defaultSize = 200} = {}, {getEditor = () => {}, getFilterRenderer = () => {}, getFormatter = () => {}} = {}) => getAttributeFields(describe).filter(e => !(columnSettings[e.name] && columnSettings[e.name].hide)).map( (desc) => ({ sortable, key: desc.name, - width: columnSettings[desc.name] && columnSettings[desc.name].width || 200, + width: columnSettings[desc.name] && columnSettings[desc.name].width || (defaultSize ? defaultSize : undefined), name: desc.name, resizable, editable, @@ -119,11 +146,12 @@ module.exports = { * @param {function} rowGetter the method to retrieve the feature * @param {object} describe the describe feature type * @param {object} actionOpts some options + * @param {object} columns columns definition * @return {object} The events with the additional parameters */ - getGridEvents: (gridEvents = {}, rowGetter, describe, actionOpts) => Object.keys(gridEvents).reduce((events, currentEventKey) => ({ + getGridEvents: (gridEvents = {}, rowGetter, describe, actionOpts, columns) => Object.keys(gridEvents).reduce((events, currentEventKey) => ({ ...events, - [currentEventKey]: (...args) => gridEvents[currentEventKey](...args, rowGetter, describe, actionOpts) + [currentEventKey]: (...args) => gridEvents[currentEventKey](...args, rowGetter, describe, actionOpts, columns) }), {}), isProperty: (k, d) => !!getPropertyDesciptor(k, d), isValidValueForPropertyName: (v, k, d) => isValidValueForPropertyName(v, getPropertyName(k, d), d), @@ -161,25 +189,45 @@ module.exports = { total: totalFeatures, maxPages: Math.ceil(totalFeatures / maxFeatures) - 1 }), - getRowIdx, - getPagesToLoad: (startPage, endPage, pages, size) => { - let firstMissingPage; - let lastMissingPage; - for (let i = startPage; i <= endPage && firstMissingPage === undefined; i++) { - if (getRowIdx(i * size, pages, size) === -1) { - firstMissingPage = i; - } + getCurrentPaginationOptions, + /** + * updates the pages and the features of the request to support virtual scroll. + * This is virtual scroll with support for + * @param {object} result An object with result --> geoJSON of type "FeatureCollection" + * @param {object} requestOptions contains startPage and endPage needed. + * @param {object} oldData features and pages previously loaded + * @param {object} paginationOptions. The options for pagination `size` and `maxStoredPages`, . + * + */ + updatePages: (result, { endPage, startPage } = {}, { pages, features } = {}, {size, maxStoredPages, startIndex} = {}) => { + const nPs = getPagesToLoad(startPage, endPage, pages, size); + const needPages = (nPs[1] - nPs[0] + 1); + let fts = get(result, "features", []); + if (fts.length !== needPages * size) { + fts = fts.concat(fill(Array(needPages * size - fts.length), false)); } - for (let i = endPage; i >= startPage && lastMissingPage === undefined; i--) { - if (getRowIdx(i * size, pages, size) === -1) { - lastMissingPage = i; + let oldPages = pages; + let oldFeatures = features; + // 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 = oldPages.length + needPages - Math.max(maxStoredPages, (endPage - startPage + 1)); + if (nSpaces > 0) { + const firstRow = startPage * size; + const lastRow = endPage * size; + // Remove the farthest page from last loaded pages + const averageIdx = firstRow + (lastRow - firstRow) / 2; + for (let i = 0; i < nSpaces; i++) { + const idxFarthestEl = getIdxFarthestEl(averageIdx, pages, firstRow, lastRow); + const idxToRemove = idxFarthestEl * size; + oldPages = removePage(idxFarthestEl, oldPages); + oldFeatures = removePageFeatures(features, idxToRemove, size); } } - return [firstMissingPage, lastMissingPage].filter(p => p !== undefined); - }, - getIdxFarthestEl: (startIdx, pages = [], firstRow, lastRow) => { - return pages.map(val => firstRow <= val && val <= lastRow ? 0 : Math.abs(val - startIdx)).reduce((i, distance, idx, vals) => distance > vals[i] && idx || i, 0); + let pagesLoaded = []; + for (let i = 0; i < needPages; i++) { + pagesLoaded.push(startIndex + (size * i)); + } + return { pages: oldPages.concat(pagesLoaded), features: oldFeatures.concat(fts) }; }, - removePage: (idxFarthestEl, pages) => pages.filter( (el, i) => i !== idxFarthestEl), - removePageFeatures: (features, idxToRemove, size) => features.filter((el, i) => i < idxToRemove || i >= idxToRemove + size) + getRowIdx, + getPagesToLoad }; diff --git a/web/client/utils/LayersUtils.js b/web/client/utils/LayersUtils.js index bec2bf39ce..8e2cfd99e5 100644 --- a/web/client/utils/LayersUtils.js +++ b/web/client/utils/LayersUtils.js @@ -400,6 +400,14 @@ const LayersUtils = { } return addBaseParams(reqUrl, layer.baseParams || {}); }, + /** + * Gets the layer search url or the current url + * + * @memberof utils.LayerUtils + * @param {Object} layer + * @returns {string} layer url + */ + getSearchUrl: (l = {}) => l.search && l.search.url || l.url, invalidateUnsupportedLayer(layer, maptype) { return isSupportedLayer(layer, maptype) ? checkInvalidParam(layer) : assign({}, layer, {invalid: true}); }, diff --git a/web/client/utils/ogc/WFS/RequestBuilder.js b/web/client/utils/ogc/WFS/RequestBuilder.js index 50f4eb9945..65fe252f67 100644 --- a/web/client/utils/ogc/WFS/RequestBuilder.js +++ b/web/client/utils/ogc/WFS/RequestBuilder.js @@ -6,6 +6,7 @@ * LICENSE file in the root directory of this source tree. */ const filterBuilder = require('../Filter/FilterBuilder'); +const {castArray} = require('lodash'); const {wfsToGmlVersion} = require("./base"); const getStaticAttributesWFS1 = (ver) => 'service="WFS" version="' + ver + '" ' + (ver === "1.0.0" ? 'outputFormat="GML2" ' : "") + @@ -84,9 +85,19 @@ module.exports = function({wfsVersion = "1.1.0", gmlVersion, filterNS, wfsNS="wf + ((startIndex || startIndex === 0) ? ` startIndex="${startIndex}"` : "") + ((maxFeatures || maxFeatures === 0) ? ` ${getMaxFeatures(maxFeatures)}` : ""); }; + const propertyName = (property) => + castArray(property) + .map(p => `<${wfsNS}:PropertyName>${p}`) + .join(""); return { + propertyName, ...filterBuilder({gmlVersion: gmlV, wfsVersion, filterNS: filterNS || wfsVersion === "2.0" ? "fes" : "ogc"}), getFeature: (content, opts) => `<${wfsNS}:GetFeature ${requestAttributes(opts)}>${Array.isArray(content) ? content.join("") : content}`, + sortBy: (property, order = "ASC") => + `<${wfsNS}:SortBy><${wfsNS}:SortProperty>` + + `${propertyName(property)}` + + `<${wfsNS}:SortOrder>${order}` + ``, query: (featureName, content, {srsName ="EPSG:4326"} = {}) => `<${wfsNS}:Query ${wfsVersion === "2.0" ? "typeNames" : "typeName"}="${featureName}" srsName="${srsName}">` + `${Array.isArray(content) ? content.join("") : content}`