diff --git a/.gitignore b/.gitignore index a8eb012de..272a0a1b4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ Input_keystore.keystore CMakeLists.txt.user .github/secrets/ios/LutraConsulting*.mobileprovision google_play_key.json -fastlane/report.xml \ No newline at end of file +fastlane/report.xml +CMakeUserPresets.json \ No newline at end of file diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 098cc9de5..36b2bdcd9 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -92,6 +92,7 @@ set(MM_SRCS streamingintervaltype.cpp synchronizationerror.cpp synchronizationmanager.cpp + valuerelationcontroller.cpp valuerelationfeaturesmodel.cpp variablesmanager.cpp workspacesmodel.cpp @@ -189,6 +190,7 @@ set(MM_HDRS synchronizationerror.h synchronizationmanager.h synchronizationoptions.h + valuerelationcontroller.h valuerelationfeaturesmodel.h variablesmanager.h workspacesmodel.h diff --git a/app/attributes/attributecontroller.cpp b/app/attributes/attributecontroller.cpp index e8697ef42..6c071485a 100644 --- a/app/attributes/attributecontroller.cpp +++ b/app/attributes/attributecontroller.cpp @@ -883,6 +883,10 @@ void AttributeController::recalculateDerivedItems( bool isFormValueChange, bool // Create context QgsFields fields = mFeatureLayerPair.feature().fields(); QgsExpressionContext expressionContext = layer->createExpressionContext(); + if ( mParentController ) + { + expressionContext << QgsExpressionContextUtils::parentFormScope( mParentController->featureLayerPair().feature() ); + } expressionContext << QgsExpressionContextUtils::formScope( mFeatureLayerPair.feature() ); if ( mVariablesManager ) expressionContext << mVariablesManager->positionScope(); @@ -900,7 +904,7 @@ void AttributeController::recalculateDerivedItems( bool isFormValueChange, bool // Evaluate HTML and Text element expressions recalculateRichTextWidgets( changedFormItems, expressionContext ); - // Evaluate tab items visiblity + // Evaluate tab items visibility { QVector>::iterator tabItemsIterator = mTabItems.begin(); while ( tabItemsIterator != mTabItems.end() ) @@ -1632,7 +1636,8 @@ void AttributeController::renamePhotos() continue; } - const QString targetDir = InputUtils::resolveTargetDir( QgsProject::instance()->homePath(), config, mFeatureLayerPair, QgsProject::instance() ); + const FeatureLayerPair parentPair = mParentController ? mParentController->featureLayerPair() : FeatureLayerPair(); + const QString targetDir = InputUtils::resolveTargetDir( QgsProject::instance()->homePath(), config, mFeatureLayerPair, parentPair, QgsProject::instance() ); const QString prefix = InputUtils::resolvePrefixForRelativePath( config[ QStringLiteral( "RelativeStorage" ) ].toInt(), QgsProject::instance()->homePath(), targetDir ); const QString src = InputUtils::getAbsolutePath( mFeatureLayerPair.feature().attribute( item->fieldIndex() ).toString(), prefix ); QString newName = val.toString(); @@ -1675,7 +1680,8 @@ void AttributeController::saveSketches() if ( item->rawValue().isValid() ) { const QVariantMap config = item->editorWidgetConfig(); - const QString targetDir = InputUtils::resolveTargetDir( QgsProject::instance()->homePath(), config, mFeatureLayerPair, QgsProject::instance() ); + const FeatureLayerPair &parentPair = mParentController ? mParentController->featureLayerPair() : FeatureLayerPair(); + const QString targetDir = InputUtils::resolveTargetDir( QgsProject::instance()->homePath(), config, mFeatureLayerPair, parentPair, QgsProject::instance() ); const QString prefix = InputUtils::resolvePrefixForRelativePath( config[ QStringLiteral( "RelativeStorage" ) ].toInt(), QgsProject::instance()->homePath(), targetDir ); const QString src = InputUtils::getAbsolutePath( mFeatureLayerPair.feature().attribute( item->fieldIndex() ).toString(), prefix ); diff --git a/app/inpututils.cpp b/app/inpututils.cpp index f90a88c95..2f3921b6e 100644 --- a/app/inpututils.cpp +++ b/app/inpututils.cpp @@ -987,7 +987,7 @@ bool InputUtils::fileExists( const QString &path ) return ( check_file.exists() && check_file.isFile() ); } -QString InputUtils::resolveTargetDir( const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, QgsProject *activeProject ) +QString InputUtils::resolveTargetDir( const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, const FeatureLayerPair &parentPair, QgsProject *activeProject ) { QString expression; QMap collection = config.value( QStringLiteral( "PropertyCollection" ) ).toMap(); @@ -1001,7 +1001,7 @@ QString InputUtils::resolveTargetDir( const QString &homePath, const QVariantMap if ( !expression.isEmpty() ) { - QString result = evaluateExpression( pair, activeProject, expression ); + QString result = evaluateExpression( pair, parentPair, activeProject, expression ); sanitizePath( result ); return result; } @@ -1043,7 +1043,7 @@ QString InputUtils::getAbsolutePath( const QString &path, const QString &prefixP QString InputUtils::resolvePath( const QString &path, const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, QgsProject *activeProject ) { int relativeStorageMode = config.value( QStringLiteral( "RelativeStorage" ) ).toInt(); - QString targetDir = resolveTargetDir( homePath, config, pair, activeProject ); + QString targetDir = resolveTargetDir( homePath, config, pair, FeatureLayerPair(), activeProject ); QString prefixToRelativePath = resolvePrefixForRelativePath( relativeStorageMode, homePath, targetDir ); return getAbsolutePath( path, prefixToRelativePath ); @@ -1550,12 +1550,14 @@ QString InputUtils::dumpScreenInfo() const return msg; } -QString InputUtils::evaluateExpression( const FeatureLayerPair &pair, QgsProject *activeProject, const QString &expression ) +QString InputUtils::evaluateExpression( const FeatureLayerPair &pair, const FeatureLayerPair &parentPair, QgsProject *activeProject, const QString &expression ) { QList scopes; scopes << QgsExpressionContextUtils::globalScope(); scopes << QgsExpressionContextUtils::projectScope( activeProject ); scopes << QgsExpressionContextUtils::layerScope( pair.layer() ); + scopes << QgsExpressionContextUtils::parentFormScope( parentPair.feature() ); + scopes << QgsExpressionContextUtils::formScope( pair.feature() ); QgsExpressionContext context( scopes ); context.setFeature( pair.feature() ); diff --git a/app/inpututils.h b/app/inpututils.h index adda3cb60..804a3ead9 100644 --- a/app/inpututils.h +++ b/app/inpututils.h @@ -344,7 +344,7 @@ class InputUtils: public QObject * @param activeProject QgsProject - needed for expression evaluation * @return Path to the image */ - Q_INVOKABLE static QString resolvePath( const QString &path, const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, QgsProject *activeProject ); + static QString resolvePath( const QString &path, const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, QgsProject *activeProject ); /** * This evaluates the "default path" with the following order: @@ -352,7 +352,7 @@ class InputUtils: public QObject * 2. use default path value if not empty, * 3. use project home folder */ - Q_INVOKABLE static QString resolveTargetDir( const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, QgsProject *activeProject ); + Q_INVOKABLE static QString resolveTargetDir( const QString &homePath, const QVariantMap &config, const FeatureLayerPair &pair, const FeatureLayerPair &parentPair, QgsProject *activeProject ); /** * Function used for resolving path of an image for a field with ExternalResource widget type. @@ -472,12 +472,13 @@ class InputUtils: public QObject /** * Evaluates expression. - * \param pair Used to define a context scope. - * \param activeProject Used to define a context scope. - * \param expression + * \param pair Used to define layer & form context scope. + * \param parentPair Used to define parent form context scope. + * \param activeProject Used to define project context scope. + * \param expression Expression to evaluate * \return Evaluated expression */ - Q_INVOKABLE static QString evaluateExpression( const FeatureLayerPair &pair, QgsProject *activeProject, const QString &expression ); + static QString evaluateExpression( const FeatureLayerPair &pair, const FeatureLayerPair &parentPair, QgsProject *activeProject, const QString &expression ); /** * Returns the QVariant typeName of a \a field. diff --git a/app/layerfeaturesmodel.h b/app/layerfeaturesmodel.h index 47995c4ed..5fa5c3ed4 100644 --- a/app/layerfeaturesmodel.h +++ b/app/layerfeaturesmodel.h @@ -94,6 +94,8 @@ class LayerFeaturesModel : public FeaturesModel virtual void setupFeatureRequest( QgsFeatureRequest &request ); + virtual QString buildSearchExpression(); + virtual void populate(); void reset() override; @@ -104,7 +106,6 @@ class LayerFeaturesModel : public FeaturesModel void onFutureFinished(); private: - QString buildSearchExpression(); //! Performs getFeatures on layer. Takes ownership of \a layer and tries to move it to current thread. QgsFeatureList fetchFeatures( QgsVectorLayerFeatureSource *layer, QgsFeatureRequest req, int searchId ); diff --git a/app/main.cpp b/app/main.cpp index 93c9aa7e7..7c397f278 100644 --- a/app/main.cpp +++ b/app/main.cpp @@ -96,6 +96,7 @@ #include "relationreferencefeaturesmodel.h" #include "fieldvalidator.h" #include "valuerelationfeaturesmodel.h" +#include "valuerelationcontroller.h" #include "snaputils.h" #include "guidelinecontroller.h" #include "multieditmanager.h" @@ -332,6 +333,7 @@ void initDeclarative() qmlRegisterType< LayerFeaturesModel >( "mm", 1, 0, "LayerFeaturesModel" ); qmlRegisterType< RelationFeaturesModel >( "mm", 1, 0, "RelationFeaturesModel" ); qmlRegisterType< ValueRelationFeaturesModel >( "mm", 1, 0, "ValueRelationFeaturesModel" ); + qmlRegisterType< ValueRelationController >( "mm", 1, 0, "ValueRelationController" ); qmlRegisterType< RelationReferenceFeaturesModel >( "mm", 1, 0, "RelationReferenceFeaturesModel" ); qmlRegisterType< BluetoothDiscoveryModel >( "mm", 1, 0, "BluetoothDiscoveryModel" ); qmlRegisterType< PositionTrackingManager >( "mm", 1, 0, "PositionTrackingManager" ); diff --git a/app/qml/components/MMButton.qml b/app/qml/components/MMButton.qml index 019c651d0..99ad85947 100644 --- a/app/qml/components/MMButton.qml +++ b/app/qml/components/MMButton.qml @@ -166,7 +166,8 @@ Button { implicitWidth: { let margin = __style.margin20 - if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 + if ( root.type === MMButton.Types.Tertiary ) margin = 0 + else if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 else if ( root.size === MMButton.Sizes.Small ) margin = __style.margin16 return row.paintedChildrenWidth + 2 * margin } @@ -214,7 +215,8 @@ Button { property real paintedChildrenWidth: buttonIconLeft.paintedWidth + buttonContent.implicitWidth + buttonIconRight.paintedWidth + spacing property real maxWidth: { let margin = __style.margin20 - if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 + if ( root.type === MMButton.Types.Tertiary ) margin = 0 + else if ( root.size === MMButton.Sizes.ExtraSmall ) margin = __style.margin8 else if ( root.size === MMButton.Sizes.Small ) margin = __style.margin16 return parent.width - 2 * margin } diff --git a/app/qml/components/MMDrawerHeader.qml b/app/qml/components/MMDrawerHeader.qml index b40c2ce1e..eec4ec823 100644 --- a/app/qml/components/MMDrawerHeader.qml +++ b/app/qml/components/MMDrawerHeader.qml @@ -13,7 +13,7 @@ import QtQuick.Layouts import "." -//! Best to use MMDrawerHeader as the header component for the MMPage object +//! Best to use MMDrawerHeader as the header component for the MMDrawer object Rectangle { id: root @@ -25,6 +25,8 @@ Rectangle { property alias closeButton: closeBtn property alias topLeftItemContent: topLeftButtonGroup.children + property alias topLeftItem: topLeftButtonGroup + property alias titleComponent: headerTitleText color: __style.transparentColor @@ -36,11 +38,16 @@ Rectangle { Item { id: topLeftButtonGroup + x: __style.pageMargins + __style.safeAreaLeft + y: root.height / 2 - height / 2 + width: childrenRect.width - height: parent.height + height: childrenRect.height } Text { + id: headerTitleText + property real leftMarginShift: { return Math.max( internal.closeBtnRealWidth, topLeftButtonGroup.width ) + internal.headerSpacing + __style.pageMargins } diff --git a/app/qml/components/MMListMultiselectDrawer.qml b/app/qml/components/MMListMultiselectDrawer.qml index 5caa085e2..ae9b35e4a 100644 --- a/app/qml/components/MMListMultiselectDrawer.qml +++ b/app/qml/components/MMListMultiselectDrawer.qml @@ -24,6 +24,7 @@ MMDrawer { property bool withSearch: true property bool multiSelect: false + property bool isLoading: false property var selected: [] // in/out property, contains a list of (pre-)selected item values property bool showFullScreen: false @@ -150,7 +151,9 @@ MMDrawer { Component { id: defaultEmptyStateComponent - MMListEmptyLoaderDelegate {} + MMListEmptyLoaderDelegate { + isLoading: root.isLoading + } } // QDate/QDateTime values get parsed to JS Date objects in QML, and they do strict comparison by default, which also @@ -173,4 +176,9 @@ MMDrawer { root.selected = root.selected.filter( x => !isEqualDate( x, value ) ) } } + + function focusSearchBar() { + root.showFullScreen = true + searchBar.textField.forceActiveFocus() + } } diff --git a/app/qml/filters/MMFiltersDrawer.qml b/app/qml/filters/MMFiltersDrawer.qml index 909fed3ea..672830f51 100644 --- a/app/qml/filters/MMFiltersDrawer.qml +++ b/app/qml/filters/MMFiltersDrawer.qml @@ -61,12 +61,6 @@ MMComponents.MMDrawer { bgndColorHover: __style.grapeColor fontColorHover: __style.negativeLightColor - anchors { - left: parent.left - leftMargin: __style.pageMargins + __style.safeAreaLeft - verticalCenter: parent.verticalCenter - } - onClicked: { internal.filterValues = {} diff --git a/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml b/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml index 623ba8128..f36ea757d 100644 --- a/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml +++ b/app/qml/filters/components/MMFilterDropdownUniqueValuesInput.qml @@ -70,14 +70,10 @@ Column { sourceComponent: MMComponents.MMListMultiselectDrawer { drawerHeader.title: root.filterName - withSearch: uniqueValuesModel.count > 5 + withSearch: uniqueValuesModel.count > 8 multiSelect: root.isMultiSelect - emptyStateDelegate: Component { - MMComponents.MMListEmptyLoaderDelegate { - isLoading: uniqueValuesModel.isLoading - } - } + isLoading: uniqueValuesModel.isLoading list.model: MM.SearchProxyModel { id: searchProxyModel diff --git a/app/qml/filters/components/MMFilterDropdownValueMapInput.qml b/app/qml/filters/components/MMFilterDropdownValueMapInput.qml index fb04a0384..72dbc615b 100644 --- a/app/qml/filters/components/MMFilterDropdownValueMapInput.qml +++ b/app/qml/filters/components/MMFilterDropdownValueMapInput.qml @@ -69,14 +69,10 @@ Column { sourceComponent: MMComponents.MMListMultiselectDrawer { drawerHeader.title: root.filterName - withSearch: valueMapModel.count > 5 + withSearch: valueMapModel.count > 8 multiSelect: root.isMultiSelect - emptyStateDelegate: Component { - MMComponents.MMListEmptyLoaderDelegate { - isLoading: valueMapModel.isLoading - } - } + isLoading: valueMapModel.isLoading list.model: MM.SearchProxyModel { id: searchProxyModel diff --git a/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml b/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml index a192c80e1..690aa670c 100644 --- a/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml +++ b/app/qml/filters/components/MMFilterDropdownValueRelationInput.qml @@ -65,30 +65,47 @@ Column { active: false - sourceComponent: MMComponents.MMListMultiselectDrawer { + id: listDrawer + drawerHeader.title: root.filterName - withSearch: vrDropdownModel.count > 5 + withSearch: vrDropdownModel.count > 8 multiSelect: root.isMultiSelect - emptyStateDelegate: Component { - MMComponents.MMListEmptyLoaderDelegate { - isLoading: vrDropdownModel.fetchingResults - } - } + isLoading: vrDropdownModel.fetchingResults list.model: MM.ValueRelationFeaturesModel { id: vrDropdownModel config: root.widgetConfig + + property bool firstFetchFinished: false + + // We show search for lists with more then 8 features. + // We need to intentionally break the binding here because "count" changes + // when users search for something and that would hide the search bar + onFetchingResultsChanged: { + if ( !fetchingResults && !firstFetchFinished ) + { + if ( count > 8 ) + { + listDrawer.withSearch = true + } + else + { + listDrawer.withSearch = false + } + + firstFetchFinished = true + } + } } - textRole: "FeatureTitle" - valueRole: "Key" + textRole: "ValueColumn" + valueRole: "KeyColumn" onSelectionFinished: function( selectedItems ) { - // // Large fids could be converted to scientific notation on their way to cpp, // so we convert them to string first in JS. diff --git a/app/qml/form/MMFormPage.qml b/app/qml/form/MMFormPage.qml index 8a1cf0037..efb111df5 100644 --- a/app/qml/form/MMFormPage.qml +++ b/app/qml/form/MMFormPage.qml @@ -321,7 +321,7 @@ Page { property var fieldActiveProject: root.project property var fieldAssociatedRelation: model.Relation - property var fieldFeatureLayerPair: root.controller.featureLayerPair + property MM.AttributeController fieldController: root.controller property string fieldHomePath: root.project ? root.project.homePath : "" // for photo editor property bool fieldRememberValueSupported: root.controller.rememberAttributesController.rememberValuesAllowed && root.state === "add" && model.EditorWidget !== "Hidden" && Type === MM.FormItem.Field @@ -372,19 +372,11 @@ Page { Connections { target: root.controller - // Important for relation form editors // <--- TODO: remove me if all works, unused - function onFeatureLayerPairChanged() { - if ( formEditorsLoader.item && formEditorsLoader.item.featureLayerPairChanged ) - { - formEditorsLoader.item.featureLayerPairChanged() - } - } - // Important for value relation form editors function onFormRecalculated() { - if ( formEditorsLoader.item && formEditorsLoader.item.reload ) + if ( formEditorsLoader.item && formEditorsLoader.item.hotReload ) { - formEditorsLoader.item.reload() + formEditorsLoader.item.hotReload() } } } diff --git a/app/qml/form/MMFormStackController.qml b/app/qml/form/MMFormStackController.qml index b88533793..134b796e1 100644 --- a/app/qml/form/MMFormStackController.qml +++ b/app/qml/form/MMFormStackController.qml @@ -144,7 +144,7 @@ Item { // https://github.com/MerginMaps/mobile/issues/2879 for ( let i = 0; i < formsStack.depth; i++ ) { let form = formsStack.get( i ) - form.featureLayerPair = __inputUtils.createFeatureLayerPair() + form.featureLayerPair = __inputUtils.createFeatureLayerPair form.relationToApply = null form.controllerToApply = null form.project = null diff --git a/app/qml/form/editors/MMFormGalleryEditor.qml b/app/qml/form/editors/MMFormGalleryEditor.qml index a2adb4040..997952e61 100644 --- a/app/qml/form/editors/MMFormGalleryEditor.qml +++ b/app/qml/form/editors/MMFormGalleryEditor.qml @@ -18,8 +18,8 @@ import "../../components/private" as MMPrivateComponents MMPrivateComponents.MMBaseInput { id: root + property MM.AttributeController _fieldController: parent.fieldController property var _fieldAssociatedRelation: parent.fieldAssociatedRelation - property var _fieldFeatureLayerPair: parent.fieldFeatureLayerPair property var _fieldActiveProject: parent.fieldActiveProject property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle @@ -48,7 +48,7 @@ MMPrivateComponents.MMBaseInput { id: rmodel relation: root._fieldAssociatedRelation - parentFeatureLayerPair: root._fieldFeatureLayerPair + parentFeatureLayerPair: root._fieldController.featureLayerPair homePath: root._fieldActiveProject.homePath } @@ -97,7 +97,7 @@ MMPrivateComponents.MMBaseInput { MMComponents.MMSingleClickMouseArea { anchors.fill: parent - onSingleClicked: root.createLinkedFeature( root._fieldFeatureLayerPair, root._fieldAssociatedRelation ) + onSingleClicked: root.createLinkedFeature( root._fieldController.featureLayerPair, root._fieldAssociatedRelation ) } } diff --git a/app/qml/form/editors/MMFormPhotoEditor.qml b/app/qml/form/editors/MMFormPhotoEditor.qml index 17d29c81e..9d2295ef8 100644 --- a/app/qml/form/editors/MMFormPhotoEditor.qml +++ b/app/qml/form/editors/MMFormPhotoEditor.qml @@ -63,7 +63,7 @@ MMFormPhotoViewer { property string _fieldHomePath: parent.fieldHomePath property var _fieldActiveProject: parent.fieldActiveProject - property var _fieldFeatureLayerPair: parent.fieldFeatureLayerPair + property MM.AttributeController _fieldController: parent.fieldController property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle property bool _fieldFormIsReadOnly: parent.fieldFormIsReadOnly @@ -237,7 +237,8 @@ MMFormPhotoViewer { property string targetDir: __inputUtils.resolveTargetDir( root._fieldHomePath, root._fieldConfig, - root._fieldFeatureLayerPair, + root._fieldController.featureLayerPair, + root._fieldController.parentController?.featureLayerPair ?? __inputUtils.createFeatureLayerPair(), root._fieldActiveProject ) @@ -417,7 +418,7 @@ MMFormPhotoViewer { * which references another field in the same form, to save photos in certain directory. */ function updateTargetDir() { - targetDir = __inputUtils.resolveTargetDir( root._fieldHomePath, root._fieldConfig, root._fieldFeatureLayerPair, root._fieldActiveProject ) + targetDir = __inputUtils.resolveTargetDir( root._fieldHomePath, root._fieldConfig, root._fieldController.featureLayerPair, root._fieldController.parentController?.featureLayerPair ?? __inputUtils.createFeatureLayerPair(), root._fieldActiveProject ) } } } diff --git a/app/qml/form/editors/MMFormRelationEditor.qml b/app/qml/form/editors/MMFormRelationEditor.qml index 2d3ab54ea..8ca7af478 100644 --- a/app/qml/form/editors/MMFormRelationEditor.qml +++ b/app/qml/form/editors/MMFormRelationEditor.qml @@ -28,8 +28,8 @@ MMPrivateComponents.MMBaseInput { id: root property var _fieldAssociatedRelation: parent.fieldAssociatedRelation - property var _fieldFeatureLayerPair: parent.fieldFeatureLayerPair property var _fieldActiveProject: parent.fieldActiveProject + property MM.AttributeController _fieldController: parent.fieldController property string _fieldTitle: parent.fieldTitle property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle @@ -97,7 +97,7 @@ MMPrivateComponents.MMBaseInput { anchors.fill: parent onSingleClicked: { root.forceActiveFocus() // clear focus from all elements to prevent freezing #3483 - root.createLinkedFeature( root._fieldFeatureLayerPair, root._fieldAssociatedRelation ) + root.createLinkedFeature( root._fieldController.featureLayerPair, root._fieldAssociatedRelation ) } } } @@ -111,7 +111,7 @@ MMPrivateComponents.MMBaseInput { id: rmodel relation: root._fieldAssociatedRelation - parentFeatureLayerPair: root._fieldFeatureLayerPair + parentFeatureLayerPair: root._fieldController.featureLayerPair homePath: root._fieldActiveProject.homePath onModelReset: { @@ -217,7 +217,7 @@ MMPrivateComponents.MMBaseInput { onClosed: listLoader.active = false onFeatureClicked: ( featurePair ) => root.openLinkedFeature( featurePair ) onSearchTextChanged: ( searchText ) => rmodel.searchExpression = searchText - onButtonClicked: root.createLinkedFeature( root._fieldFeatureLayerPair, root._fieldAssociatedRelation ) + onButtonClicked: root.createLinkedFeature( root._fieldController.featureLayerPair, root._fieldAssociatedRelation ) Component.onCompleted: open() } diff --git a/app/qml/form/editors/MMFormValueMapEditor.qml b/app/qml/form/editors/MMFormValueMapEditor.qml index effcbedb9..fe6955d84 100644 --- a/app/qml/form/editors/MMFormValueMapEditor.qml +++ b/app/qml/form/editors/MMFormValueMapEditor.qml @@ -62,7 +62,6 @@ MMFormComboboxBaseEditor { } on_FieldValueChanged: { - if ( _fieldValueIsNull || _fieldValue === undefined ) { text = "" preselectedItems = [] @@ -82,17 +81,21 @@ MMFormComboboxBaseEditor { dropdownLoader.sourceComponent: Component { MMComponents.MMListMultiselectDrawer { - drawerHeader.title: root._fieldTitle + drawerHeader.titleFont: __style.t2 + + drawerHeader.topLeftItem.visible: !root._fieldValueIsNull + drawerHeader.topLeftItemContent: MMComponents.MMButton { + text: qsTr( "Clear" ) - emptyStateDelegate: Item { - width: parent.width - height: noItemsText.implicitHeight + __style.margin40 - - MMComponents.MMText { - id: noItemsText - text: qsTr( "No items" ) - anchors.centerIn: parent + type: MMButton.Types.Tertiary + + fontColor: __style.darkGreyColor + fontColorHover: __style.nightColor + + onClicked: { + root.editorValueChanged( "", true ) + close() } } @@ -145,7 +148,11 @@ MMFormComboboxBaseEditor { value: Object.values( config[i] )[0] } - listModel.append( modelItem ) + // filter out nulls + if ( modelItem.text !== "" ) + { + listModel.append( modelItem ) + } // Is this the current item? If so, set the text if ( !root._fieldValueIsNull ) { diff --git a/app/qml/form/editors/MMFormValueRelationEditor.qml b/app/qml/form/editors/MMFormValueRelationEditor.qml index ae4016be2..fe86db853 100644 --- a/app/qml/form/editors/MMFormValueRelationEditor.qml +++ b/app/qml/form/editors/MMFormValueRelationEditor.qml @@ -29,7 +29,7 @@ MMFormComboboxBaseEditor { property var _fieldConfig: parent.fieldConfig property bool _fieldValueIsNull: parent.fieldValueIsNull property bool _fieldHasMixedValues: parent.fieldHasMixedValues - property var _fieldFeatureLayerPair: parent.fieldFeatureLayerPair + property MM.AttributeController _fieldController: parent.fieldController property bool _fieldShouldShowTitle: parent.fieldShouldShowTitle property bool _fieldFormIsReadOnly: parent.fieldFormIsReadOnly @@ -58,42 +58,76 @@ MMFormComboboxBaseEditor { hasCheckbox: _fieldRememberValueSupported checkboxChecked: _fieldRememberValueState - on_FieldValueChanged: { - vrModel.pair = root._fieldFeatureLayerPair - } + on_FieldValueChanged: lookupDisplayText() + on_FieldHasMixedValuesChanged: lookupDisplayText() - onCheckboxCheckedChanged: { - root.rememberValueBoxClicked( checkboxChecked ) - } + onCheckboxCheckedChanged: root.rememberValueBoxClicked( checkboxChecked ) dropdownLoader.sourceComponent: Component { MMComponents.MMListMultiselectDrawer { + id: listDrawer + drawerHeader.title: root._fieldTitle + drawerHeader.titleFont: __style.t2 + + drawerHeader.topLeftItem.visible: !root._fieldValueIsNull + drawerHeader.topLeftItemContent: MMComponents.MMButton { + text: qsTr( "Clear" ) + + type: MMButton.Types.Tertiary + + fontColor: __style.darkGreyColor + fontColorHover: __style.nightColor - emptyStateDelegate: Item { - width: parent.width - height: noItemsText.implicitHeight + __style.margin40 - - MMComponents.MMText { - id: noItemsText - text: qsTr( "No items" ) - anchors.centerIn: parent + onClicked: { + root.editorValueChanged( "", true ) + close() } } - multiSelect: internal.allowMultivalue - withSearch: vrModel.count > 5 - showFullScreen: multiSelect || withSearch + withSearch: false + multiSelect: _controller.isMultiSelection - valueRole: "FeatureId" - textRole: "FeatureTitle" + valueRole: "KeyColumn" + textRole: "ValueColumn" + + isLoading: vrDropdownModel.fetchingResults list.model: MM.ValueRelationFeaturesModel { id: vrDropdownModel + property bool firstFetchFinished: false + config: root._fieldConfig - pair: root._fieldFeatureLayerPair + pair: root._fieldController.featureLayerPair + + // We show search for lists with more then 8 features. + // We need to intentionally break the binding here because "count" changes + // when users search for something and that would hide the search bar + onFetchingResultsChanged: { + if ( !fetchingResults && !firstFetchFinished ) + { + if ( count > 8 ) + { + listDrawer.withSearch = true + + // Additionally, focus the searchbar immediately in case "UseCompleter" is enabled + if ( internal.useCompleter ) + { + listDrawer.focusSearchBar() + } + } + else + { + listDrawer.withSearch = false + } + + firstFetchFinished = true + } + } + + Component.onCompleted: reloadFeatures() } onSearchTextChanged: ( searchText ) => vrDropdownModel.searchExpression = searchText @@ -101,90 +135,54 @@ MMFormComboboxBaseEditor { onClosed: dropdownLoader.active = false onSelectionFinished: function ( selectedItems ) { + const keys = _controller.arrayToQgisFormat( selectedItems ) + const isNull = selectedItems.length === 0 - if ( internal.allowMultivalue ) - { - let isNull = selectedItems.length === 0 - - if ( !isNull ) - { - // We need to convert feature id to string prior to sending it to C++ in order to - // avoid conversion to scientific notation. - selectedItems = selectedItems.map( function(x) { return x.toString() } ) - } - root.editorValueChanged( vrModel.convertToQgisType( selectedItems ), isNull ) - } - else - { - // We need to convert feature id to string prior to sending it to C++ in order to - // avoid conversion to scientific notation. - selectedItems = selectedItems.toString() - - root.editorValueChanged( vrModel.convertToKey( selectedItems ), false ) - } - + root.editorValueChanged( keys, isNull ) close() } Component.onCompleted: { - // We want to set the initial value of 'selected' property but not bind it so we avoid a binding loop - if ( internal.allowMultivalue ) { - selected = vrModel.convertFromQgisType( root._fieldValue, MM.FeaturesModel.FeatureId ) - } - else { - selected = [root._fieldValue] - } + // Pre-select the currently stored keys so the drawer opens with the + // right items highlighted. + selected = _controller.qgisFormatToArray( root._fieldValue ) + open() } } } - MM.ValueRelationFeaturesModel { - id: vrModel + MM.ValueRelationController { + id: _controller config: root._fieldConfig - pair: root._fieldFeatureLayerPair - onInvalidate: { - if ( root._fieldHasMixedValues ) - { - return // ignore invalidate signal if value is MixedAttributeValue - } - if ( root._fieldValueIsNull ) - { - return // ignore invalidate signal if value is already NULL - } - if ( root._fieldIsReadOnly ) - { - return // ignore invalidate signal if form is not in edit mode - } - root.editorValueChanged( "", true ) - } + isEditable: !root._fieldFormIsReadOnly && root._fieldIsEditable - onFetchingResultsChanged: function ( isFetching ) { - if ( !isFetching ) - { - setText() - } - } + onDisplayTextChanged: root.text = _controller.displayText + onInvalidateSelection: root.editorValueChanged( "", true ) + onPresentRawValue: root.text = root._fieldValue + } + + QtObject { + id: internal + + property bool useCompleter: root?._fieldConfig?.["UseCompleter"] ?? false } - function reload() + function hotReload() { - if ( !root.isReadOnly ) + if ( !root._fieldHasMixedValues ) { - vrModel.pair = root._fieldFeatureLayerPair + _controller.lookupDisplayTextOnHotreload( root._fieldValue, root._fieldController.featureLayerPair.feature ) } } - function setText() + function lookupDisplayText() { - root.text = vrModel.convertFromQgisType( root._fieldValue, MM.FeaturesModel.FeatureTitle ).join( ', ' ) - } - - QtObject { - id: internal - - property bool allowMultivalue: root._fieldConfig["AllowMulti"] + if ( !root._fieldHasMixedValues ) + { + _controller.lookupDisplayTextOnValueChanged( root._fieldValue ) + } } } diff --git a/app/qml/gps/MMMeasureDrawer.qml b/app/qml/gps/MMMeasureDrawer.qml index 3305f6358..5af0c11d5 100644 --- a/app/qml/gps/MMMeasureDrawer.qml +++ b/app/qml/gps/MMMeasureDrawer.qml @@ -59,12 +59,6 @@ MMComponents.MMDrawer { enabled: measurementFinalized || canUndo - anchors { - left: parent.left - leftMargin: __style.pageMargins + __style.safeAreaLeft - verticalCenter: parent.verticalCenter - } - onClicked: measurementFinalized ? root.mapTool.resetMeasurement() : root.mapTool.removePoint() } diff --git a/app/qml/map/MMMapSketchesDrawer.qml b/app/qml/map/MMMapSketchesDrawer.qml index 83d5138cd..25aa4ae4d 100644 --- a/app/qml/map/MMMapSketchesDrawer.qml +++ b/app/qml/map/MMMapSketchesDrawer.qml @@ -34,15 +34,9 @@ MMComponents.MMDrawer { PropertyAnimation { properties: "implicitHeight"; easing.type: Easing.InOutQuad } } - drawerHeader.topLeftItemContent: Row{ + drawerHeader.topLeftItemContent: Row { width: parent.width - 2 * __style.pageMargins spacing: __style.margin12 - anchors{ - left: parent.left - leftMargin: __style.pageMargins + __style.safeAreaLeft - verticalCenter: parent.verticalCenter - rightMargin: __style.pageMargins + __style.safeAreaRight - } MMComponents.MMRoundButton { iconSource: __style.undoIcon @@ -69,7 +63,7 @@ MMComponents.MMDrawer { iconColor: root.sketchingController?.eraserActive ? __style.grassColor : __style.forestColor onClicked: { - if(root.sketchingController) + if ( root.sketchingController ) { root.sketchingController.eraserActive = true root.sketchingController.activeColor = null @@ -78,13 +72,12 @@ MMComponents.MMDrawer { } } - drawerContent: - ColumnLayout{ + drawerContent: ColumnLayout { width: parent.width spacing: __style.margin2 - MMComponents.MMColorPicker{ + MMComponents.MMColorPicker { id: colorPicker colors: root.sketchingController?.availableColors() ?? __style.photoSketchingWhiteColor @@ -92,7 +85,7 @@ MMComponents.MMDrawer { Layout.maximumWidth: parent.width onActiveColorChanged: { - if(root.sketchingController) + if ( root.sketchingController ) { root.sketchingController.activeColor = colorPicker.activeColor root.sketchingController.eraserActive = false @@ -100,7 +93,8 @@ MMComponents.MMDrawer { } } } - MMComponents.MMListSpacer{ + + MMComponents.MMListSpacer { height: 2 } } \ No newline at end of file diff --git a/app/synchronizationmanager.cpp b/app/synchronizationmanager.cpp index f5322de7c..73d3f6e06 100644 --- a/app/synchronizationmanager.cpp +++ b/app/synchronizationmanager.cpp @@ -9,8 +9,11 @@ #include +#include "coreutils.h" #include "synchronizationmanager.h" +using namespace Qt::Literals; + SynchronizationManager::SynchronizationManager( MerginApi *merginApi, QObject *parent @@ -49,6 +52,8 @@ void SynchronizationManager::syncProject( const Project &project, SyncOptions::A return; } + CoreUtils::log( u"Sync Manager"_s, u"Requested download of project %2"_s.arg( project.mergin.projectName ) ); + // project is not local yet -> we download it for the first time bool syncHasStarted = mMerginApi->pullProject( project.mergin.projectNamespace, project.mergin.projectName, auth == SyncOptions::Authorized ); @@ -71,6 +76,8 @@ void SynchronizationManager::syncProject( const LocalProject &project, SyncOptio return; } + CoreUtils::log( u"Sync Manager"_s, u"Requested %1 sync of project %2"_s.arg( requestOrigin == SyncOptions::ManualRequest ? "manual" : "automatic" ).arg( project.projectName ) ); + if ( !project.hasMerginMetadata() ) { if ( requestOrigin == SyncOptions::ManualRequest ) diff --git a/app/test/testformeditors.cpp b/app/test/testformeditors.cpp index d2d5f2be5..013b5f5c9 100644 --- a/app/test/testformeditors.cpp +++ b/app/test/testformeditors.cpp @@ -16,6 +16,7 @@ #include "relationfeaturesmodel.h" #include "relationreferencefeaturesmodel.h" #include "valuerelationfeaturesmodel.h" +#include "valuerelationcontroller.h" #include #include @@ -448,134 +449,204 @@ void TestFormEditors::testRelationsWidgetPresence() QVERIFY( relationReferencesCount == 1 ); } -void TestFormEditors::testValueRelationsEditor() +void TestFormEditors::testValueRelationConversions() { - /* Test project: project_value_relations - * It has value relations sets up followingly: + /* Tests qgisFormatToArray() and arrayToQgisFormat() for null, single, + * and multi values. * - * - Main Layer has VR to: - * - sub layer - * - subsub layer ( with filter expression that subsub is categorized based on sub ) - * - another layer ( key is not fid, but textual ) + * AllowMulti=false: + * QGIS value -> keys: "1" -> ["1"] + * keys -> QGIS value: ["42"] -> "42" + * + * AllowMulti=true: + * QGIS value -> keys: "{1,3,4}" -> ["1","3","4"] + * keys -> QGIS value: ["1","3","4"] -> "{1,3,4}"; ["1"] -> "{1}" */ + QgsProject::instance()->removeAllMapLayers(); - QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; - QString projectName = "proj.qgz"; - - QVERIFY( QgsProject::instance()->read( projectDir + "/" + projectName ) ); - - QgsMapLayer *mainL = QgsProject::instance()->mapLayersByName( QStringLiteral( "main" ) ).at( 0 ); - QgsVectorLayer *mainLayer = static_cast( mainL ); - - QVERIFY( mainLayer && mainLayer->isValid() ); - - QgsMapLayer *subL = QgsProject::instance()->mapLayersByName( QStringLiteral( "sub" ) ).at( 0 ); - QgsVectorLayer *subLayer = static_cast( subL ); - - QVERIFY( subLayer && subLayer->isValid() ); - - QgsMapLayer *subsubL = QgsProject::instance()->mapLayersByName( QStringLiteral( "subsub" ) ).at( 0 ); - QgsVectorLayer *subsubLayer = static_cast( subsubL ); - - QVERIFY( subsubLayer && subsubLayer->isValid() ); - - QgsMapLayer *anotherL = QgsProject::instance()->mapLayersByName( QStringLiteral( "another" ) ).at( 0 ); - QgsVectorLayer *anotherLayer = static_cast( anotherL ); - - QVERIFY( anotherLayer && anotherLayer->isValid() ); - - // test ValueRelationsFeaturesModel, see if it contains correct data for existing features - - QgsFeature f = mainLayer->getFeature( 1 ); - FeatureLayerPair pair( f, mainLayer ); - - AttributeController controller; - controller.setFeatureLayerPair( pair ); - - const TabItem *tab = controller.tabItem( 0 ); - QVector items = tab->formItems(); - - QVERIFY( items.length() == 5 ); + QgsVectorLayer *layer = TestUtils::createVRLookupLayer( 5 ); + QVERIFY( layer && layer->isValid() ); + QgsProject::instance()->addMapLayer( layer ); - // order: 0 - fid, 1 - Name, 2 - subfk, 3 - anotherfk, 4 - subsubfk - - // ------- FIELD SubFK - - const FormItem *subFkItem = controller.formItem( items.at( 2 ) ); - - ValueRelationFeaturesModel subVRModel; - QSignalSpy subSpy( &subVRModel, &LayerFeaturesModel::fetchingResultsChanged ); - - subVRModel.setConfig( subFkItem->editorWidgetConfig() ); - subVRModel.setPair( pair ); - - subSpy.wait(); - QCOMPARE( subVRModel.rowCount(), subLayer->dataProvider()->featureCount() ); - QCOMPARE( subVRModel.layer()->id(), subLayer->id() ); - - // ------- FIELD SubSubFK - - const FormItem *subsubFkItem = controller.formItem( items.at( 4 ) ); - - ValueRelationFeaturesModel subsubVRModel; - QSignalSpy subsubSpy( &subsubVRModel, &LayerFeaturesModel::fetchingResultsChanged ); - subsubVRModel.setConfig( subsubFkItem->editorWidgetConfig() ); - subsubVRModel.setPair( pair ); - - subsubSpy.wait(); - QCOMPARE( subsubVRModel.rowCount(), 2 ); // due to a filter expression - QCOMPARE( subsubVRModel.layer()->id(), subsubLayer->id() ); - - // test setup of filter expression - QgsFeatureRequest request; - subsubVRModel.setupFeatureRequest( request ); - - QVERIFY( !request.filterExpression()->operator QString().isEmpty() ); - QVERIFY( request.filterExpression()->isValid() ); - - // test filter expression in combination with search - subsubVRModel.setSearchExpression( QStringLiteral( "2" ) ); + // Single value test + ValueRelationController controller; + controller.setConfig( + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), false } + } ); - subsubSpy.wait(); - QCOMPARE( subsubVRModel.rowCount(), 1 ); + // null/empty : empty list + QCOMPARE( controller.qgisFormatToArray( QVariant() ), QStringList() ); + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "" ) ), QStringList() ); - // test title field on result - QModelIndex index = subsubVRModel.index( 0, 0 ); - FeatureLayerPair tempPair = subsubVRModel.data( index, FeaturesModel::FeaturePair ).value(); + // single value "1" : ["1"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "1" ) ), + QStringList( { QStringLiteral( "1" ) } ) ); - QCOMPARE( subsubVRModel.featureTitle( tempPair ), QStringLiteral( "A2" ) ); + // empty keys : "" + QCOMPARE( controller.arrayToQgisFormat( {} ), QString() ); - // ------- FIELD AnotherFK + // single key : plain value + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "42" ) } ), + QStringLiteral( "42" ) ); - const FormItem *anotherFkItem = controller.formItem( items.at( 3 ) ); + // Multi value test + controller.setConfig( + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), true } + } ); - ValueRelationFeaturesModel anotherVRModel; - QSignalSpy anotherSpy( &subsubVRModel, &LayerFeaturesModel::fetchingResultsChanged ); - anotherVRModel.setConfig( anotherFkItem->editorWidgetConfig() ); - anotherVRModel.setPair( pair ); + // null : empty list + QCOMPARE( controller.qgisFormatToArray( QVariant() ), QStringList() ); - anotherSpy.wait(); - QCOMPARE( anotherVRModel.rowCount(), anotherLayer->dataProvider()->featureCount() ); - QCOMPARE( anotherVRModel.layer()->id(), anotherLayer->id() ); + // "{1,3,4}" : ["1","3","4"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "{1,3,4}" ) ), + QStringList( { QStringLiteral( "1" ), QStringLiteral( "3" ), QStringLiteral( "4" ) } ) ); - // test invalidate call and conversion functions + // "{1}" : ["1"] + QCOMPARE( controller.qgisFormatToArray( QStringLiteral( "{1}" ) ), + QStringList( { QStringLiteral( "1" ) } ) ); - QSignalSpy invalidateSignal( &anotherVRModel, &ValueRelationFeaturesModel::invalidate ); + // empty keys : "" + QCOMPARE( controller.arrayToQgisFormat( {} ), QString() ); - QVariant response = anotherVRModel.convertFromQgisType( QStringLiteral( "{100,101}" ), FeaturesModel::FeatureTitle ); - QCOMPARE( invalidateSignal.count(), 1 ); + // ["1","3","4"] : "{1,3,4}" + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "1" ), QStringLiteral( "3" ), QStringLiteral( "4" ) } ), + QStringLiteral( "{1,3,4}" ) ); - response = anotherVRModel.convertFromQgisType( QStringLiteral( "{B,C}" ), FeaturesModel::FeatureId ); - QCOMPARE( response, QVariant( QVariantList( { 2, 3 } ) ) ); // QVariantList inside QVariant because of internal JS<->C++ QVariant conversions + // ["1"] : "{1}" + QCOMPARE( controller.arrayToQgisFormat( { QStringLiteral( "1" ) } ), + QStringLiteral( "{1}" ) ); - // ------ Test big FID numbers (> 1000000), due to a scientific notations in toString methods - QCOMPARE( subVRModel.convertToKey( 4 ), "4" ); + QgsProject::instance()->removeAllMapLayers(); +} - controller.setFormValue( subFkItem->id(), subVRModel.convertToKey( 4 ) ); - subsubVRModel.setPair( controller.featureLayerPair() ); - subsubVRModel.setSearchExpression( "" ); +void TestFormEditors::testValueRelationControllerLookup() +{ + /* Tests async display-text lookup for ValueRelationController: + * + * baseConfig controller (no FilterExpression): + * 1. Missing key -> presentRawValue fires; invalidateSelection does NOT fire; displayText="" + * 2. lookupDisplayTextOnHotreload without FilterExpression -> returns early, no signals + * 3. Basic lookup: lookupDisplayTextOnValueChanged("1") -> "Cat1-A" + * + * filterConfig controller (FilterExpression set): + * 4. lookupDisplayTextOnHotreload, key valid in context -> displayText updated ("Cat1-A") + * 5. lookupDisplayTextOnHotreload, key no longer in context -> invalidateSelection fires + * + * Cases 1-2 run before case 3 so that mDisplayText is "" when case 1 checks + * displaySpy.isEmpty() -> the setDisplayText guard skips emission for equal values. + * + * Lookup layer: + * key=1 label="Cat1-A" category=1 + * key=2 label="Cat1-B" category=1 + * key=3 label="Cat2-A" category=2 + * key=4 label="Cat2-B" category=2 + * + * Filter expression: "category" = current_value('cat') + * Form feature cat=1 -> keys 1,2 reachable; cat=2 -> keys 3,4 reachable. + */ + QgsProject::instance()->removeAllMapLayers(); + + // create the lookup layer + QgsVectorLayer *lookupLayer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string&field=category:integer" ), + QStringLiteral( "vr_lookup" ), + QStringLiteral( "memory" ) + ); + QVERIFY( lookupLayer && lookupLayer->isValid() ); + + const QStringList labels = { QStringLiteral( "Cat1-A" ), QStringLiteral( "Cat1-B" ), + QStringLiteral( "Cat2-A" ), QStringLiteral( "Cat2-B" ) + }; + QgsFeatureList features; + for ( int i = 0; i < 4; ++i ) + { + QgsFeature feature( lookupLayer->fields() ); + feature.setAttribute( QStringLiteral( "key" ), i + 1 ); + feature.setAttribute( QStringLiteral( "label" ), labels.at( i ) ); + feature.setAttribute( QStringLiteral( "category" ), i < 2 ? 1 : 2 ); + features << feature; + } + lookupLayer->dataProvider()->addFeatures( features ); + QgsProject::instance()->addMapLayer( lookupLayer ); + + // create the form layer, that provides fields for building form-scope features + QgsVectorLayer *formLayer = new QgsVectorLayer( + QStringLiteral( "None?field=cat:integer" ), + QStringLiteral( "vr_form" ), + QStringLiteral( "memory" ) + ); + QVERIFY( formLayer && formLayer->isValid() ); + QgsProject::instance()->addMapLayer( formLayer ); + + const QVariantMap baseConfig = + { + { QStringLiteral( "Layer" ), lookupLayer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "AllowMulti" ), false } + }; - subsubSpy.wait(); - QgsFeature bigF = subsubLayer->getFeature( 100000000 ); - QCOMPARE( subsubVRModel.convertToKey( bigF.id() ), bigF.id() ); + QVariantMap filterConfig = baseConfig; + filterConfig[ QStringLiteral( "FilterExpression" ) ] = + QStringLiteral( "\"category\" = current_value('cat')" ); + + ValueRelationController baseController; + baseController.setConfig( baseConfig ); + + QSignalSpy rawSpy( &baseController, &ValueRelationController::presentRawValue ); + QSignalSpy invalidateSpy( &baseController, &ValueRelationController::invalidateSelection ); + QSignalSpy displaySpy( &baseController, &ValueRelationController::displayTextChanged ); + + // 1. Missing key, no filter -> presentRawValue; NOT invalidateSelection + baseController.lookupDisplayTextOnValueChanged( QStringLiteral( "99999" ) ); + QVERIFY( rawSpy.wait( 5000 ) ); + QVERIFY( displaySpy.isEmpty() ); // mDisplayText was already ""; setDisplayText skips emission + QVERIFY( invalidateSpy.isEmpty() ); + QCOMPARE( baseController.displayText(), QString() ); + + // 2. Hotreload without FilterExpression -> early return, no signals + rawSpy.clear(); + displaySpy.clear(); + baseController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), QgsFeature() ); + QVERIFY( displaySpy.isEmpty() ); + + // 3. Basic lookup: key "1" -> "Cat1-A" + displaySpy.clear(); + baseController.lookupDisplayTextOnValueChanged( QStringLiteral( "1" ) ); + QVERIFY( displaySpy.wait( 5000 ) ); + QCOMPARE( baseController.displayText(), QStringLiteral( "Cat1-A" ) ); + + ValueRelationController filterController; + filterController.setConfig( filterConfig ); + + QSignalSpy filterDisplaySpy( &filterController, &ValueRelationController::displayTextChanged ); + QSignalSpy filterInvalidateSpy( &filterController, &ValueRelationController::invalidateSelection ); + + // 4. Hotreload with filter, key valid in context -> displayText updated + // Form context: cat=1 -> key 1 ("Cat1-A") is reachable (category=1) + QgsFeature formFeature( formLayer->fields() ); + formFeature.setAttribute( QStringLiteral( "cat" ), 1 ); + formFeature.setValid( true ); + filterController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), formFeature ); + QVERIFY( filterDisplaySpy.wait( 5000 ) ); + QCOMPARE( filterController.displayText(), QStringLiteral( "Cat1-A" ) ); + + // 5. Hotreload with filter, key not in context -> invalidateSelection + // Form context: cat=2 -> key 1 ("Cat1-A") is NOT reachable (category=1 ≠ 2) + filterDisplaySpy.clear(); + QgsFeature formFeature2( formLayer->fields() ); + formFeature2.setAttribute( QStringLiteral( "cat" ), 2 ); + formFeature2.setValid( true ); + filterController.lookupDisplayTextOnHotreload( QStringLiteral( "1" ), formFeature2 ); + QVERIFY( filterInvalidateSpy.wait( 5000 ) ); + + QgsProject::instance()->removeAllMapLayers(); } diff --git a/app/test/testformeditors.h b/app/test/testformeditors.h index e53bc49e5..382eb3eee 100644 --- a/app/test/testformeditors.h +++ b/app/test/testformeditors.h @@ -24,7 +24,8 @@ class TestFormEditors : public QObject void testRelationsEditor(); void testRelationsReferenceEditor(); void testRelationsWidgetPresence(); - void testValueRelationsEditor(); + void testValueRelationConversions(); + void testValueRelationControllerLookup(); }; #endif // TESTFORMEDITORS_H diff --git a/app/test/testmodels.cpp b/app/test/testmodels.cpp index 456e203c9..48682ace1 100644 --- a/app/test/testmodels.cpp +++ b/app/test/testmodels.cpp @@ -13,6 +13,7 @@ #include "staticfeaturesmodel.h" #include "inputmapsettings.h" #include "valuerelationfeaturesmodel.h" +#include "valuerelationcontroller.h" #include "projectsmodel.h" #include "projectsproxymodel.h" @@ -178,125 +179,259 @@ void TestModels::testLayerFeaturesModelSorted() QCOMPARE( model.data( model.index( 8, 0 ), FeaturesModel::ModelRoles::FeatureId ), 100000000 ); } -void TestModels::testValueRelationFeaturesModel() +void TestModels::testValueRelationOrdering() { - QString projectDir = TestUtils::testDataDir() + "/project_value_relations"; - QString projectName = "proj.qgz"; - - QVERIFY( QgsProject::instance()->read( projectDir + "/" + projectName ) ); - - QgsMapLayer *mainL = QgsProject::instance()->mapLayersByName( QStringLiteral( "main" ) ).at( 0 ); - QgsVectorLayer *mainLayer = static_cast( mainL ); - - QVERIFY( mainLayer && mainLayer->isValid() ); - - QgsMapLayer *subsubL = QgsProject::instance()->mapLayersByName( QStringLiteral( "subsub" ) ).at( 0 ); - QgsVectorLayer *subsubLayer = static_cast( subsubL ); - - QVERIFY( subsubLayer && subsubLayer->isValid() ); + /* Tests the four ordering permutations against a layer whose insertion order + * is neither key-sorted nor label-sorted: + * + * key->label: 1->Alpha 2->Delta 3->Gamma 4->Beta + * + * 1. OrderByKey asc -> 1(Alpha), 2(Delta), 3(Gamma), 4(Beta) + * 2. OrderByKey desc -> 4(Beta), 3(Gamma), 2(Delta), 1(Alpha) + * 3. OrderByValue asc -> 1(Alpha), 4(Beta), 2(Delta), 3(Gamma) + * 4. OrderByField "label" asc -> same as 3, but different code path + * 5. OrderByField desc -> 3(Gamma), 2(Delta), 4(Beta), 1(Alpha) + */ + QgsProject::instance()->removeAllMapLayers(); + + QgsVectorLayer *layer = TestUtils::createVROrderingLayer(); + QVERIFY( layer && layer->isValid() ); + QCOMPARE( static_cast( layer->featureCount() ), 4 ); + QgsProject::instance()->addMapLayer( layer ); - QgsFeature f = mainLayer->getFeature( 1 ); - FeatureLayerPair pair( f, mainLayer ); + const QVariantMap baseConfig = + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) } + }; ValueRelationFeaturesModel model; - + model.setConfig( baseConfig ); + QCOMPARE( model.rowCount(), 0 ); // setConfig should not populate the model QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); - // setup value relation, initially unsorted - QVariantMap config = + auto keyAt = [&]( int row ) { - { QStringLiteral( "Layer" ), QStringLiteral( "subsub_df9d0ba0_2ec8_4a2c_9f96_84576e37c126" ) }, - { QStringLiteral( "Key" ), QStringLiteral( "fid" ) }, - { QStringLiteral( "Value" ), QStringLiteral( "Name" ) }, + return model.data( model.index( row, 0 ), ValueRelationFeaturesModel::KeyColumn ).toString(); + }; + auto valAt = [&]( int row ) + { + return model.data( model.index( row, 0 ), ValueRelationFeaturesModel::ValueColumn ).toString(); }; - model.setConfig( config ); - model.setPair( pair ); - spy.wait(); + auto reload = [&]( const QVariantMap & config ) + { + spy.clear(); + model.setConfig( config ); + model.reloadFeatures(); + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - QCOMPARE( model.rowCount(), 9 ); - QCOMPARE( model.layer()->id(), subsubLayer->id() ); + // 1. OrderByKey ascending + QVariantMap config = baseConfig; + config[ QStringLiteral( "OrderByKey" ) ] = true; + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( keyAt( 0 ), QStringLiteral( "1" ) ); + QCOMPARE( keyAt( 1 ), QStringLiteral( "2" ) ); + QCOMPARE( keyAt( 2 ), QStringLiteral( "3" ) ); + QCOMPARE( keyAt( 3 ), QStringLiteral( "4" ) ); + + // 2. OrderByKey descending + config[ QStringLiteral( "OrderByDescending" ) ] = true; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( keyAt( 0 ), QStringLiteral( "4" ) ); + QCOMPARE( keyAt( 1 ), QStringLiteral( "3" ) ); + QCOMPARE( keyAt( 2 ), QStringLiteral( "2" ) ); + QCOMPARE( keyAt( 3 ), QStringLiteral( "1" ) ); + + // 3. OrderByValue ascending + config = baseConfig; + config[ QStringLiteral( "OrderByValue" ) ] = true; + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Alpha" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Gamma" ) ); + + // 4. OrderByField with an explicit field name — same result as 3, different code path + config = baseConfig; + config[ QStringLiteral( "OrderByField" ) ] = true; + config[ QStringLiteral( "OrderByFieldName" ) ] = QStringLiteral( "label" ); + config[ QStringLiteral( "OrderByDescending" ) ] = false; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Alpha" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Gamma" ) ); + + // 5. OrderByField descending + config[ QStringLiteral( "OrderByDescending" ) ] = true; + reload( config ); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( valAt( 0 ), QStringLiteral( "Gamma" ) ); + QCOMPARE( valAt( 1 ), QStringLiteral( "Delta" ) ); + QCOMPARE( valAt( 2 ), QStringLiteral( "Beta" ) ); + QCOMPARE( valAt( 3 ), QStringLiteral( "Alpha" ) ); + + QgsProject::instance()->removeAllMapLayers(); +} - QCOMPARE( model.rowCount(), 9 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureId ), 1 ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureId ), 2 ); - QCOMPARE( model.data( model.index( 2, 0 ), FeaturesModel::ModelRoles::FeatureId ), 3 ); - QCOMPARE( model.data( model.index( 3, 0 ), FeaturesModel::ModelRoles::FeatureId ), 4 ); - QCOMPARE( model.data( model.index( 4, 0 ), FeaturesModel::ModelRoles::FeatureId ), 5 ); - QCOMPARE( model.data( model.index( 5, 0 ), FeaturesModel::ModelRoles::FeatureId ), 6 ); - QCOMPARE( model.data( model.index( 6, 0 ), FeaturesModel::ModelRoles::FeatureId ), 7 ); - QCOMPARE( model.data( model.index( 7, 0 ), FeaturesModel::ModelRoles::FeatureId ), 8 ); - QCOMPARE( model.data( model.index( 8, 0 ), FeaturesModel::ModelRoles::FeatureId ), 100000000 ); +void TestModels::testValueRelationSearch() +{ + /* Tests that search applies only to the value (label) column, not the key. + * + * Layer: + * key=1 label="Alpha" — key contains "1", label does not + * key=2 label="Val1" — label contains "1", key does not + * key=100 label="Gamma" — key contains "1", label does not + * + * Search "1" must return only "Val1" — proving that key=1 and key=100 are + * ignored because buildSearchExpression() filters on the value field only. + */ + QgsProject::instance()->removeAllMapLayers(); + + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_search" ), + QStringLiteral( "memory" ) + ); + QVERIFY( layer && layer->isValid() ); - // enable order by value for the value relation - model.reset(); - config[ QStringLiteral( "OrderByValue" ) ] = true; - model.setConfig( config ); - model.setPair( pair ); + struct Row { int key; QString label; }; + const QList rows = { {1, QStringLiteral( "Alpha" )}, {2, QStringLiteral( "Val1" )}, {100, QStringLiteral( "Gamma" )} }; - spy.wait(); + QgsFeatureList features; + for ( const auto &row : rows ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), row.key ); + f.setAttribute( QStringLiteral( "label" ), row.label ); + features << f; + } + layer->dataProvider()->addFeatures( features ); + QCOMPARE( layer->featureCount(), ( long long ) 3 ); + QgsProject::instance()->addMapLayer( layer ); + + const QVariantMap config = + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "OrderByValue" ), true } + }; - QCOMPARE( model.rowCount(), 9 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A2" ) ); - QCOMPARE( model.data( model.index( 2, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "B1" ) ); - QCOMPARE( model.data( model.index( 3, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "B2" ) ); - QCOMPARE( model.data( model.index( 4, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "C1" ) ); - QCOMPARE( model.data( model.index( 5, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "C2" ) ); - QCOMPARE( model.data( model.index( 6, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "D1" ) ); - QCOMPARE( model.data( model.index( 7, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "D2" ) ); - QCOMPARE( model.data( model.index( 8, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "VERYBIG" ) ); + ValueRelationFeaturesModel model; + QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + auto waitForReload = [&]() + { + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - // add a search expression to model - model.setSearchExpression( QStringLiteral( "D" ) ); + model.setConfig( config ); - spy.wait(); - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "D1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "D2" ) ); + // Initial load: all 3 features + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); - // add a filter expression to the model - config[ QStringLiteral( "FilterExpression" ) ] = "subFk = 1"; - model.setConfig( config ); + // Search "1": only "Val1" matches (value column); key=1 and key=100 are ignored + spy.clear(); + model.setSearchExpression( QStringLiteral( "1" ) ); + waitForReload(); + QCOMPARE( model.rowCount(), 1 ); + QCOMPARE( model.data( model.index( 0, 0 ), ValueRelationFeaturesModel::ValueColumn ).toString(), + QStringLiteral( "Val1" ) ); + + // Clear search: all 3 features again + spy.clear(); model.setSearchExpression( QString() ); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); - spy.wait(); + // Search with no match + spy.clear(); + model.setSearchExpression( QStringLiteral( "xyz" ) ); + waitForReload(); + QCOMPARE( model.rowCount(), 0 ); - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A1" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A2" ) ); + QgsProject::instance()->removeAllMapLayers(); +} - // remove sorting - model.reset(); - config.remove( QStringLiteral( "OrderByValue" ) ); - model.setConfig( config ); - model.setPair( pair ); +void TestModels::testValueRelationHotreload() +{ + /* Tests that calling reloadFeatures() after modifying the underlying layer + * triggers hot reload -> reloading after the drawer is already open + * Also tests auto-reload: LayerFeaturesModel connects featureAdded to populate(), + * so adding a feature during an editing session triggers an async reload. + */ + QgsProject::instance()->removeAllMapLayers(); + + QgsVectorLayer *layer = TestUtils::createVRLookupLayer( 3 ); + QVERIFY( layer && layer->isValid() ); + QgsProject::instance()->addMapLayer( layer ); - spy.wait(); + const QVariantMap config = + { + { QStringLiteral( "Layer" ), layer->id() }, + { QStringLiteral( "Key" ), QStringLiteral( "key" ) }, + { QStringLiteral( "Value" ), QStringLiteral( "label" ) }, + { QStringLiteral( "OrderByKey" ), true } + }; - QCOMPARE( model.rowCount(), 2 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A2" ) ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureTitle ), QLatin1String( "A1" ) ); + ValueRelationFeaturesModel model; + QSignalSpy spy( &model, &LayerFeaturesModel::fetchingResultsChanged ); + auto waitForReload = [&]() + { + while ( spy.count() < 2 ) + QVERIFY( spy.wait( 5000 ) ); + }; - // remove filters - model.reset(); - config.remove( QStringLiteral( "FilterExpression" ) ); model.setConfig( config ); - model.setPair( pair ); - spy.wait(); + // Initial load: 3 features + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 3 ); - QCOMPARE( model.rowCount(), 9 ); - QCOMPARE( model.data( model.index( 0, 0 ), FeaturesModel::ModelRoles::FeatureId ), 1 ); - QCOMPARE( model.data( model.index( 1, 0 ), FeaturesModel::ModelRoles::FeatureId ), 2 ); - QCOMPARE( model.data( model.index( 2, 0 ), FeaturesModel::ModelRoles::FeatureId ), 3 ); - QCOMPARE( model.data( model.index( 3, 0 ), FeaturesModel::ModelRoles::FeatureId ), 4 ); - QCOMPARE( model.data( model.index( 4, 0 ), FeaturesModel::ModelRoles::FeatureId ), 5 ); - QCOMPARE( model.data( model.index( 5, 0 ), FeaturesModel::ModelRoles::FeatureId ), 6 ); - QCOMPARE( model.data( model.index( 6, 0 ), FeaturesModel::ModelRoles::FeatureId ), 7 ); - QCOMPARE( model.data( model.index( 7, 0 ), FeaturesModel::ModelRoles::FeatureId ), 8 ); - QCOMPARE( model.data( model.index( 8, 0 ), FeaturesModel::ModelRoles::FeatureId ), 100000000 ); + // Add a feature directly to the provider + QgsFeature newFeature( layer->fields() ); + newFeature.setAttribute( QStringLiteral( "key" ), 4 ); + newFeature.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label 4" ) ); + QVERIFY( layer->dataProvider()->addFeatures( QgsFeatureList() << newFeature ) ); + + // Manual hot reload: reloadFeatures() must pick up the added feature + spy.clear(); + model.reloadFeatures(); + waitForReload(); + QCOMPARE( model.rowCount(), 4 ); + QCOMPARE( model.data( model.index( 3, 0 ), ValueRelationFeaturesModel::KeyColumn ).toString(), + QStringLiteral( "4" ) ); + + // Auto-reload via featureAdded signal: + // startEditing + addFeature fires featureAdded, which LayerFeaturesModel connects to populate(). + // addFeature and commitChanges can each trigger a populate cycle (2 emissions each); + // wait until fetching has stopped (last emission is false) to avoid reading rowCount mid-flight. + spy.clear(); + layer->startEditing(); + QgsFeature editFeature( layer->fields() ); + editFeature.setAttribute( QStringLiteral( "key" ), 5 ); + editFeature.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label 5" ) ); + QVERIFY( layer->addFeature( editFeature ) ); + QVERIFY( layer->commitChanges() ); + while ( spy.isEmpty() || spy.last().at( 0 ).toBool() ) + QVERIFY( spy.wait( 5000 ) ); + QCOMPARE( model.rowCount(), 5 ); + + QgsProject::instance()->removeAllMapLayers(); } void TestModels::testProjectsModel() diff --git a/app/test/testmodels.h b/app/test/testmodels.h index 57bbb09f1..a6ea309cf 100644 --- a/app/test/testmodels.h +++ b/app/test/testmodels.h @@ -23,7 +23,9 @@ class TestModels : public QObject void testStaticFeaturesModel(); void testLayerFeaturesModel(); void testLayerFeaturesModelSorted(); - void testValueRelationFeaturesModel(); + void testValueRelationOrdering(); + void testValueRelationSearch(); + void testValueRelationHotreload(); void testProjectsModel(); void testProjectsProxyModel(); diff --git a/app/test/testutils.cpp b/app/test/testutils.cpp index 3c65c7ccb..96e684201 100644 --- a/app/test/testutils.cpp +++ b/app/test/testutils.cpp @@ -21,6 +21,7 @@ #include "qgsvectorlayer.h" #include "qgsproject.h" +#include "qgsfeature.h" #include "qgslayertree.h" #include "qgslayertreelayer.h" @@ -379,6 +380,61 @@ void TestUtils::testIsValidUrl() QVERIFY( !InputUtils::isValidUrl( "" ) ); // empty url is considered valid by QUrl but not by us } +QgsVectorLayer *TestUtils::createVRLookupLayer( int count ) +{ + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_lookup" ), + QStringLiteral( "memory" ) + ); + if ( !layer || !layer->isValid() ) + return nullptr; + + QgsFeatureList features; + features.reserve( count ); + for ( int i = 1; i <= count; ++i ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), i ); + f.setAttribute( QStringLiteral( "label" ), QStringLiteral( "Label %1" ).arg( i ) ); + features << f; + } + layer->dataProvider()->addFeatures( features ); + return layer; +} + +QgsVectorLayer *TestUtils::createVROrderingLayer() +{ + auto *layer = new QgsVectorLayer( + QStringLiteral( "None?field=key:integer&field=label:string" ), + QStringLiteral( "vr_ordering" ), + QStringLiteral( "memory" ) + ); + if ( !layer || !layer->isValid() ) + return nullptr; + + struct Row { int key; QString label; }; + const QList rows = + { + {3, QStringLiteral( "Gamma" )}, + {1, QStringLiteral( "Alpha" )}, + {4, QStringLiteral( "Beta" )}, + {2, QStringLiteral( "Delta" )} + }; + + QgsFeatureList features; + features.reserve( rows.size() ); + for ( const auto &row : rows ) + { + QgsFeature f( layer->fields() ); + f.setAttribute( QStringLiteral( "key" ), row.key ); + f.setAttribute( QStringLiteral( "label" ), row.label ); + features << f; + } + layer->dataProvider()->addFeatures( features ); + return layer; +} + bool TestUtils::testExifPositionMetadataExists( const QString &imageSource ) { if ( !QFileInfo::exists( imageSource ) ) diff --git a/app/test/testutils.h b/app/test/testutils.h index 8643248fa..a5819f10c 100644 --- a/app/test/testutils.h +++ b/app/test/testutils.h @@ -49,6 +49,19 @@ namespace TestUtils QgsProject *loadPlanesTestProject(); + /** + * Creates an in-memory no-geometry lookup layer with \a count features. + * Fields: key (integer), label (string). + */ + QgsVectorLayer *createVRLookupLayer( int count ); + + /** + * Creates an in-memory no-geometry lookup layer for ordering tests. + * Features are inserted in an order that is neither key-sorted nor label-sorted: + * key=3 label="Gamma", key=1 label="Alpha", key=4 label="Beta", key=2 label="Delta" + */ + QgsVectorLayer *createVROrderingLayer(); + /** * Generates files and folders in rootPath based on json structure. * \param structure is a json instance, each object is considered as folder. Each folder can have a key named "files" diff --git a/app/test/testutilsfunctions.cpp b/app/test/testutilsfunctions.cpp index 2a55d201b..7ac5d6302 100644 --- a/app/test/testutilsfunctions.cpp +++ b/app/test/testutilsfunctions.cpp @@ -341,12 +341,12 @@ void TestUtilsFunctions::resolveTargetDir() FeatureLayerPair pair; // Can be dummy pair // case 1: empty config, no expression - QString resultDir = mUtils->resolveTargetDir( homePath, config, pair, activeProject ); + QString resultDir = mUtils->resolveTargetDir( homePath, config, pair, FeatureLayerPair(), activeProject ); QCOMPARE( resultDir, homePath ); // case 2: defined default root config, no expression config.insert( QStringLiteral( "DefaultRoot" ), DEFAULT_ROOT ); - QString resultDir2 = mUtils->resolveTargetDir( homePath, config, pair, activeProject ); + QString resultDir2 = mUtils->resolveTargetDir( homePath, config, pair, FeatureLayerPair(), activeProject ); QCOMPARE( resultDir2, DEFAULT_ROOT ); config.clear(); @@ -361,7 +361,7 @@ void TestUtilsFunctions::resolveTargetDir() collection.insert( QStringLiteral( "properties" ), props ); config.insert( QStringLiteral( "PropertyCollection" ), collection ); - QString resultDir3 = mUtils->resolveTargetDir( homePath, config, pair, QgsProject::instance() ); + QString resultDir3 = mUtils->resolveTargetDir( homePath, config, pair, FeatureLayerPair(), QgsProject::instance() ); QCOMPARE( resultDir3, QStringLiteral( "%1/photos" ).arg( projectDir ) ); } diff --git a/app/valuerelationcontroller.cpp b/app/valuerelationcontroller.cpp new file mode 100644 index 000000000..032042a6c --- /dev/null +++ b/app/valuerelationcontroller.cpp @@ -0,0 +1,349 @@ +/*************************************************************************** + * * + * 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 "valuerelationcontroller.h" +#include "coreutils.h" + +#include "qgsproject.h" +#include "qgsfeedback.h" +#include "qgsvaluerelationfieldformatter.h" +#include "qgsexpressioncontextutils.h" +#include "qgsvectorlayerfeatureiterator.h" + +#include + +using namespace Qt::Literals; + +ValueRelationController::ValueRelationController( QObject *parent ) + : QObject( parent ) +{ + connect( &mLookupWatcher, &QFutureWatcher::finished, + this, &ValueRelationController::onLookupFinished ); +} + +ValueRelationController::~ValueRelationController() +{ + // Cancel any in-flight fetch before the watcher and feedback are destroyed. + if ( auto fb = mLastLookupFeedback.lock() ) + { + fb->cancel(); + } + mLookupWatcher.waitForFinished(); +} + +QStringList ValueRelationController::qgisFormatToArray( const QVariant &qgsValue ) const +{ + if ( !mIsInitialized ) + { + CoreUtils::log( u"Value Relation"_s, u"Attempted to convert QGIS format to array, but the class is not initialized!"_s ); + return {}; + } + + if ( qgsValue.isNull() || qgsValue.toString().isEmpty() ) + return QStringList(); + + if ( mIsMultiSelection ) + { + const QString str = qgsValue.toString().trimmed(); + + if ( str.startsWith( '{' ) || str.startsWith( '[' ) ) + { + return QgsValueRelationFieldFormatter::valueToStringList( qgsValue ); + } + } + + return QStringList() << qgsValue.toString(); +} + +QString ValueRelationController::arrayToQgisFormat( const QStringList &keys ) const +{ + if ( !mIsInitialized ) + { + CoreUtils::log( u"Value Relation"_s, u"Attempted to convert array to QGIS format, but the class is not initialized!"_s ); + return {}; + } + + // empty -> empty + if ( keys.isEmpty() ) + return {}; + + if ( mIsMultiSelection ) + { + return QString( "{%1}" ).arg( keys.join( ',' ) ); + } + else + { + return keys.at( 0 ); + } +} + +void ValueRelationController::lookupDisplayTextOnValueChanged( const QString ¤tValue ) +{ + lookupDisplayTextAsync( currentValue ); +} + +void ValueRelationController::lookupDisplayTextOnHotreload( const QString ¤tValue, const QgsFeature &feature ) +{ + if ( mFilterExpression.isEmpty() ) + { + return; // no hotreload for fields without filter expression + } + + lookupDisplayTextAsync( currentValue, true, feature ); +} + +void ValueRelationController::lookupDisplayTextAsync( const QString ¤tValue, bool useFilterExpression, const QgsFeature &feature ) +{ + if ( !mIsInitialized || !mTargetLayer ) + { + CoreUtils::log( u"Value Relation"_s, u"Called lookupDisplayTextAsync, but the class is not initialized or layer is invalid!"_s ); + return; + } + + const QStringList keys = qgisFormatToArray( currentValue ); + + if ( keys.isEmpty() ) + { + setDisplayText( {} ); + return; + } + + // Cancel any in-flight lookup before creating a new one + if ( auto fb = mLastLookupFeedback.lock() ) + { + fb->cancel(); + mLookupWatcher.waitForFinished(); + } + + auto feedback = std::make_shared(); + mLastLookupFeedback = feedback; // weak_ptr + + // + // Build filter expression: key IN (k1, k2, ...) + // + + const QgsField keyFieldDef = mTargetLayer->fields().field( mTargetLayerKeyFieldIndex ); + + // Keys come from QML as strings, we might need to convert them to numbers + QStringList quotedKeys; + quotedKeys.reserve( keys.size() ); + for ( const QString &k : keys ) + { + QVariant typedKey( k ); + if ( keyFieldDef.isNumeric() ) + { + bool ok = false; + const qlonglong numVal = k.toLongLong( &ok ); + if ( ok ) + { + typedKey = QVariant( numVal ); + } + } + quotedKeys << QgsExpression::quotedValue( typedKey ); + } + + const QString keyExpr = QString( "%1 IN (%2)" ).arg( QgsExpression::quotedColumnRef( mTargetLayerKeyField ), quotedKeys.join( ',' ) ); + + QgsFeatureRequest request; + request.setFilterExpression( keyExpr ); + + if ( useFilterExpression && !mFilterExpression.isEmpty() ) + { + request.combineFilterExpression( mFilterExpression ); + + QgsExpressionContext ctx( QgsExpressionContextUtils::globalProjectLayerScopes( mTargetLayer ) ); + + if ( feature.isValid() && QgsValueRelationFieldFormatter::expressionRequiresFormScope( mFilterExpression ) ) + { + ctx.appendScope( QgsExpressionContextUtils::formScope( feature ) ); + } + + request.setExpressionContext( ctx ); + + mLastLookupReason = LookupReason::HotReload; + } + else + { + mLastLookupReason = LookupReason::ValueChanged; + } + + request.setFlags( Qgis::FeatureRequestFlag::NoGeometry ); + request.setSubsetOfAttributes( QgsAttributeList() << mTargetLayerKeyFieldIndex << mTargetLayerValueFieldIndex ); + request.setLimit( keys.size() ); + + request.setFeedback( feedback.get() ); + + // QgsVectorLayerFeatureSource is a thread-safe snapshot; ownership passed to the background thread + QgsVectorLayerFeatureSource *s = new QgsVectorLayerFeatureSource( mTargetLayer ); + mLookupWatcher.setFuture( QtConcurrent::run( &ValueRelationController::_performLookup, s, request, std::move( feedback ) ) ); +} + +QgsFeatureList ValueRelationController::_performLookup( QgsVectorLayerFeatureSource *source, QgsFeatureRequest req, std::shared_ptr feedback ) +{ + std::unique_ptr fs( source ); + QgsFeatureList features; + + QgsFeatureIterator it = fs->getFeatures( req ); + QgsFeature f; + while ( it.nextFeature( f ) ) + { + if ( feedback->isCanceled() ) + break; + features << f; + } + + return features; +} + +void ValueRelationController::onLookupFinished() +{ + const QgsFeatureList features = mLookupWatcher.result(); + + QStringList displayValues; + displayValues.reserve( features.size() ); + + for ( const QgsFeature &f : features ) + { + displayValues << f.attribute( mTargetLayerValueFieldIndex ).toString(); + } + + // + // This logic is not well-optimized for scenarios when you receive just + // a subset of values, e.g. lookup of "fid" IN (1,2,3) would return just + // two results - we do not invalidate the third one. + // + + if ( !displayValues.isEmpty() ) + { + setDisplayText( displayValues.join( u", "_s ) ); + return; + } + + if ( mLastLookupReason == LookupReason::HotReload ) + { + if ( mIsEditable ) + { + emit invalidateSelection(); // will reset display text to "" on the next lookup + } + // Intentionally no else branch here - if this field is not editable, we do not clear out the previous text + } + else + { + // + // Value changed, but it could not be found in the target layer, + // we show the raw value instead, see https://github.com/MerginMaps/mobile/issues/2148 + // + setDisplayText( {} ); + emit presentRawValue(); + } +} + +void ValueRelationController::clearLayer() +{ + if ( mTargetLayer ) + { + disconnect( mTargetLayer, nullptr, this, nullptr ); + mTargetLayer = nullptr; + } + + // Cancel any in-flight fetch — its result will be discarded by the session check. + if ( auto fb = mLastLookupFeedback.lock() ) + { + fb->cancel(); + } + + mIsInitialized = false; +} + +void ValueRelationController::setup() +{ + clearLayer(); + + if ( mConfig.isEmpty() ) + return; + + QgsVectorLayer *layer = QgsValueRelationFieldFormatter::resolveLayer( mConfig, QgsProject::instance() ); + if ( !layer || !layer->isValid() || layer->fields().isEmpty() ) + { + CoreUtils::log( u"ValueRelationController"_s, u"Missing or invalid referenced layer."_s ); + return; + } + + const QString keyFieldName = mConfig.value( u"Key"_s ).toString(); + const QString valueFieldName = mConfig.value( u"Value"_s ).toString(); + + if ( layer->fields().indexOf( keyFieldName ) < 0 || layer->fields().indexOf( valueFieldName ) < 0 ) + { + CoreUtils::log( u"ValueRelationController"_s, u"Missing referenced fields for value relations."_s ); + return; + } + + mTargetLayer = layer; + mTargetLayerKeyField = keyFieldName; + mTargetLayerKeyFieldIndex = layer->fields().indexOf( keyFieldName ); + mTargetLayerValueFieldIndex = layer->fields().indexOf( valueFieldName ); + + mFilterExpression = mConfig.value( u"FilterExpression"_s ).toString(); + + mIsMultiSelection = mConfig.value( u"AllowMulti"_s ).toBool(); + + mIsInitialized = true; + + emit isMultiSelectionChanged(); + + connect( mTargetLayer, &QgsMapLayer::willBeDeleted, this, &ValueRelationController::clearLayer ); +} + +QVariantMap ValueRelationController::config() const +{ + return mConfig; +} + +void ValueRelationController::setConfig( const QVariantMap &newConfig ) +{ + if ( mConfig == newConfig ) + return; + + mConfig = newConfig; + emit configChanged(); + setup(); +} + +bool ValueRelationController::isEditable() const +{ + return mIsEditable; +} + +void ValueRelationController::setIsEditable( bool newIsEditable ) +{ + if ( mIsEditable != newIsEditable ) + { + mIsEditable = newIsEditable; + emit isEditableChanged(); + } +} + +bool ValueRelationController::isMultiSelection() const +{ + return mIsMultiSelection; +} + +const QString &ValueRelationController::displayText() const +{ + return mDisplayText; +} + +void ValueRelationController::setDisplayText( const QString &newText ) +{ + if ( mDisplayText != newText ) + { + mDisplayText = newText; + emit displayTextChanged(); + } +} \ No newline at end of file diff --git a/app/valuerelationcontroller.h b/app/valuerelationcontroller.h new file mode 100644 index 000000000..4be45734d --- /dev/null +++ b/app/valuerelationcontroller.h @@ -0,0 +1,129 @@ +/*************************************************************************** + * * + * 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 VALUERELATIONCONTROLLER_H +#define VALUERELATIONCONTROLLER_H + +#include "qgsvectorlayer.h" + +#include +#include +#include +#include + +class QgsVectorLayerFeatureSource; +class QgsFeature; + +class ValueRelationController : public QObject +{ + Q_OBJECT + + // in + Q_PROPERTY( QVariantMap config READ config WRITE setConfig NOTIFY configChanged ) + Q_PROPERTY( bool isEditable READ isEditable WRITE setIsEditable NOTIFY isEditableChanged ) + + // out + Q_PROPERTY( bool isMultiSelection READ isMultiSelection NOTIFY isMultiSelectionChanged ) + Q_PROPERTY( QString displayText READ displayText NOTIFY displayTextChanged ) + + public: + + enum LookupReason + { + ValueChanged = 0, + HotReload + }; + Q_ENUM( LookupReason ); + + explicit ValueRelationController( QObject *parent = nullptr ); + ~ValueRelationController() override; + + /** + * Parses a QGIS value-relation wire value into a list of key strings. + * allowMulti=true → "{1,2,3}" becomes ["1","2","3"] + * allowMulti=false → "1" becomes ["1"] + * An empty or null input always returns an empty list. + */ + Q_INVOKABLE QStringList qgisFormatToArray( const QVariant &qgsValue ) const; + + /** + * Formats a list of key strings into the QGIS wire value "{k1,k2,...}". + * allowMulti=true → "["1","2","3"]" becomes ["1","2","3"] + * allowMulti=true → "["1"]" becomes "1" + * allowMulti=false → "1" becomes "1" + * An empty list produces "". + */ + Q_INVOKABLE QString arrayToQgisFormat( const QStringList &keys ) const; + + /** + * Starts an async fetch to resolve display label(s). + * Returns immediately; results are delivered via displayValuesReady(). + * + * Emits invalidate() when fieldValue is non-null/non-empty but + * no matching features are found AND a FilterExpression is configured. + */ + Q_INVOKABLE void lookupDisplayTextOnValueChanged( const QString ¤tValue ); + + + Q_INVOKABLE void lookupDisplayTextOnHotreload( const QString ¤tValue, const QgsFeature &feature ); + + QVariantMap config() const; + void setConfig( const QVariantMap &newConfig ); + + bool isEditable() const; + void setIsEditable( bool newIsEditable ); + + bool isMultiSelection() const; + const QString &displayText() const; + + signals: + void invalidateSelection(); // the value should reset as it is longer available (due to drill-down forms) + void presentRawValue(); // the value should reset as it is longer available (due to drill-down forms) + + void configChanged(); + void isEditableChanged(); + + void isMultiSelectionChanged(); + void displayTextChanged(); + + private slots: + void onLookupFinished(); + + private: + void lookupDisplayTextAsync( const QString ¤tValue, bool useFilterExpression = false, const QgsFeature &feature = QgsFeature() ); + void setDisplayText( const QString &newDisplayText ); + + void setup(); + void clearLayer(); + + // Runs on background thread. Takes ownership of the feature source. + static QgsFeatureList _performLookup( QgsVectorLayerFeatureSource *s, QgsFeatureRequest r, std::shared_ptr f ); + + QVariantMap mConfig; + QgsVectorLayer *mTargetLayer = nullptr; + + QString mTargetLayerKeyField; + int mTargetLayerKeyFieldIndex = -1; + int mTargetLayerValueFieldIndex = -1; + + bool mIsEditable = true; + + bool mIsInitialized = false; + bool mIsMultiSelection = false; + + QString mDisplayText; + QString mFilterExpression; + + QFutureWatcher mLookupWatcher; + std::weak_ptr mLastLookupFeedback; + + LookupReason mLastLookupReason = LookupReason::ValueChanged; +}; + +#endif // VALUERELATIONCONTROLLER_H diff --git a/app/valuerelationfeaturesmodel.cpp b/app/valuerelationfeaturesmodel.cpp index 94af425bd..6bc543d3f 100644 --- a/app/valuerelationfeaturesmodel.cpp +++ b/app/valuerelationfeaturesmodel.cpp @@ -12,6 +12,10 @@ #include "qgsvaluerelationfieldformatter.h" #include "qgsexpressioncontextutils.h" +#include "qgsvectorlayer.h" + +using namespace Qt::Literals; + ValueRelationFeaturesModel::ValueRelationFeaturesModel( QObject *parent ) : LayerFeaturesModel( parent ) @@ -20,209 +24,159 @@ ValueRelationFeaturesModel::ValueRelationFeaturesModel( QObject *parent ) ValueRelationFeaturesModel::~ValueRelationFeaturesModel() = default; -void ValueRelationFeaturesModel::setupFeatureRequest( QgsFeatureRequest &request ) +QVariant ValueRelationFeaturesModel::data( const QModelIndex &index, int role ) const { - LayerFeaturesModel::setupFeatureRequest( request ); - - if ( !mFilterExpression.isEmpty() ) - { - request.combineFilterExpression( mFilterExpression ); + if ( !index.isValid() ) + return QVariant(); - // create context for filter expression - if ( QgsValueRelationFieldFormatter::expressionIsUsable( mFilterExpression, mPair.feature() ) ) - { - QgsExpression exp( mFilterExpression ); - QgsExpressionContext filterContext = QgsExpressionContext( QgsExpressionContextUtils::globalProjectLayerScopes( LayerFeaturesModel::layer() ) ); + const int row = index.row(); + if ( row < 0 || row >= mFeatures.count() ) + return QVariant(); - if ( mPair.feature().isValid() && QgsValueRelationFieldFormatter::expressionRequiresFormScope( mFilterExpression ) ) - filterContext.appendScope( QgsExpressionContextUtils::formScope( mPair.feature() ) ); + if ( role == KeyColumn ) + return mFeatures.at( row ).feature().attribute( mKeyFieldIndex ).toString(); - request.setExpressionContext( filterContext ); - } - } + if ( role == ValueColumn ) + return mFeatures.at( row ).feature().attribute( mValueFieldIndex ).toString(); - if ( mConfig.value( QStringLiteral( "OrderByValue" ) ).toBool() ) - { - // replace any existing order by clause with our value field - request.setOrderBy( QgsFeatureRequest::OrderBy( { QgsFeatureRequest::OrderByClause( mTitleField ) } ) ); - } + return LayerFeaturesModel::data( index, role ); } QHash ValueRelationFeaturesModel::roleNames() const { QHash roles = LayerFeaturesModel::roleNames(); - roles[KeyRole] = QStringLiteral( "Key" ).toLatin1(); - + roles[KeyColumn] = QByteArrayLiteral( "KeyColumn" ); + roles[ValueColumn] = QByteArrayLiteral( "ValueColumn" ); return roles; } -QVariant ValueRelationFeaturesModel::data( const QModelIndex &index, int role ) const +void ValueRelationFeaturesModel::setupFeatureRequest( QgsFeatureRequest &request ) { - int row = index.row(); - if ( row < 0 || row >= mFeatures.count() ) - return QVariant(); - - if ( !index.isValid() ) - return QVariant(); - - const FeatureLayerPair pair = mFeatures.at( index.row() ); - - if ( role == KeyRole ) - { - return pair.feature().attribute( mKeyField ); - } + LayerFeaturesModel::setupFeatureRequest( request ); - return LayerFeaturesModel::data( index, role ); -} + // minimal subset of attributes -void ValueRelationFeaturesModel::setup() -{ - if ( mConfig.isEmpty() ) - return; + request.setSubsetOfAttributes( QgsAttributeList() << mKeyFieldIndex << mValueFieldIndex ); + request.setFlags( Qgis::FeatureRequestFlag::NoGeometry ); - QgsVectorLayer *layer = QgsValueRelationFieldFormatter::resolveLayer( mConfig, QgsProject::instance() ); + // filter expression - if ( layer && layer->fields().size() != 0 ) + if ( !mFilterExpression.isEmpty() && mPair.isValid() ) { - QgsFields fields = layer->fields(); - - QString keyFieldName = mConfig.value( QStringLiteral( "Key" ) ).toString(); - QString valueFieldName = mConfig.value( QStringLiteral( "Value" ) ).toString(); + request.combineFilterExpression( mFilterExpression ); - if ( fields.indexOf( keyFieldName ) >= 0 && fields.indexOf( valueFieldName ) >= 0 ) + if ( QgsValueRelationFieldFormatter::expressionIsUsable( mFilterExpression, mPair.feature() ) ) { - mKeyField = keyFieldName; - mTitleField = valueFieldName; + QgsExpressionContext ctx( QgsExpressionContextUtils::globalProjectLayerScopes( layer() ) ); - mFilterExpression = mConfig.value( QStringLiteral( "FilterExpression" ) ).toString(); - LayerFeaturesModel::setLayer( layer ); + if ( mPair.feature().isValid() && QgsValueRelationFieldFormatter::expressionRequiresFormScope( mFilterExpression ) ) + { + ctx.appendScope( QgsExpressionContextUtils::formScope( mPair.feature() ) ); + } - mAllowMulti = mConfig.value( QStringLiteral( "AllowMulti" ) ).toBool(); - mIsInitialized = true; + request.setExpressionContext( ctx ); } - else - CoreUtils::log( QStringLiteral( "ValueRelations" ), QStringLiteral( "Missing referenced fields for value relations." ) ); } - else - CoreUtils::log( QStringLiteral( "ValueRelations" ), QStringLiteral( "Missing referenced layer for value relations." ) ); -} -void ValueRelationFeaturesModel::reset() -{ - mKeyField.clear(); - mTitleField.clear(); - mPair = FeatureLayerPair(); - mConfig = QVariantMap(); - mIsInitialized = false; - LayerFeaturesModel::reset(); + // order + + request.setOrderBy( QgsFeatureRequest::OrderBy( { QgsFeatureRequest::OrderByClause( mOrderByField, mOrderByAsc, false ) } ) ); + + // limit + + request.setLimit( VR_FEATURES_LIMIT ); } -QVariant ValueRelationFeaturesModel::featureTitle( const FeatureLayerPair &pair ) const +QString ValueRelationFeaturesModel::buildSearchExpression() { - if ( !mTitleField.isEmpty() ) + // Let's search only in the value column, this is a minimal approach compared to the base class implementation + const QString searchExpr = searchExpression().trimmed(); + + if ( searchExpr.isEmpty() ) { - return pair.feature().attribute( mTitleField ); + return {}; } - return LayerFeaturesModel::featureTitle( pair ); + return u"(%1 ILIKE '%%2%')"_s.arg( QgsExpression::quotedColumnRef( mValueField ), searchExpr ); } -QVariant ValueRelationFeaturesModel::convertToKey( const QVariant &id ) +void ValueRelationFeaturesModel::setup() { - QgsFeature f = convertRoleValue( FeaturesModel::FeatureId, id, Feature ).value(); - return f.attribute( mKeyField ); -} + mIsInitialized = false; -QVariant ValueRelationFeaturesModel::convertToQgisType( const QVariantList &featureIds ) -{ - if ( !mIsInitialized ) - { - return QVariant(); - } + if ( mConfig.isEmpty() ) + return; - QVariant qgsFormat; + QgsVectorLayer *vLayer = QgsValueRelationFieldFormatter::resolveLayer( mConfig, QgsProject::instance() ); - QStringList keys; - for ( const QVariant &id : featureIds ) + if ( !vLayer || !vLayer->isValid() || vLayer->fields().isEmpty() ) { - keys << convertToKey( id ).toString(); + CoreUtils::log( u"Value Relation"_s, u"Missing or invalid referenced layer"_s ); + return; } - qgsFormat = QStringLiteral( "{%1}" ).arg( keys.join( ',' ) ); + const QString keyFieldName = mConfig.value( u"Key"_s ).toString(); + const QString valueFieldName = mConfig.value( u"Value"_s ).toString(); - return qgsFormat; -} - -QVariant ValueRelationFeaturesModel::convertFromQgisType( QVariant qgsValue, ModelRoles toRole ) -{ - if ( !mIsInitialized ) + if ( vLayer->fields().indexOf( keyFieldName ) < 0 || vLayer->fields().indexOf( valueFieldName ) < 0 ) { - return QVariant(); + CoreUtils::log( u"ValueRelationFeaturesModel"_s, u"Missing referenced fields for value relations."_s ); + return; } - QStringList keyList; + mKeyField = keyFieldName; + mValueField = valueFieldName; + mKeyFieldIndex = vLayer->fields().indexOf( keyFieldName ); + mValueFieldIndex = vLayer->fields().indexOf( valueFieldName ); - if ( mAllowMulti ) - { - keyList = QgsValueRelationFieldFormatter::valueToStringList( qgsValue ); - } - else - { - keyList << qgsValue.toString(); - } + mFilterExpression = mConfig.value( u"FilterExpression"_s ).toString(); + + // setLayer() internally resets mAttributeList to all fields, so we must + // override it afterwards with only the two columns we actually need. + LayerFeaturesModel::setLayer( vLayer ); - QList roleList; + mAttributeList = { mKeyFieldIndex, mValueFieldIndex }; - // optimize it a little bit - QMap keyMap; - for ( const QString &key : keyList ) + mOrderByAsc = !mConfig.value( u"OrderByDescending"_s ).toBool(); + + if ( mConfig.value( u"OrderByKey"_s ).toBool() ) { - keyMap.insert( key, QLatin1String() ); + mOrderByField = mKeyField; } - - for ( int ix = 0; ix < rowCount(); ++ix ) + else if ( mConfig.value( u"OrderByField"_s ).toBool() ) { - QgsFeature f = mFeatures.at( ix ).feature(); - - if ( keyMap.contains( f.attribute( mKeyField ).toString() ) ) + QString fieldToOrderBy = mConfig.value( u"OrderByFieldName"_s ).toString(); + if ( fieldToOrderBy.isEmpty() ) { - if ( toRole == FeatureId ) - roleList.append( f.id() ); - else - { - QVariant attr = convertRoleValue( FeatureId, f.id(), toRole ); - if ( !attr.isNull() ) - roleList.append( attr ); - } + CoreUtils::log( u"Value Relation"_s, u"Requested to order results by field, but the field name is empty"_s ); } - } - if ( roleList.isEmpty() && !qgsValue.isNull() ) + mOrderByField = fieldToOrderBy; + } + else { - // could not convert qgs value - emit invalidate(); + // let's use "OrderByValue" by default + mOrderByField = mValueField; } - return roleList; -} + mIsInitialized = true; -FeatureLayerPair ValueRelationFeaturesModel::pair() const -{ - return mPair; + // Note: populate() is intentionally NOT called here. + // The QML drawer calls it explicitly in Component.onCompleted so that features + // are only fetched when the user actually opens the drawer. } -void ValueRelationFeaturesModel::setPair( const FeatureLayerPair &newPair ) +void ValueRelationFeaturesModel::reset() { - if ( mPair == newPair ) - return; - - mPair = newPair; - emit pairChanged( mPair ); - - if ( mIsInitialized ) - { - populate(); - } + mKeyField.clear(); + mValueField.clear(); + mKeyFieldIndex = -1; + mValueFieldIndex = -1; + mFilterExpression.clear(); + mConfig = QVariantMap(); + mPair = FeatureLayerPair(); + mIsInitialized = false; + LayerFeaturesModel::reset(); } QVariantMap ValueRelationFeaturesModel::config() const @@ -240,3 +194,19 @@ void ValueRelationFeaturesModel::setConfig( const QVariantMap &newConfig ) setup(); } + +FeatureLayerPair ValueRelationFeaturesModel::pair() const +{ + return mPair; +} + +void ValueRelationFeaturesModel::setPair( const FeatureLayerPair &newPair ) +{ + if ( mPair == newPair ) + return; + + mPair = newPair; + emit pairChanged( mPair ); + // No automatic repopulation — the pair is set once at drawer-open time. + // reloadFeatures() is called separately by Component.onCompleted. +} diff --git a/app/valuerelationfeaturesmodel.h b/app/valuerelationfeaturesmodel.h index c4d4f8fb9..d760b07f7 100644 --- a/app/valuerelationfeaturesmodel.h +++ b/app/valuerelationfeaturesmodel.h @@ -14,27 +14,44 @@ #include "featurelayerpair.h" #include - +#include /** - * ValueRelationFeaturesModel class lists features from a specific layer regarding to a filterExpression of value relations. - * It is used as a model in ValueRelations QML editors. + * ValueRelationFeaturesModel backs the selection drawer in the value-relation + * drawers. + * + * Features are never loaded automatically, caller must call reloadFeatures() + * explicitly. + * + * The inherited searchExpression property triggers an async re-query with + * the user's search text combined with any FilterExpression (if a valid feature + * is provided). + * + * Model is reduced to load only KeyColumn and ValueColumn attributes. + * It searches only within ValueColumn attribute. */ class ValueRelationFeaturesModel : public LayerFeaturesModel { Q_OBJECT - Q_PROPERTY( FeatureLayerPair pair READ pair WRITE setPair NOTIFY pairChanged ) Q_PROPERTY( QVariantMap config READ config WRITE setConfig NOTIFY configChanged ) + /** + * Used solely during setupFeatureRequest() to build a form-scope expression + * context so that form-scoped filter expressions (e.g. current_value()) + * resolve correctly for drill-down / cascading value relations. + */ + Q_PROPERTY( FeatureLayerPair pair READ pair WRITE setPair NOTIFY pairChanged ) + public: - enum ValueRelationFeaturesModelRoles + enum ValueRelationRoles { - KeyRole = LayerFeaturesModel::LastRole + 1, // the key-column value - LastRole = KeyRole + KeyColumn = LayerFeaturesModel::LayerModelRoles::LastRole + 1, + ValueColumn = KeyColumn + 1, + LastRole = ValueColumn }; - Q_ENUM( ValueRelationFeaturesModelRoles ); + Q_ENUM( ValueRelationRoles ); explicit ValueRelationFeaturesModel( QObject *parent = nullptr ); ~ValueRelationFeaturesModel() override; @@ -45,35 +62,33 @@ class ValueRelationFeaturesModel : public LayerFeaturesModel void setup() override; void reset() override; void setupFeatureRequest( QgsFeatureRequest &request ) override; - QVariant featureTitle( const FeatureLayerPair &pair ) const override; + QString buildSearchExpression() override; - Q_INVOKABLE QVariant convertToKey( const QVariant &id ); - Q_INVOKABLE QVariant convertToQgisType( const QVariantList &featureIds ); // feature id -> key - Q_INVOKABLE QVariant convertFromQgisType( QVariant qgsValue, FeaturesModel::ModelRoles ); // key -> other role (feature id/title) + QVariantMap config() const; + void setConfig( const QVariantMap &newConfig ); FeatureLayerPair pair() const; void setPair( const FeatureLayerPair &newPair ); - QVariantMap config() const; - void setConfig( const QVariantMap &newConfig ); - signals: - void pairChanged( const FeatureLayerPair &pair ); void configChanged( const QVariantMap &config ); - void invalidate(); // invalidate signal is emitted when value to convert is not present in model + void pairChanged( const FeatureLayerPair &pair ); private: - QMap mCache; - QVariantMap mConfig; - FeatureLayerPair mPair; // feature layer pair that has opened the form + static constexpr int VR_FEATURES_LIMIT = 1000; - bool mAllowMulti = false; + QVariantMap mConfig; + FeatureLayerPair mPair; QString mKeyField; - QString mTitleField; + QString mValueField; + int mKeyFieldIndex = -1; + int mValueFieldIndex = -1; QString mFilterExpression; + bool mIsInitialized = false; - bool mIsInitialized = false; // model successfully read config and is ready to use + QString mOrderByField; + bool mOrderByAsc = false; }; #endif // VALUERELATIONFEATURESMODEL_H