diff --git a/images/images.qrc b/images/images.qrc index 5e5307b9ff9..3f9457832b8 100644 --- a/images/images.qrc +++ b/images/images.qrc @@ -363,7 +363,7 @@ themes/default/mIconDataDefineExpression.svg themes/default/mIconDataDefineExpressionError.svg themes/default/mIconDataDefineExpressionOn.svg - themes/default/mIconDb2.svg + themes/default/mIconDb2.svg themes/default/mIconDbSchema.png themes/default/mIconDelete.png themes/default/mIconDeselected.svg @@ -488,7 +488,7 @@ themes/default/rendererCategorizedSymbol.svg themes/default/rendererGraduatedSymbol.png themes/default/rendererGraduatedSymbol.svg - themes/default/rendererNullSymbol.svg + themes/default/rendererNullSymbol.svg themes/default/rendererSingleSymbol.png themes/default/rendererSingleSymbol.svg themes/default/rendererRuleBasedSymbol.svg @@ -576,6 +576,9 @@ icons/qgis-icon-60x60_xmas.png themes/default/mActionTracing.png themes/default/vector_grid.png + themes/default/multieditChangedValues.svg + themes/default/multieditMixedValues.svg + themes/default/multieditSameValues.svg qgis_tips/symbol_levels.png diff --git a/images/themes/default/multieditChangedValues.svg b/images/themes/default/multieditChangedValues.svg new file mode 100644 index 00000000000..8d8f754961a --- /dev/null +++ b/images/themes/default/multieditChangedValues.svg @@ -0,0 +1,1061 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/themes/default/multieditMixedValues.svg b/images/themes/default/multieditMixedValues.svg new file mode 100644 index 00000000000..4d746a56b44 --- /dev/null +++ b/images/themes/default/multieditMixedValues.svg @@ -0,0 +1,957 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/images/themes/default/multieditSameValues.svg b/images/themes/default/multieditSameValues.svg new file mode 100644 index 00000000000..1a7db2d7a65 --- /dev/null +++ b/images/themes/default/multieditSameValues.svg @@ -0,0 +1,125 @@ + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/python/gui/attributetable/qgsdualview.sip b/python/gui/attributetable/qgsdualview.sip index 667e1f3f1e3..a70a74c078b 100644 --- a/python/gui/attributetable/qgsdualview.sip +++ b/python/gui/attributetable/qgsdualview.sip @@ -47,9 +47,17 @@ class QgsDualView : QStackedWidget * Change the current view mode. * * @param view The view mode to set + * @see view() */ void setView( ViewMode view ); + /** + * Returns the current view mode. + * @see setView() + * @note added in QGIS 2.16 + */ + ViewMode view() const; + /** * Set the filter mode * @@ -130,6 +138,11 @@ class QgsDualView : QStackedWidget void openConditionalStyles(); + /** Sets whether multi edit mode is enabled. + * @note added in QGIS 2.16 + */ + void setMultiEditEnabled( bool enabled ); + signals: /** * Is emitted, whenever the display expression is successfully changed diff --git a/python/gui/editorwidgets/qgsmultiedittoolbutton.sip b/python/gui/editorwidgets/qgsmultiedittoolbutton.sip new file mode 100644 index 00000000000..1b9fb4acebd --- /dev/null +++ b/python/gui/editorwidgets/qgsmultiedittoolbutton.sip @@ -0,0 +1,75 @@ +/** \ingroup gui + * \class QgsMultiEditToolButton + * A tool button widget which is displayed next to editor widgets in attribute forms, and + * allows for controlling how the widget behaves and interacts with the form while in multi + * edit mode. + * \note Added in version 2.16 + */ +class QgsMultiEditToolButton : QToolButton +{ +%TypeHeaderCode +#include +%End + public: + + //! Button states + enum State + { + Default, /*!< Default state, all features have same value for widget */ + MixedValues, /*!< Mixed state, some features have different values for the widget */ + Changed, /*!< Value for widget has changed but changes have not yet been committed */ + }; + + /** Constructor for QgsMultiEditToolButton. + * @param parent parent object + */ + explicit QgsMultiEditToolButton( QWidget *parent /TransferThis/ = nullptr ); + + /** Returns the current displayed state of the button. + */ + State state() const; + + /** Sets the field associated with this button. This is used to customise the widget menu + * and tooltips to match the field properties. + * @param field associated field + */ + void setField( const QgsField& field ); + + public slots: + + /** Sets whether the associated field contains mixed values. + * @param mixed whether field values are mixed + * @see isMixed() + * @see setIsChanged() + * @see resetChanges() + */ + void setIsMixed( bool mixed ); + + /** Sets whether the associated field has changed. + * @param changed whether field has changed + * @see isChanged() + * @see setIsMixed() + * @see resetChanges() + */ + void setIsChanged( bool changed ); + + /** Resets the changed state for the field. + * @see setIsMixed() + * @see setIsChanged() + * @see changesCommitted() + */ + void resetChanges(); + + /** Called when field values have been changed and field now contains all the same values. + * @see resetChanges() + */ + void changesCommitted(); + + signals: + + //! Emitted when the "set field value for all features" option is selected. + void setFieldValueTriggered(); + + //! Emitted when the "reset to original values" option is selected. + void resetFieldValueTriggered(); +}; diff --git a/python/gui/gui.sip b/python/gui/gui.sip index d4878a71760..f8d03415a87 100644 --- a/python/gui/gui.sip +++ b/python/gui/gui.sip @@ -30,6 +30,7 @@ %Include qgsattributeeditor.sip %Include qgsattributeeditorcontext.sip %Include qgsattributeform.sip +%Include qgsattributeformeditorwidget.sip %Include qgsattributeforminterface.sip %Include qgsattributetypeloaddialog.sip %Include qgsbrowsertreeview.sip @@ -244,6 +245,7 @@ %Include editorwidgets/core/qgswidgetwrapper.sip %Include editorwidgets/qgsdatetimeedit.sip %Include editorwidgets/qgsdoublespinbox.sip +%Include editorwidgets/qgsmultiedittoolbutton.sip %Include editorwidgets/qgsrelationreferencewidget.sip %Include editorwidgets/qgsrelationreferencewidgetwrapper.sip %Include editorwidgets/qgsrelationwidgetwrapper.sip diff --git a/python/gui/qgsattributedialog.sip b/python/gui/qgsattributedialog.sip index 4a6c47be0db..f1aa9ce5644 100644 --- a/python/gui/qgsattributedialog.sip +++ b/python/gui/qgsattributedialog.sip @@ -74,8 +74,16 @@ class QgsAttributeDialog : QDialog * If set to true, the dialog will add a new feature when the form is accepted. * * @param isAddDialog If set to true, turn this dialog into an add feature dialog. + * @deprecated use setMode() instead */ - void setIsAddDialog( bool isAddDialog ); + void setIsAddDialog( bool isAddDialog ) /Deprecated/; + + /** + * Toggles the form mode. + * @param mode form mode. Eg if set to QgsAttributeForm::AddFeatureMode, the dialog will be editable even with an invalid feature and + * will add a new feature when the form is accepted. + */ + void setMode( QgsAttributeForm::Mode mode ); /** * Sets the edit command message (Undo) that will be used when the dialog is accepted diff --git a/python/gui/qgsattributeform.sip b/python/gui/qgsattributeform.sip index f34d8d89f9d..f636de540be 100644 --- a/python/gui/qgsattributeform.sip +++ b/python/gui/qgsattributeform.sip @@ -20,6 +20,15 @@ class QgsAttributeForm : QWidget %End public: + + //! Form modes + enum Mode + { + SingleEditMode, /*!< Single edit mode, for editing a single feature */ + MultiEditMode, /*!< Multi edit mode, for editing fields of multiple features at once */ + // TODO: SearchMode, /*!< Form values are used for searching/filtering the layer */ + }; + explicit QgsAttributeForm( QgsVectorLayer* vl, const QgsFeature& feature = QgsFeature(), const QgsAttributeEditorContext& context = QgsAttributeEditorContext(), QWidget *parent /TransferThis/ = 0 ); ~QgsAttributeForm(); @@ -61,6 +70,19 @@ class QgsAttributeForm : QWidget */ bool editable(); + /** Returns the current mode of the form. + * @note added in QGIS 2.16 + * @see setMode() + */ + Mode mode() const; + + /** Sets the current mode of the form. + * @param mode form mode + * @note added in QGIS 2.16 + * @see mode() + */ + void setMode( Mode mode ); + /** * Toggles the form mode between edit feature and add feature. * If set to true, the dialog will be editable even with an invalid feature. @@ -87,6 +109,12 @@ class QgsAttributeForm : QWidget */ bool eventFilter( QObject* object, QEvent* event ); + /** Sets all feature IDs which are to be edited if the form is in multiedit mode + * @param fids feature ID list + * @note added in QGIS 2.16 + */ + void setMultiEditFeatureIds( const QgsFeatureIds& fids ); + signals: /** * Notifies about changes of attributes diff --git a/python/gui/qgsattributeformeditorwidget.sip b/python/gui/qgsattributeformeditorwidget.sip new file mode 100644 index 00000000000..9b0eb88e1de --- /dev/null +++ b/python/gui/qgsattributeformeditorwidget.sip @@ -0,0 +1,78 @@ +/** \ingroup gui + * \class QgsAttributeFormEditorWidget + * A widget consisting of both an editor widget and additional widgets for controlling the behaviour + * of the editor widget depending on a number of possible modes. For instance, if the parent attribute + * form is in the multi edit mode, this widget will show both the editor widget and a tool button for + * controlling the multi edit results. + * \note Added in version 2.16 + */ +class QgsAttributeFormEditorWidget : QWidget +{ +%TypeHeaderCode +#include +%End + + public: + + //! Widget modes + enum Mode + { + DefaultMode, /*!< Default mode, only the editor widget is shown */ + MultiEditMode, /*!< Multi edit mode, both the editor widget and a QgsMultiEditToolButton is shown */ + // TODO: SearchMode, /*!< Layer search/filter mode */ + }; + + /** Constructor for QgsAttributeFormEditorWidget. + * @param editorWidget associated editor widget wrapper + * @param form parent attribute form + */ + explicit QgsAttributeFormEditorWidget( QgsEditorWidgetWrapper* editorWidget, QgsAttributeForm* form /TransferThis/ ); + + ~QgsAttributeFormEditorWidget(); + + /** Sets the current mode for the widget. The widget will adapt its state and visible widgets to + * reflect the updated mode. Eg, showing multi edit tool buttons if the mode is set to MultiEditMode. + * @param mode widget mode + * @see mode() + */ + void setMode( Mode mode ); + + /** Returns the current mode for the widget. + * @see setMode() + */ + Mode mode() const; + + /** Resets the widget to an initial value. + * @param initialValue initial value to show in widget + * @param mixedValue set to true to initially show the mixed values state + */ + void initialize( const QVariant& initialValue, bool mixedValues = false ); + + /** Returns true if the widget's value has been changed since it was initialized. + * @see initialize() + */ + bool hasChanged() const; + + /** Returns the current value of the attached editor widget. + */ + QVariant currentValue() const; + + public slots: + + /** Sets whether the widget should be displayed in a "mixed values" mode. + * @param mixed set to true to show in a mixed values state + */ + void setIsMixed( bool mixed ); + + /** Called when field values have been committed; + */ + void changesCommitted(); + + signals: + + //! Emitted when the widget's value changes + //! @param value new widget value + void valueChanged( const QVariant& value ); + + +}; diff --git a/src/app/qgsattributetabledialog.cpp b/src/app/qgsattributetabledialog.cpp index 0d7f41b4535..e1feac2d3d6 100644 --- a/src/app/qgsattributetabledialog.cpp +++ b/src/app/qgsattributetabledialog.cpp @@ -62,6 +62,15 @@ static QgsExpressionContext _getExpressionContext( const void* context ) return expContext; } +void QgsAttributeTableDialog::updateMultiEditButtonState() +{ + mToggleMultiEditButton->setEnabled( mLayer->isEditable() ); + if ( mLayer->isEditable() && mMainView->view() != QgsDualView::AttributeEditor ) + { + mToggleMultiEditButton->setChecked( false ); + } +} + QgsAttributeTableDialog::QgsAttributeTableDialog( QgsVectorLayer *theLayer, QWidget *parent, Qt::WindowFlags flags ) : QDialog( parent, flags ) , mDock( nullptr ) @@ -246,6 +255,9 @@ QgsAttributeTableDialog::QgsAttributeTableDialog( QgsVectorLayer *theLayer, QWid mMainView->setView( static_cast< QgsDualView::ViewMode >( initialView ) ); mMainViewButtonGroup->button( initialView )->setChecked( true ); + connect( mToggleMultiEditButton, SIGNAL( toggled( bool ) ), mMainView, SLOT( setMultiEditEnabled( bool ) ) ); + updateMultiEditButtonState(); + editingToggled(); } @@ -648,6 +660,7 @@ void QgsAttributeTableDialog::on_mDeleteSelectedButton_clicked() void QgsAttributeTableDialog::on_mMainView_currentChanged( int viewMode ) { mMainViewButtonGroup->button( viewMode )->click(); + updateMultiEditButtonState(); QSettings s; s.setValue( "/qgis/attributeTableLastView", static_cast< int >( viewMode ) ); @@ -670,6 +683,7 @@ void QgsAttributeTableDialog::editingToggled() mToggleEditingButton->setChecked( mLayer->isEditable() ); mSaveEditsButton->setEnabled( mLayer->isEditable() ); mReloadButton->setEnabled( ! mLayer->isEditable() ); + updateMultiEditButtonState(); mToggleEditingButton->blockSignals( false ); bool canChangeAttributes = mLayer->dataProvider()->capabilities() & QgsVectorDataProvider::ChangeAttributeValues; diff --git a/src/app/qgsattributetabledialog.h b/src/app/qgsattributetabledialog.h index 1dd1cf786f2..5d61c93e337 100644 --- a/src/app/qgsattributetabledialog.h +++ b/src/app/qgsattributetabledialog.h @@ -224,6 +224,8 @@ class APP_EXPORT QgsAttributeTableDialog : public QDialog, private Ui::QgsAttrib QgsRubberBand* mRubberBand; QgsSearchWidgetWrapper* mCurrentSearchWidgetWrapper; + void updateMultiEditButtonState(); + friend class TestQgsAttributeTable; }; diff --git a/src/app/qgsfeatureaction.cpp b/src/app/qgsfeatureaction.cpp index 106cfcfa5ea..02c56946a42 100644 --- a/src/app/qgsfeatureaction.cpp +++ b/src/app/qgsfeatureaction.cpp @@ -111,7 +111,7 @@ bool QgsFeatureAction::editFeature( bool showModal ) QgsAttributeDialog *dialog = newDialog( false ); if ( !mFeature->isValid() ) - dialog->setIsAddDialog( true ); + dialog->setMode( QgsAttributeForm::AddFeatureMode ); if ( showModal ) { @@ -193,7 +193,7 @@ bool QgsFeatureAction::addFeature( const QgsAttributeMap& defaultAttributes, boo else { QgsAttributeDialog *dialog = newDialog( false ); - dialog->setIsAddDialog( true ); + dialog->setMode( QgsAttributeForm::AddFeatureMode ); dialog->setEditCommandMessage( text() ); connect( dialog->attributeForm(), SIGNAL( featureSaved( const QgsFeature & ) ), this, SLOT( onFeatureSaved( const QgsFeature & ) ) ); diff --git a/src/app/qgsmaptoolfillring.cpp b/src/app/qgsmaptoolfillring.cpp index 22898c77520..bd213dd4a5f 100644 --- a/src/app/qgsmaptoolfillring.cpp +++ b/src/app/qgsmaptoolfillring.cpp @@ -157,7 +157,7 @@ void QgsMapToolFillRing::cadCanvasReleaseEvent( QgsMapMouseEvent * e ) else { QgsAttributeDialog *dialog = new QgsAttributeDialog( vlayer, ft, false, nullptr, true ); - dialog->setIsAddDialog( true ); + dialog->setMode( QgsAttributeForm::AddFeatureMode ); res = dialog->exec(); // will also add the feature } diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 7e40579b117..60ebce05dc8 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -112,6 +112,7 @@ SET(QGIS_GUI_SRCS editorwidgets/qgsfilenamewidgetfactory.cpp editorwidgets/qgshiddenwidgetwrapper.cpp editorwidgets/qgshiddenwidgetfactory.cpp + editorwidgets/qgsmultiedittoolbutton.cpp editorwidgets/qgsphotoconfigdlg.cpp editorwidgets/qgsphotowidgetwrapper.cpp editorwidgets/qgsphotowidgetfactory.cpp @@ -155,6 +156,7 @@ SET(QGIS_GUI_SRCS qgsattributedialog.cpp qgsattributeeditor.cpp qgsattributeform.cpp + qgsattributeformeditorwidget.cpp qgsattributeforminterface.cpp qgsattributeformlegacyinterface.cpp qgsattributetypeloaddialog.cpp @@ -301,6 +303,7 @@ SET(QGIS_GUI_MOC_HDRS qgsattributedialog.h qgsattributeeditor.h qgsattributeform.h + qgsattributeformeditorwidget.h qgsattributetypeloaddialog.h qgsblendmodecombobox.h qgsbrowsertreeview.h @@ -514,6 +517,7 @@ SET(QGIS_GUI_MOC_HDRS editorwidgets/qgsexternalresourcewidgetwrapper.h editorwidgets/qgsfilenamewidgetwrapper.h editorwidgets/qgshiddenwidgetwrapper.h + editorwidgets/qgsmultiedittoolbutton.h editorwidgets/qgsphotoconfigdlg.h editorwidgets/qgsphotowidgetwrapper.h editorwidgets/qgsrangeconfigdlg.h diff --git a/src/gui/attributetable/qgsdualview.cpp b/src/gui/attributetable/qgsdualview.cpp index 4407dd94b97..cdcdded9315 100644 --- a/src/gui/attributetable/qgsdualview.cpp +++ b/src/gui/attributetable/qgsdualview.cpp @@ -203,6 +203,11 @@ void QgsDualView::setView( QgsDualView::ViewMode view ) setCurrentIndex( view ); } +QgsDualView::ViewMode QgsDualView::view() const +{ + return static_cast< QgsDualView::ViewMode >( currentIndex() ); +} + void QgsDualView::setFilterMode( QgsAttributeTableFilterModel::FilterMode filterMode ) { mFilterModel->setFilterMode( filterMode ); @@ -290,6 +295,14 @@ void QgsDualView::openConditionalStyles() mConditionalFormatWidget->viewRules(); } +void QgsDualView::setMultiEditEnabled( bool enabled ) +{ + if ( enabled ) + setView( AttributeEditor ); + + mAttributeForm->setMode( enabled ? QgsAttributeForm::MultiEditMode : QgsAttributeForm::SingleEditMode ); +} + void QgsDualView::previewExpressionBuilder() { // Show expression builder diff --git a/src/gui/attributetable/qgsdualview.h b/src/gui/attributetable/qgsdualview.h index 1eefcfbb44a..1b027775ea5 100644 --- a/src/gui/attributetable/qgsdualview.h +++ b/src/gui/attributetable/qgsdualview.h @@ -85,9 +85,17 @@ class GUI_EXPORT QgsDualView : public QStackedWidget, private Ui::QgsDualViewBas * Change the current view mode. * * @param view The view mode to set + * @see view() */ void setView( ViewMode view ); + /** + * Returns the current view mode. + * @see setView() + * @note added in QGIS 2.16 + */ + ViewMode view() const; + /** * Set the filter mode * @@ -165,6 +173,11 @@ class GUI_EXPORT QgsDualView : public QStackedWidget, private Ui::QgsDualViewBas void openConditionalStyles(); + /** Sets whether multi edit mode is enabled. + * @note added in QGIS 2.16 + */ + void setMultiEditEnabled( bool enabled ); + signals: /** * Is emitted, whenever the display expression is successfully changed diff --git a/src/gui/attributetable/qgsfeaturelistview.cpp b/src/gui/attributetable/qgsfeaturelistview.cpp index 1bc73a4bf36..56f185e4218 100644 --- a/src/gui/attributetable/qgsfeaturelistview.cpp +++ b/src/gui/attributetable/qgsfeaturelistview.cpp @@ -82,6 +82,7 @@ void QgsFeatureListView::setModel( QgsFeatureListModel* featureListModel ) connect( mCurrentEditSelectionModel, SIGNAL( selectionChanged( QItemSelection, QItemSelection ) ), SLOT( editSelectionChanged( QItemSelection, QItemSelection ) ) ); + connect( mModel->layerCache()->layer(), SIGNAL( attributeValueChanged( QgsFeatureId, int, QVariant ) ), this, SLOT( repaintRequested() ) ); } bool QgsFeatureListView::setDisplayExpression( const QString& expression ) diff --git a/src/gui/editorwidgets/qgsmultiedittoolbutton.cpp b/src/gui/editorwidgets/qgsmultiedittoolbutton.cpp new file mode 100644 index 00000000000..b201b9ab172 --- /dev/null +++ b/src/gui/editorwidgets/qgsmultiedittoolbutton.cpp @@ -0,0 +1,115 @@ +/*************************************************************************** + qgsmultiedittoolbutton.cpp + -------------------------- + Date : March 2016 + Copyright : (C) 2016 Nyall Dawson + Email : nyall dot dawson at gmail.com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgsmultiedittoolbutton.h" +#include "qgsapplication.h" +#include +QgsMultiEditToolButton::QgsMultiEditToolButton( QWidget* parent ) + : QToolButton( parent ) + , mIsMixedValues( false ) + , mIsChanged( false ) + , mState( Default ) + , mMenu( nullptr ) +{ + setFocusPolicy( Qt::StrongFocus ); + + // set default tool button icon properties + setFixedSize( 22, 22 ); + setStyleSheet( QString( "QToolButton{ background: none; border: 1px solid rgba(0, 0, 0, 0%);} QToolButton:focus { border: 1px solid palette(highlight); }" ) ); + setIconSize( QSize( 16, 16 ) ); + setPopupMode( QToolButton::InstantPopup ); + + mMenu = new QMenu( this ); + connect( mMenu, SIGNAL( aboutToShow() ), this, SLOT( aboutToShowMenu() ) ); + setMenu( mMenu ); + + // sets initial appearance + updateState(); +} + +void QgsMultiEditToolButton::aboutToShowMenu() +{ + mMenu->clear(); + + switch ( mState ) + { + case Default: + { + QAction* noAction = mMenu->addAction( tr( "No changes to commit" ) ); + noAction->setEnabled( false ); + break; + } + case MixedValues: + { + QString title = !mField.name().isEmpty() ? tr( "Set %1 for all selected features" ).arg( mField.name() ) + : tr( "Set field for all selected features" ); + QAction* setFieldAction = mMenu->addAction( title ); + connect( setFieldAction, SIGNAL( triggered( bool ) ), this, SLOT( setFieldTriggered() ) ); + break; + } + case Changed: + { + QAction* resetFieldAction = mMenu->addAction( tr( "Reset to original values" ) ); + connect( resetFieldAction, SIGNAL( triggered( bool ) ), this, SLOT( resetFieldTriggered() ) ); + break; + } + } +} + +void QgsMultiEditToolButton::setFieldTriggered() +{ + mIsChanged = true; + updateState(); + emit setFieldValueTriggered(); +} + +void QgsMultiEditToolButton::resetFieldTriggered() +{ + mIsChanged = false; + updateState(); + emit resetFieldValueTriggered(); +} + +void QgsMultiEditToolButton::updateState() +{ + //changed state takes priority over mixed values state + if ( mIsChanged ) + mState = Changed; + else if ( mIsMixedValues ) + mState = MixedValues; + else + mState = Default; + + QIcon icon; + QString tooltip; + switch ( mState ) + { + case Default: + icon = QgsApplication::getThemeIcon( "/multieditSameValues.svg" ); + tooltip = tr( "All features in selection have equal value for '%1'" ).arg( mField.name() ); + break; + case MixedValues: + icon = QgsApplication::getThemeIcon( "/multieditMixedValues.svg" ); + tooltip = tr( "Some features in selection have different values for '%1'" ).arg( mField.name() ); + break; + case Changed: + icon = QgsApplication::getThemeIcon( "/multieditChangedValues.svg" ); + tooltip = tr( "Values for '%1' have unsaved changes" ).arg( mField.name() ); + break; + } + + setIcon( icon ); + setToolTip( tooltip ); +} diff --git a/src/gui/editorwidgets/qgsmultiedittoolbutton.h b/src/gui/editorwidgets/qgsmultiedittoolbutton.h new file mode 100644 index 00000000000..9aeaa289d65 --- /dev/null +++ b/src/gui/editorwidgets/qgsmultiedittoolbutton.h @@ -0,0 +1,115 @@ +/*************************************************************************** + qgsmultiedittoolbutton.h + ------------------------ + Date : March 2016 + Copyright : (C) 2016 Nyall Dawson + Email : nyall dot dawson at gmail.com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef QGSMULTIEDITTOOLBUTTON_H +#define QGSMULTIEDITTOOLBUTTON_H + +#include "qgsfield.h" +#include + +/** \ingroup gui + * \class QgsMultiEditToolButton + * A tool button widget which is displayed next to editor widgets in attribute forms, and + * allows for controlling how the widget behaves and interacts with the form while in multi + * edit mode. + * \note Added in version 2.16 + */ +class GUI_EXPORT QgsMultiEditToolButton : public QToolButton +{ + Q_OBJECT + + public: + + //! Button states + enum State + { + Default, /*!< Default state, all features have same value for widget */ + MixedValues, /*!< Mixed state, some features have different values for the widget */ + Changed, /*!< Value for widget has changed but changes have not yet been committed */ + }; + + /** Constructor for QgsMultiEditToolButton. + * @param parent parent object + */ + explicit QgsMultiEditToolButton( QWidget *parent = nullptr ); + + /** Returns the current displayed state of the button. + */ + State state() const { return mState; } + + /** Sets the field associated with this button. This is used to customise the widget menu + * and tooltips to match the field properties. + * @param field associated field + */ + void setField( const QgsField& field ) { mField = field; } + + public slots: + + /** Sets whether the associated field contains mixed values. + * @param mixed whether field values are mixed + * @see isMixed() + * @see setIsChanged() + * @see resetChanges() + */ + void setIsMixed( bool mixed ) { mIsMixedValues = mixed; updateState(); } + + /** Sets whether the associated field has changed. + * @param changed whether field has changed + * @see isChanged() + * @see setIsMixed() + * @see resetChanges() + */ + void setIsChanged( bool changed ) { mIsChanged = changed; updateState(); } + + /** Resets the changed state for the field. + * @see setIsMixed() + * @see setIsChanged() + * @see changesCommitted() + */ + void resetChanges() { mIsChanged = false; updateState(); } + + /** Called when field values have been changed and field now contains all the same values. + * @see resetChanges() + */ + void changesCommitted() { mIsMixedValues = false; mIsChanged = false; updateState(); } + + signals: + + //! Emitted when the "set field value for all features" option is selected. + void setFieldValueTriggered(); + + //! Emitted when the "reset to original values" option is selected. + void resetFieldValueTriggered(); + + private slots: + + void aboutToShowMenu(); + void setFieldTriggered(); + void resetFieldTriggered(); + + private: + + bool mIsMixedValues; + bool mIsChanged; + State mState; + QgsField mField; + + QMenu* mMenu; + + void updateState(); + +}; + +#endif // QGSMULTIEDITTOOLBUTTON_H diff --git a/src/gui/qgsattributedialog.h b/src/gui/qgsattributedialog.h index e7dd32bd904..c8f3d01d9ff 100644 --- a/src/gui/qgsattributedialog.h +++ b/src/gui/qgsattributedialog.h @@ -108,8 +108,16 @@ class GUI_EXPORT QgsAttributeDialog : public QDialog * If set to true, the dialog will add a new feature when the form is accepted. * * @param isAddDialog If set to true, turn this dialog into an add feature dialog. + * @deprecated use setMode() instead */ - void setIsAddDialog( bool isAddDialog ) { mAttributeForm->setIsAddDialog( isAddDialog ); } + Q_DECL_DEPRECATED void setIsAddDialog( bool isAddDialog ) { mAttributeForm->setMode( isAddDialog ? QgsAttributeForm::AddFeatureMode : QgsAttributeForm::SingleEditMode ); } + + /** + * Toggles the form mode. + * @param mode form mode. Eg if set to QgsAttributeForm::AddFeatureMode, the dialog will be editable even with an invalid feature and + * will add a new feature when the form is accepted. + */ + void setMode( QgsAttributeForm::Mode mode ) { mAttributeForm->setMode( mode ); } /** * Sets the edit command message (Undo) that will be used when the dialog is accepted diff --git a/src/gui/qgsattributeform.cpp b/src/gui/qgsattributeform.cpp index 4f9397ba621..1ddb9b12319 100644 --- a/src/gui/qgsattributeform.cpp +++ b/src/gui/qgsattributeform.cpp @@ -23,6 +23,9 @@ #include "qgspythonrunner.h" #include "qgsrelationwidgetwrapper.h" #include "qgsvectordataprovider.h" +#include "qgsattributeformeditorwidget.h" +#include "qgsmessagebar.h" +#include "qgsmessagebaritem.h" #include #include @@ -38,19 +41,25 @@ #include #include #include +#include int QgsAttributeForm::sFormCounter = 0; QgsAttributeForm::QgsAttributeForm( QgsVectorLayer* vl, const QgsFeature &feature, const QgsAttributeEditorContext &context, QWidget* parent ) : QWidget( parent ) , mLayer( vl ) + , mMessageBar( nullptr ) + , mMultiEditUnsavedMessageBarItem( nullptr ) + , mMultiEditMessageBarItem( nullptr ) , mContext( context ) , mButtonBox( nullptr ) , mFormNr( sFormCounter++ ) , mIsSaving( false ) - , mIsAddDialog( false ) , mPreventFeatureRefresh( false ) + , mIsSettingFeature( false ) + , mIsSettingMultiEditFeatures( false ) , mEditCommandMessage( tr( "Attributes changed" ) ) + , mMode( SingleEditMode ) { init(); initPython(); @@ -65,6 +74,7 @@ QgsAttributeForm::QgsAttributeForm( QgsVectorLayer* vl, const QgsFeature &featur connect( vl, SIGNAL( updatedFields() ), this, SLOT( onUpdatedFields() ) ); connect( vl, SIGNAL( beforeAddingExpressionField( QString ) ), this, SLOT( preventFeatureRefresh() ) ); connect( vl, SIGNAL( beforeRemovingExpressionField( int ) ), this, SLOT( preventFeatureRefresh() ) ); + connect( vl, SIGNAL( selectionChanged() ), this, SLOT( layerSelectionChanged() ) ); } QgsAttributeForm::~QgsAttributeForm() @@ -78,7 +88,7 @@ void QgsAttributeForm::hideButtonBox() mButtonBox->hide(); // Make sure that changes are taken into account if somebody tries to figure out if there have been some - if ( !mIsAddDialog ) + if ( mMode == SingleEditMode ) connect( mLayer, SIGNAL( beforeModifiedCheck() ), this, SLOT( save() ) ); } @@ -105,9 +115,72 @@ bool QgsAttributeForm::editable() return mFeature.isValid() && mLayer->isEditable(); } +void QgsAttributeForm::setMode( QgsAttributeForm::Mode mode ) +{ + if ( mode == mMode ) + return; + + if ( mMode == MultiEditMode ) + { + //switching out of multi edit mode triggers a save + save(); + } + + mMode = mode; + + if ( mButtonBox->isVisible() && mMode == SingleEditMode ) + { + connect( mLayer, SIGNAL( beforeModifiedCheck() ), this, SLOT( save() ) ); + } + else + { + disconnect( mLayer, SIGNAL( beforeModifiedCheck() ), this, SLOT( save() ) ); + } + + //update all form editor widget modes to match + Q_FOREACH ( QgsAttributeFormEditorWidget* w, findChildren< QgsAttributeFormEditorWidget* >() ) + { + switch ( mode ) + { + case QgsAttributeForm::SingleEditMode: + w->setMode( QgsAttributeFormEditorWidget::DefaultMode ); + break; + + case QgsAttributeForm::AddFeatureMode: + w->setMode( QgsAttributeFormEditorWidget::DefaultMode ); + break; + + case QgsAttributeForm::MultiEditMode: + w->setMode( QgsAttributeFormEditorWidget::MultiEditMode ); + break; + +#if 0 + case QgsAttributeForm::SearchMode: + w->setMode( QgsAttributeFormEditorWidget::SearchMode ); + break; +#endif + } + } + + switch ( mode ) + { + case QgsAttributeForm::SingleEditMode: + setFeature( mFeature ); + break; + + case QgsAttributeForm::AddFeatureMode: + break; + + case QgsAttributeForm::MultiEditMode: + resetMultiEdit( false ); + break; + } + +} + void QgsAttributeForm::setIsAddDialog( bool isAddDialog ) { - mIsAddDialog = isAddDialog; + setMode( isAddDialog ? AddFeatureMode : SingleEditMode ); synchronizeEnabledState(); } @@ -126,44 +199,47 @@ void QgsAttributeForm::changeAttribute( const QString& field, const QVariant& va void QgsAttributeForm::setFeature( const QgsFeature& feature ) { + mIsSettingFeature = true; mFeature = feature; - resetValues(); - - synchronizeEnabledState(); - - Q_FOREACH ( QgsAttributeFormInterface* iface, mInterfaces ) + switch ( mMode ) { - iface->featureChanged(); + case SingleEditMode: + case AddFeatureMode: + { + resetValues(); + + synchronizeEnabledState(); + + Q_FOREACH ( QgsAttributeFormInterface* iface, mInterfaces ) + { + iface->featureChanged(); + } + break; + } + case MultiEditMode: + { + //ignore setFeature + break; + } } + mIsSettingFeature = false; } -bool QgsAttributeForm::save() +bool QgsAttributeForm::saveEdits() { - if ( mIsSaving ) - return true; - - mIsSaving = true; - - bool changedLayer = false; - bool success = true; - - emit beforeSave( success ); - - // Somebody wants to prevent this form from saving - if ( !success ) - return false; + bool changedLayer = false; QgsFeature updatedFeature = QgsFeature( mFeature ); - if ( mFeature.isValid() || mIsAddDialog ) + if ( mFeature.isValid() || mMode == AddFeatureMode ) { bool doUpdate = false; // An add dialog should perform an action by default // and not only if attributes have "changed" - if ( mIsAddDialog ) + if ( mMode == AddFeatureMode ) doUpdate = true; QgsAttributes src = mFeature.attributes(); @@ -199,7 +275,7 @@ bool QgsAttributeForm::save() if ( doUpdate ) { - if ( mIsAddDialog ) + if ( mMode == AddFeatureMode ) { mFeature.setValid( true ); mLayer->beginEditCommand( mEditCommandMessage ); @@ -208,7 +284,7 @@ bool QgsAttributeForm::save() { mFeature.setAttributes( updatedFeature.attributes() ); mLayer->endEditCommand(); - mIsAddDialog = false; + setMode( SingleEditMode ); changedLayer = true; } else @@ -262,6 +338,120 @@ bool QgsAttributeForm::save() if ( changedLayer ) mLayer->triggerRepaint(); + return success; +} + +void QgsAttributeForm::resetMultiEdit( bool promptToSave ) +{ + if ( promptToSave ) + save(); + + setMultiEditFeatureIds( mLayer->selectedFeaturesIds() ); +} + +void QgsAttributeForm::multiEditMessageClicked( const QString& link ) +{ + clearMultiEditMessages(); + resetMultiEdit( link == "#apply" ); +} + +bool QgsAttributeForm::saveMultiEdits() +{ + //find changed attributes + QgsAttributeMap newAttributeValues; + QMap< int, QgsAttributeFormEditorWidget* >::const_iterator wIt = mFormEditorWidgets.constBegin(); + for ( ; wIt != mFormEditorWidgets.constEnd(); ++ wIt ) + { + QgsAttributeFormEditorWidget* w = wIt.value(); + if ( !w->hasChanged() ) + continue; + + if ( !w->currentValue().isValid() // if the widget returns invalid (== do not change) + || mLayer->editFormConfig()->readOnly( wIt.key() ) ) // or the field cannot be edited ... + { + continue; + } + + // let editor know we've accepted the changes + w->changesCommitted(); + + newAttributeValues.insert( wIt.key(), w->currentValue() ); + } + + if ( newAttributeValues.isEmpty() ) + { + //nothing to change + return true; + } + +#if 0 + // prompt for save + int res = QMessageBox::information( this, tr( "Multiedit attributes" ), + tr( "Edits will be applied to all selected features" ), QMessageBox::Ok | QMessageBox::Cancel ); + if ( res != QMessageBox::Ok ) + { + resetMultiEdit(); + return false; + } +#endif + + mLayer->beginEditCommand( tr( "Updated multiple feature attributes" ) ); + + bool success = true; + + Q_FOREACH ( QgsFeatureId fid, mMultiEditFeatureIds ) + { + QgsAttributeMap::const_iterator aIt = newAttributeValues.constBegin(); + for ( ; aIt != newAttributeValues.constEnd(); ++aIt ) + { + success &= mLayer->changeAttributeValue( fid, aIt.key(), aIt.value() ); + } + } + + clearMultiEditMessages(); + if ( success ) + { + mLayer->endEditCommand(); + mLayer->triggerRepaint(); + mMultiEditMessageBarItem = new QgsMessageBarItem( tr( "Attribute changes for multiple features applied" ), QgsMessageBar::SUCCESS, messageTimeout() ); + } + else + { + mLayer->destroyEditCommand(); + mMultiEditMessageBarItem = new QgsMessageBarItem( tr( "Changes could not be applied" ), QgsMessageBar::WARNING, messageTimeout() ); + } + + mMessageBar->pushItem( mMultiEditMessageBarItem ); + return success; +} + +bool QgsAttributeForm::save() +{ + if ( mIsSaving ) + return true; + + mIsSaving = true; + + bool success = true; + + emit beforeSave( success ); + + // Somebody wants to prevent this form from saving + if ( !success ) + return false; + + switch ( mMode ) + { + case SingleEditMode: + case AddFeatureMode: + success = saveEdits(); + break; + + case MultiEditMode: + success = saveMultiEdits(); + break; + } + mIsSaving = false; return success; @@ -275,13 +465,54 @@ void QgsAttributeForm::resetValues() } } +void QgsAttributeForm::clearMultiEditMessages() +{ + if ( mMultiEditUnsavedMessageBarItem ) + { + mMessageBar->popWidget( mMultiEditUnsavedMessageBarItem ); + mMultiEditUnsavedMessageBarItem = nullptr; + } + if ( mMultiEditMessageBarItem ) + { + mMessageBar->popWidget( mMultiEditMessageBarItem ); + mMultiEditMessageBarItem = nullptr; + } +} + void QgsAttributeForm::onAttributeChanged( const QVariant& value ) { QgsEditorWidgetWrapper* eww = qobject_cast( sender() ); Q_ASSERT( eww ); - emit attributeChanged( eww->field().name(), value ); + switch ( mMode ) + { + case SingleEditMode: + case AddFeatureMode: + { + // don't emit signal if it was triggered by a feature change + if ( !mIsSettingFeature ) + { + emit attributeChanged( eww->field().name(), value ); + } + break; + } + case MultiEditMode: + { + if ( !mIsSettingMultiEditFeatures ) + { + QLabel *msgLabel = new QLabel( tr( "Unsaved multiedit changes: apply changes or reset changes." ), mMessageBar ); + msgLabel->setAlignment( Qt::AlignLeft | Qt::AlignVCenter ); + msgLabel->setSizePolicy( QSizePolicy::MinimumExpanding, QSizePolicy::Fixed ); + connect( msgLabel, SIGNAL( linkActivated( QString ) ), this, SLOT( multiEditMessageClicked( QString ) ) ); + clearMultiEditMessages(); + + mMultiEditUnsavedMessageBarItem = new QgsMessageBarItem( msgLabel, QgsMessageBar::WARNING ); + mMessageBar->pushItem( mMultiEditUnsavedMessageBarItem ); + } + break; + } + } } void QgsAttributeForm::onAttributeAdded( int idx ) @@ -362,7 +593,7 @@ void QgsAttributeForm::refreshFeature() void QgsAttributeForm::synchronizeEnabledState() { - bool isEditable = ( mFeature.isValid() || mIsAddDialog ) && mLayer->isEditable(); + bool isEditable = ( mFeature.isValid() || mMode == AddFeatureMode ) && mLayer->isEditable(); Q_FOREACH ( QgsWidgetWrapper* ww, mWidgets ) { @@ -408,7 +639,16 @@ void QgsAttributeForm::init() delete layout(); // Get a layout - setLayout( new QGridLayout( this ) ); + QGridLayout* layout = new QGridLayout(); + setLayout( layout ); + + mFormEditorWidgets.clear(); + + // a bar to warn the user with non-blocking messages + setContentsMargins( 0, 0, 0, 0 ); + mMessageBar = new QgsMessageBar( this ); + mMessageBar->setSizePolicy( QSizePolicy::MinimumExpanding, QSizePolicy::Fixed ); + layout->addWidget( mMessageBar, 0, 0, 1, 1 ); // Try to load Ui-File for layout if ( mLayer->editFormConfig()->layout() == QgsEditFormConfig::UiFileLayout && !mLayer->editFormConfig()->uiForm().isEmpty() ) @@ -423,7 +663,7 @@ void QgsAttributeForm::init() loader.setWorkingDirectory( fi.dir() ); formWidget = loader.load( &file, this ); formWidget->setWindowFlags( Qt::Widget ); - layout()->addWidget( formWidget ); + layout->addWidget( formWidget ); formWidget->show(); file.close(); mButtonBox = findChild(); @@ -437,7 +677,7 @@ void QgsAttributeForm::init() if ( !formWidget && mLayer->editFormConfig()->layout() == QgsEditFormConfig::TabLayout ) { QTabWidget* tabWidget = new QTabWidget(); - layout()->addWidget( tabWidget ); + layout->addWidget( tabWidget ); Q_FOREACH ( QgsAttributeEditorElement* widgDef, mLayer->editFormConfig()->tabs() ) { @@ -481,7 +721,7 @@ void QgsAttributeForm::init() scrollArea->setFrameShape( QFrame::NoFrame ); scrollArea->setFrameShadow( QFrame::Plain ); scrollArea->setFocusProxy( this ); - layout()->addWidget( scrollArea ); + layout->addWidget( scrollArea ); int row = 0; Q_FOREACH ( const QgsField& field, mLayer->fields().toList() ) @@ -504,10 +744,17 @@ void QgsAttributeForm::init() // This will also create the widget QWidget *l = new QLabel( fieldName ); QgsEditorWidgetWrapper* eww = QgsEditorWidgetRegistry::instance()->create( widgetType, mLayer, idx, widgetConfig, nullptr, this, mContext ); - QWidget *w = eww ? eww->widget() : new QLabel( QString( "

Failed to create widget with type '%1'

" ).arg( widgetType ) ); - if ( w ) - w->setObjectName( field.name() ); + QWidget* w = nullptr; + if ( eww ) + { + w = new QgsAttributeFormEditorWidget( eww, this ); + mFormEditorWidgets.insert( idx, static_cast< QgsAttributeFormEditorWidget* >( w ) ); + } + else + { + w = new QLabel( QString( "

Failed to create widget with type '%1'

" ).arg( widgetType ) ); + } if ( eww ) addWidgetWrapper( eww ); @@ -545,7 +792,7 @@ void QgsAttributeForm::init() { mButtonBox = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel ); mButtonBox->setObjectName( "buttonBox" ); - layout()->addWidget( mButtonBox ); + layout->addWidget( mButtonBox ); } mButtonBox->setVisible( buttonBoxVisible ); @@ -701,7 +948,11 @@ QgsAttributeForm::WidgetInfo QgsAttributeForm::createWidgetFromDef( const QgsAtt const QgsEditorWidgetConfig widgetConfig = mLayer->editFormConfig()->widgetConfig( fldIdx ); QgsEditorWidgetWrapper* eww = QgsEditorWidgetRegistry::instance()->create( widgetType, mLayer, fldIdx, widgetConfig, nullptr, this, mContext ); - newWidgetInfo.widget = eww->widget(); + + QWidget* w = new QgsAttributeFormEditorWidget( eww, this ); + mFormEditorWidgets.insert( fldIdx, static_cast< QgsAttributeFormEditorWidget* >( w ) ); + + newWidgetInfo.widget = w; addWidgetWrapper( eww ); newWidgetInfo.widget->setObjectName( mLayer->fields().at( fldIdx ).name() ); @@ -922,3 +1173,108 @@ bool QgsAttributeForm::eventFilter( QObject* object, QEvent* e ) return false; } + +void QgsAttributeForm::scanForEqualAttributes( QgsFeatureIterator& fit, QSet< int >& mixedValueFields, QHash< int, QVariant >& fieldSharedValues ) const +{ + mixedValueFields.clear(); + fieldSharedValues.clear(); + + QgsFeature f; + bool first = true; + while ( fit.nextFeature( f ) ) + { + for ( int i = 0; i < mLayer->fields().count(); ++i ) + { + if ( mixedValueFields.contains( i ) ) + continue; + + if ( first ) + { + fieldSharedValues[i] = f.attribute( i ); + } + else + { + if ( fieldSharedValues.value( i ) != f.attribute( i ) ) + { + fieldSharedValues.remove( i ); + mixedValueFields.insert( i ); + } + } + } + first = false; + + if ( mixedValueFields.count() == mLayer->fields().count() ) + { + // all attributes are mixed, no need to keep scanning + break; + } + } +} + + +void QgsAttributeForm::layerSelectionChanged() +{ + switch ( mMode ) + { + case SingleEditMode: + case AddFeatureMode: + break; + + case MultiEditMode: + resetMultiEdit( true ); + break; + } +} + +void QgsAttributeForm::setMultiEditFeatureIds( const QgsFeatureIds& fids ) +{ + mIsSettingMultiEditFeatures = true; + mMultiEditFeatureIds = fids; + + if ( fids.isEmpty() ) + { + // no selected features + QMap< int, QgsAttributeFormEditorWidget* >::const_iterator wIt = mFormEditorWidgets.constBegin(); + for ( ; wIt != mFormEditorWidgets.constEnd(); ++ wIt ) + { + wIt.value()->initialize( QVariant() ); + } + mIsSettingMultiEditFeatures = false; + return; + } + + QgsFeatureIterator fit = mLayer->getFeatures( QgsFeatureRequest().setFilterFids( fids ) ); + + // Scan through all features to determine which attributes are initially the same + QSet< int > mixedValueFields; + QHash< int, QVariant > fieldSharedValues; + scanForEqualAttributes( fit, mixedValueFields, fieldSharedValues ); + + // also fetch just first feature + fit = mLayer->getFeatures( QgsFeatureRequest().setFilterFid( *fids.constBegin() ) ); + QgsFeature firstFeature; + fit.nextFeature( firstFeature ); + + Q_FOREACH ( int field, mixedValueFields ) + { + if ( QgsAttributeFormEditorWidget* w = mFormEditorWidgets.value( field, nullptr ) ) + { + w->initialize( firstFeature.attribute( field ), true ); + } + } + QHash< int, QVariant >::const_iterator sharedValueIt = fieldSharedValues.constBegin(); + for ( ; sharedValueIt != fieldSharedValues.constEnd(); ++sharedValueIt ) + { + if ( QgsAttributeFormEditorWidget* w = mFormEditorWidgets.value( sharedValueIt.key(), nullptr ) ) + { + w->initialize( sharedValueIt.value(), false ); + } + } + mIsSettingMultiEditFeatures = false; +} + +int QgsAttributeForm::messageTimeout() +{ + QSettings settings; + return settings.value( "/qgis/messageTimeout", 5 ).toInt(); +} diff --git a/src/gui/qgsattributeform.h b/src/gui/qgsattributeform.h index d806f1f343b..e04a386d2d0 100644 --- a/src/gui/qgsattributeform.h +++ b/src/gui/qgsattributeform.h @@ -25,12 +25,26 @@ #include class QgsAttributeFormInterface; +class QgsAttributeFormEditorWidget; +class QgsMessageBar; +class QgsMessageBarItem; class GUI_EXPORT QgsAttributeForm : public QWidget { Q_OBJECT public: + + //! Form modes + enum Mode + { + SingleEditMode, /*!< Single edit mode, for editing a single feature */ + AddFeatureMode, /*!< Add feature mode, for setting attributes for a new feature. In this mode the dialog will be editable even with an invalid feature and + will add a new feature when the form is accepted. */ + MultiEditMode, /*!< Multi edit mode, for editing fields of multiple features at once */ + // TODO: SearchMode, /*!< Form values are used for searching/filtering the layer */ + }; + explicit QgsAttributeForm( QgsVectorLayer* vl, const QgsFeature &feature = QgsFeature(), const QgsAttributeEditorContext& context = QgsAttributeEditorContext(), QWidget *parent = nullptr ); ~QgsAttributeForm(); @@ -72,14 +86,28 @@ class GUI_EXPORT QgsAttributeForm : public QWidget */ bool editable(); + /** Returns the current mode of the form. + * @note added in QGIS 2.16 + * @see setMode() + */ + Mode mode() const { return mMode; } + + /** Sets the current mode of the form. + * @param mode form mode + * @note added in QGIS 2.16 + * @see mode() + */ + void setMode( Mode mode ); + /** * Toggles the form mode between edit feature and add feature. * If set to true, the dialog will be editable even with an invalid feature. * If set to true, the dialog will add a new feature when the form is accepted. * * @param isAddDialog If set to true, turn this dialog into an add feature dialog. + * @deprecated use setMode() instead */ - void setIsAddDialog( bool isAddDialog ); + Q_DECL_DEPRECATED void setIsAddDialog( bool isAddDialog ); /** * Sets the edit command message (Undo) that will be used when the dialog is accepted @@ -98,6 +126,12 @@ class GUI_EXPORT QgsAttributeForm : public QWidget */ bool eventFilter( QObject* object, QEvent* event ) override; + /** Sets all feature IDs which are to be edited if the form is in multiedit mode + * @param fids feature ID list + * @note added in QGIS 2.16 + */ + void setMultiEditFeatureIds( const QgsFeatureIds& fids ); + signals: /** * Notifies about changes of attributes @@ -177,6 +211,13 @@ class GUI_EXPORT QgsAttributeForm : public QWidget void preventFeatureRefresh(); void synchronizeEnabledState(); + void layerSelectionChanged(); + + //! Save multi edit changes + bool saveMultiEdits(); + void resetMultiEdit( bool promptToSave = false ); + void multiEditMessageClicked( const QString& link ); + private: void init(); @@ -210,12 +251,24 @@ class GUI_EXPORT QgsAttributeForm : public QWidget void createWrappers(); void connectWrappers(); + void scanForEqualAttributes( QgsFeatureIterator& fit, QSet< int >& mixedValueFields, QHash< int, QVariant >& fieldSharedValues ) const; + + //! Save single feature or add feature edits + bool saveEdits(); + + int messageTimeout(); + void clearMultiEditMessages(); + QgsVectorLayer* mLayer; QgsFeature mFeature; + QgsMessageBar* mMessageBar; + QgsMessageBarItem* mMultiEditUnsavedMessageBarItem; + QgsMessageBarItem* mMultiEditMessageBarItem; QList mWidgets; QgsAttributeEditorContext mContext; QDialogButtonBox* mButtonBox; QList mInterfaces; + QMap< int, QgsAttributeFormEditorWidget* > mFormEditorWidgets; // Variables below are used for python static int sFormCounter; @@ -224,12 +277,22 @@ class GUI_EXPORT QgsAttributeForm : public QWidget //! Set to true while saving to prevent recursive saves bool mIsSaving; - bool mIsAddDialog; //! Flag to prevent refreshFeature() to change mFeature bool mPreventFeatureRefresh; + //! Set to true while setting feature to prevent attributeChanged signal + bool mIsSettingFeature; + bool mIsSettingMultiEditFeatures; + + QgsFeatureIds mMultiEditFeatureIds; + QString mEditCommandMessage; + + Mode mMode; + + friend class TestQgsDualView; }; #endif // QGSATTRIBUTEFORM_H + diff --git a/src/gui/qgsattributeformeditorwidget.cpp b/src/gui/qgsattributeformeditorwidget.cpp new file mode 100644 index 00000000000..6b202be3a4d --- /dev/null +++ b/src/gui/qgsattributeformeditorwidget.cpp @@ -0,0 +1,173 @@ +/*************************************************************************** + qgsattributeformeditorwidget.cpp + ------------------------------- + Date : March 2016 + Copyright : (C) 2016 Nyall Dawson + Email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#include "qgsattributeformeditorwidget.h" +#include "qgsattributeform.h" +#include "qgsmultiedittoolbutton.h" +#include "qgseditorwidgetwrapper.h" +#include +#include + +QgsAttributeFormEditorWidget::QgsAttributeFormEditorWidget( QgsEditorWidgetWrapper* editorWidget, QgsAttributeForm* form ) + : QWidget( form ) + , mWidget( editorWidget ) + , mForm( form ) + , mMode( DefaultMode ) + , mMultiEditButton( new QgsMultiEditToolButton() ) + , mBlockValueUpdate( false ) + , mIsMixed( false ) + , mIsChanged( false ) +{ + if ( !mWidget || !mForm ) + return; + + QLayout* l = new QHBoxLayout(); + l->setMargin( 0 ); + l->setContentsMargins( 0, 0, 0, 0 ); + l->addWidget( mWidget->widget() ); + + if ( mWidget->widget() ) + { + mWidget->widget()->setObjectName( mWidget->field().name() ); + } + connect( mWidget, SIGNAL( valueChanged( const QVariant& ) ), this, SLOT( editorWidgetChanged( const QVariant & ) ) ); + connect( mMultiEditButton, SIGNAL( resetFieldValueTriggered() ), this, SLOT( resetValue() ) ); + connect( mMultiEditButton, SIGNAL( setFieldValueTriggered() ), this, SLOT( setFieldTriggered() ) ); + + mMultiEditButton->setField( mWidget->field() ); + + setLayout( l ); + updateWidgets(); +} + +QgsAttributeFormEditorWidget::~QgsAttributeFormEditorWidget() +{ + //there's a chance these widgets are not currently added to the layout, so have no parent set + delete mMultiEditButton; +} + +void QgsAttributeFormEditorWidget::setMode( QgsAttributeFormEditorWidget::Mode mode ) +{ + mMode = mode; + updateWidgets(); +} + +void QgsAttributeFormEditorWidget::setIsMixed( bool mixed ) +{ + if ( mixed ) + mWidget->showIndeterminateState( ); + mMultiEditButton->setIsMixed( mixed ); + mIsMixed = mixed; +} + +void QgsAttributeFormEditorWidget::changesCommitted() +{ + if ( mWidget ) + mPreviousValue = mWidget->value(); + + setIsMixed( false ); + mMultiEditButton->changesCommitted(); + mIsChanged = false; +} + +void QgsAttributeFormEditorWidget::initialize( const QVariant& initialValue, bool mixedValues ) +{ + if ( mWidget ) + { + mBlockValueUpdate = true; + mWidget->setValue( initialValue ); + mBlockValueUpdate = false; + } + mPreviousValue = initialValue; + setIsMixed( mixedValues ); + mMultiEditButton->setIsChanged( false ); + mIsChanged = false; +} + +QVariant QgsAttributeFormEditorWidget::currentValue() const +{ + return mWidget->value(); +} + +void QgsAttributeFormEditorWidget::editorWidgetChanged( const QVariant& value ) +{ + if ( mBlockValueUpdate ) + return; + + mIsChanged = true; + + switch ( mMode ) + { + case DefaultMode: + break; + case MultiEditMode: + mMultiEditButton->setIsChanged( true ); + } + + emit valueChanged( value ); +} + +void QgsAttributeFormEditorWidget::resetValue() +{ + mIsChanged = false; + mBlockValueUpdate = true; + mWidget->setValue( mPreviousValue ); + mBlockValueUpdate = false; + + switch ( mMode ) + { + case DefaultMode: + break; + case MultiEditMode: + { + mMultiEditButton->setIsChanged( false ); + if ( mWidget && mIsMixed ) + mWidget->showIndeterminateState(); + break; + } + } +} + +void QgsAttributeFormEditorWidget::setFieldTriggered() +{ + mIsChanged = true; +} + +QgsVectorLayer* QgsAttributeFormEditorWidget::layer() +{ + return mForm ? mForm->layer() : nullptr; +} + +void QgsAttributeFormEditorWidget::updateWidgets() +{ + bool hasMultiEditButton = ( layout()->indexOf( mMultiEditButton ) >= 0 ); + bool fieldReadOnly = layer()->editFormConfig()->readOnly( mWidget->fieldIdx() ); + + if ( hasMultiEditButton ) + { + if ( mMode != MultiEditMode || fieldReadOnly ) + { + layout()->removeWidget( mMultiEditButton ); + mMultiEditButton->setParent( nullptr ); + } + } + else + { + if ( mMode == MultiEditMode && !fieldReadOnly ) + { + layout()->addWidget( mMultiEditButton ); + } + } +} diff --git a/src/gui/qgsattributeformeditorwidget.h b/src/gui/qgsattributeformeditorwidget.h new file mode 100644 index 00000000000..917f8f0e91c --- /dev/null +++ b/src/gui/qgsattributeformeditorwidget.h @@ -0,0 +1,130 @@ +/*************************************************************************** + qgsattributeformeditorwidget.h + ----------------------------- + Date : March 2016 + Copyright : (C) 2016 Nyall Dawson + Email : nyall dot dawson at gmail dot com + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef QGSATTRIBUTEFORMEDITORWIDGET_H +#define QGSATTRIBUTEFORMEDITORWIDGET_H + +#include +#include + +class QgsAttributeForm; +class QgsEditorWidgetWrapper; +class QgsMultiEditToolButton; +class QgsVectorLayer; + + +/** \ingroup gui + * \class QgsAttributeFormEditorWidget + * A widget consisting of both an editor widget and additional widgets for controlling the behaviour + * of the editor widget depending on a number of possible modes. For instance, if the parent attribute + * form is in the multi edit mode, this widget will show both the editor widget and a tool button for + * controlling the multi edit results. + * \note Added in version 2.16 + */ +class GUI_EXPORT QgsAttributeFormEditorWidget : public QWidget +{ + Q_OBJECT + + public: + + //! Widget modes + enum Mode + { + DefaultMode, /*!< Default mode, only the editor widget is shown */ + MultiEditMode, /*!< Multi edit mode, both the editor widget and a QgsMultiEditToolButton is shown */ + // TODO: SearchMode, /*!< Layer search/filter mode */ + }; + + /** Constructor for QgsAttributeFormEditorWidget. + * @param editorWidget associated editor widget wrapper + * @param form parent attribute form + */ + explicit QgsAttributeFormEditorWidget( QgsEditorWidgetWrapper* editorWidget, QgsAttributeForm* form ); + + ~QgsAttributeFormEditorWidget(); + + /** Sets the current mode for the widget. The widget will adapt its state and visible widgets to + * reflect the updated mode. Eg, showing multi edit tool buttons if the mode is set to MultiEditMode. + * @param mode widget mode + * @see mode() + */ + void setMode( Mode mode ); + + /** Returns the current mode for the widget. + * @see setMode() + */ + Mode mode() const { return mMode; } + + /** Resets the widget to an initial value. + * @param initialValue initial value to show in widget + * @param mixedValue set to true to initially show the mixed values state + */ + void initialize( const QVariant& initialValue, bool mixedValues = false ); + + /** Returns true if the widget's value has been changed since it was initialized. + * @see initialize() + */ + bool hasChanged() const { return mIsChanged; } + + /** Returns the current value of the attached editor widget. + */ + QVariant currentValue() const; + + public slots: + + /** Sets whether the widget should be displayed in a "mixed values" mode. + * @param mixed set to true to show in a mixed values state + */ + void setIsMixed( bool mixed ); + + /** Called when field values have been committed; + */ + void changesCommitted(); + + signals: + + //! Emitted when the widget's value changes + //! @param value new widget value + void valueChanged( const QVariant& value ); + + private slots: + + //! Triggered when editor widget's value changes + void editorWidgetChanged( const QVariant& value ); + + //! Triggered when multi edit tool button requests value reset + void resetValue(); + + //! Triggered when the multi edit tool button "set field value" action is selected + void setFieldTriggered(); + + private: + + QgsEditorWidgetWrapper* mWidget; + QgsAttributeForm* mForm; + Mode mMode; + + QgsMultiEditToolButton* mMultiEditButton; + QVariant mPreviousValue; + bool mBlockValueUpdate; + bool mIsMixed; + bool mIsChanged; + + + QgsVectorLayer* layer(); + void updateWidgets(); +}; + +#endif // QGSATTRIBUTEFORMEDITORWIDGET_H diff --git a/src/ui/qgsattributetabledialog.ui b/src/ui/qgsattributetabledialog.ui index 48989388fbe..024ad31a5e8 100644 --- a/src/ui/qgsattributetabledialog.ui +++ b/src/ui/qgsattributetabledialog.ui @@ -75,6 +75,35 @@ + + + + Toggle multi edit mode + + + + + + + + + + :/images/themes/default/mActionAllEdits.svg:/images/themes/default/mActionAllEdits.svg + + + + 18 + 18 + + + + true + + + true + + + @@ -858,6 +887,7 @@ mToggleEditingButton + mToggleMultiEditButton mSaveEditsButton mReloadButton mAddFeature diff --git a/tests/src/gui/CMakeLists.txt b/tests/src/gui/CMakeLists.txt index 4da57a55bb9..b44a0b51a30 100644 --- a/tests/src/gui/CMakeLists.txt +++ b/tests/src/gui/CMakeLists.txt @@ -10,6 +10,8 @@ INCLUDE_DIRECTORIES(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/../../../src/ui ${CMAKE_CURRENT_SOURCE_DIR}/../core #for render checker class ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/gui + ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/gui/editorwidgets + ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/gui/editorwidgets/core ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/gui/symbology-ng ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/gui/raster ${CMAKE_CURRENT_SOURCE_DIR}/../../../src/core diff --git a/tests/src/gui/testqgsdualview.cpp b/tests/src/gui/testqgsdualview.cpp index 7cfb9df1726..723f12ee5e2 100644 --- a/tests/src/gui/testqgsdualview.cpp +++ b/tests/src/gui/testqgsdualview.cpp @@ -19,8 +19,10 @@ #include #include #include +#include "qgsattributeform.h" #include #include +#include "qgsvectordataprovider.h" #include #include @@ -42,6 +44,8 @@ class TestQgsDualView : public QObject void testSelectAll(); + void testAttributeFormSharedValueScanning(); + private: QgsMapCanvas* mCanvas; QgsVectorLayer* mPointsLayer; @@ -103,6 +107,72 @@ void TestQgsDualView::testSelectAll() QVERIFY( mPointsLayer->selectedFeatureCount() == 1 ); } +void TestQgsDualView::testAttributeFormSharedValueScanning() +{ + // test QgsAttributeForm::scanForEqualAttributes + + QSet< int > mixedValueFields; + QHash< int, QVariant > fieldSharedValues; + + // make a temporary layer to check through + QgsVectorLayer* layer = new QgsVectorLayer( "Point?field=col1:integer&field=col2:integer&field=col3:integer&field=col4:integer", "test", "memory" ); + QVERIFY( layer->isValid() ); + QgsFeature f1( layer->dataProvider()->fields(), 1 ); + f1.setAttribute( "col1", 1 ); + f1.setAttribute( "col2", 1 ); + f1.setAttribute( "col3", 3 ); + f1.setAttribute( "col4", 1 ); + QgsFeature f2( layer->dataProvider()->fields(), 2 ); + f2.setAttribute( "col1", 1 ); + f2.setAttribute( "col2", 2 ); + f2.setAttribute( "col3", 3 ); + f2.setAttribute( "col4", 2 ); + QgsFeature f3( layer->dataProvider()->fields(), 3 ); + f3.setAttribute( "col1", 1 ); + f3.setAttribute( "col2", 2 ); + f3.setAttribute( "col3", 3 ); + f3.setAttribute( "col4", 2 ); + QgsFeature f4( layer->dataProvider()->fields(), 4 ); + f4.setAttribute( "col1", 1 ); + f4.setAttribute( "col2", 1 ); + f4.setAttribute( "col3", 3 ); + f4.setAttribute( "col4", 2 ); + layer->dataProvider()->addFeatures( QgsFeatureList() << f1 << f2 << f3 << f4 ); + + QgsAttributeForm form( layer ); + + QgsFeatureIterator it = layer->getFeatures(); + + form.scanForEqualAttributes( it, mixedValueFields, fieldSharedValues ); + + QCOMPARE( mixedValueFields, QSet< int >() << 1 << 3 ); + QCOMPARE( fieldSharedValues.value( 0 ).toInt(), 1 ); + QCOMPARE( fieldSharedValues.value( 2 ).toInt(), 3 ); + + // add another feature so all attributes are different + QgsFeature f5( layer->dataProvider()->fields(), 5 ); + f5.setAttribute( "col1", 11 ); + f5.setAttribute( "col2", 11 ); + f5.setAttribute( "col3", 13 ); + f5.setAttribute( "col4", 12 ); + layer->dataProvider()->addFeatures( QgsFeatureList() << f5 ); + + it = layer->getFeatures(); + + form.scanForEqualAttributes( it, mixedValueFields, fieldSharedValues ); + QCOMPARE( mixedValueFields, QSet< int >() << 0 << 1 << 2 << 3 ); + QVERIFY( fieldSharedValues.isEmpty() ); + + // single feature, all attributes should be shared + it = layer->getFeatures( QgsFeatureRequest().setFilterFid( 4 ) ); + form.scanForEqualAttributes( it, mixedValueFields, fieldSharedValues ); + QCOMPARE( fieldSharedValues.value( 0 ).toInt(), 1 ); + QCOMPARE( fieldSharedValues.value( 1 ).toInt(), 1 ); + QCOMPARE( fieldSharedValues.value( 2 ).toInt(), 3 ); + QCOMPARE( fieldSharedValues.value( 3 ).toInt(), 2 ); + QVERIFY( mixedValueFields.isEmpty() ); +} + QTEST_MAIN( TestQgsDualView ) #include "testqgsdualview.moc" diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index b1623e0f035..dd909b03844 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -43,6 +43,7 @@ ADD_PYTHON_TEST(PyQgsGeometryTest test_qgsgeometry.py) ADD_PYTHON_TEST(PyQgsGraduatedSymbolRendererV2 test_qgsgraduatedsymbolrendererv2.py) ADD_PYTHON_TEST(PyQgsMapUnitScale test_qgsmapunitscale.py) ADD_PYTHON_TEST(PyQgsMemoryProvider test_provider_memory.py) +ADD_PYTHON_TEST(PyQgsMultiEditToolButton test_qgsmultiedittoolbutton.py) ADD_PYTHON_TEST(PyQgsNetworkContentFetcher test_qgsnetworkcontentfetcher.py) ADD_PYTHON_TEST(PyQgsNullSymbolRenderer test_qgsnullsymbolrenderer.py) ADD_PYTHON_TEST(PyQgsPalLabelingBase test_qgspallabeling_base.py) diff --git a/tests/src/python/test_qgsmultiedittoolbutton.py b/tests/src/python/test_qgsmultiedittoolbutton.py new file mode 100644 index 00000000000..da057d2431c --- /dev/null +++ b/tests/src/python/test_qgsmultiedittoolbutton.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsMultiEditToolButton. + +.. note:: This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. +""" +__author__ = 'Nyall Dawson' +__date__ = '16/03/2016' +__copyright__ = 'Copyright 2016, The QGIS Project' +# This will get replaced with a git SHA1 when you do a git archive +__revision__ = '$Format:%H$' + +import qgis # switch sip api + +from qgis.gui import QgsMultiEditToolButton + +from qgis.testing import (start_app, + unittest + ) + +start_app() + + +class TestQgsMultiEditToolButton(unittest.TestCase): + + def test_state_logic(self): + """ + Test that the logic involving button states is correct + """ + w = QgsMultiEditToolButton() + self.assertEqual(w.state(), QgsMultiEditToolButton.Default) + + # set is changed should update state to changed + w.setIsChanged(True) + self.assertEqual(w.state(), QgsMultiEditToolButton.Changed) + w.setIsChanged(False) + self.assertEqual(w.state(), QgsMultiEditToolButton.Default) + #resetting changes should fall back to default state + w.setIsChanged(True) + w.resetChanges() + self.assertEqual(w.state(), QgsMultiEditToolButton.Default) + #setting changes committed should result in default state + w.setIsChanged(True) + w.changesCommitted() + self.assertEqual(w.state(), QgsMultiEditToolButton.Default) + + #Test with mixed values + w.setIsMixed(True) + self.assertEqual(w.state(), QgsMultiEditToolButton.MixedValues) + #changed state takes priority over mixed state + w.setIsChanged(True) + self.assertEqual(w.state(), QgsMultiEditToolButton.Changed) + w.setIsChanged(False) + #should reset to mixed state + self.assertEqual(w.state(), QgsMultiEditToolButton.MixedValues) + #resetting changes should fall back to mixed state + w.setIsChanged(True) + w.resetChanges() + self.assertEqual(w.state(), QgsMultiEditToolButton.MixedValues) + #setting changes committed should result in default state + w.setIsChanged(True) + w.changesCommitted() + self.assertEqual(w.state(), QgsMultiEditToolButton.Default) + +if __name__ == '__main__': + unittest.main()