From df5f31e4f7a3eb3098814291c3fe2e17870953ab Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 21 Aug 2026 10:20:47 +0200 Subject: [PATCH 1/9] feat(search): revive unified search Modernize the account-scoped search window with provider, date, and people filters, connected-service results, and aggregate and provider detail views. Introduce cancellable per-provider search state, stable reveal ordering, stale-response protection, pagination, scoped retries, and persistent keyboard selection. Align the search UI with shared wizard styling and add coverage for the search models, QML behavior, people lookup, and image handling. Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- resources.qrc | 2 + src/gui/activity/qml/ActivityItem.qml | 2 +- src/gui/activity/qml/ActivityList.qml | 2 +- src/gui/iconutils.cpp | 4 +- src/gui/owncloudgui.cpp | 2 + src/gui/search/CMakeLists.txt | 3 + src/gui/search/SearchWindow.qml | 501 ++++- .../search/UnifiedSearchInputContainer.qml | 123 +- .../search/UnifiedSearchResultDelegate.qml | 246 +++ src/gui/search/UnifiedSearchResultItem.qml | 36 +- src/gui/search/unifiedsearchpeoplemodel.cpp | 226 +++ src/gui/search/unifiedsearchpeoplemodel.h | 67 + src/gui/search/unifiedsearchresult.cpp | 12 + src/gui/search/unifiedsearchresult.h | 10 + .../search/unifiedsearchresultslistmodel.cpp | 1721 ++++++++++++----- .../search/unifiedsearchresultslistmodel.h | 267 ++- src/gui/wizard/qml/WizardButton.qml | 106 +- src/gui/wizard/qml/WizardChipButton.qml | 13 + src/gui/wizard/qml/WizardMenuItem.qml | 78 + test/CMakeLists.txt | 16 + .../com/nextcloud/desktopclient/Theme.qml | 16 + .../com/nextcloud/desktopclient/UserModel.qml | 12 + .../com/nextcloud/desktopclient/qmldir | 3 + test/qml/search/searchqmltestrunner.cpp | 43 + test/qml/search/testsearch.qml | 581 ++++++ test/testiconutils.cpp | 7 + test/testunifiedsearchlistmodel.cpp | 400 +++- test/testunifiedsearchpeoplemodel.cpp | 195 ++ theme.qrc.in | 3 + theme/Style/Style.qml | 12 +- theme/arrow-left.svg | 7 + theme/arrow-right.svg | 7 + theme/black/filter.svg | 3 + 33 files changed, 3919 insertions(+), 807 deletions(-) create mode 100644 src/gui/search/UnifiedSearchResultDelegate.qml create mode 100644 src/gui/search/unifiedsearchpeoplemodel.cpp create mode 100644 src/gui/search/unifiedsearchpeoplemodel.h create mode 100644 src/gui/wizard/qml/WizardChipButton.qml create mode 100644 src/gui/wizard/qml/WizardMenuItem.qml create mode 100644 test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml create mode 100644 test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml create mode 100644 test/qml/search/imports/com/nextcloud/desktopclient/qmldir create mode 100644 test/qml/search/searchqmltestrunner.cpp create mode 100644 test/qml/search/testsearch.qml create mode 100644 test/testunifiedsearchpeoplemodel.cpp create mode 100644 theme/arrow-left.svg create mode 100644 theme/arrow-right.svg create mode 100644 theme/black/filter.svg diff --git a/resources.qrc b/resources.qrc index bc09408ae841a..1aa1474620b7f 100644 --- a/resources.qrc +++ b/resources.qrc @@ -72,8 +72,10 @@ src/gui/wizard/qml/ServerPage.qml src/gui/wizard/qml/SyncOptionsPage.qml src/gui/wizard/qml/WizardButton.qml + src/gui/wizard/qml/WizardChipButton.qml src/gui/wizard/qml/WizardComboBox.qml src/gui/wizard/qml/WizardDialogFrame.qml + src/gui/wizard/qml/WizardMenuItem.qml src/gui/wizard/qml/WizardTextField.qml src/gui/macOS/ui/FileProviderFileDelegate.qml src/gui/integration/FileActionsWindow.qml diff --git a/src/gui/activity/qml/ActivityItem.qml b/src/gui/activity/qml/ActivityItem.qml index 34d5176bb5f0d..4af0671c2c424 100644 --- a/src/gui/activity/qml/ActivityItem.qml +++ b/src/gui/activity/qml/ActivityItem.qml @@ -45,7 +45,7 @@ ItemDelegate { ActivityItemContent { id: activityContent - adaptiveTextColor: root.activeFocus ? palette.highlightedText : palette.text + adaptiveTextColor: palette.text Layout.fillWidth: true Layout.minimumHeight: Style.minActivityHeight diff --git a/src/gui/activity/qml/ActivityList.qml b/src/gui/activity/qml/ActivityList.qml index 37dee50d50139..5c3924ed977f7 100644 --- a/src/gui/activity/qml/ActivityList.qml +++ b/src/gui/activity/qml/ActivityList.qml @@ -79,7 +79,7 @@ ScrollView { highlight: Rectangle { id: activityHover anchors.fill: activityList.currentItem - color: palette.highlight + color: Style.listItemHoverBackground radius: Style.mediumRoundedButtonRadius visible: activityList.activeFocus } diff --git a/src/gui/iconutils.cpp b/src/gui/iconutils.cpp index f153b13ebe53c..07ff138f184a7 100644 --- a/src/gui/iconutils.cpp +++ b/src/gui/iconutils.cpp @@ -164,8 +164,8 @@ QImage drawSvgWithCustomFillColor(const QString &sourceSvgPath, return {}; } - const auto reqSize = (requestedSize.isValid() && requestedSize.height() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize(); - if (!reqSize.isValid() || !reqSize.height() || !reqSize.height()) { + const auto reqSize = (requestedSize.isValid() && requestedSize.width() && requestedSize.height()) ? requestedSize : svgRenderer.defaultSize(); + if (!reqSize.isValid() || !reqSize.width() || !reqSize.height()) { return {}; } diff --git a/src/gui/owncloudgui.cpp b/src/gui/owncloudgui.cpp index 17fd2b6bfaf76..443fb9f507d20 100644 --- a/src/gui/owncloudgui.cpp +++ b/src/gui/owncloudgui.cpp @@ -39,6 +39,7 @@ #include "tray/trayactivationpolicy.h" #include "tray/trayaccountappsmodel.h" #include "search/unifiedsearchresultslistmodel.h" +#include "search/unifiedsearchpeoplemodel.h" #include "integration/fileactionsmodel.h" #include "governance/applygovernancelabel.h" #include "governance/deletegovernancelabel.h" @@ -159,6 +160,7 @@ ownCloudGui::ownCloudGui(Application *parent) qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileDetails"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "ShareeModel"); + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SortedShareModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "SyncConflictsModel"); qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "FileActionsModel"); diff --git a/src/gui/search/CMakeLists.txt b/src/gui/search/CMakeLists.txt index d4dbcddc13dbd..fe62878b0816b 100644 --- a/src/gui/search/CMakeLists.txt +++ b/src/gui/search/CMakeLists.txt @@ -9,6 +9,8 @@ target_sources(nextcloudGuiSearch unifiedsearchresult.cpp unifiedsearchresultslistmodel.h unifiedsearchresultslistmodel.cpp + unifiedsearchpeoplemodel.h + unifiedsearchpeoplemodel.cpp ) ecm_add_qml_module(nextcloudGuiSearch @@ -17,6 +19,7 @@ ecm_add_qml_module(nextcloudGuiSearch QML_FILES SearchWindow.qml UnifiedSearchInputContainer.qml + UnifiedSearchResultDelegate.qml UnifiedSearchResultFetchMoreTrigger.qml UnifiedSearchResultItem.qml UnifiedSearchResultItemSkeleton.qml diff --git a/src/gui/search/SearchWindow.qml b/src/gui/search/SearchWindow.qml index c953b391081b6..7bdb228a60de1 100644 --- a/src/gui/search/SearchWindow.qml +++ b/src/gui/search/SearchWindow.qml @@ -10,19 +10,18 @@ import QtQuick.Layouts import Style import com.nextcloud.desktopclient import "qrc:/qml/src/gui" +import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { id: root property var account: null property var searchModel: null - readonly property string headline: qsTr("Search") - readonly property int searchState: searchModel - ? searchModel.searchState - : UnifiedSearchResultsListModel.Placeholder - readonly property bool isSearchInProgress: searchModel !== null && searchModel.isSearchInProgress - readonly property bool canEditSearch: searchModel !== null && searchModel.canEditSearch - readonly property bool isAccountConnected: searchModel !== null && searchModel.isAccountConnected + property bool filtersRevealed: false + readonly property bool aggregateView: searchModel && searchModel.viewMode === UnifiedSearchResultsListModel.Aggregate + readonly property bool filtersVisible: aggregateView && searchModel && searchModel.providersReady + && (filtersRevealed || searchModel.searchTerm.length > 0 || searchModel.activeFilters.length > 0) + readonly property int searchState: searchModel ? searchModel.searchState : UnifiedSearchResultsListModel.Placeholder title: "" width: Style.searchWindowWidth @@ -31,57 +30,239 @@ WizardStyledWindow { minimumHeight: Style.wizardStandaloneWindowMinimumHeight function focusSearchInput() { - if (visible && searchInput.enabled) { - searchInput.forceActiveFocus() + if (visible && searchInput.enabled) searchInput.forceActiveFocus() + } + + function hasActiveFilter(type) { + if (!searchModel) { + return false + } + const filters = searchModel.activeFilters + for (let index = 0; index < filters.length; ++index) { + if (filters[index].type === type) { + return true + } } + return false } Shortcut { sequences: [StandardKey.Cancel] + enabled: !typeMenu.opened && !dateMenu.opened && !peoplePopup.opened && !customRangeDialog.opened onActivated: root.close() } - Component.onCompleted: Qt.callLater(focusSearchInput) - onVisibleChanged: { - if (visible) { - Qt.callLater(focusSearchInput) + UnifiedSearchPeopleModel { + id: peopleModel + accountState: root.searchModel ? root.searchModel.accountState : null + } + + Connections { + target: root.searchModel + function onSelectedRowChanged() { + if (root.searchModel && root.searchModel.selectedRow >= 0 + && root.searchModel.selectedRow < resultsList.count) { + resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Contain) + } + } + function onViewModeChanged() { + Qt.callLater(function() { + if (root.searchModel && root.searchModel.selectedRow >= 0 + && root.searchModel.selectedRow < resultsList.count) { + resultsList.positionViewAtIndex(root.searchModel.selectedRow, ListView.Beginning) + } + }) + } + function onAccessibilityStatusChanged() { + if (root.searchModel.accessibilityStatus.length > 0) + Accessible.announce(root.searchModel.accessibilityStatus, Accessible.Polite) } } + Component.onCompleted: Qt.callLater(focusSearchInput) + onVisibleChanged: if (visible) Qt.callLater(focusSearchInput) + ColumnLayout { anchors.fill: parent - anchors.leftMargin: Style.wizardWindowMargin - anchors.rightMargin: Style.wizardWindowMargin + anchors.margins: Style.wizardWindowMargin anchors.topMargin: Style.wizardWindowTopMargin - anchors.bottomMargin: Style.wizardWindowMargin - spacing: Style.wizardSectionSpacing + spacing: Style.smallSpacing WindowAccountHeader { Layout.fillWidth: true - title: root.headline + title: qsTr("Search") user: root.account } UnifiedSearchInputContainer { id: searchInput - Layout.fillWidth: true Layout.preferredHeight: Style.unifiedSearchInputContainerHeight enabled: root.searchModel !== null - readOnly: !root.canEditSearch + readOnly: !root.searchModel || !root.searchModel.canEditSearch text: root.searchModel ? root.searchModel.searchTerm : "" - placeholderText: root.account !== null && !root.isAccountConnected + isSearchInProgress: root.searchModel + && (root.searchModel.isSearchInProgress || root.searchModel.waitingForSearchTermEditEnd) + placeholderText: root.searchModel && !root.searchModel.isAccountConnected ? qsTr("Search is available when this account is connected") : qsTr("Search files, messages, events …") - isSearchInProgress: root.isSearchInProgress - onTextEdited: { - if (root.searchModel) { - root.searchModel.searchTerm = searchInput.text + onTextEdited: if (root.searchModel) root.searchModel.searchTerm = text + onClearText: if (root.searchModel) root.searchModel.searchTerm = "" + onToggleFilters: root.filtersRevealed = !root.filtersRevealed + onMoveSelection: direction => root.searchModel.moveSelection(direction) + onActivateSelection: root.searchModel.activateSelected() + } + + Item { + id: detailHeader + + objectName: "searchDetailHeader" + Layout.fillWidth: true + Layout.preferredHeight: 40 + visible: root.searchModel && !root.aggregateView + + ToolButton { + id: backButton + + objectName: "searchDetailBackButton" + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Back") + icon.source: "image://svgimage-custom-color/" + + (root.LayoutMirroring.enabled ? "arrow-right.svg/" : "arrow-left.svg/") + + Style.wizardPrimaryText + icon.width: Style.smallIconSize + icon.height: Style.smallIconSize + display: AbstractButton.TextBesideIcon + Accessible.name: qsTr("Back to all search results") + onClicked: { + root.searchModel.closeProviderDetail() + root.focusSearchInput() } } - onClearText: { - if (root.searchModel) { - root.searchModel.searchTerm = "" + Label { + objectName: "searchDetailProviderTitle" + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: backButton.width + Style.smallSpacing + anchors.rightMargin: backButton.width + Style.smallSpacing + anchors.verticalCenter: parent.verticalCenter + text: root.searchModel ? root.searchModel.detailProviderName : "" + font.bold: true + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } + } + + Flow { + id: filterFlow + Layout.fillWidth: true + Layout.preferredHeight: visible ? childrenRect.height : 0 + visible: root.filtersVisible + spacing: Style.smallSpacing + + WizardButton { + objectName: "typeFilterButton" + width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) + text: qsTr("Type") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/folder.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("provider") + Accessible.name: qsTr("Filter by type") + onClicked: typeMenu.open() + Menu { + id: typeMenu + width: parent.width * 1.5 + Repeater { + model: root.searchModel ? root.searchModel.providers : [] + delegate: WizardMenuItem { + required property var modelData + text: (modelData.selected ? "✓ " : "") + modelData.name + icon.source: modelData.icon ? "image://tray-image-provider/" + modelData.icon : "" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + onTriggered: root.searchModel.toggleProviderFilter(modelData.id) + } + } + } + } + WizardButton { + objectName: "dateFilterButton" + width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) + text: qsTr("Date") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/calendar.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("date") + enabled: root.searchModel && root.searchModel.dateFilterAvailable + Accessible.name: qsTr("Filter by date") + Accessible.description: enabled ? "" : qsTr("No search source supports date filtering") + onClicked: dateMenu.open() + Menu { + id: dateMenu + WizardMenuItem { + objectName: "dateTodayMenuItem" + text: qsTr("Today") + onTriggered: root.searchModel.setDatePreset("today") + } + WizardMenuItem { text: qsTr("Last 7 days"); onTriggered: root.searchModel.setDatePreset("last7days") } + WizardMenuItem { text: qsTr("Last 30 days"); onTriggered: root.searchModel.setDatePreset("last30days") } + WizardMenuItem { text: qsTr("This year"); onTriggered: root.searchModel.setDatePreset("thisyear") } + WizardMenuItem { text: qsTr("Last year"); onTriggered: root.searchModel.setDatePreset("lastyear") } + MenuSeparator {} + WizardMenuItem { + text: qsTr("Custom range …") + onTriggered: { + customRangeDialog.validationError = false + customRangeDialog.open() + } + } + WizardMenuItem { text: qsTr("Clear date"); onTriggered: root.searchModel.clearDateFilter() } + } + } + WizardButton { + id: peopleButton + objectName: "peopleFilterButton" + width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) + text: qsTr("People") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/account-group.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("person") + enabled: root.searchModel && root.searchModel.peopleFilterAvailable + Accessible.name: qsTr("Filter by person") + Accessible.description: enabled ? "" : qsTr("No search source supports people filtering") + onClicked: peoplePopup.open() + } + } + + Flow { + Layout.fillWidth: true + Layout.preferredHeight: visible ? implicitHeight : 0 + visible: root.filtersVisible && root.searchModel && root.searchModel.activeFilters.length > 0 + spacing: Style.smallSpacing + Repeater { + model: root.searchModel ? root.searchModel.activeFilters : [] + delegate: WizardChipButton { + id: chipButton + objectName: "activeFilterChip" + required property var modelData + text: modelData.label + textSuffix: "×" + iconBeforeText: true + iconSource: modelData.icon ? "image://tray-image-provider/" + modelData.icon : "" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + Accessible.name: qsTr("Remove %1 filter").arg(modelData.label) + onClicked: root.searchModel.removeFilter(modelData.type, modelData.id) } } } @@ -96,12 +277,17 @@ WizardStyledWindow { Layout.fillWidth: true Layout.fillHeight: true - ErrorBox { - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top + ColumnLayout { + anchors.centerIn: parent + width: Math.min(parent.width, 420) visible: root.searchState === UnifiedSearchResultsListModel.SearchError - text: root.searchModel ? root.searchModel.errorString : "" + ErrorBox { Layout.fillWidth: true; text: root.searchModel ? root.searchModel.errorString : "" } + WizardButton { + Layout.alignment: Qt.AlignHCenter + primary: true + text: qsTr("Retry") + onClicked: root.searchModel.retry() + } } UnifiedSearchPlaceholderView { @@ -115,63 +301,232 @@ WizardStyledWindow { text: root.searchModel ? root.searchModel.searchTerm : "" } - Loader { - anchors.fill: parent - anchors.margins: Style.smallSpacing - active: root.searchState === UnifiedSearchResultsListModel.Skeleton - asynchronous: true - - sourceComponent: UnifiedSearchResultItemSkeletonContainer { - anchors.fill: parent - spacing: searchResultsListView.spacing - animationRectangleWidth: root.width - } - } - ScrollView { - id: searchResultsScrollView - anchors.fill: parent contentWidth: availableWidth visible: root.searchState === UnifiedSearchResultsListModel.Results - + || root.searchState === UnifiedSearchResultsListModel.Skeleton ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ListView { - id: searchResultsListView + id: resultsList - spacing: Style.smallSpacing + objectName: "searchResultsList" clip: true - keyNavigationEnabled: true reuseItems: true + spacing: Style.extraSmallSpacing model: root.searchModel - + currentIndex: root.searchModel ? root.searchModel.selectedRow : -1 Accessible.role: Accessible.List - Accessible.name: qsTr("Search results list") - - delegate: UnifiedSearchResultListItem { - width: searchResultsListView.width - isSearchInProgress: root.isSearchInProgress - currentFetchMoreInProgressProviderId: root.searchModel - ? root.searchModel.currentFetchMoreInProgressProviderId - : "" - fetchMoreTriggerClicked: root.searchModel - ? root.searchModel.fetchMoreTriggerClicked - : function() {} - resultClicked: root.searchModel - ? root.searchModel.resultClicked - : function() {} - ListView.onPooled: isPooled = true - ListView.onReused: isPooled = false + Accessible.name: qsTr("Search results") + + delegate: Item { + id: delegateData + + required property string providerName + required property string providerId + required property string providerIcon + required property string resultTitle + required property string subline + required property url resourceUrlRole + required property string darkIcons + required property string lightIcons + required property bool darkIconsIsThumbnail + required property bool lightIconsIsThumbnail + required property string darkImagePlaceholder + required property string lightImagePlaceholder + required property bool isRounded + required property int type + required property bool isSelected + required property bool isPartialMatch + required property bool hasOverflow + required property bool isLoading + + readonly property alias loadedItem: resultDelegate.loadedItem + + width: resultsList.width + height: resultDelegate.implicitHeight + + UnifiedSearchResultDelegate { + id: resultDelegate + + anchors.fill: parent + searchModel: root.searchModel + providerName: delegateData.providerName + providerId: delegateData.providerId + providerIcon: delegateData.providerIcon + resultTitle: delegateData.resultTitle + subline: delegateData.subline + resourceUrlRole: delegateData.resourceUrlRole + darkIcons: delegateData.darkIcons + lightIcons: delegateData.lightIcons + darkIconsIsThumbnail: delegateData.darkIconsIsThumbnail + lightIconsIsThumbnail: delegateData.lightIconsIsThumbnail + darkImagePlaceholder: delegateData.darkImagePlaceholder + lightImagePlaceholder: delegateData.lightImagePlaceholder + isRounded: delegateData.isRounded + resultType: delegateData.type + isSelected: delegateData.isSelected + isPartialMatch: delegateData.isPartialMatch + hasOverflow: delegateData.hasOverflow + isLoading: delegateData.isLoading + } + } + + footer: Column { + objectName: "searchResultsLoadingFooter" + width: resultsList.width + height: visible ? implicitHeight : 0 + spacing: Style.smallSpacing + visible: root.searchModel && root.searchModel.isSearchInProgress + Accessible.ignored: true + Repeater { + model: 3 + Rectangle { + required property int index + width: resultsList.width * (0.72 + index * 0.07) + height: 44 + radius: 8 + color: palette.alternateBase + opacity: 0.55 + } + } + } + } + } + } + + RowLayout { + objectName: "partialFailureFooter" + Layout.fillWidth: true + visible: root.aggregateView && root.searchModel && root.searchModel.hasPartialFailure + Label { Layout.fillWidth: true; text: qsTr("Some sources unavailable"); color: palette.placeholderText } + WizardButton { text: qsTr("Retry"); onClicked: root.searchModel.retryFailedProviders() } + } + + WizardButton { + Layout.fillWidth: true + visible: root.searchModel && root.searchModel.showConnectedServicesAction + text: root.searchModel && root.searchModel.externalProvidersEnabled + ? qsTr("Less from connected services") : qsTr("More from connected services") + onClicked: root.searchModel.setExternalProvidersEnabled(!root.searchModel.externalProvidersEnabled) + } + } + + Popup { + id: peoplePopup + parent: Overlay.overlay + width: Math.min(root.width - 40, 420) + height: 340 + x: (root.width - width) / 2 + y: 150 + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onOpened: peopleSearch.forceActiveFocus() + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.smallSpacing + TextField { + id: peopleSearch + Layout.fillWidth: true + placeholderText: qsTr("Search people") + onTextEdited: peopleModel.searchTerm = text + } + Label { visible: peopleModel.errorString.length > 0; text: peopleModel.errorString; wrapMode: Text.Wrap } + WizardButton { + visible: peopleModel.errorString.length > 0 + text: qsTr("Retry") + onClicked: peopleModel.retry() + } + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: peopleModel + delegate: ItemDelegate { + id: personDelegate + required property string userId + required property string displayName + required property string avatarUrl + width: ListView.view.width + height: 44 + text: displayName + hoverEnabled: true + Accessible.description: userId + background: Rectangle { + color: personDelegate.hovered || personDelegate.down + ? Style.listItemHoverBackground + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + HoverHandler { + cursorShape: Qt.PointingHandCursor } + contentItem: RowLayout { + Image { + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + sourceSize.width: 32 + sourceSize.height: 32 + asynchronous: true + source: personDelegate.avatarUrl.length > 0 + ? "image://tray-image-provider/" + personDelegate.avatarUrl : "" + Accessible.ignored: true + } + Label { Layout.fillWidth: true; text: personDelegate.displayName; elide: Text.ElideRight } + } + onClicked: { + root.searchModel.setPersonFilter(userId, displayName, avatarUrl) + peoplePopup.close() + root.focusSearchInput() + } + } + } + } + } + + Dialog { + id: customRangeDialog + property bool validationError: false + anchors.centerIn: parent + title: qsTr("Custom date range") + modal: true + + footer: RowLayout { + spacing: Style.wizardFooterSpacing - section.property: "providerName" - section.criteria: ViewSection.FullString - section.delegate: UnifiedSearchResultSectionItem { - width: searchResultsListView.width + Item { + Layout.fillWidth: true + } + + WizardButton { + text: qsTr("Cancel") + onClicked: customRangeDialog.close() + } + + WizardButton { + primary: true + text: qsTr("Apply") + onClicked: { + customRangeDialog.validationError = !root.searchModel.setCustomDateRange(customSince.text, customUntil.text) + if (!customRangeDialog.validationError) { + customRangeDialog.close() } } } } + + ColumnLayout { + Label { text: qsTr("Start date (YYYY-MM-DD)") } + TextField { id: customSince; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } + Label { text: qsTr("End date (YYYY-MM-DD)") } + TextField { id: customUntil; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } + Label { + visible: customRangeDialog.validationError + text: qsTr("Enter valid dates with the start date before the end date.") + color: palette.accent + } + } } + } diff --git a/src/gui/search/UnifiedSearchInputContainer.qml b/src/gui/search/UnifiedSearchInputContainer.qml index 6d66558f7b323..b8da32ac87807 100644 --- a/src/gui/search/UnifiedSearchInputContainer.qml +++ b/src/gui/search/UnifiedSearchInputContainer.qml @@ -1,98 +1,83 @@ /* - * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-2.0-or-later */ -import QtQml import QtQuick import QtQuick.Controls -import Qt5Compat.GraphicalEffects import Style - import com.nextcloud.desktopclient -import "qrc:/qml/src/gui/common" -import "qrc:/qml/src/gui/tray" -NCContextMenuTextField { +TextField { id: root signal clearText() + signal toggleFilters() + signal moveSelection(int direction) + signal activateSelection() property bool isSearchInProgress: false + readonly property color iconColor: palette.placeholderText + readonly property int controlSize: Math.max(40, height - 4) - readonly property color textFieldIconsColor: palette.placeholderText - - readonly property int iconInset: Style.smallSpacing - - readonly property real leadingControlWidth: root.isSearchInProgress ? busyIndicator.width : searchIconImage.width - readonly property real trailingControlWidth: clearTextButton.visible ? clearTextButton.width : 0 - - topPadding: topInset - bottomPadding: bottomInset - leftPadding: iconInset + leadingControlWidth + Style.smallSpacing - rightPadding: iconInset + trailingControlWidth + Style.smallSpacing + leftPadding: 8 + controlSize + rightPadding: 8 + controlSize verticalAlignment: Qt.AlignVCenter - placeholderText: qsTr("Search files, messages, events …") - Image { - id: searchIconImage + background: Rectangle { + radius: 8 + color: Style.wizardFieldBackground + border.width: 1 + border.color: Style.wizardFieldBorder + } - anchors { - left: root.left - leftMargin: iconInset - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - } + Keys.onPressed: event => { + if (inputMethodComposing) return + if (event.key === Qt.Key_Down) moveSelection(UnifiedSearchResultsListModel.Next) + else if (event.key === Qt.Key_Up) moveSelection(UnifiedSearchResultsListModel.Previous) + else if (event.key === Qt.Key_Home) moveSelection(UnifiedSearchResultsListModel.First) + else if (event.key === Qt.Key_End) moveSelection(UnifiedSearchResultsListModel.Last) + else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) activateSelection() + else return + event.accepted = true + } - fillMode: Image.PreserveAspectFit - smooth: true - antialiasing: true - mipmap: true - source: "image://svgimage-custom-color/search.svg" + "/" + root.textFieldIconsColor + Image { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 + sourceSize.width: width + sourceSize.height: height + source: "image://svgimage-custom-color/search.svg/" + root.iconColor visible: !root.isSearchInProgress + Accessible.ignored: true } - NCBusyIndicator { - id: busyIndicator - - anchors { - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - left: root.left - leftMargin: iconInset - } - - width: height - color: root.textFieldIconsColor + BusyIndicator { + anchors.left: parent.left + anchors.leftMargin: 8 + anchors.verticalCenter: parent.verticalCenter + width: 24 + height: 24 visible: root.isSearchInProgress running: visible + Accessible.ignored: true } - Image { - id: clearTextButton - - anchors { - top: root.top - topMargin: Style.extraSmallSpacing - bottom: root.bottom - bottomMargin: Style.extraSmallSpacing - right: root.right - rightMargin: iconInset - } - - fillMode: Image.PreserveAspectFit - visible: root.text - source: "image://svgimage-custom-color/clear.svg" + "/" + root.textFieldIconsColor - - MouseArea { - id: clearTextButtonMouseArea - anchors.fill: parent - onClicked: root.clearText() - } + ToolButton { + anchors.right: parent.right + anchors.rightMargin: 2 + anchors.verticalCenter: parent.verticalCenter + width: root.controlSize + height: root.controlSize + icon.source: root.text.length > 0 ? "image://svgimage-custom-color/clear.svg/" + root.iconColor + : "image://svgimage-custom-color/filter.svg/" + root.iconColor + visible: root.text.length > 0 || root.activeFocus + Accessible.name: root.text.length > 0 ? qsTr("Clear search") : qsTr("Show search filters") + Accessible.description: root.text.length > 0 ? qsTr("Keeps the active filters") : "" + onClicked: root.text.length > 0 ? root.clearText() : root.toggleFilters() } } diff --git a/src/gui/search/UnifiedSearchResultDelegate.qml b/src/gui/search/UnifiedSearchResultDelegate.qml new file mode 100644 index 0000000000000..b6afca10cf6b9 --- /dev/null +++ b/src/gui/search/UnifiedSearchResultDelegate.qml @@ -0,0 +1,246 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import com.nextcloud.desktopclient + +Item { + id: root + + required property var searchModel + required property string providerName + required property string providerId + required property string providerIcon + required property string resultTitle + required property string subline + required property url resourceUrlRole + required property string darkIcons + required property string lightIcons + required property bool darkIconsIsThumbnail + required property bool lightIconsIsThumbnail + required property string darkImagePlaceholder + required property string lightImagePlaceholder + required property bool isRounded + required property int resultType + required property bool isSelected + required property bool isPartialMatch + required property bool hasOverflow + required property bool isLoading + + readonly property alias loadedItem: rowContent.item + + implicitHeight: { + if (resultType === UnifiedSearchResultsListModel.ProviderHeader) { + return 44 + } + if (resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) { + return 40 + } + if (resultType === UnifiedSearchResultsListModel.FetchMoreTrigger + || resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) { + return 44 + } + return Style.unifiedSearchItemHeight + } + height: implicitHeight + + Loader { + id: rowContent + + anchors.fill: parent + sourceComponent: { + if (root.resultType === UnifiedSearchResultsListModel.ProviderHeader) { + return providerHeader + } + if (root.resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) { + return partialHeader + } + if (root.resultType === UnifiedSearchResultsListModel.FetchMoreTrigger + || root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) { + return pagingRow + } + return resultRow + } + } + + Component { + id: providerHeader + + Button { + id: providerHeaderButton + + objectName: "providerHeaderRow" + width: root.width + height: 44 + flat: true + text: root.hasOverflow ? qsTr("More from %1 →").arg(root.providerName) : root.providerName + font.bold: false + font.pixelSize: Style.unifiedSearchResultTitleFontSize + leftPadding: 0 + rightPadding: 0 + hoverEnabled: root.hasOverflow + activeFocusOnTab: root.hasOverflow + Accessible.role: root.hasOverflow ? Accessible.Button : Accessible.StaticText + Accessible.name: text + + HoverHandler { + enabled: root.hasOverflow + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + color: root.hasOverflow && providerHeaderButton.hovered + ? Style.listItemHoverBackground + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + contentItem: Label { + text: providerHeaderButton.text + color: Style.wizardPrimaryText + font: providerHeaderButton.font + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + + onClicked: { + if (root.hasOverflow) { + root.searchModel.openProviderDetail(root.providerId) + } + } + } + } + + Component { + id: partialHeader + + Label { + objectName: "partialMatchesHeaderRow" + width: root.width + height: 40 + verticalAlignment: Text.AlignVCenter + text: qsTr("Partial matches") + color: Style.wizardSecondaryText + font.bold: true + } + } + + Component { + id: resultRow + + ItemDelegate { + id: resultDelegateButton + + objectName: "searchResultRow" + width: root.width + height: Style.unifiedSearchItemHeight + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + activeFocusOnTab: false + opacity: root.isPartialMatch ? 0.72 : 1.0 + hoverEnabled: true + Accessible.role: Accessible.ListItem + Accessible.name: root.resultTitle + Accessible.description: root.subline + Accessible.selected: root.isSelected + + background: Rectangle { + color: resultDelegateButton.hovered + ? Style.listItemHoverBackground + : (root.isSelected ? Style.wizardSecondaryButtonPressed : "transparent") + radius: Style.mediumRoundedButtonRadius + } + + contentItem: UnifiedSearchResultItem { + accessibilityEnabled: false + title: root.resultTitle + subline: root.subline + icons: Style.darkMode ? root.darkIcons : root.lightIcons + iconsIsThumbnail: Style.darkMode ? root.darkIconsIsThumbnail : root.lightIconsIsThumbnail + iconPlaceholder: Style.darkMode ? root.darkImagePlaceholder : root.lightImagePlaceholder + isRounded: root.isRounded && iconsIsThumbnail + } + + onClicked: root.searchModel.resultClicked(root.providerId, root.resourceUrlRole) + } + } + + Component { + id: pagingRow + + ItemDelegate { + id: pagingDelegate + + objectName: "searchPagingRow" + width: root.width + enabled: !root.isLoading + hoverEnabled: true + leftPadding: Style.unifiedSearchResultIconLeftMargin + rightPadding: Style.unifiedSearchResultTextRightMargin + topPadding: 0 + bottomPadding: 0 + text: root.isLoading + ? qsTr("Loading more results …") + : (root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger + ? qsTr("Retry loading more results") : qsTr("Load more results")) + icon.source: "image://svgimage-custom-color/more.svg/" + Style.wizardPrimaryText + icon.width: Style.smallIconSize + icon.height: Style.smallIconSize + Accessible.role: Accessible.Button + Accessible.name: text + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + background: Rectangle { + color: pagingDelegate.hovered ? Style.listItemHoverBackground : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + contentItem: RowLayout { + spacing: Style.smallSpacing + + BusyIndicator { + Layout.preferredWidth: Style.smallIconSize + Layout.preferredHeight: Style.smallIconSize + running: root.isLoading + visible: running + } + + Image { + Layout.preferredWidth: Style.smallIconSize + Layout.preferredHeight: Style.smallIconSize + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + source: pagingDelegate.icon.source + visible: !root.isLoading + Accessible.ignored: true + } + + Label { + Layout.fillWidth: true + text: pagingDelegate.text + color: Style.wizardPrimaryText + font.bold: true + font.pixelSize: Style.unifiedSearchResultTitleFontSize + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + } + + onClicked: root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger + ? root.searchModel.retryLoadMore(root.providerId) + : root.searchModel.loadMore(root.providerId) + } + } +} diff --git a/src/gui/search/UnifiedSearchResultItem.qml b/src/gui/search/UnifiedSearchResultItem.qml index fae127653b466..7e74805cbdc5e 100644 --- a/src/gui/search/UnifiedSearchResultItem.qml +++ b/src/gui/search/UnifiedSearchResultItem.qml @@ -10,11 +10,12 @@ import QtQuick.Layouts import Qt5Compat.GraphicalEffects import Style -import "qrc:/qml/src/gui/tray" RowLayout { id: unifiedSearchResultItemDetails + objectName: "searchResultContent" + property string title: "" property string subline: "" property string icons: "" @@ -22,6 +23,7 @@ RowLayout { property bool iconsIsThumbnail: false property bool isRounded: false + property bool accessibilityEnabled: true property int iconWidth: iconsIsThumbnail && icons !== "" ? Style.unifiedSearchResultIconWidth : Style.unifiedSearchResultSmallIconWidth property int titleFontSize: Style.unifiedSearchResultTitleFontSize @@ -32,8 +34,9 @@ RowLayout { Accessible.role: Accessible.ListItem - Accessible.name: resultTitle - Accessible.onPressAction: unifiedSearchResultMouseArea.clicked() + Accessible.name: title + Accessible.description: subline + Accessible.ignored: !accessibilityEnabled spacing: Style.trayHorizontalMargin @@ -86,16 +89,35 @@ RowLayout { } } - ListItemLineAndSubline { + ColumnLayout { id: unifiedSearchResultTextContainer - spacing: Style.standardSpacing + objectName: "searchResultTextContainer" + spacing: Style.unifiedSearchResultTextSpacing Layout.fillWidth: true Layout.rightMargin: Style.trayHorizontalMargin - lineText: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ") - sublineText: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ") + Label { + objectName: "searchResultTitle" + Layout.fillWidth: true + text: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ") + textFormat: Text.PlainText + color: unifiedSearchResultItemDetails.titleColor + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.titleFontSize + } + + Label { + objectName: "searchResultSubline" + Layout.fillWidth: true + text: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ") + textFormat: Text.PlainText + color: unifiedSearchResultItemDetails.sublineColor + visible: text.length > 0 + elide: Text.ElideRight + font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize + } } } diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp new file mode 100644 index 0000000000000..0d2ce68340367 --- /dev/null +++ b/src/gui/search/unifiedsearchpeoplemodel.cpp @@ -0,0 +1,226 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "unifiedsearchpeoplemodel.h" + +#include "account.h" +#include "accountstate.h" +#include "networkjobs.h" + +#include +#include +#include +#include +#include + +namespace { +constexpr auto maximumPeopleResults = 50; +} + +namespace OCC { +UnifiedSearchPeopleModel::UnifiedSearchPeopleModel(QObject *parent, int debounceInterval) + : QAbstractListModel(parent) +{ + _debounceTimer.setSingleShot(true); + _debounceTimer.setInterval(debounceInterval); + connect(&_debounceTimer, &QTimer::timeout, this, &UnifiedSearchPeopleModel::startSearch); +} + +UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() { cancel(); } + +QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, CheckIndexOption::IndexIsValid)) return {}; + const auto &person = _people.at(index.row()); + switch (role) { + case UserIdRole: return person.id; + case DisplayNameRole: return person.displayName; + case AvatarUrlRole: return person.avatarUrl; + } + return {}; +} + +int UnifiedSearchPeopleModel::rowCount(const QModelIndex &parent) const { return parent.isValid() ? 0 : _people.size(); } +QHash UnifiedSearchPeopleModel::roleNames() const +{ + static const auto roles = QHash{ + {UserIdRole, "userId"}, + {DisplayNameRole, "displayName"}, + {AvatarUrlRole, "avatarUrl"}, + }; + return roles; +} +AccountState *UnifiedSearchPeopleModel::accountState() const { return _accountState.data(); } +QString UnifiedSearchPeopleModel::searchTerm() const { return _searchTerm; } +bool UnifiedSearchPeopleModel::busy() const { return _busy; } +QString UnifiedSearchPeopleModel::errorString() const { return _errorString; } + +void UnifiedSearchPeopleModel::setAccountState(AccountState *accountState) +{ + if (_accountState == accountState) return; + cancel(); + clearPeople(); + if (_accountState) disconnect(_accountState, nullptr, this, nullptr); + _accountState = accountState; + if (_accountState) { + connect(_accountState, &AccountState::isConnectedChanged, this, [this] { + if (!_accountState->isConnected()) { + cancel(); + clearPeople(); + setErrorString(tr("People search is unavailable.")); + } else { + setErrorString({}); + _debounceTimer.start(0); + } + }); + connect(_accountState, &QObject::destroyed, this, [this] { + cancel(); + clearPeople(); + setErrorString(tr("People search is unavailable.")); + Q_EMIT accountStateChanged(); + }); + } + Q_EMIT accountStateChanged(); + if (_accountState && _accountState->isConnected()) { + setErrorString({}); + _debounceTimer.start(0); + } else { + setErrorString(tr("People search is unavailable.")); + } +} + +void UnifiedSearchPeopleModel::setSearchTerm(const QString &searchTerm) +{ + if (_searchTerm == searchTerm) return; + _searchTerm = searchTerm; + Q_EMIT searchTermChanged(); + cancel(); + clearPeople(); + setErrorString({}); + _debounceTimer.start(); +} + +void UnifiedSearchPeopleModel::retry() { startSearch(); } + +void UnifiedSearchPeopleModel::startSearch() +{ + cancel(); + if (!_accountState || !_accountState->account() || !_accountState->isConnected()) { + setErrorString(tr("People search is unavailable.")); + return; + } + const auto account = _accountState->account(); + if (_searchTerm.trimmed().isEmpty()) { + QVector people; + const auto selfId = account->davUser(); + if (!selfId.isEmpty()) { + const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); + const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(selfId))))).toString(); + people.push_back({selfId, selfName, avatar}); + } + replacePeople(std::move(people)); + setErrorString({}); + return; + } + + const auto generation = ++_generation; + auto *const job = new JsonApiJob(account, QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/sharees")); + QUrlQuery query; + query.addQueryItem(QStringLiteral("search"), _searchTerm); + query.addQueryItem(QStringLiteral("shareType"), QStringLiteral("0")); + query.addQueryItem(QStringLiteral("lookup"), QStringLiteral("false")); + query.addQueryItem(QStringLiteral("page"), QStringLiteral("1")); + query.addQueryItem(QStringLiteral("perPage"), QStringLiteral("50")); + job->addQueryParams(query); + _job = job; + setBusy(true); + connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job, account](const QJsonDocument &reply, int statusCode) { + if (generation != _generation || _job != job) return; + _job.clear(); + setBusy(false); + if (statusCode != 200) { + clearPeople(); + setErrorString(tr("Could not load people. Try again.")); + return; + } + QVector people; + QSet seen; + const auto data = reply.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject(); + const auto appendUsers = [&people, &seen, &account](const QJsonArray &users) { + for (const auto &value : users) { + if (people.size() >= maximumPeopleResults) { + break; + } + const auto object = value.toObject(); + const auto id = object.value(QStringLiteral("value")).toObject().value(QStringLiteral("shareWith")).toString(); + if (id.isEmpty() || seen.contains(id)) continue; + seen.insert(id); + const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64").arg(QString::fromUtf8(QUrl::toPercentEncoding(id))))).toString(); + people.push_back({id, object.value(QStringLiteral("label")).toString(id), avatar}); + } + }; + appendUsers(data.value(QStringLiteral("exact")).toObject().value(QStringLiteral("users")).toArray()); + appendUsers(data.value(QStringLiteral("users")).toArray()); + const auto selfId = account->davUser(); + const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); + if (!selfId.isEmpty() && !seen.contains(selfId) + && (_searchTerm.isEmpty() || selfId.contains(_searchTerm, Qt::CaseInsensitive) || selfName.contains(_searchTerm, Qt::CaseInsensitive))) { + const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64").arg(QString::fromUtf8(QUrl::toPercentEncoding(selfId))))).toString(); + people.prepend({selfId, selfName, avatar}); + if (people.size() > maximumPeopleResults) { + people.removeLast(); + } + } + replacePeople(std::move(people)); + setErrorString({}); + }); + job->start(); +} + +void UnifiedSearchPeopleModel::cancel() +{ + _debounceTimer.stop(); + ++_generation; + if (_job) { + disconnect(_job, nullptr, this, nullptr); + if (const auto job = qobject_cast(_job.data()); job && job->reply() && job->reply()->isRunning()) job->reply()->abort(); + _job->deleteLater(); + _job.clear(); + } + setBusy(false); +} + +void UnifiedSearchPeopleModel::clearPeople() +{ + if (_people.isEmpty()) { + return; + } + beginResetModel(); + _people.clear(); + endResetModel(); +} + +void UnifiedSearchPeopleModel::replacePeople(QVector people) +{ + beginResetModel(); + _people = std::move(people); + endResetModel(); +} + +void UnifiedSearchPeopleModel::setBusy(bool busy) +{ + if (_busy == busy) return; + _busy = busy; + Q_EMIT busyChanged(); +} + +void UnifiedSearchPeopleModel::setErrorString(const QString &errorString) +{ + if (_errorString == errorString) return; + _errorString = errorString; + Q_EMIT errorStringChanged(); +} +} diff --git a/src/gui/search/unifiedsearchpeoplemodel.h b/src/gui/search/unifiedsearchpeoplemodel.h new file mode 100644 index 0000000000000..c763a94023bc4 --- /dev/null +++ b/src/gui/search/unifiedsearchpeoplemodel.h @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#pragma once + +#include "accountstate.h" + +#include +#include +#include + +namespace OCC { + +/** @brief Account-authenticated user suggestions for Unified Search. */ +class UnifiedSearchPeopleModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(AccountState *accountState READ accountState WRITE setAccountState NOTIFY accountStateChanged) + Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) + Q_PROPERTY(bool busy READ busy NOTIFY busyChanged) + Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) + +public: + enum Role { UserIdRole = Qt::UserRole + 1, DisplayNameRole, AvatarUrlRole }; + + explicit UnifiedSearchPeopleModel(QObject *parent = nullptr, int debounceInterval = 300); + ~UnifiedSearchPeopleModel() override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + [[nodiscard]] int rowCount(const QModelIndex &parent = {}) const override; + [[nodiscard]] QHash roleNames() const override; + [[nodiscard]] AccountState *accountState() const; + [[nodiscard]] QString searchTerm() const; + [[nodiscard]] bool busy() const; + [[nodiscard]] QString errorString() const; + Q_INVOKABLE void retry(); + +public Q_SLOTS: + void setAccountState(AccountState *accountState); + void setSearchTerm(const QString &searchTerm); + +Q_SIGNALS: + void accountStateChanged(); + void searchTermChanged(); + void busyChanged(); + void errorStringChanged(); + +private: + struct Person { QString id; QString displayName; QString avatarUrl; }; + void startSearch(); + void cancel(); + void clearPeople(); + void replacePeople(QVector people); + void setBusy(bool busy); + void setErrorString(const QString &errorString); + + QPointer _accountState; + QVector _people; + QString _searchTerm; + QString _errorString; + QTimer _debounceTimer; + QPointer _job; + quint64 _generation = 0; + bool _busy = false; +}; +} diff --git a/src/gui/search/unifiedsearchresult.cpp b/src/gui/search/unifiedsearchresult.cpp index 928266fbc7328..a4fe0ace870e7 100644 --- a/src/gui/search/unifiedsearchresult.cpp +++ b/src/gui/search/unifiedsearchresult.cpp @@ -18,9 +18,21 @@ QString UnifiedSearchResult::typeAsString(UnifiedSearchResult::Type type) result = QStringLiteral("Default"); break; + case ProviderHeader: + result = QStringLiteral("ProviderHeader"); + break; + + case PartialMatchesHeader: + result = QStringLiteral("PartialMatchesHeader"); + break; + case FetchMoreTrigger: result = QStringLiteral("FetchMoreTrigger"); break; + + case RetryFetchMoreTrigger: + result = QStringLiteral("RetryFetchMoreTrigger"); + break; } return result; } diff --git a/src/gui/search/unifiedsearchresult.h b/src/gui/search/unifiedsearchresult.h index b291b8c6cb676..f7d17247214d4 100644 --- a/src/gui/search/unifiedsearchresult.h +++ b/src/gui/search/unifiedsearchresult.h @@ -21,7 +21,10 @@ struct UnifiedSearchResult { enum Type : quint8 { Default, + ProviderHeader, + PartialMatchesHeader, FetchMoreTrigger, + RetryFetchMoreTrigger, }; static QString typeAsString(UnifiedSearchResult::Type type); @@ -30,7 +33,14 @@ struct UnifiedSearchResult QString _subline; QString _providerId; QString _providerName; + QString _providerIcon; + QString _stableKey; bool _isRounded = false; + bool _isSelected = false; + bool _isSelectable = false; + bool _isPartialMatch = false; + bool _hasOverflow = false; + bool _isLoading = false; qint32 _order = std::numeric_limits::max(); QUrl _resourceUrl; QString _darkIcons; diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp index 3f59109955d83..0db895a50ccac 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.cpp +++ b/src/gui/search/unifiedsearchresultslistmodel.cpp @@ -7,18 +7,28 @@ #include "account.h" #include "accountstate.h" -#include "guiutility.h" #include "folderman.h" +#include "guiutility.h" #include "networkjobs.h" -#include - -#include #include +#include +#include +#include +#include +#include +#include +#include + +#include +#include using namespace Qt::StringLiterals; namespace { +constexpr qsizetype aggregateResultsPerProvider = 3; +constexpr auto requestResultsPerProvider = 10; + QString imagePlaceholderUrlForProviderId(const QString &providerId, const bool darkMode) { const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/"); @@ -64,737 +74,1506 @@ QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix, const boo QString iconUrlForDefaultIconName(const QString &defaultIconName, const bool darkMode) { const QUrl urlForIcon{defaultIconName}; - if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) { return defaultIconName; } const auto colorIconPath = darkMode ? QStringLiteral(":/client/theme/white/") : QStringLiteral(":/client/theme/black/"); - if (defaultIconName.startsWith(QStringLiteral("icon-"))) { const auto parts = defaultIconName.split(u'-'); - if (parts.size() > 1) { - const QString blackOrWhiteIconFilePath = colorIconPath + parts[1] + QStringLiteral(".svg"); - - if (QFile::exists(blackOrWhiteIconFilePath)) { - return blackOrWhiteIconFilePath; + const auto themedIcon = colorIconPath + parts[1] + QStringLiteral(".svg"); + if (QFile::exists(themedIcon)) { + return themedIcon; } - - const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg"); - - if (QFile::exists(iconFilePath)) { - return iconFilePath; + const auto icon = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg"); + if (QFile::exists(icon)) { + return icon; } } - - const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName, darkMode); - - if (!iconNameFromIconPrefix.isEmpty()) { - return iconNameFromIconPrefix; - } + return localIconPathFromIconPrefix(defaultIconName, darkMode); } return colorIconPath % QStringLiteral("change.svg"); } -QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl) +QString absoluteServerResource(const QString &resource, const QUrl &serverUrl) { - auto serverUrlCopy = serverUrl; - auto thumbnailUrlCopy = thumbnailUrl; - - if (thumbnailUrlCopy.startsWith(u'/') || thumbnailUrlCopy.startsWith(u'\\')) { - // relative image resource URL, just needs some concatenation with current server URL - // some icons may contain parameters after (?) - const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(u'?') - ? thumbnailUrlCopy.split(u'?', Qt::SkipEmptyParts) - : QStringList{thumbnailUrlCopy}; - Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty()); - serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]); - thumbnailUrlCopy = serverUrlCopy.toString(); - if (thumbnailUrlCopySplitted.size() > 1) { - thumbnailUrlCopy += u'?' + thumbnailUrlCopySplitted[1]; - } + if (resource.isEmpty()) { + return {}; } - return thumbnailUrlCopy; -} - -QString generateUrlForIcon(const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode) -{ - auto serverUrlCopy = serverUrl; - - auto fallbackIconCopy = fallbackIcon; + const auto url = QUrl(resource); + if (!url.isRelative() && !url.scheme().isEmpty()) { + return resource; + } - if (fallbackIconCopy.startsWith(u'/') || fallbackIconCopy.startsWith(u'\\')) { - // relative image resource URL, just needs some concatenation with current server URL - // some icons may contain parameters after (?) - const QStringList fallbackIconPathSplitted = - fallbackIconCopy.contains(u'?') ? fallbackIconCopy.split(u'?') : QStringList{fallbackIconCopy}; - Q_ASSERT(!fallbackIconPathSplitted.isEmpty()); - serverUrlCopy.setPath(fallbackIconPathSplitted[0]); - fallbackIconCopy = serverUrlCopy.toString(); - if (fallbackIconPathSplitted.size() > 1) { - fallbackIconCopy += u'?' + fallbackIconPathSplitted[1]; - } - } else if (!fallbackIconCopy.isEmpty()) { - // could be one of names for standard icons (e.g. icon-mail) - const auto defaultIconUrl = iconUrlForDefaultIconName(fallbackIconCopy, darkMode); - if (!defaultIconUrl.isEmpty()) { - fallbackIconCopy = defaultIconUrl; + auto absoluteUrl = serverUrl; + if (resource.startsWith(u'/') || resource.startsWith(u'\\')) { + const auto queryPosition = resource.indexOf(u'?'); + absoluteUrl.setPath(queryPosition < 0 ? resource : resource.left(queryPosition)); + if (queryPosition >= 0) { + absoluteUrl.setQuery(resource.mid(queryPosition + 1)); } + } else { + absoluteUrl = serverUrl.resolved(QUrl(resource)); } - - return fallbackIconCopy; + return absoluteUrl.toString(); } -// Return image URL and whether it is a thumbnail or not -std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallbackIcon, const QUrl &serverUrl, const bool darkMode) +QString normalizedIcon(const QString &icon, const QUrl &serverUrl, const bool darkMode) { - if (thumbnailUrl.isEmpty() && fallbackIcon.isEmpty()) { + if (icon.isEmpty()) { return {}; } - - if (serverUrl.isEmpty()) { - const QStringList listImages = {thumbnailUrl, fallbackIcon}; - return {listImages.join(u';'), false}; + if (icon.startsWith(QStringLiteral(":/"))) { + return icon; } + if (icon.startsWith(u'/') || icon.startsWith(u'\\')) { + return absoluteServerResource(icon, serverUrl); + } + return iconUrlForDefaultIconName(icon, darkMode); +} - const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl); - const auto urlForFallbackIcon = generateUrlForIcon(fallbackIcon, serverUrl, darkMode); - - qDebug() << "SEARCH" << urlForThumbnail << urlForFallbackIcon; +std::pair iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, + const QString &fallbackIcon, + const QUrl &serverUrl, + const bool darkMode) +{ + const auto thumbnail = absoluteServerResource(thumbnailUrl, serverUrl); + const auto icon = normalizedIcon(fallbackIcon, serverUrl, darkMode); + auto icons = QStringList{}; + if (!thumbnail.isEmpty()) { + icons.push_back(thumbnail); + } + if (!icon.isEmpty()) { + icons.push_back(icon); + } + return {icons.join(u';'), !thumbnail.isEmpty()}; +} - if (urlForThumbnail.isEmpty() && !urlForFallbackIcon.isEmpty()) { - return {urlForFallbackIcon, false}; +QString navigationAppIconForResult(const OCC::AccountState *accountState, + const QString &providerId, + const QString &resultTitle, + const bool darkMode) +{ + if (!accountState || providerId != QStringLiteral("settings_apps")) { + return {}; } - if (!urlForThumbnail.isEmpty() && urlForFallbackIcon.isEmpty()) { - return {urlForThumbnail, true}; + const auto apps = accountState->appList(); + const auto appIt = std::find_if(apps.cbegin(), apps.cend(), [&resultTitle](const auto *app) { + return app && app->name().compare(resultTitle, Qt::CaseInsensitive) == 0; + }); + if (appIt == apps.cend() || (*appIt)->iconUrl().isEmpty()) { + return {}; } - const QStringList listImages{urlForThumbnail, urlForFallbackIcon}; - return {listImages.join(u';'), true}; + return (*appIt)->iconUrl().toString() + % (darkMode ? QStringLiteral("/white") : QStringLiteral("/black")); } - -constexpr int searchTermEditingFinishedSearchStartDelay = 800; - -// server-side bug of returning the cursor > 0 and isPaginated == 'true', using '5' as it is done on Android client's end now -constexpr int minimumEntresNumberToShowLoadMore = 5; } + namespace OCC { Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg) -UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent) +UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, + QObject *parent, + int debounceInterval, + int revealInterval) : QAbstractListModel(parent) , _accountState(accountState) { + _debounceTimer.setSingleShot(true); + _debounceTimer.setInterval(debounceInterval); + _revealTimer.setSingleShot(true); + _revealTimer.setInterval(revealInterval); + + connect(&_debounceTimer, &QTimer::timeout, this, [this] { + if (_providersReady) { + startSearch(); + } else { + _pendingSearch = true; + if (!_providersLoading) { + discoverProviders(); + } + } + setWaitingForSearchTermEditEnd(false); + }); + connect(&_revealTimer, &QTimer::timeout, this, &UnifiedSearchResultsListModel::closeRevealWindow); + connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); + connect(this, &UnifiedSearchResultsListModel::isSearchInProgressChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); + connect(this, &UnifiedSearchResultsListModel::searchTermChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); connect(this, &UnifiedSearchResultsListModel::errorStringChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); connect(this, &UnifiedSearchResultsListModel::waitingForSearchTermEditEndChanged, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &QAbstractListModel::rowsInserted, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &QAbstractListModel::rowsRemoved, this, &UnifiedSearchResultsListModel::searchStateChanged); connect(this, &QAbstractListModel::modelReset, this, &UnifiedSearchResultsListModel::searchStateChanged); - connect(this, &UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged); + connect(this, &UnifiedSearchResultsListModel::providersChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); + connect(this, &UnifiedSearchResultsListModel::viewModeChanged, this, &UnifiedSearchResultsListModel::showConnectedServicesActionChanged); + + _lastKnownConnected = isAccountConnected(); if (_accountState) { - connect(_accountState, &AccountState::isConnectedChanged, this, &UnifiedSearchResultsListModel::canEditSearchChanged); + connect(_accountState, &AccountState::isConnectedChanged, this, [this] { + const auto connected = isAccountConnected(); + Q_EMIT canEditSearchChanged(); + if (_lastKnownConnected && !connected) { + const auto wasInProgress = isSearchInProgress(); + ++_queryGeneration; + abortSearchJobs(); + abortProviderDiscovery(); + _debounceTimer.stop(); + setWaitingForSearchTermEditEnd(false); + resetProviderRuntime(); + setProvidersReady(false); + rebuildProjection(); + setErrorString(tr("Search is unavailable while this account is offline.")); + updateProgressSignals(wasInProgress); + } else if (!_lastKnownConnected && connected) { + setErrorString({}); + _pendingSearch = hasSearchTerm(); + discoverProviders(); + } + _lastKnownConnected = connected; + }); + } + + if (isAccountConnected()) { + QTimer::singleShot(0, this, &UnifiedSearchResultsListModel::discoverProviders); + } else { + setErrorString(tr("Search is unavailable while this account is offline.")); } } -QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const +UnifiedSearchResultsListModel::~UnifiedSearchResultsListModel() { - Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); + abortSearchJobs(); + abortProviderDiscovery(); +} +QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const +{ + if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) { + return {}; + } + const auto &result = _results.at(index.row()); switch (role) { case ProviderNameRole: - return _results.at(index.row())._providerName; + return result._providerName; case ProviderIdRole: - return _results.at(index.row())._providerId; + return result._providerId; + case ProviderIconRole: + return result._providerIcon; case DarkImagePlaceholderRole: - return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, true); + return imagePlaceholderUrlForProviderId(result._providerId, true); case LightImagePlaceholderRole: - return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId, false); + return imagePlaceholderUrlForProviderId(result._providerId, false); case DarkIconsRole: - return _results.at(index.row())._darkIcons; + return result._darkIcons; case LightIconsRole: - return _results.at(index.row())._lightIcons; + return result._lightIcons; case DarkIconsIsThumbnailRole: - return _results.at(index.row())._darkIconsIsThumbnail; + return result._darkIconsIsThumbnail; case LightIconsIsThumbnailRole: - return _results.at(index.row())._lightIconsIsThumbnail; + return result._lightIconsIsThumbnail; case TitleRole: - return _results.at(index.row())._title; + return result._title; case SublineRole: - return _results.at(index.row())._subline; + return result._subline; case ResourceUrlRole: - return _results.at(index.row())._resourceUrl; + return result._resourceUrl; case RoundedRole: - return _results.at(index.row())._isRounded; + return result._isRounded; case TypeRole: - return _results.at(index.row())._type; + return static_cast(result._type); case TypeAsStringRole: - return UnifiedSearchResult::typeAsString(_results.at(index.row())._type); + return UnifiedSearchResult::typeAsString(result._type); + case StableKeyRole: + return result._stableKey; + case SelectedRole: + return result._isSelected; + case SelectableRole: + return result._isSelectable; + case PartialMatchRole: + return result._isPartialMatch; + case HasOverflowRole: + return result._hasOverflow; + case LoadingRole: + return result._isLoading; } - return {}; } int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const { - if (parent.isValid()) { - return 0; - } - - return _results.size(); + return parent.isValid() ? 0 : _results.size(); } QHash UnifiedSearchResultsListModel::roleNames() const { - auto roles = QAbstractListModel::roleNames(); - roles[ProviderNameRole] = "providerName"; - roles[ProviderIdRole] = "providerId"; - roles[DarkIconsRole] = "darkIcons"; - roles[LightIconsRole] = "lightIcons"; - roles[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail"; - roles[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail"; - roles[DarkImagePlaceholderRole] = "darkImagePlaceholder"; - roles[LightImagePlaceholderRole] = "lightImagePlaceholder"; - roles[TitleRole] = "resultTitle"; - roles[SublineRole] = "subline"; - roles[ResourceUrlRole] = "resourceUrlRole"; - roles[TypeRole] = "type"; - roles[TypeAsStringRole] = "typeAsString"; - roles[RoundedRole] = "isRounded"; + static const auto roles = [this] { + auto result = QAbstractListModel::roleNames(); + result[ProviderNameRole] = "providerName"; + result[ProviderIdRole] = "providerId"; + result[ProviderIconRole] = "providerIcon"; + result[DarkIconsRole] = "darkIcons"; + result[LightIconsRole] = "lightIcons"; + result[DarkIconsIsThumbnailRole] = "darkIconsIsThumbnail"; + result[LightIconsIsThumbnailRole] = "lightIconsIsThumbnail"; + result[DarkImagePlaceholderRole] = "darkImagePlaceholder"; + result[LightImagePlaceholderRole] = "lightImagePlaceholder"; + result[TitleRole] = "resultTitle"; + result[SublineRole] = "subline"; + result[ResourceUrlRole] = "resourceUrlRole"; + result[TypeRole] = "type"; + result[TypeAsStringRole] = "typeAsString"; + result[RoundedRole] = "isRounded"; + result[StableKeyRole] = "stableKey"; + result[SelectedRole] = "isSelected"; + result[SelectableRole] = "isSelectable"; + result[PartialMatchRole] = "isPartialMatch"; + result[HasOverflowRole] = "hasOverflow"; + result[LoadingRole] = "isLoading"; + return result; + }(); return roles; } -QString UnifiedSearchResultsListModel::searchTerm() const +bool UnifiedSearchResultsListModel::isSearchInProgress() const { - return _searchTerm; + return _inFlightSearchRequests > 0 || (_providersLoading && hasSearchTerm()); } -QString UnifiedSearchResultsListModel::errorString() const +QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const { return _currentFetchMoreInProgressProviderId; } +QString UnifiedSearchResultsListModel::searchTerm() const { return _searchTerm; } +QString UnifiedSearchResultsListModel::errorString() const { return _errorString; } +bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const { return _waitingForSearchTermEditEnd; } +bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const { return !_currentFetchMoreInProgressProviderId.isEmpty(); } +bool UnifiedSearchResultsListModel::hasSearchTerm() const { return !_searchTerm.trimmed().isEmpty(); } +bool UnifiedSearchResultsListModel::hasSearchError() const { return !_errorString.isEmpty(); } +bool UnifiedSearchResultsListModel::canEditSearch() const { return _accountState && _accountState->account() && isAccountConnected(); } +bool UnifiedSearchResultsListModel::isAccountConnected() const { return _accountState && _accountState->isConnected(); } + +UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const { - return _errorString; + if (!_results.isEmpty()) { + return SearchState::Results; + } + if (hasSearchError() && !isSearchInProgress()) { + return SearchState::SearchError; + } + if (!hasSearchTerm()) { + return SearchState::Placeholder; + } + if (_waitingForSearchTermEditEnd || isSearchInProgress()) { + return SearchState::Skeleton; + } + return SearchState::NothingFound; } -QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const +QVariantList UnifiedSearchResultsListModel::providers() const { - return _currentFetchMoreInProgressProviderId; + QVariantList result; + for (const auto &providerId : _providerOrder) { + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend()) { + continue; + } + const auto &provider = providerIt.value(); + result.push_back(QVariantMap{ + {QStringLiteral("id"), provider.id}, + {QStringLiteral("name"), provider.name}, + {QStringLiteral("icon"), providerIcon(provider, false)}, + {QStringLiteral("selected"), _selectedProviderIds.contains(provider.id)}, + {QStringLiteral("external"), provider.isExternalProvider}, + }); + } + return result; } -bool UnifiedSearchResultsListModel::waitingForSearchTermEditEnd() const +QVariantList UnifiedSearchResultsListModel::activeFilters() const { - return _waitingForSearchTermEditEnd; + QVariantList result; + for (const auto &providerId : _selectedProviderIds) { + if (!_providers.contains(providerId)) { + continue; + } + const auto &provider = _providers.constFind(providerId).value(); + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("provider")}, + {QStringLiteral("id"), providerId}, + {QStringLiteral("label"), provider.name}, + {QStringLiteral("icon"), providerIcon(provider, false)}}); + } + if (_since.isValid() && _until.isValid()) { + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("date")}, + {QStringLiteral("id"), QStringLiteral("date")}, + {QStringLiteral("label"), _dateLabel}}); + } + if (!_personId.isEmpty()) { + result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("person")}, + {QStringLiteral("id"), _personId}, + {QStringLiteral("label"), _personName}, + {QStringLiteral("icon"), _personAvatarUrl}}); + } + return result; +} + +bool UnifiedSearchResultsListModel::providersReady() const { return _providersReady; } + +bool UnifiedSearchResultsListModel::dateFilterAvailable() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { + return provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until")); + }); +} + +bool UnifiedSearchResultsListModel::peopleFilterAvailable() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { + return provider.filters.contains(QStringLiteral("person")); + }); +} + +bool UnifiedSearchResultsListModel::hasExternalProviders() const +{ + return std::any_of(_providers.cbegin(), _providers.cend(), [](const auto &provider) { return provider.isExternalProvider; }); } +bool UnifiedSearchResultsListModel::externalProvidersEnabled() const { return _externalProvidersEnabled; } + +bool UnifiedSearchResultsListModel::showConnectedServicesAction() const +{ + return _viewMode == ViewMode::Aggregate && hasSearchTerm() && hasExternalProviders() && !isSearchInProgress(); +} + +bool UnifiedSearchResultsListModel::hasPartialFailure() const { return _hasPartialFailure; } +UnifiedSearchResultsListModel::ViewMode UnifiedSearchResultsListModel::viewMode() const { return _viewMode; } + +QString UnifiedSearchResultsListModel::detailProviderName() const +{ + const auto providerIt = _providers.constFind(_detailProviderId); + return providerIt == _providers.cend() ? QString() : providerIt->name; +} + +int UnifiedSearchResultsListModel::selectedRow() const { return _selectedRow; } +QString UnifiedSearchResultsListModel::accessibilityStatus() const { return _accessibilityStatus; } +AccountState *UnifiedSearchResultsListModel::accountState() const { return _accountState; } + void UnifiedSearchResultsListModel::setSearchTerm(const QString &term) { - if (term == _searchTerm) { + if (_searchTerm == term) { return; } - _searchTerm = term; Q_EMIT searchTermChanged(); + scheduleSearch(); +} + +void UnifiedSearchResultsListModel::discoverProviders() +{ + if (!_accountState || !_accountState->account()) { + setErrorString(tr("Failed to fetch search providers.")); + return; + } + if (!isAccountConnected()) { + setErrorString(tr("Search is unavailable while this account is offline.")); + return; + } - if (!_errorString.isEmpty()) { - _errorString.clear(); - Q_EMIT errorStringChanged(); + const auto wasInProgress = isSearchInProgress(); + abortProviderDiscovery(); + _providersLoading = true; + setProvidersReady(false); + const auto generation = ++_providerGeneration; + + auto *const job = new JsonApiJob(_accountState->account(), QStringLiteral("ocs/v2.php/search/providers")); + _providerDiscoveryJob = job; + connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job](const QJsonDocument &json, const int statusCode) { + providerDiscoveryFinished(json, statusCode, generation, job); + }); + connect(job, &QObject::destroyed, this, [this, job] { + if (_providerDiscoveryJob == job) { + _providerDiscoveryJob.clear(); + } + }); + job->start(); + updateProgressSignals(wasInProgress); +} + +void UnifiedSearchResultsListModel::providerDiscoveryFinished(const QJsonDocument &json, + int statusCode, + quint64 generation, + QObject *jobObject) +{ + if (generation != _providerGeneration || jobObject != _providerDiscoveryJob) { + return; } + const auto wasInProgress = isSearchInProgress(); + _providerDiscoveryJob.clear(); + _providersLoading = false; + _providers.clear(); + _providerOrder.clear(); - disconnectAndClearSearchJobs(); + if (statusCode != 200) { + if (!_selectedProviderIds.isEmpty()) { + _selectedProviderIds.clear(); + Q_EMIT activeFiltersChanged(); + } + setErrorString(tr("Failed to fetch search providers.")); + Q_EMIT providersChanged(); + updateProgressSignals(wasInProgress); + return; + } - clearCurrentFetchMoreInProgressProviderId(); + const auto providerList = json.object().value("ocs"_L1).toObject().value("data"_L1).toArray(); + QVector appOrder; + QHash> providersByApp; + auto discoveryIndex = 0; + for (const auto &providerValue : providerList) { + const auto providerObject = providerValue.toObject(); + UnifiedSearchProvider provider; + provider.id = providerObject.value("id"_L1).toString(); + provider.name = providerObject.value("name"_L1).toString(); + if (provider.id.isEmpty() || provider.name.isEmpty()) { + continue; + } + provider.appId = providerObject.value("appId"_L1).toString(provider.id); + provider.icon = providerObject.value("icon"_L1).toString(); + provider.order = providerObject.contains("order"_L1) + ? providerObject.value("order"_L1).toInt(std::numeric_limits::max()) + : std::numeric_limits::max(); + provider.discoveryIndex = discoveryIndex++; + const auto externalValue = providerObject.value("isExternalProvider"_L1); + provider.isExternalProvider = externalValue.isBool() + ? externalValue.toBool() + : externalValue.toString().compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0; + const auto filtersValue = providerObject.value("filters"_L1); + if (filtersValue.isUndefined() || filtersValue.isNull()) { + provider.filters.insert(QStringLiteral("term")); + } else if (filtersValue.isArray()) { + for (const auto &filter : filtersValue.toArray()) { + provider.filters.insert(filter.toString()); + } + if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term")); + } else { + const auto filters = filtersValue.toObject(); + for (auto it = filters.constBegin(); it != filters.constEnd(); ++it) { + provider.filters.insert(it.key()); + } + if (provider.filters.isEmpty()) provider.filters.insert(QStringLiteral("term")); + } + _providers.insert(provider.id, provider); + if (!providersByApp.contains(provider.appId)) { + appOrder.push_back(provider.appId); + } + providersByApp[provider.appId].push_back(provider.id); + } + const auto providerLess = [this](const QString &leftId, const QString &rightId) { + const auto &left = _providers[leftId]; + const auto &right = _providers[rightId]; + return std::tie(left.order, left.discoveryIndex) < std::tie(right.order, right.discoveryIndex); + }; + for (auto it = providersByApp.begin(); it != providersByApp.end(); ++it) { + std::stable_sort(it->begin(), it->end(), providerLess); + } + std::stable_sort(appOrder.begin(), appOrder.end(), [&providersByApp, &providerLess](const QString &leftApp, const QString &rightApp) { + return providerLess(providersByApp[leftApp].constFirst(), providersByApp[rightApp].constFirst()); + }); + for (const auto &appId : appOrder) { + _providerOrder.append(providersByApp.value(appId)); + } - disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + const auto previousSelectedProviderIds = _selectedProviderIds; + _selectedProviderIds.removeIf([this](const auto &providerId) { + return !_providers.contains(providerId); + }); + if (_selectedProviderIds != previousSelectedProviderIds) { + Q_EMIT activeFiltersChanged(); + } - if (_unifiedSearchTextEditingFinishedTimer.isActive()) { - _unifiedSearchTextEditingFinishedTimer.stop(); - _waitingForSearchTermEditEnd = false; - Q_EMIT waitingForSearchTermEditEndChanged(); + setProvidersReady(!_providers.isEmpty()); + setErrorString(_providersReady ? QString() : tr("No search providers are available.")); + Q_EMIT providersChanged(); + updateProgressSignals(wasInProgress); + + if (_pendingSearch && hasSearchTerm() && _providersReady) { + _pendingSearch = false; + startSearch(); } +} - if (!_searchTerm.isEmpty()) { - _unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay); - connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); - _unifiedSearchTextEditingFinishedTimer.start(); - _waitingForSearchTermEditEnd = true; - Q_EMIT waitingForSearchTermEditEndChanged(); +void UnifiedSearchResultsListModel::scheduleSearch() +{ + const auto wasInProgress = isSearchInProgress(); + ++_queryGeneration; + abortSearchJobs(); + _revealTimer.stop(); + _debounceTimer.stop(); + _pendingSearch = false; + _revealWindowClosed = false; + resetProviderRuntime(); + setErrorString({}); + if (_viewMode != ViewMode::Aggregate) { + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + Q_EMIT viewModeChanged(); } + _aggregateSelectedStableKey.clear(); + rebuildProjection(); - if (!_results.isEmpty()) { - beginResetModel(); - _results.clear(); - endResetModel(); + if (hasSearchTerm()) { + setWaitingForSearchTermEditEnd(true); + _debounceTimer.start(); + } else { + setWaitingForSearchTermEditEnd(false); + setAccessibilityStatus(tr("Search cleared")); } + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::isSearchInProgress() const +void UnifiedSearchResultsListModel::startSearch() { - return !_searchJobConnections.isEmpty(); + if (!_accountState || !_accountState->account() || !isAccountConnected() || !hasSearchTerm() || !_providersReady) { + return; + } + const auto wasInProgress = isSearchInProgress(); + abortSearchJobs(); + _revealOrder.clear(); + _revealWindowClosed = false; + resetProviderRuntime(); + setErrorString({}); + + auto requestCount = 0; + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + provider.partialMatch = !providerSupportsAllContentFilters(provider); + provider.status = ProviderStatus::Loading; + ++requestCount; + } + if (requestCount == 0) { + rebuildProjection(); + updateAccessibilityStatus(); + updateProgressSignals(wasInProgress); + return; + } + + _revealTimer.start(); + for (const auto &providerId : _providerOrder) { + if (_providers[providerId].status == ProviderStatus::Loading) { + startSearchForProvider(providerId); + } + } + setAccessibilityStatus(tr("Searching")); + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::isFetchMoreInProgress() const +void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, bool pagination) { - return !_currentFetchMoreInProgressProviderId.isEmpty(); + if (!_accountState || !_accountState->account() || !_providers.contains(providerId)) { + return; + } + auto &provider = _providers[providerId]; + const auto generation = _queryGeneration; + auto *const job = new JsonApiJob(_accountState->account(), + QStringLiteral("ocs/v2.php/search/providers/%1/search").arg(QString::fromUtf8(QUrl::toPercentEncoding(providerId)))); + job->addQueryParams(queryForProvider(provider, pagination)); + connect(job, &JsonApiJob::jsonReceived, this, + [this, providerId, pagination, generation, job](const QJsonDocument &json, const int statusCode) { + providerSearchFinished(json, statusCode, providerId, pagination, generation, job); + }); + trackSearchJob(job); + if (pagination) { + provider.paging = true; + provider.loadMoreFailed = false; + _currentFetchMoreInProgressProviderId = providerId; + Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + if (!updateDetailPagingState(provider)) { + rebuildProjection(); + } + } + job->start(); } -bool UnifiedSearchResultsListModel::hasSearchTerm() const +void UnifiedSearchResultsListModel::providerSearchFinished(const QJsonDocument &json, + int statusCode, + const QString &providerId, + bool pagination, + quint64 generation, + QObject *jobObject) { - return !_searchTerm.isEmpty(); + const auto wasInProgress = isSearchInProgress(); + untrackSearchJob(jobObject); + if (generation != _queryGeneration || !_providers.contains(providerId)) { + updateProgressSignals(wasInProgress); + return; + } + + auto &provider = _providers[providerId]; + const auto previousEntryCount = provider.entries.size(); + if (pagination) { + provider.paging = false; + if (_currentFetchMoreInProgressProviderId == providerId) { + _currentFetchMoreInProgressProviderId.clear(); + Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + } + } + + if (statusCode != 200) { + if (pagination) { + provider.loadMoreFailed = true; + } else { + provider.status = ProviderStatus::Failed; + } + } else { + const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); + auto parsedEntries = parseEntries(data.value("entries"_L1).toArray(), provider, + pagination ? provider.entries.size() : 0); + const auto pageHasMore = !parsedEntries.isEmpty() + && data.value("isPaginated"_L1).toBool(false) + && !data.value("cursor"_L1).isNull() + && !data.value("cursor"_L1).isUndefined(); + if (pagination) { + provider.entries.append(std::move(parsedEntries)); + provider.hasMore = pageHasMore; + } else { + provider.entries = std::move(parsedEntries); + provider.status = ProviderStatus::Blocked; + provider.hasMore = pageHasMore; + } + provider.cursor = data.value("cursor"_L1); + provider.loadMoreFailed = false; + } + + if (!pagination) { + promoteReadyProviders(); + updateAggregateState(); + } else if (!updateDetailProjectionAfterPagination(provider, previousEntryCount)) { + rebuildProjection(); + } + updateAccessibilityStatus(); + updateProgressSignals(wasInProgress); } -bool UnifiedSearchResultsListModel::hasSearchError() const +QVector UnifiedSearchResultsListModel::parseEntries(const QJsonArray &entries, + const UnifiedSearchProvider &provider, + int firstEntryIndex) const { - return !_errorString.isEmpty(); + QVector parsedEntries; + const auto account = _accountState ? _accountState->account() : AccountPtr(); + const auto accountUrl = account ? account->url() : QUrl(); + auto entryIndex = firstEntryIndex; + for (const auto &entryValue : entries) { + if (parsedEntries.size() >= requestResultsPerProvider) { + break; + } + const auto entry = entryValue.toObject(); + if (entry.isEmpty()) { + continue; + } + UnifiedSearchResult result; + result._providerId = provider.id; + result._providerName = provider.name; + result._order = provider.order; + result._isRounded = entry.value("rounded"_L1).toBool(false); + result._title = entry.value("title"_L1).toString(); + result._subline = entry.value("subline"_L1).toString(); + result._resourceUrl = openableResourceUrl(QUrl(entry.value("resourceUrl"_L1).toString()), accountUrl); + const auto entryIcon = entry.value("icon"_L1).toString(); + const auto darkNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, true); + const auto lightNavigationAppIcon = navigationAppIconForResult(_accountState, provider.id, result._title, false); + const auto darkFallbackIcon = !darkNavigationAppIcon.isEmpty() + ? darkNavigationAppIcon + : (entryIcon.isEmpty() ? providerIcon(provider, true) : entryIcon); + const auto lightFallbackIcon = !lightNavigationAppIcon.isEmpty() + ? lightNavigationAppIcon + : (entryIcon.isEmpty() ? providerIcon(provider, false) : entryIcon); + const auto darkIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), darkFallbackIcon, accountUrl, true); + const auto lightIcons = iconsFromThumbnailAndFallbackIcon(entry.value("thumbnailUrl"_L1).toString(), lightFallbackIcon, accountUrl, false); + result._darkIcons = darkIcons.first; + result._lightIcons = lightIcons.first; + result._darkIconsIsThumbnail = darkIcons.second; + result._lightIconsIsThumbnail = lightIcons.second; + result._providerIcon = providerIcon(provider, false); + result._isSelectable = true; + result._stableKey = stableKeyForResult(provider.id, result, entryIndex++); + parsedEntries.push_back(result); + } + return parsedEntries; } -bool UnifiedSearchResultsListModel::canEditSearch() const +void UnifiedSearchResultsListModel::promoteReadyProviders() { - return isAccountConnected() && !isFetchMoreInProgress(); + auto unresolvedPredecessor = false; + auto changed = false; + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + if (provider.status == ProviderStatus::Loading) { + unresolvedPredecessor = true; + } else if (provider.status == ProviderStatus::Blocked) { + if (_revealWindowClosed || !unresolvedPredecessor) { + provider.status = ProviderStatus::Loaded; + if (!_revealOrder.contains(providerId)) { + _revealOrder.push_back(providerId); + } + changed = true; + } else { + unresolvedPredecessor = true; + } + } + } + if (changed) { + rebuildProjection(); + } } -bool UnifiedSearchResultsListModel::isAccountConnected() const +void UnifiedSearchResultsListModel::closeRevealWindow() { - return _accountState && _accountState->isConnected(); + _revealWindowClosed = true; + promoteReadyProviders(); } -UnifiedSearchResultsListModel::SearchState UnifiedSearchResultsListModel::searchState() const +void UnifiedSearchResultsListModel::rebuildProjection() { - if (rowCount() > 0) { - return SearchState::Results; + if (_selectedRow >= 0 && _selectedRow < _results.size() && _results[_selectedRow]._isSelectable) { + _selectedStableKey = _results[_selectedRow]._stableKey; } - if (hasSearchError()) { - return (!isSearchInProgress() && !isFetchMoreInProgress()) ? SearchState::SearchError : SearchState::None; + QVector projection; + if (_viewMode == ViewMode::ProviderDetail && _providers.contains(_detailProviderId)) { + const auto &provider = _providers[_detailProviderId]; + projection = provider.entries; + if (provider.loadMoreFailed || provider.hasMore) { + projection.push_back(pagingRowForProvider(provider)); + } + } else { + QSet filteredResourceUrls; + for (const auto &providerId : _revealOrder) { + const auto &provider = _providers[providerId]; + if (provider.partialMatch) { + continue; + } + for (const auto &entry : provider.entries) { + if (!entry._resourceUrl.isEmpty()) { + filteredResourceUrls.insert(entry._resourceUrl.toString()); + } + } + appendProviderProjection(provider, false, {}, projection); + } + + auto partialHeaderAdded = false; + for (const auto &providerId : _revealOrder) { + const auto &provider = _providers[providerId]; + if (!provider.partialMatch || provider.entries.isEmpty()) { + continue; + } + const auto hasVisibleEntry = std::any_of(provider.entries.cbegin(), provider.entries.cend(), + [&filteredResourceUrls](const auto &entry) { + return entry._resourceUrl.isEmpty() || !filteredResourceUrls.contains(entry._resourceUrl.toString()); + }); + if (!hasVisibleEntry) { + continue; + } + if (!partialHeaderAdded) { + UnifiedSearchResult header; + header._type = UnifiedSearchResult::Type::PartialMatchesHeader; + header._title = tr("Partial matches"); + header._stableKey = QStringLiteral("partial-matches"); + projection.push_back(header); + partialHeaderAdded = true; + } + appendProviderProjection(provider, true, filteredResourceUrls, projection); + } } - if (!hasSearchTerm()) { - return SearchState::Placeholder; + auto selectedRow = -1; + for (auto row = 0; row < projection.size(); ++row) { + if (!projection[row]._isSelectable) { + continue; + } + if (selectedRow < 0) { + selectedRow = row; + } + if (!_selectedStableKey.isEmpty() && projection[row]._stableKey == _selectedStableKey) { + selectedRow = row; + break; + } + } + for (auto row = 0; row < projection.size(); ++row) { + projection[row]._isSelected = row == selectedRow; } - if (!isSearchInProgress() && !waitingForSearchTermEditEnd()) { - return SearchState::NothingFound; + beginResetModel(); + _results = std::move(projection); + endResetModel(); + setSelectedRow(selectedRow); + if (selectedRow >= 0) { + _selectedStableKey = _results[selectedRow]._stableKey; + } else { + _selectedStableKey.clear(); } +} - return SearchState::Skeleton; +UnifiedSearchResult UnifiedSearchResultsListModel::pagingRowForProvider(const UnifiedSearchProvider &provider) const +{ + UnifiedSearchResult pagingRow; + pagingRow._providerId = provider.id; + pagingRow._providerName = provider.name; + pagingRow._type = provider.loadMoreFailed + ? UnifiedSearchResult::Type::RetryFetchMoreTrigger + : UnifiedSearchResult::Type::FetchMoreTrigger; + pagingRow._isLoading = provider.paging; + pagingRow._stableKey = QStringLiteral("paging:%1").arg(provider.id); + return pagingRow; } -void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const +bool UnifiedSearchResultsListModel::updateDetailPagingState(const UnifiedSearchProvider &provider) { - const QUrlQuery urlQuery{resourceUrl}; - const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::ComponentFormattingOption::FullyDecoded); - const auto fileName = - urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::ComponentFormattingOption::FullyDecoded); + if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id || _results.isEmpty()) { + return false; + } + const auto row = _results.size() - 1; + auto &pagingRow = _results[row]; + if (pagingRow._providerId != provider.id + || (pagingRow._type != UnifiedSearchResult::Type::FetchMoreTrigger + && pagingRow._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) { + return false; + } + pagingRow = pagingRowForProvider(provider); + Q_EMIT dataChanged(index(row), index(row), {TypeRole, TypeAsStringRole, LoadingRole}); + return true; +} - if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) { - if (!_accountState || !_accountState->account()) { - return; - } +bool UnifiedSearchResultsListModel::updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider, + const int previousEntryCount) +{ + if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != provider.id + || previousEntryCount < 0 || provider.entries.size() < previousEntryCount + || _results.size() != previousEntryCount + 1) { + return false; + } + const auto pagingRowIndex = _results.size() - 1; + if (_results[pagingRowIndex]._providerId != provider.id + || (_results[pagingRowIndex]._type != UnifiedSearchResult::Type::FetchMoreTrigger + && _results[pagingRowIndex]._type != UnifiedSearchResult::Type::RetryFetchMoreTrigger)) { + return false; + } - const QString relativePath = dir + u'/' + fileName; - const auto localFiles = - FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account()); + if (provider.loadMoreFailed) { + _results[pagingRowIndex] = pagingRowForProvider(provider); + Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex)); + return true; + } - if (!localFiles.isEmpty()) { - qCInfo(lcUnifiedSearch) << "Opening file:" << localFiles.constFirst(); - QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst())); - return; - } + const auto appendedCount = provider.entries.size() - previousEntryCount; + if (appendedCount == 0) { + beginRemoveRows({}, pagingRowIndex, pagingRowIndex); + _results.removeLast(); + endRemoveRows(); + return true; } - Utility::openBrowser(resourceUrl); + + _results[pagingRowIndex] = provider.entries[previousEntryCount]; + Q_EMIT dataChanged(index(pagingRowIndex), index(pagingRowIndex)); + + QVector tail; + tail.reserve(appendedCount); + for (auto entryIndex = previousEntryCount + 1; entryIndex < provider.entries.size(); ++entryIndex) { + tail.push_back(provider.entries[entryIndex]); + } + if (provider.hasMore) { + tail.push_back(pagingRowForProvider(provider)); + } + if (!tail.isEmpty()) { + const auto firstRow = _results.size(); + const auto lastRow = firstRow + tail.size() - 1; + beginInsertRows({}, firstRow, lastRow); + _results.append(tail); + endInsertRows(); + } + return true; } -void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) +void UnifiedSearchResultsListModel::appendProviderProjection(const UnifiedSearchProvider &provider, + bool partial, + const QSet &filteredResourceUrls, + QVector &projection) const { - if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) { + QVector visibleEntries; + visibleEntries.reserve(aggregateResultsPerProvider + 1); + for (const auto &entry : provider.entries) { + if (partial && !entry._resourceUrl.isEmpty() && filteredResourceUrls.contains(entry._resourceUrl.toString())) { + continue; + } + auto visibleEntry = entry; + visibleEntry._isPartialMatch = partial; + visibleEntries.push_back(visibleEntry); + if (visibleEntries.size() > aggregateResultsPerProvider) { + break; + } + } + if (visibleEntries.isEmpty()) { return; } - const auto providerInfo = _providers.value(providerId, {}); + UnifiedSearchResult header; + header._providerId = provider.id; + header._providerName = provider.name; + header._providerIcon = visibleEntries.constFirst()._providerIcon; + header._title = provider.name; + header._hasOverflow = visibleEntries.size() > aggregateResultsPerProvider || provider.hasMore; + header._isPartialMatch = partial; + header._type = UnifiedSearchResult::Type::ProviderHeader; + header._stableKey = QStringLiteral("header:%1:%2").arg(partial ? QStringLiteral("partial") : QStringLiteral("full"), provider.id); + projection.push_back(header); + + const auto visibleCount = std::min(aggregateResultsPerProvider, visibleEntries.size()); + for (auto index = 0; index < visibleCount; ++index) { + projection.push_back(visibleEntries[index]); + } +} - if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) { - // Load more items - _currentFetchMoreInProgressProviderId = providerId; +void UnifiedSearchResultsListModel::resetProviderRuntime() +{ + for (auto it = _providers.begin(); it != _providers.end(); ++it) { + it->status = ProviderStatus::Idle; + it->entries.clear(); + it->cursor = {}; + it->hasMore = false; + it->loadMoreFailed = false; + it->paging = false; + it->partialMatch = false; + } + _revealOrder.clear(); + if (_hasPartialFailure) { + _hasPartialFailure = false; + Q_EMIT hasPartialFailureChanged(); + } + if (!_currentFetchMoreInProgressProviderId.isEmpty()) { + _currentFetchMoreInProgressProviderId.clear(); Q_EMIT currentFetchMoreInProgressProviderIdChanged(); - startSearchForProvider(providerId, providerInfo._cursor); } } -void UnifiedSearchResultsListModel::slotSearchTermEditingFinished() +void UnifiedSearchResultsListModel::abortSearchJobs() { - _waitingForSearchTermEditEnd = false; - Q_EMIT waitingForSearchTermEditEndChanged(); - - disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this, - &UnifiedSearchResultsListModel::slotSearchTermEditingFinished); + const auto hadJobs = !_activeSearchJobs.isEmpty() || _inFlightSearchRequests > 0; + for (const auto &jobObject : std::as_const(_activeSearchJobs)) { + if (!jobObject) { + continue; + } + disconnect(jobObject, nullptr, this, nullptr); + if (const auto job = qobject_cast(jobObject.data()); job && job->reply() && job->reply()->isRunning()) { + job->reply()->abort(); + } + jobObject->deleteLater(); + } + _activeSearchJobs.clear(); + _inFlightSearchRequests = 0; + if (hadJobs) { + Q_EMIT isSearchInProgressChanged(); + } +} - if (!_accountState || !_accountState->account()) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Account state is invalid. Could not start search!"); +void UnifiedSearchResultsListModel::abortProviderDiscovery() +{ + if (!_providerDiscoveryJob) { + _providersLoading = false; return; } - - if (_providers.isEmpty()) { - auto job = new JsonApiJob(_accountState->account(), QLatin1String("ocs/v2.php/search/providers")); - QObject::connect(job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotFetchProvidersFinished); - job->start(); - } else { - startSearch(); + disconnect(_providerDiscoveryJob, nullptr, this, nullptr); + if (const auto job = qobject_cast(_providerDiscoveryJob.data()); job && job->reply() && job->reply()->isRunning()) { + job->reply()->abort(); } + _providerDiscoveryJob->deleteLater(); + _providerDiscoveryJob.clear(); + _providersLoading = false; } -void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode) +void UnifiedSearchResultsListModel::trackSearchJob(QObject *jobObject) { - const auto job = qobject_cast(sender()); + const auto wasInProgress = isSearchInProgress(); + _activeSearchJobs.push_back(jobObject); + ++_inFlightSearchRequests; + connect(jobObject, &QObject::destroyed, this, [this, jobObject] { untrackSearchJob(jobObject); }); + updateProgressSignals(wasInProgress); +} - if (!job) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Failed to fetch providers.").arg(_searchTerm); - _errorString += tr("Failed to fetch providers.") + u'\n'; - Q_EMIT errorStringChanged(); +void UnifiedSearchResultsListModel::untrackSearchJob(QObject *jobObject) +{ + const auto it = std::find_if(_activeSearchJobs.begin(), _activeSearchJobs.end(), [jobObject](const auto &candidate) { + return candidate == jobObject; + }); + if (it == _activeSearchJobs.end()) { return; } + _activeSearchJobs.erase(it); + _inFlightSearchRequests = std::max(0, _inFlightSearchRequests - 1); +} - if (statusCode != 200) { - qCCritical(lcUnifiedSearch) << QStringLiteral("%1: Failed to fetch search providers for '%2'. Error: %3") - .arg(statusCode) - .arg(_searchTerm) - .arg(job->errorString()); - _errorString += - tr("Failed to fetch search providers for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) - + u'\n'; - Q_EMIT errorStringChanged(); - return; +void UnifiedSearchResultsListModel::updateProgressSignals(bool wasInProgress) +{ + if (wasInProgress != isSearchInProgress()) { + Q_EMIT isSearchInProgressChanged(); } - const auto providerList = - json.object().value("ocs"_L1).toObject().value("data"_L1).toVariant().toList(); +} - for (const auto &provider : providerList) { - const auto providerMap = provider.toMap(); - const auto id = providerMap["id"_L1].toString(); - const auto name = providerMap["name"_L1].toString(); - if (!name.isEmpty() && id != "talk-message-current"_L1) { - UnifiedSearchProvider newProvider; - newProvider._name = name; - newProvider._id = id; - newProvider._order = providerMap["order"_L1].toInt(); - _providers.insert(newProvider._id, newProvider); +void UnifiedSearchResultsListModel::updateAggregateState() +{ + auto applicableCount = 0; + auto failedCount = 0; + auto settledCount = 0; + for (const auto &providerId : _providerOrder) { + const auto &provider = _providers[providerId]; + if (!providerIsApplicable(provider)) { + continue; + } + ++applicableCount; + if (provider.status == ProviderStatus::Failed) { + ++failedCount; + ++settledCount; + } else if (provider.status == ProviderStatus::Loaded || provider.status == ProviderStatus::Blocked) { + ++settledCount; } } - if (!_providers.empty()) { - startSearch(); + const auto partialFailure = failedCount > 0 && failedCount < applicableCount; + if (_hasPartialFailure != partialFailure) { + _hasPartialFailure = partialFailure; + Q_EMIT hasPartialFailureChanged(); + } + if (applicableCount > 0 && failedCount == applicableCount) { + setErrorString(tr("Search failed for all available sources. Please try again.")); + } else if (settledCount == applicableCount) { + setErrorString({}); + _revealTimer.stop(); + promoteReadyProviders(); } + Q_EMIT showConnectedServicesActionChanged(); } -void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode) +void UnifiedSearchResultsListModel::updateAccessibilityStatus() { - Q_ASSERT(_accountState && _accountState->account()); - - const auto job = qobject_cast(sender()); - - if (!job) { - qCCritical(lcUnifiedSearch) << QStringLiteral("Search has failed for '%2'.").arg(_searchTerm); - _errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + u'\n'; - Q_EMIT errorStringChanged(); + if (isSearchInProgress()) { + setAccessibilityStatus(tr("Searching")); return; } - - const auto providerId = job->property("providerId").toString(); - - if (providerId.isEmpty()) { - return; - } - - if (!_searchJobConnections.isEmpty()) { - _searchJobConnections.remove(providerId); - - if (_searchJobConnections.isEmpty()) { - Q_EMIT isSearchInProgressChanged(); + auto resultCount = 0; + for (const auto &result : _results) { + if (result._type == UnifiedSearchResult::Type::Default) { + ++resultCount; } } + if (_viewMode == ViewMode::ProviderDetail) { + setAccessibilityStatus(tr("%1 results in %2").arg(resultCount).arg(detailProviderName())); + } else if (resultCount == 0) { + setAccessibilityStatus(tr("No matching results")); + } else if (_hasPartialFailure) { + setAccessibilityStatus(tr("%1 results. Some sources are unavailable.").arg(resultCount)); + } else { + setAccessibilityStatus(tr("%1 results").arg(resultCount)); + } +} - if (providerId == _currentFetchMoreInProgressProviderId) { - clearCurrentFetchMoreInProgressProviderId(); +void UnifiedSearchResultsListModel::restartForFilterChange() +{ + Q_EMIT activeFiltersChanged(); + Q_EMIT providersChanged(); + if (_viewMode != ViewMode::Aggregate) { + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + Q_EMIT viewModeChanged(); + } + if (hasSearchTerm()) { + scheduleSearch(); } +} - if (statusCode != 200) { - qCCritical(lcUnifiedSearch) << QStringLiteral("%1: Search has failed for '%2'. Error: %3") - .arg(statusCode) - .arg(_searchTerm) - .arg(job->errorString()); - _errorString += - tr("Search has failed for '%1'. Error: %2").arg(_searchTerm).arg(job->errorString()) + u'\n'; - Q_EMIT errorStringChanged(); +void UnifiedSearchResultsListModel::setErrorString(const QString &error) +{ + if (_errorString == error) { return; } + _errorString = error; + Q_EMIT errorStringChanged(); +} - const auto data = json.object().value("ocs"_L1).toObject().value("data"_L1).toObject(); - if (!data.isEmpty()) { - parseResultsForProvider(data, providerId, job->property("appendResults").toBool()); +void UnifiedSearchResultsListModel::setProvidersReady(const bool ready) +{ + if (_providersReady == ready) { + return; } + _providersReady = ready; + Q_EMIT providersReadyChanged(); } -void UnifiedSearchResultsListModel::startSearch() +void UnifiedSearchResultsListModel::setWaitingForSearchTermEditEnd(bool waiting) { - Q_ASSERT(_accountState && _accountState->account()); - - disconnectAndClearSearchJobs(); + if (_waitingForSearchTermEditEnd == waiting) { + return; + } + const auto wasInProgress = isSearchInProgress(); + _waitingForSearchTermEditEnd = waiting; + Q_EMIT waitingForSearchTermEditEndChanged(); + updateProgressSignals(wasInProgress); +} - if (!_accountState || !_accountState->account()) { +void UnifiedSearchResultsListModel::setSelectedRow(int row) +{ + if (_selectedRow == row) { return; } + _selectedRow = row; + Q_EMIT selectedRowChanged(); +} - if (!_results.isEmpty()) { - beginResetModel(); - _results.clear(); - endResetModel(); +void UnifiedSearchResultsListModel::setAccessibilityStatus(const QString &status) +{ + if (_accessibilityStatus == status) { + return; } + _accessibilityStatus = status; + Q_EMIT accessibilityStatusChanged(); +} - for (const auto &provider : std::as_const(_providers)) { - startSearchForProvider(provider._id); +bool UnifiedSearchResultsListModel::providerIsApplicable(const UnifiedSearchProvider &provider) const +{ + if (!_selectedProviderIds.isEmpty()) { + return _selectedProviderIds.contains(provider.id); } + return !provider.isExternalProvider || _externalProvidersEnabled; } -void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor) +bool UnifiedSearchResultsListModel::providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const { - Q_ASSERT(_accountState && _accountState->account()); + const auto supportsDate = !_since.isValid() || (provider.filters.contains(QStringLiteral("since")) && provider.filters.contains(QStringLiteral("until"))); + const auto supportsPerson = _personId.isEmpty() || provider.filters.contains(QStringLiteral("person")); + return supportsDate && supportsPerson; +} - if (!_accountState || !_accountState->account()) { - return; +QString UnifiedSearchResultsListModel::providerIcon(const UnifiedSearchProvider &provider, const bool darkMode) const +{ + if (_accountState) { + if (const auto app = _accountState->findApp(provider.appId); app && !app->iconUrl().isEmpty()) { + return app->iconUrl().toString(); + } } - auto job = new JsonApiJob(_accountState->account(), - QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId)); + const auto account = _accountState ? _accountState->account() : AccountPtr(); + return normalizedIcon(provider.icon, account ? account->url() : QUrl(), darkMode); +} - QUrlQuery params; - params.addQueryItem(QStringLiteral("term"), _searchTerm); - if (cursor > 0) { - params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor)); - job->setProperty("appendResults", true); +QUrlQuery UnifiedSearchResultsListModel::queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const +{ + QUrlQuery query; + query.addQueryItem(QStringLiteral("term"), _searchTerm); + query.addQueryItem(QStringLiteral("limit"), QString::number(requestResultsPerProvider)); + if (_since.isValid() && provider.filters.contains(QStringLiteral("since"))) { + query.addQueryItem(QStringLiteral("since"), _since.toUTC().toString(Qt::ISODateWithMs)); } - job->setProperty("providerId", providerId); - job->addQueryParams(params); - const auto wasSearchInProgress = isSearchInProgress(); - _searchJobConnections.insert(providerId, - QObject::connect( - job, &JsonApiJob::jsonReceived, this, &UnifiedSearchResultsListModel::slotSearchForProviderFinished)); - if (isSearchInProgress() && !wasSearchInProgress) { - Q_EMIT isSearchInProgressChanged(); + if (_until.isValid() && provider.filters.contains(QStringLiteral("until"))) { + query.addQueryItem(QStringLiteral("until"), _until.toUTC().toString(Qt::ISODateWithMs)); } - job->start(); + if (!_personId.isEmpty() && provider.filters.contains(QStringLiteral("person"))) { + query.addQueryItem(QStringLiteral("person"), _personId); + } + if (pagination && !provider.cursor.isNull() && !provider.cursor.isUndefined()) { + query.addQueryItem(QStringLiteral("cursor"), provider.cursor.toVariant().toString()); + } + return query; } -void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore) +QString UnifiedSearchResultsListModel::stableKeyForResult(const QString &providerId, + const UnifiedSearchResult &result, + int entryIndex) { - const auto cursor = data.value("cursor"_L1).toInt(); - const auto entries = data.value("entries"_L1).toVariant().toList(); + return QStringLiteral("result:%1:%2:%3").arg(providerId, result._resourceUrl.toString(), QString::number(entryIndex)); +} - auto &provider = _providers[providerId]; +QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl) +{ + return resourceUrl.isRelative() ? accountUrl.resolved(resourceUrl) : resourceUrl; +} - if (provider._id.isEmpty() && fetchedMore) { - _providers.remove(providerId); - return; +void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const +{ + const QUrlQuery urlQuery{resourceUrl}; + const auto dir = urlQuery.queryItemValue(QStringLiteral("dir"), QUrl::FullyDecoded); + const auto fileName = urlQuery.queryItemValue(QStringLiteral("scrollto"), QUrl::FullyDecoded); + if (providerId.contains("file"_L1, Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) { + if (!_accountState || !_accountState->account()) { + return; + } + const auto relativePath = dir + u'/' + fileName; + const auto localFiles = FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account()); + if (!localFiles.isEmpty()) { + QDesktopServices::openUrl(QUrl::fromLocalFile(localFiles.constFirst())); + return; + } } + Utility::openBrowser(resourceUrl); +} - if (entries.isEmpty()) { - // we may have received false pagination information from the server, such as, we expect more - // results available via pagination, but, there are no more left, so, we need to stop paginating for - // this provider - provider._isPaginated = false; - - if (fetchedMore) { - removeFetchMoreTrigger(provider._id); - } +void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId) { loadMore(providerId); } +void UnifiedSearchResultsListModel::toggleProviderFilter(const QString &providerId) +{ + if (!_providers.contains(providerId)) { return; } - - provider._isPaginated = data.value("isPaginated"_L1).toBool(); - provider._cursor = cursor; - - if (provider._pageSize == -1) { - provider._pageSize = cursor; + if (_selectedProviderIds.contains(providerId)) { + _selectedProviderIds.removeAll(providerId); + } else { + _selectedProviderIds.push_back(providerId); } + restartForFilterChange(); +} - if ((provider._pageSize != -1 && entries.size() < provider._pageSize) - || entries.size() < minimumEntresNumberToShowLoadMore) { - // for some providers we are still getting a non-null cursor and isPaginated true even thought - // there are no more results to paginate - provider._isPaginated = false; +void UnifiedSearchResultsListModel::clearTypeFilters() +{ + if (_selectedProviderIds.isEmpty()) { + return; } + _selectedProviderIds.clear(); + restartForFilterChange(); +} - QVector newEntries; - - for (const auto &entry : entries) { - const auto entryMap = entry.toMap(); - if (entryMap.isEmpty()) { - continue; - } - UnifiedSearchResult result; - result._providerId = provider._id; - result._order = provider._order; - result._providerName = provider._name; - result._isRounded = entryMap.value("rounded"_L1).toBool(); - result._title = entryMap.value("title"_L1).toString(); - result._subline = entryMap.value("subline"_L1).toString(); - - const auto resourceUrl = entryMap.value("resourceUrl"_L1).toUrl(); - const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl(); - - result._resourceUrl = openableResourceUrl(resourceUrl, accountUrl); - const auto darkIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(), - entryMap.value("icon"_L1).toString(), accountUrl, true); - const auto lightIconsData = iconsFromThumbnailAndFallbackIcon(entryMap.value("thumbnailUrl"_L1).toString(), - entryMap.value("icon"_L1).toString(), accountUrl, false); - result._darkIcons = darkIconsData.first; - result._lightIcons = lightIconsData.first; - result._darkIconsIsThumbnail = darkIconsData.second; - result._lightIconsIsThumbnail = lightIconsData.second; - - newEntries.push_back(result); - } - - if (fetchedMore) { - appendResultsToProvider(newEntries, provider); +void UnifiedSearchResultsListModel::setDatePreset(const QString &preset) +{ + const auto today = QDate::currentDate(); + auto first = today; + auto last = today; + if (preset == QStringLiteral("today")) { + _dateLabel = tr("Today"); + } else if (preset == QStringLiteral("7days") || preset == QStringLiteral("last7days")) { + first = today.addDays(-6); + _dateLabel = tr("Last 7 days"); + } else if (preset == QStringLiteral("30days") || preset == QStringLiteral("last30days")) { + first = today.addDays(-29); + _dateLabel = tr("Last 30 days"); + } else if (preset == QStringLiteral("thisyear")) { + first = QDate(today.year(), 1, 1); + last = QDate(today.year(), 12, 31); + _dateLabel = tr("This year"); + } else if (preset == QStringLiteral("lastyear")) { + first = QDate(today.year() - 1, 1, 1); + last = QDate(today.year() - 1, 12, 31); + _dateLabel = tr("Last year"); } else { - appendResults(newEntries, provider); + return; } + const auto zone = QTimeZone::systemTimeZone(); + _since = QDateTime(first, QTime(0, 0), zone); + _until = QDateTime(last, QTime(23, 59, 59, 999), zone); + restartForFilterChange(); } -QUrl UnifiedSearchResultsListModel::openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl) +bool UnifiedSearchResultsListModel::setCustomDateRange(const QString &sinceDate, const QString &untilDate) { - if (!resourceUrl.isRelative()) { - return resourceUrl; + const auto first = QDate::fromString(sinceDate, Qt::ISODate); + const auto last = QDate::fromString(untilDate, Qt::ISODate); + if (!first.isValid() || !last.isValid() || last < first) { + return false; } - - QUrl finalResourceUrl(accountUrl); - finalResourceUrl.setPath(resourceUrl.toString()); - return finalResourceUrl; + const auto zone = QTimeZone::systemTimeZone(); + _since = QDateTime(first, QTime(0, 0), zone); + _until = QDateTime(last, QTime(23, 59, 59, 999), zone); + _dateLabel = tr("%1 – %2").arg(QLocale().toString(first, QLocale::ShortFormat), QLocale().toString(last, QLocale::ShortFormat)); + restartForFilterChange(); + return true; } -void UnifiedSearchResultsListModel::appendResults(QVector results, const UnifiedSearchProvider &provider) +void UnifiedSearchResultsListModel::clearDateFilter() { - if (provider._cursor > 0 && provider._isPaginated) { - UnifiedSearchResult fetchMoreTrigger; - fetchMoreTrigger._providerId = provider._id; - fetchMoreTrigger._providerName = provider._name; - fetchMoreTrigger._order = provider._order; - fetchMoreTrigger._type = UnifiedSearchResult::Type::FetchMoreTrigger; - results.push_back(fetchMoreTrigger); + if (!_since.isValid() && !_until.isValid()) { + return; } + _since = {}; + _until = {}; + _dateLabel.clear(); + restartForFilterChange(); +} - - if (_results.isEmpty()) { - beginInsertRows({}, 0, results.size() - 1); - _results = results; - endInsertRows(); +void UnifiedSearchResultsListModel::setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl) +{ + if (userId.isEmpty()) { return; } + _personId = userId; + _personName = displayName; + _personAvatarUrl = avatarUrl; + restartForFilterChange(); +} - // insertion is done with sorting (first -> by order, then -> by name) - const auto itToInsertTo = std::find_if(std::begin(_results), std::end(_results), - [&provider](const UnifiedSearchResult ¤t) { - // insert before other results of higher order when possible - if (current._order > provider._order) { - return true; - } else { - if (current._order == provider._order) { - // insert before results of higher QString value when possible - return current._providerName > provider._name; - } +void UnifiedSearchResultsListModel::clearPersonFilter() +{ + if (_personId.isEmpty()) { + return; + } + _personId.clear(); + _personName.clear(); + _personAvatarUrl.clear(); + restartForFilterChange(); +} - return false; - } - }); +void UnifiedSearchResultsListModel::removeFilter(const QString &type, const QString &id) +{ + if (type == QStringLiteral("provider")) { + if (_selectedProviderIds.removeAll(id) > 0) { + restartForFilterChange(); + } + } else if (type == QStringLiteral("date")) { + clearDateFilter(); + } else if (type == QStringLiteral("person")) { + clearPersonFilter(); + } +} - const auto first = static_cast(std::distance(std::begin(_results), itToInsertTo)); - const auto last = first + results.size() - 1; +void UnifiedSearchResultsListModel::setExternalProvidersEnabled(bool enabled) +{ + if (_externalProvidersEnabled == enabled) { + return; + } + _externalProvidersEnabled = enabled; + Q_EMIT externalProvidersEnabledChanged(); + if (hasSearchTerm()) { + scheduleSearch(); + } +} - beginInsertRows({}, first, last); - std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo)); - endInsertRows(); +void UnifiedSearchResultsListModel::openProviderDetail(const QString &providerId) +{ + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend() + || (providerIt->entries.size() <= aggregateResultsPerProvider && !providerIt->hasMore)) { + return; + } + _aggregateSelectedStableKey = _selectedStableKey; + _detailProviderId = providerId; + _viewMode = ViewMode::ProviderDetail; + _selectedStableKey.clear(); + Q_EMIT viewModeChanged(); + rebuildProjection(); + updateAccessibilityStatus(); } -void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider) +void UnifiedSearchResultsListModel::closeProviderDetail() { - if (results.isEmpty()) { + if (_viewMode == ViewMode::Aggregate) { return; } + _viewMode = ViewMode::Aggregate; + _detailProviderId.clear(); + _selectedStableKey = _aggregateSelectedStableKey; + _aggregateSelectedStableKey.clear(); + Q_EMIT viewModeChanged(); + rebuildProjection(); + updateAccessibilityStatus(); +} - const auto providerId = provider._id; - /* we need to find the last result that is not a fetch-more-trigger or category-separator for the current - provider */ - const auto itLastResultForProviderReverse = - std::find_if(std::rbegin(_results), std::rend(_results), [&providerId](const UnifiedSearchResult &result) { - return result._providerId == providerId && result._type == UnifiedSearchResult::Type::Default; - }); +void UnifiedSearchResultsListModel::loadMore(const QString &providerId) +{ + if (_viewMode != ViewMode::ProviderDetail || _detailProviderId != providerId || !_providers.contains(providerId)) { + return; + } + const auto &provider = _providers[providerId]; + if (provider.paging || (!provider.hasMore && !provider.loadMoreFailed)) { + return; + } + startSearchForProvider(providerId, true); +} - if (itLastResultForProviderReverse != std::rend(_results)) { - // #1 Insert rows - // convert reverse_iterator to iterator - const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base(); - const auto first = static_cast(std::distance(std::begin(_results), itLastResultForProvider + 1)); - const auto last = first + results.size() - 1; - beginInsertRows({}, first, last); - std::copy(std::begin(results), std::end(results), std::inserter(_results, itLastResultForProvider + 1)); - endInsertRows(); +void UnifiedSearchResultsListModel::retryLoadMore(const QString &providerId) { loadMore(providerId); } - // #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider - if (!provider._isPaginated) { - removeFetchMoreTrigger(providerId); +void UnifiedSearchResultsListModel::retryFailedProviders() +{ + if (!hasSearchTerm()) { + return; + } + _revealWindowClosed = true; + setErrorString({}); + for (const auto &providerId : _providerOrder) { + auto &provider = _providers[providerId]; + if (provider.status == ProviderStatus::Failed && providerIsApplicable(provider)) { + provider.status = ProviderStatus::Loading; + startSearchForProvider(providerId); } } + if (_hasPartialFailure) { + _hasPartialFailure = false; + Q_EMIT hasPartialFailureChanged(); + } } -void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId) +void UnifiedSearchResultsListModel::retry() { - const auto itFetchMoreTriggerForProviderReverse = std::find_if( - std::rbegin(_results), - std::rend(_results), - [providerId](const UnifiedSearchResult &result) { - return result._providerId == providerId && result._type == UnifiedSearchResult::Type::FetchMoreTrigger; - }); - - if (itFetchMoreTriggerForProviderReverse != std::rend(_results)) { - // convert reverse_iterator to iterator - const auto itFetchMoreTriggerForProvider = (itFetchMoreTriggerForProviderReverse + 1).base(); - - if (itFetchMoreTriggerForProvider != std::end(_results) - && itFetchMoreTriggerForProvider != std::begin(_results)) { - const auto eraseIndex = static_cast(std::distance(std::begin(_results), itFetchMoreTriggerForProvider)); - Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast(_results.size())); - beginRemoveRows({}, eraseIndex, eraseIndex); - _results.erase(itFetchMoreTriggerForProvider); - endRemoveRows(); - } + if (!_providersReady) { + discoverProviders(); + } else if (hasSearchTerm()) { + scheduleSearch(); } } -void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs() +void UnifiedSearchResultsListModel::moveSelection(SelectionDirection direction) { - for (const auto &connection : std::as_const(_searchJobConnections)) { - if (connection) { - QObject::disconnect(connection); + QVector selectableRows; + for (auto row = 0; row < _results.size(); ++row) { + if (_results[row]._isSelectable) { + selectableRows.push_back(row); } } + if (selectableRows.isEmpty()) { + return; + } - if (!_searchJobConnections.isEmpty()) { - _searchJobConnections.clear(); - Q_EMIT isSearchInProgressChanged(); + auto newRow = selectableRows.constFirst(); + const auto currentPosition = selectableRows.indexOf(_selectedRow); + switch (direction) { + case SelectionDirection::Previous: + newRow = currentPosition <= 0 ? selectableRows.constFirst() : selectableRows[currentPosition - 1]; + break; + case SelectionDirection::Next: + newRow = currentPosition < 0 || currentPosition >= selectableRows.size() - 1 + ? selectableRows.constLast() + : selectableRows[currentPosition + 1]; + break; + case SelectionDirection::First: + newRow = selectableRows.constFirst(); + break; + case SelectionDirection::Last: + newRow = selectableRows.constLast(); + break; + } + if (newRow == _selectedRow) { + return; + } + const auto previousRow = _selectedRow; + if (previousRow >= 0 && previousRow < _results.size()) { + _results[previousRow]._isSelected = false; + } + _results[newRow]._isSelected = true; + _selectedStableKey = _results[newRow]._stableKey; + setSelectedRow(newRow); + if (previousRow >= 0) { + Q_EMIT dataChanged(index(previousRow), index(previousRow), {SelectedRole}); } + Q_EMIT dataChanged(index(newRow), index(newRow), {SelectedRole}); } -void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId() +void UnifiedSearchResultsListModel::activateSelected() const { - if (!_currentFetchMoreInProgressProviderId.isEmpty()) { - _currentFetchMoreInProgressProviderId.clear(); - Q_EMIT currentFetchMoreInProgressProviderIdChanged(); + if (_selectedRow < 0 || _selectedRow >= _results.size()) { + return; + } + const auto &result = _results[_selectedRow]; + if (result._isSelectable) { + resultClicked(result._providerId, result._resourceUrl); } } - } diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h index 2a8863f8c44f5..2ebaf327372d4 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.h +++ b/src/gui/search/unifiedsearchresultslistmodel.h @@ -5,28 +5,36 @@ #pragma once +#include "accountstate.h" #include "unifiedsearchresult.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -#include +#include namespace OCC { -class AccountState; /** - * @brief The UnifiedSearchResultsListModel + * @brief Account-scoped presentation model for Nextcloud Unified Search. * @ingroup gui - * Simple list model to provide the list view with data for the Unified Search results. + * + * The model owns provider discovery, concurrent searches, stable provider + * reveal order, filters, aggregate/detail projections and keyboard selection. */ - class UnifiedSearchResultsListModel : public QAbstractListModel { Q_OBJECT Q_PROPERTY(bool isSearchInProgress READ isSearchInProgress NOTIFY isSearchInProgressChanged) - Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY - currentFetchMoreInProgressProviderIdChanged) + Q_PROPERTY(QString currentFetchMoreInProgressProviderId READ currentFetchMoreInProgressProviderId NOTIFY currentFetchMoreInProgressProviderIdChanged) Q_PROPERTY(QString errorString READ errorString NOTIFY errorStringChanged) Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged) Q_PROPERTY(bool waitingForSearchTermEditEnd READ waitingForSearchTermEditEnd NOTIFY waitingForSearchTermEditEndChanged) @@ -36,16 +44,20 @@ class UnifiedSearchResultsListModel : public QAbstractListModel Q_PROPERTY(bool canEditSearch READ canEditSearch NOTIFY canEditSearchChanged) Q_PROPERTY(bool isAccountConnected READ isAccountConnected NOTIFY canEditSearchChanged) Q_PROPERTY(SearchState searchState READ searchState NOTIFY searchStateChanged) - - struct UnifiedSearchProvider - { - QString _id; - QString _name; - qint32 _cursor = -1; // current pagination value - qint32 _pageSize = -1; // how many max items per step of pagination - bool _isPaginated = false; - qint32 _order = std::numeric_limits::max(); // sorting order (smaller number has bigger priority) - }; + Q_PROPERTY(QVariantList providers READ providers NOTIFY providersChanged) + Q_PROPERTY(QVariantList activeFilters READ activeFilters NOTIFY activeFiltersChanged) + Q_PROPERTY(bool providersReady READ providersReady NOTIFY providersReadyChanged) + Q_PROPERTY(bool dateFilterAvailable READ dateFilterAvailable NOTIFY providersChanged) + Q_PROPERTY(bool peopleFilterAvailable READ peopleFilterAvailable NOTIFY providersChanged) + Q_PROPERTY(bool hasExternalProviders READ hasExternalProviders NOTIFY providersChanged) + Q_PROPERTY(bool externalProvidersEnabled READ externalProvidersEnabled NOTIFY externalProvidersEnabledChanged) + Q_PROPERTY(bool showConnectedServicesAction READ showConnectedServicesAction NOTIFY showConnectedServicesActionChanged) + Q_PROPERTY(bool hasPartialFailure READ hasPartialFailure NOTIFY hasPartialFailureChanged) + Q_PROPERTY(ViewMode viewMode READ viewMode NOTIFY viewModeChanged) + Q_PROPERTY(QString detailProviderName READ detailProviderName NOTIFY viewModeChanged) + Q_PROPERTY(int selectedRow READ selectedRow NOTIFY selectedRowChanged) + Q_PROPERTY(QString accessibilityStatus READ accessibilityStatus NOTIFY accessibilityStatusChanged) + Q_PROPERTY(AccountState *accountState READ accountState CONSTANT) public: enum class SearchState { @@ -58,9 +70,33 @@ class UnifiedSearchResultsListModel : public QAbstractListModel }; Q_ENUM(SearchState) + enum class ViewMode { + Aggregate, + ProviderDetail, + }; + Q_ENUM(ViewMode) + + enum class SelectionDirection { + Previous, + Next, + First, + Last, + }; + Q_ENUM(SelectionDirection) + + enum class ResultType { + Default = static_cast(UnifiedSearchResult::Type::Default), + ProviderHeader = static_cast(UnifiedSearchResult::Type::ProviderHeader), + PartialMatchesHeader = static_cast(UnifiedSearchResult::Type::PartialMatchesHeader), + FetchMoreTrigger = static_cast(UnifiedSearchResult::Type::FetchMoreTrigger), + RetryFetchMoreTrigger = static_cast(UnifiedSearchResult::Type::RetryFetchMoreTrigger), + }; + Q_ENUM(ResultType) + enum DataRole { ProviderNameRole = Qt::UserRole + 1, ProviderIdRole, + ProviderIconRole, DarkImagePlaceholderRole, LightImagePlaceholderRole, DarkIconsRole, @@ -73,50 +109,69 @@ class UnifiedSearchResultsListModel : public QAbstractListModel RoundedRole, TypeRole, TypeAsStringRole, + StableKeyRole, + SelectedRole, + SelectableRole, + PartialMatchRole, + HasOverflowRole, + LoadingRole, }; - explicit UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent = nullptr); + explicit UnifiedSearchResultsListModel(AccountState *accountState, + QObject *parent = nullptr, + int debounceInterval = 300, + int revealInterval = 1000); + ~UnifiedSearchResultsListModel() override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QHash roleNames() const override; [[nodiscard]] bool isSearchInProgress() const; - [[nodiscard]] QString currentFetchMoreInProgressProviderId() const; [[nodiscard]] QString searchTerm() const; [[nodiscard]] QString errorString() const; [[nodiscard]] bool waitingForSearchTermEditEnd() const; - [[nodiscard]] bool isFetchMoreInProgress() const; [[nodiscard]] bool hasSearchTerm() const; [[nodiscard]] bool hasSearchError() const; [[nodiscard]] bool canEditSearch() const; - /** @brief Returns whether the account is connected. */ [[nodiscard]] bool isAccountConnected() const; [[nodiscard]] SearchState searchState() const; + [[nodiscard]] QVariantList providers() const; + [[nodiscard]] QVariantList activeFilters() const; + [[nodiscard]] bool providersReady() const; + [[nodiscard]] bool dateFilterAvailable() const; + [[nodiscard]] bool peopleFilterAvailable() const; + [[nodiscard]] bool hasExternalProviders() const; + [[nodiscard]] bool externalProvidersEnabled() const; + [[nodiscard]] bool showConnectedServicesAction() const; + [[nodiscard]] bool hasPartialFailure() const; + [[nodiscard]] ViewMode viewMode() const; + [[nodiscard]] QString detailProviderName() const; + [[nodiscard]] int selectedRow() const; + [[nodiscard]] QString accessibilityStatus() const; + [[nodiscard]] AccountState *accountState() const; Q_INVOKABLE void resultClicked(const QString &providerId, const QUrl &resourceUrl) const; Q_INVOKABLE void fetchMoreTriggerClicked(const QString &providerId); - - [[nodiscard]] QHash roleNames() const override; - -private: - void startSearch(); - void startSearchForProvider(const QString &providerId, qint32 cursor = -1); - - void parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore = false); - - // append initial search results to the list - void appendResults(QVector results, const UnifiedSearchProvider &provider); - - // append pagination results to existing results from the initial search - void appendResultsToProvider(const QVector &results, const UnifiedSearchProvider &provider); - - void removeFetchMoreTrigger(const QString &providerId); - - void disconnectAndClearSearchJobs(); - - void clearCurrentFetchMoreInProgressProviderId(); + Q_INVOKABLE void toggleProviderFilter(const QString &providerId); + Q_INVOKABLE void clearTypeFilters(); + Q_INVOKABLE void setDatePreset(const QString &preset); + Q_INVOKABLE bool setCustomDateRange(const QString &sinceDate, const QString &untilDate); + Q_INVOKABLE void clearDateFilter(); + Q_INVOKABLE void setPersonFilter(const QString &userId, const QString &displayName, const QString &avatarUrl = {}); + Q_INVOKABLE void clearPersonFilter(); + Q_INVOKABLE void removeFilter(const QString &type, const QString &id = {}); + Q_INVOKABLE void setExternalProvidersEnabled(bool enabled); + Q_INVOKABLE void openProviderDetail(const QString &providerId); + Q_INVOKABLE void closeProviderDetail(); + Q_INVOKABLE void loadMore(const QString &providerId); + Q_INVOKABLE void retryLoadMore(const QString &providerId); + Q_INVOKABLE void retryFailedProviders(); + Q_INVOKABLE void retry(); + Q_INVOKABLE void moveSelection(SelectionDirection direction); + Q_INVOKABLE void activateSelected() const; Q_SIGNALS: void currentFetchMoreInProgressProviderIdChanged(); @@ -126,31 +181,133 @@ class UnifiedSearchResultsListModel : public QAbstractListModel void waitingForSearchTermEditEndChanged(); void canEditSearchChanged(); void searchStateChanged(); + void providersChanged(); + void providersReadyChanged(); + void activeFiltersChanged(); + void externalProvidersEnabledChanged(); + void showConnectedServicesActionChanged(); + void hasPartialFailureChanged(); + void viewModeChanged(); + void selectedRowChanged(); + void accessibilityStatusChanged(); public Q_SLOTS: void setSearchTerm(const QString &term); -private Q_SLOTS: - void slotSearchTermEditingFinished(); - void slotFetchProvidersFinished(const QJsonDocument &json, int statusCode); - void slotSearchForProviderFinished(const QJsonDocument &json, int statusCode); - private: - static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl); + enum class ProviderStatus { + Idle, + Loading, + Blocked, + Loaded, + Failed, + }; - QMap _providers; + struct UnifiedSearchProvider + { + QString id; + QString appId; + QString name; + QString icon; + QSet filters; + int order = std::numeric_limits::max(); + int discoveryIndex = -1; + bool isExternalProvider = false; + + ProviderStatus status = ProviderStatus::Idle; + QVector entries; + QJsonValue cursor; + bool hasMore = false; + bool loadMoreFailed = false; + bool paging = false; + bool partialMatch = false; + }; + + void discoverProviders(); + void providerDiscoveryFinished(const QJsonDocument &json, int statusCode, quint64 generation, QObject *jobObject); + void scheduleSearch(); + void startSearch(); + void startSearchForProvider(const QString &providerId, bool pagination = false); + void providerSearchFinished(const QJsonDocument &json, + int statusCode, + const QString &providerId, + bool pagination, + quint64 generation, + QObject *jobObject); + [[nodiscard]] QVector parseEntries(const QJsonArray &entries, + const UnifiedSearchProvider &provider, + int firstEntryIndex = 0) const; + void promoteReadyProviders(); + void closeRevealWindow(); + void rebuildProjection(); + [[nodiscard]] UnifiedSearchResult pagingRowForProvider(const UnifiedSearchProvider &provider) const; + [[nodiscard]] bool updateDetailProjectionAfterPagination(const UnifiedSearchProvider &provider, int previousEntryCount); + [[nodiscard]] bool updateDetailPagingState(const UnifiedSearchProvider &provider); + void appendProviderProjection(const UnifiedSearchProvider &provider, + bool partial, + const QSet &filteredResourceUrls, + QVector &projection) const; + void resetProviderRuntime(); + void abortSearchJobs(); + void abortProviderDiscovery(); + void trackSearchJob(QObject *jobObject); + void untrackSearchJob(QObject *jobObject); + void updateProgressSignals(bool wasInProgress); + void updateAggregateState(); + void updateAccessibilityStatus(); + void restartForFilterChange(); + void setErrorString(const QString &error); + void setProvidersReady(bool ready); + void setWaitingForSearchTermEditEnd(bool waiting); + void setSelectedRow(int row); + void setAccessibilityStatus(const QString &status); + [[nodiscard]] bool providerIsApplicable(const UnifiedSearchProvider &provider) const; + [[nodiscard]] bool providerSupportsAllContentFilters(const UnifiedSearchProvider &provider) const; + [[nodiscard]] QString providerIcon(const UnifiedSearchProvider &provider, bool darkMode) const; + [[nodiscard]] QUrlQuery queryForProvider(const UnifiedSearchProvider &provider, bool pagination) const; + [[nodiscard]] static QString stableKeyForResult(const QString &providerId, + const UnifiedSearchResult &result, + int entryIndex); + [[nodiscard]] static QUrl openableResourceUrl(const QUrl &resourceUrl, const QUrl &accountUrl); + + QHash _providers; + QVector _providerOrder; + QVector _revealOrder; QVector _results; + QVector> _activeSearchJobs; + QPointer _providerDiscoveryJob; QString _searchTerm; QString _errorString; - bool _waitingForSearchTermEditEnd = false; - QString _currentFetchMoreInProgressProviderId; + QStringList _selectedProviderIds; + QDateTime _since; + QDateTime _until; + QString _dateLabel; + QString _personId; + QString _personName; + QString _personAvatarUrl; + QString _detailProviderId; + QString _selectedStableKey; + QString _aggregateSelectedStableKey; + QString _accessibilityStatus; - QMap _searchJobConnections; - - QTimer _unifiedSearchTextEditingFinishedTimer; - + bool _waitingForSearchTermEditEnd = false; + bool _providersLoading = false; + bool _providersReady = false; + bool _pendingSearch = false; + bool _revealWindowClosed = false; + bool _externalProvidersEnabled = false; + bool _hasPartialFailure = false; + bool _lastKnownConnected = false; + int _inFlightSearchRequests = 0; + int _selectedRow = -1; + quint64 _queryGeneration = 0; + quint64 _providerGeneration = 0; + ViewMode _viewMode = ViewMode::Aggregate; + + QTimer _debounceTimer; + QTimer _revealTimer; AccountState *_accountState = nullptr; }; } diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index fe8de4c521ab5..4adfc4852d900 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -4,8 +4,9 @@ */ import QtQuick -import QtQuick.Controls import QtQuick.Controls.Basic as BasicControls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects import Style BasicControls.Button { @@ -14,7 +15,11 @@ BasicControls.Button { property bool primary: false property string iconSource: "" property bool iconBeforeText: false + property bool tintIcon: false + property color iconTintColor: Style.wizardPrimaryText + property real cornerRadius: Style.mediumRoundedButtonRadius property string textSuffix: "" + property string trailingIconSource: "" readonly property color primaryColor: Style.wizardPrimaryButtonBackground readonly property color primaryPressedColor: Style.wizardPrimaryButtonPressed readonly property color secondaryColor: Style.wizardSecondaryButtonBackground @@ -31,52 +36,78 @@ BasicControls.Button { Accessible.role: Accessible.Button Accessible.name: textSuffix === "" ? text : text + " " + textSuffix - contentItem: Item { - implicitWidth: contentRow.implicitWidth - implicitHeight: contentRow.implicitHeight + contentItem: RowLayout { + spacing: 6 - Row { - id: contentRow - - anchors.centerIn: parent - spacing: 6 + Item { + visible: root.iconSource !== "" && root.iconBeforeText + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize Image { - visible: root.iconSource !== "" && root.iconBeforeText - source: root.iconSource + id: leadingIconImage + + anchors.fill: parent + visible: !root.tintIcon + source: root.iconSource !== "" && root.iconBeforeText ? root.iconSource : "" sourceSize.width: Style.smallIconSize sourceSize.height: Style.smallIconSize - width: visible ? Style.smallIconSize : 0 - height: Style.smallIconSize - anchors.verticalCenter: parent.verticalCenter fillMode: Image.PreserveAspectFit + Accessible.ignored: true } - Text { - text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix - font: root.font - color: root.enabled - ? (root.primary ? Style.wizardSelectedText : root.palette.buttonText) - : Style.wizardDisabledText - anchors.verticalCenter: parent.verticalCenter - elide: Text.ElideRight + ColorOverlay { + objectName: "wizardButtonLeadingIconTint" + anchors.fill: leadingIconImage + visible: root.tintIcon + source: leadingIconImage + color: root.iconTintColor + cached: true + Accessible.ignored: true } + } - Image { - visible: root.iconSource !== "" && !root.iconBeforeText - source: root.iconSource - sourceSize.width: Style.smallIconSize - sourceSize.height: Style.smallIconSize - width: visible ? Style.smallIconSize : 0 - height: Style.smallIconSize - anchors.verticalCenter: parent.verticalCenter - fillMode: Image.PreserveAspectFit - } + Text { + objectName: "wizardButtonText" + Layout.fillWidth: true + text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix + font: root.font + color: root.enabled + ? (root.primary ? Style.wizardSelectedText : root.palette.buttonText) + : Style.wizardDisabledText + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + Image { + visible: root.iconSource !== "" && !root.iconBeforeText + source: root.iconSource !== "" && !root.iconBeforeText ? root.iconSource : "" + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true + } + + Image { + id: trailingIcon + + objectName: "wizardButtonTrailingIcon" + visible: root.trailingIconSource !== "" + source: root.trailingIconSource + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true } } background: Rectangle { - radius: Style.mediumRoundedButtonRadius + radius: root.cornerRadius border.width: root.primary ? 0 : 1 border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor color: { @@ -84,15 +115,20 @@ BasicControls.Button { return root.disabledColor } if (root.primary) { - return root.down ? root.primaryPressedColor : root.primaryColor + return root.down || hoverArea.containsMouse + ? root.primaryPressedColor + : root.primaryColor } - return root.down + return root.down || hoverArea.containsMouse ? root.secondaryPressedColor : root.secondaryColor } } MouseArea { + id: hoverArea + + objectName: "wizardButtonHoverArea" anchors.fill: parent acceptedButtons: Qt.NoButton enabled: root.enabled diff --git a/src/gui/wizard/qml/WizardChipButton.qml b/src/gui/wizard/qml/WizardChipButton.qml new file mode 100644 index 0000000000000..11d692cbeaba0 --- /dev/null +++ b/src/gui/wizard/qml/WizardChipButton.qml @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import Style + +WizardButton { + implicitHeight: Style.wizardChipButtonHeight + leftPadding: Style.standardSpacing + rightPadding: Style.standardSpacing + cornerRadius: Style.veryRoundedButtonRadius +} diff --git a/src/gui/wizard/qml/WizardMenuItem.qml b/src/gui/wizard/qml/WizardMenuItem.qml new file mode 100644 index 0000000000000..2598da697534e --- /dev/null +++ b/src/gui/wizard/qml/WizardMenuItem.qml @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects + +import Style + +BasicControls.MenuItem { + id: root + + property bool tintIcon: false + property color iconTintColor: Style.wizardPrimaryText + + hoverEnabled: true + implicitHeight: Style.standardPrimaryButtonHeight + leftPadding: 12 + rightPadding: 12 + font.pixelSize: Style.pixelSize + Style.extraSmallSpacing + + contentItem: RowLayout { + spacing: Style.smallSpacing + + Item { + Layout.preferredWidth: visible ? Style.smallIconSize : 0 + Layout.preferredHeight: Style.smallIconSize + visible: root.icon.source.toString() !== "" + + Image { + id: menuIconImage + + anchors.fill: parent + visible: !root.tintIcon + source: root.icon.source.toString() !== "" ? root.icon.source : "" + sourceSize.width: Style.smallIconSize + sourceSize.height: Style.smallIconSize + fillMode: Image.PreserveAspectFit + Accessible.ignored: true + } + + ColorOverlay { + objectName: "wizardMenuItemIconTint" + anchors.fill: menuIconImage + visible: root.tintIcon + source: menuIconImage + color: root.iconTintColor + cached: true + Accessible.ignored: true + } + + Accessible.ignored: true + } + + Text { + Layout.fillWidth: true + text: root.text + font: root.font + color: root.enabled ? Style.wizardPrimaryText : Style.wizardDisabledText + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + } + + background: Rectangle { + color: root.hovered || root.highlighted || root.down + ? Style.wizardSecondaryButtonPressed + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } +} diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index f30eba02090e3..4a8f520031237 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -10,6 +10,7 @@ set(CMAKE_AUTOMOC TRUE) find_package(Qt${QT_VERSION_MAJOR}Core5Compat ${REQUIRED_QT_VERSION} CONFIG QUIET) find_package(Qt${QT_VERSION_MAJOR} OPTIONAL_COMPONENTS HttpServer CONFIG QUIET) +find_package(Qt${QT_VERSION_MAJOR} ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS QuickTest) add_library(testutils STATIC @@ -112,6 +113,7 @@ if(NOT APPLE) nextcloud_add_test(SystraySyncControlQt) endif() nextcloud_add_test(UnifiedSearchListmodel) +nextcloud_add_test(UnifiedSearchPeopleModel) nextcloud_add_test(ActivityListModel) nextcloud_add_test(SortedActivityListModel) nextcloud_add_test(ActivityData) @@ -119,6 +121,20 @@ add_test(NAME ActivityFileMenuQmlTest COMMAND Qt6::qmltestrunner -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/activityfilemenu/testactivityfilemenu.qml" ) +add_executable(SearchQmlTest qml/search/searchqmltestrunner.cpp) +target_link_libraries(SearchQmlTest PRIVATE nextcloudCore Qt::QuickTest) +target_compile_definitions(SearchQmlTest PRIVATE + SEARCH_QML_TEST_IMPORT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/qml/search/imports" +) +set_target_properties(SearchQmlTest PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${BIN_OUTPUT_DIRECTORY} + FOLDER Tests +) +add_test(NAME SearchQmlTest + COMMAND SearchQmlTest -input "${CMAKE_CURRENT_SOURCE_DIR}/qml/search/testsearch.qml" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) +set_tests_properties(SearchQmlTest PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") nextcloud_add_test(TalkReply) nextcloud_add_test(LockFile) nextcloud_add_test(ShareModel) diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml new file mode 100644 index 0000000000000..727397b64a081 --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/Theme.qml @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma Singleton + +import QtQuick + +QtObject { + readonly property bool darkMode: false + readonly property color wizardHeaderBackgroundColor: "#0082c9" + readonly property color wizardHeaderTitleColor: "#ffffff" + readonly property string stateOfflineImageSource: "" + readonly property string stateOnlineImageSource: "" +} diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml new file mode 100644 index 0000000000000..751f9c58afb79 --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/UserModel.qml @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma Singleton + +import QtQml + +QtObject { + readonly property var currentUser: null +} diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/qmldir b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir new file mode 100644 index 0000000000000..38a642a9a7ccb --- /dev/null +++ b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir @@ -0,0 +1,3 @@ +module com.nextcloud.desktopclient +singleton Theme 1.0 Theme.qml +singleton UserModel 1.0 UserModel.qml diff --git a/test/qml/search/searchqmltestrunner.cpp b/test/qml/search/searchqmltestrunner.cpp new file mode 100644 index 0000000000000..ebfe99bfb42a5 --- /dev/null +++ b/test/qml/search/searchqmltestrunner.cpp @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include +#include + +#include "gui/search/unifiedsearchpeoplemodel.h" +#include "gui/search/unifiedsearchresultslistmodel.h" + +class SearchQmlTestSetup : public QObject +{ + Q_OBJECT + +public: + SearchQmlTestSetup() + { + Q_INIT_RESOURCE(resources); + Q_INIT_RESOURCE(theme); + + qmlRegisterType("com.nextcloud.desktopclient", 1, 0, "UnifiedSearchPeopleModel"); + qmlRegisterUncreatableType("com.nextcloud.desktopclient", + 1, + 0, + "UnifiedSearchResultsListModel", + "UnifiedSearchResultsListModel"); + } + +public slots: + void qmlEngineAvailable(QQmlEngine *engine) + { + engine->addImportPath(QStringLiteral(SEARCH_QML_TEST_IMPORT_PATH)); + engine->addImportPath(QCoreApplication::applicationDirPath()); + engine->addImportPath(QCoreApplication::applicationDirPath() + QStringLiteral("/qml")); + engine->addImportPath(QStringLiteral("qrc:/qml/theme")); + } +}; + +QUICK_TEST_MAIN_WITH_SETUP(search, SearchQmlTestSetup) + +#include "searchqmltestrunner.moc" diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml new file mode 100644 index 0000000000000..425e795548b7f --- /dev/null +++ b/test/qml/search/testsearch.qml @@ -0,0 +1,581 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtTest +import com.nextcloud.desktopclient +import com.nextcloud.desktopclient.search +import Style +import "qrc:/qml/src/gui/wizard/qml" + +Item { + width: 640 + height: 220 + + Component { + id: productionSearchWindow + + SearchWindow {} + } + + Component { + id: windowSearchModel + + ListModel { + property int viewMode: UnifiedSearchResultsListModel.Aggregate + property int searchState: UnifiedSearchResultsListModel.Results + property int selectedRow: 0 + property string searchTerm: "calendar" + property string accessibilityStatus: "" + property string detailProviderName: "Files" + property string errorString: "" + property var accountState: null + property var activeFilters: [] + property var providers: [] + property bool providersReady: true + property bool canEditSearch: true + property bool waitingForSearchTermEditEnd: false + property bool isSearchInProgress: false + property bool isAccountConnected: true + property bool dateFilterAvailable: false + property bool peopleFilterAvailable: false + property bool hasPartialFailure: false + property bool showConnectedServicesAction: false + property bool externalProvidersEnabled: false + property string currentFetchMoreInProgressProviderId: "" + readonly property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId.length > 0 + + function moveSelection(direction) {} + function activateSelected() {} + function closeProviderDetail() {} + function retry() {} + function retryFailedProviders() {} + function setExternalProvidersEnabled(enabled) {} + function removeFilter(type, id) {} + + Component.onCompleted: { + const row = { + providerName: "Files", + providerId: "files", + providerIcon: "", + resultTitle: "Calendar", + subline: "Documents", + resourceUrlRole: "https://cloud.example.test/f/1", + darkIcons: "", + lightIcons: "", + darkIconsIsThumbnail: false, + lightIconsIsThumbnail: false, + darkImagePlaceholder: "", + lightImagePlaceholder: "", + isRounded: false, + type: UnifiedSearchResultsListModel.Default, + isSelected: true, + isPartialMatch: false, + hasOverflow: false, + isLoading: false + } + append(row) + row.resultTitle = "Calendar 2" + row.resourceUrlRole = "https://cloud.example.test/f/2" + row.isSelected = false + append(row) + } + } + } + + QtObject { + id: fakeSearchModel + + property int openedProviderDetails: 0 + property int activatedResults: 0 + property int loadedPages: 0 + property int retriedPages: 0 + + function openProviderDetail(providerId) { + if (providerId === "files") { + ++openedProviderDetails + } + } + function resultClicked(providerId, resourceUrl) { + if (providerId === "files" && resourceUrl.toString().length > 0) { + ++activatedResults + } + } + function retryLoadMore(providerId) { + if (providerId === "files") { + ++retriedPages + } + } + function loadMore(providerId) { + if (providerId === "files") { + ++loadedPages + } + } + } + + UnifiedSearchInputContainer { + id: input + width: parent.width + height: 44 + } + + UnifiedSearchResultDelegate { + id: resultDelegate + + y: 60 + width: 500 + searchModel: fakeSearchModel + providerName: "Files" + providerId: "files" + providerIcon: "" + resultTitle: "README.md" + subline: "Documents" + resourceUrlRole: "https://cloud.example.test/f/1" + darkIcons: "" + lightIcons: "" + darkIconsIsThumbnail: false + lightIconsIsThumbnail: false + darkImagePlaceholder: "" + lightImagePlaceholder: "" + isRounded: false + resultType: UnifiedSearchResultsListModel.ProviderHeader + isSelected: false + isPartialMatch: false + hasOverflow: true + isLoading: false + } + + WizardButton { + id: wizardHoverButton + + x: 500 + y: 170 + width: 120 + text: qsTr("Hover") + } + + WizardMenuItem { + id: wizardMenuHoverItem + + x: 350 + y: 170 + width: 120 + text: qsTr("Menu hover") + icon.source: "qrc:/client/theme/black/folder.svg" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + } + + SignalSpy { id: clearSpy; target: input; signalName: "clearText" } + SignalSpy { id: filterSpy; target: input; signalName: "toggleFilters" } + SignalSpy { id: moveSpy; target: input; signalName: "moveSelection" } + SignalSpy { id: activateSpy; target: input; signalName: "activateSelection" } + + TestCase { + name: "SearchQmlModule" + when: windowShown + + function init() { + resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader + resultDelegate.isSelected = false + resultDelegate.isLoading = false + fakeSearchModel.loadedPages = 0 + fakeSearchModel.retriedPages = 0 + } + + function test_searchWindowLoadsFromPackagedModule() { + const searchWindow = productionSearchWindow.createObject(null) + verify(searchWindow !== null) + compare(searchWindow.height, Style.searchWindowHeight) + verify(searchWindow.height >= 620 + Style.unifiedSearchItemHeight) + searchWindow.destroy() + } + + function test_selectionBindingSurvivesViewAndModelChanges() { + const firstModel = windowSearchModel.createObject(this, { selectedRow: 0 }) + const secondModel = windowSearchModel.createObject(this, { selectedRow: 1 }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: firstModel, + visible: true + }) + const resultsList = findChild(searchWindow, "searchResultsList") + + verify(resultsList !== null) + tryCompare(resultsList, "count", 2) + tryVerify(() => resultsList.itemAtIndex(0) !== null) + verify(resultsList.itemAtIndex(0).loadedItem !== null) + compare(resultsList.itemAtIndex(0).loadedItem.objectName, "searchResultRow") + compare(resultsList.currentIndex, 0) + + firstModel.viewMode = UnifiedSearchResultsListModel.ProviderDetail + wait(0) + searchWindow.searchModel = secondModel + tryCompare(resultsList, "currentIndex", 1) + + searchWindow.destroy() + firstModel.destroy() + secondModel.destroy() + } + + function test_partialFailureFooterIsAggregateOnly() { + const model = windowSearchModel.createObject(this, { hasPartialFailure: true }) + const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const footer = findChild(searchWindow, "partialFailureFooter") + + verify(footer !== null) + compare(footer.visible, true) + model.viewMode = UnifiedSearchResultsListModel.ProviderDetail + tryCompare(footer, "visible", false) + model.viewMode = UnifiedSearchResultsListModel.Aggregate + tryCompare(footer, "visible", true) + + searchWindow.destroy() + model.destroy() + } + + function test_hiddenLoadingFooterDoesNotReserveSpace() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const footer = findChild(searchWindow, "searchResultsLoadingFooter") + + verify(footer !== null) + compare(footer.visible, false) + compare(footer.height, 0) + model.isSearchInProgress = true + tryVerify(() => footer.height > 0) + + searchWindow.destroy() + model.destroy() + } + + function test_detailHeaderCentersProviderAndShowsBackArrow() { + const model = windowSearchModel.createObject(this, { + viewMode: UnifiedSearchResultsListModel.ProviderDetail, + detailProviderName: "A provider name that is intentionally much too long for the available search header" + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const header = findChild(searchWindow, "searchDetailHeader") + const title = findChild(searchWindow, "searchDetailProviderTitle") + const backButton = findChild(searchWindow, "searchDetailBackButton") + + verify(header !== null) + verify(title !== null) + verify(backButton !== null) + compare(title.font.bold, true) + verify(title.font.pixelSize > Style.unifiedSearchResultTitleFontSize) + const titleCenter = title.mapToItem(header, title.width / 2, title.height / 2) + fuzzyCompare(titleCenter.x, header.width / 2, 1) + const backButtonRight = backButton.mapToItem(header, backButton.width, 0).x + const titleLeft = title.mapToItem(header, 0, 0).x + verify(titleLeft > backButtonRight) + verify(title.implicitWidth > title.width) + verify(backButton.icon.source.toString().includes("arrow-left.svg")) + compare(backButton.icon.width, Style.smallIconSize) + compare(backButton.icon.height, Style.smallIconSize) + + searchWindow.destroy() + model.destroy() + } + + function test_filterButtonsUseWizardHoverAndTrailingCarets() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const dateButton = findChild(searchWindow, "dateFilterButton") + const peopleButton = findChild(searchWindow, "peopleFilterButton") + + verify(typeButton !== null) + verify(dateButton !== null) + verify(peopleButton !== null) + const todayMenuItem = findChild(searchWindow, "dateTodayMenuItem") + verify(todayMenuItem !== null) + compare(todayMenuItem.hoverEnabled, true) + for (const button of [typeButton, dateButton, peopleButton]) { + verify(button.trailingIconSource.toString().includes("caret-down.svg")) + const caret = findChild(button, "wizardButtonTrailingIcon") + verify(caret !== null) + const caretPosition = caret.mapToItem(button, 0, 0) + verify(caretPosition.x > button.width / 2) + fuzzyCompare(caretPosition.y + caret.height / 2, button.height / 2, 1) + } + + searchWindow.destroy() + model.destroy() + } + + function test_activeFilterUsesCompactPillButton() { + const model = windowSearchModel.createObject(this, { + activeFilters: [{ + type: "date", + id: "last7days", + label: "Last 7 days", + icon: "" + }] + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const chip = findChild(searchWindow, "activeFilterChip") + const dateButton = findChild(searchWindow, "dateFilterButton") + + verify(chip !== null) + verify(dateButton !== null) + compare(chip.implicitHeight, Style.wizardChipButtonHeight) + verify(chip.implicitHeight < dateButton.implicitHeight) + compare(chip.background.radius, Style.veryRoundedButtonRadius) + verify(chip.leftPadding < dateButton.leftPadding) + compare(chip.rightPadding, chip.leftPadding) + + searchWindow.destroy() + model.destroy() + } + + } + + TestCase { + name: "UnifiedSearchInput" + when: windowShown + + function init() { + input.text = "" + input.forceActiveFocus() + clearSpy.clear() + filterSpy.clear() + moveSpy.clear() + activateSpy.clear() + } + + function test_keyboardNavigationKeepsInputFocus() { + keyClick(Qt.Key_Down) + keyClick(Qt.Key_End) + keyClick(Qt.Key_Return) + compare(moveSpy.count, 2) + compare(activateSpy.count, 1) + verify(input.activeFocus) + } + + function test_activeFocusKeepsNeutralFrame() { + verify(input.activeFocus) + compare(input.background.border.width, 1) + compare(input.background.border.color, Style.wizardFieldBorder) + } + + function test_textEditingSignalIsIndependentFromClear() { + keyClick(Qt.Key_C) + keyClick(Qt.Key_A) + keyClick(Qt.Key_L) + keyClick(Qt.Key_E) + keyClick(Qt.Key_N) + keyClick(Qt.Key_D) + keyClick(Qt.Key_A) + keyClick(Qt.Key_R) + compare(input.text, "calendar") + compare(clearSpy.count, 0) + compare(filterSpy.count, 0) + } + } + + TestCase { + name: "WizardButton" + when: windowShown + + function test_hoverUsesSharedWizardSurface() { + mouseMove(input, 1, 1) + wait(0) + const restingColor = wizardHoverButton.background.color.toString() + const hoverArea = findChild(wizardHoverButton, "wizardButtonHoverArea") + + verify(hoverArea !== null) + mouseMove(wizardHoverButton, 2, 2) + tryCompare(hoverArea, "containsMouse", true) + verify(wizardHoverButton.background.color.toString() !== restingColor) + } + + function test_tintedLeadingIconUsesRequestedColor() { + wizardHoverButton.iconBeforeText = true + wizardHoverButton.tintIcon = true + wizardHoverButton.iconTintColor = Style.wizardPrimaryText + wizardHoverButton.iconSource = "qrc:/client/theme/black/folder.svg" + wait(0) + + const iconTint = findChild(wizardHoverButton, "wizardButtonLeadingIconTint") + verify(iconTint !== null) + compare(iconTint.visible, true) + compare(iconTint.color.toString(), Style.wizardPrimaryText.toString()) + } + + function test_trailingIconCannotOverlapLongText() { + wizardHoverButton.iconBeforeText = false + wizardHoverButton.iconSource = "" + wizardHoverButton.trailingIconSource = "qrc:/client/theme/black/caret-down.svg" + wizardHoverButton.text = "An intentionally long translated button label" + wait(0) + + const textItem = findChild(wizardHoverButton, "wizardButtonText") + const trailingIcon = findChild(wizardHoverButton, "wizardButtonTrailingIcon") + verify(textItem !== null) + verify(trailingIcon !== null) + const textRight = textItem.mapToItem(wizardHoverButton, textItem.width, 0).x + const iconLeft = trailingIcon.mapToItem(wizardHoverButton, 0, 0).x + verify(textRight < iconLeft) + } + } + + TestCase { + name: "WizardMenuItem" + when: windowShown + + function test_hoverUsesSharedWizardMenuSurface() { + mouseMove(input, 1, 1) + wait(0) + const restingColor = wizardMenuHoverItem.background.color.toString() + + mouseMove(wizardMenuHoverItem, 2, 2) + tryCompare(wizardMenuHoverItem, "hovered", true) + verify(wizardMenuHoverItem.background.color.toString() !== restingColor) + } + + function test_tintedIconUsesRequestedColor() { + const iconTint = findChild(wizardMenuHoverItem, "wizardMenuItemIconTint") + verify(iconTint !== null) + compare(iconTint.visible, true) + compare(iconTint.color.toString(), Style.wizardPrimaryText.toString()) + } + } + + TestCase { + name: "UnifiedSearchResultDelegate" + when: windowShown + + function init() { + resultDelegate.resultType = UnifiedSearchResultsListModel.ProviderHeader + resultDelegate.hasOverflow = true + resultDelegate.isSelected = false + fakeSearchModel.openedProviderDetails = 0 + fakeSearchModel.activatedResults = 0 + fakeSearchModel.loadedPages = 0 + fakeSearchModel.retriedPages = 0 + wait(0) + } + + function test_providerHeaderReceivesDelegateDataAndGeometry() { + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "providerHeaderRow") + compare(resultDelegate.loadedItem.width, resultDelegate.width) + compare(resultDelegate.height, 44) + compare(resultDelegate.loadedItem.text, "More from Files →") + compare(resultDelegate.loadedItem.font.bold, false) + compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.hoverEnabled, true) + compare(findChild(resultDelegate.loadedItem, "providerHeaderIconTint"), null) + + mouseMove(input, 1, 1) + wait(0) + const restingColor = resultDelegate.loadedItem.background.color.toString() + mouseMove(resultDelegate.loadedItem, 2, 2) + tryVerify(() => resultDelegate.loadedItem.background.color.toString() !== restingColor) + + mouseClick(resultDelegate.loadedItem) + compare(fakeSearchModel.openedProviderDetails, 1) + } + + function test_plainProviderHeaderStaysRegularAndNonInteractive() { + resultDelegate.hasOverflow = false + wait(0) + + compare(resultDelegate.loadedItem.text, "Files") + compare(resultDelegate.loadedItem.font.bold, false) + compare(resultDelegate.loadedItem.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.hoverEnabled, false) + } + + function test_resultRowReceivesDelegateDataAndGeometry() { + resultDelegate.resultType = UnifiedSearchResultsListModel.Default + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchResultRow") + compare(resultDelegate.loadedItem.width, resultDelegate.width) + compare(resultDelegate.height, Style.unifiedSearchItemHeight) + verify(resultDelegate.height <= 44) + compare(resultDelegate.loadedItem.leftPadding, 0) + compare(resultDelegate.loadedItem.rightPadding, 0) + compare(resultDelegate.loadedItem.topPadding, 0) + compare(resultDelegate.loadedItem.bottomPadding, 0) + compare(resultDelegate.loadedItem.contentItem.height, resultDelegate.loadedItem.height) + compare(resultDelegate.loadedItem.background.height, resultDelegate.loadedItem.height) + compare(resultDelegate.loadedItem.contentItem.Accessible.ignored, true) + + const title = findChild(resultDelegate.loadedItem, "searchResultTitle") + const subline = findChild(resultDelegate.loadedItem, "searchResultSubline") + const textContainer = findChild(resultDelegate.loadedItem, "searchResultTextContainer") + verify(title !== null) + verify(subline !== null) + verify(textContainer !== null) + compare(title.font.pixelSize, Style.unifiedSearchResultTitleFontSize) + compare(subline.font.pixelSize, title.font.pixelSize) + compare(textContainer.spacing, Style.unifiedSearchResultTextSpacing) + + resultDelegate.isSelected = true + wait(0) + const selectedColor = resultDelegate.loadedItem.background.color.toString() + resultDelegate.isSelected = false + mouseMove(input, 1, 1) + wait(0) + mouseMove(resultDelegate.loadedItem, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) + verify(resultDelegate.loadedItem.background.color.toString() !== selectedColor) + + mouseClick(resultDelegate.loadedItem) + compare(fakeSearchModel.activatedResults, 1) + } + + function test_loadMoreUsesAFlatItemRow() { + resultDelegate.resultType = UnifiedSearchResultsListModel.FetchMoreTrigger + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchPagingRow") + compare(resultDelegate.loadedItem.text, qsTr("Load more results")) + verify(resultDelegate.loadedItem.icon.source.toString().includes("more.svg")) + verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) + compare(resultDelegate.loadedItem.height, 44) + mouseMove(input, 1, 1) + wait(0) + compare(resultDelegate.loadedItem.background.color.toString(), "#00000000") + + mouseMove(resultDelegate.loadedItem, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) + + mouseClick(resultDelegate.loadedItem) + compare(fakeSearchModel.loadedPages, 1) + } + + function test_retryPagingUsesAFlatItemRow() { + resultDelegate.resultType = UnifiedSearchResultsListModel.RetryFetchMoreTrigger + wait(0) + + verify(resultDelegate.loadedItem !== null) + compare(resultDelegate.loadedItem.objectName, "searchPagingRow") + compare(resultDelegate.loadedItem.text, qsTr("Retry loading more results")) + verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) + compare(resultDelegate.loadedItem.height, 44) + + mouseClick(resultDelegate.loadedItem) + compare(fakeSearchModel.retriedPages, 1) + } + } +} diff --git a/test/testiconutils.cpp b/test/testiconutils.cpp index 3098827dedff9..e5973383ae4e5 100644 --- a/test/testiconutils.cpp +++ b/test/testiconutils.cpp @@ -43,6 +43,13 @@ private Q_SLOTS: QVERIFY(!OCC::Ui::IconUtils::drawSvgWithCustomFillColor(blackSvgDirPath + QStringLiteral("/") + blackImages.at(0), QColorConstants::Svg::green).isNull()); + const auto imageFromZeroWidthRequest = OCC::Ui::IconUtils::drawSvgWithCustomFillColor( + blackSvgDirPath + QStringLiteral("/") + blackImages.at(0), + QColorConstants::Svg::green, + nullptr, + QSize(0, 24)); + QVERIFY(!imageFromZeroWidthRequest.isNull()); + const QString whiteSvgDirPath{QString{OCC::Theme::themePrefix} + QStringLiteral("white")}; const QDir whiteSvgDir(whiteSvgDirPath); const QStringList whiteImages = whiteSvgDir.entryList(QStringList("*.svg")); diff --git a/test/testunifiedsearchlistmodel.cpp b/test/testunifiedsearchlistmodel.cpp index e20b19f9e7fca..e1bdcd912f73a 100644 --- a/test/testunifiedsearchlistmodel.cpp +++ b/test/testunifiedsearchlistmodel.cpp @@ -8,6 +8,7 @@ #include "account.h" #include "accountstate.h" #include "syncenginetestutils.h" +#include "testhelper.h" #include #include @@ -134,15 +135,25 @@ class FakeSearchResultsStorage } // initialize the JSON response containing the fake list of providers and their properties - void initProvidersResponse() + void initProvidersResponse(const QString &excludedProviderId = {}) { QList providersList; for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) { + if (fakeProviderInitInfo._id == excludedProviderId) { + continue; + } providersList.push_back(QVariantMap{ {QStringLiteral("id"), fakeProviderInitInfo._id}, + {QStringLiteral("appId"), fakeProviderInitInfo._id}, {QStringLiteral("name"), fakeProviderInitInfo._name}, {QStringLiteral("order"), fakeProviderInitInfo._order}, + {QStringLiteral("filters"), QVariantMap{ + {QStringLiteral("term"), QVariantMap{}}, + {QStringLiteral("since"), QVariantMap{}}, + {QStringLiteral("until"), QVariantMap{}}, + {QStringLiteral("person"), QVariantMap{}}, + }}, }); } @@ -242,6 +253,65 @@ class FakeSearchResultsStorage .toJson(QJsonDocument::Compact); } + if (searchTerm == QStringLiteral("[app-icons]")) { + auto entries = QVariantList{}; + if (providerId == QStringLiteral("settings_apps")) { + const auto titles = QStringList{ + QStringLiteral("Dashboard"), + QStringLiteral("Talk"), + QStringLiteral("Activity"), + QStringLiteral("Unknown app"), + }; + for (const auto &title : titles) { + entries.push_back(QVariantMap{ + {QStringLiteral("thumbnailUrl"), QString()}, + {QStringLiteral("title"), title}, + {QStringLiteral("subline"), QString()}, + {QStringLiteral("resourceUrl"), QStringLiteral("http://example.de/index.php/apps/dashboard/")}, + {QStringLiteral("icon"), QStringLiteral("icon-arrow-right")}, + {QStringLiteral("rounded"), false}, + }); + } + } + const auto dataMap = QVariantMap{ + {QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), false}, + {QStringLiteral("cursor"), QVariant()}, + {QStringLiteral("entries"), entries}, + }; + const auto ocsMap = QVariantMap{{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact); + } + + if (searchTerm == QStringLiteral("[small-page]")) { + auto entries = resultsForProvider(providerId, 0); + while (entries.size() > 2) { + entries.removeLast(); + } + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), true}, {QStringLiteral("cursor"), cursor + entries.size()}, + {QStringLiteral("entries"), entries}}; + const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}) + .toJson(QJsonDocument::Compact); + } + + if (searchTerm == QStringLiteral("[oversized-page]")) { + auto entries = QList(); + const auto availableEntries = resultsForProvider(providerId, 0); + if (!availableEntries.isEmpty()) { + for (auto index = 0; index < 25; ++index) { + entries.push_back(availableEntries.constFirst()); + } + } + const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name}, + {QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), QVariant()}, + {QStringLiteral("entries"), entries}}; + const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}}; + return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}) + .toJson(QJsonDocument::Compact); + } + const auto provider = _searchResultsData.value(providerId, Provider()); const auto nextCursor = cursor + pageSize; @@ -283,9 +353,10 @@ class TestUnifiedSearchListmodel : public QObject QScopedPointer fakeQnam; OCC::AccountPtr account; - QScopedPointer accountState; + QScopedPointer accountState; QScopedPointer model; QScopedPointer modelTester; + QList requestedUrls; QScopedPointer fakeDesktopServicesUrlHandler; @@ -304,10 +375,11 @@ private Q_SLOTS: account->setCredentials(new FakeCredentials{fakeQnam.data()}); account->setUrl(QUrl(("http://example.de"))); - accountState.reset(new OCC::AccountState(account)); + accountState.reset(new FakeAccountState(account)); fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) { Q_UNUSED(device); + requestedUrls.push_back(req.url()); QNetworkReply *reply = nullptr; const auto urlQuery = QUrlQuery(req.url()); @@ -370,15 +442,10 @@ private Q_SLOTS: QCOMPARE(model->searchTerm(), QStringLiteral("discuss")); QCOMPARE(searhTermChanged.count(), 1); - // #3 test that model has not started search yet QVERIFY(!model->isSearchInProgress()); - - // #4 test that model has started the search after specific delay QSignalSpy searchInProgressChanged(model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); - // allow search jobs to get created within the model QVERIFY(searchInProgressChanged.wait()); - QCOMPARE(searchInProgressChanged.count(), 1); QVERIFY(model->isSearchInProgress()); // #5 test that model has stopped the search after setting empty search term @@ -395,21 +462,8 @@ private Q_SLOTS: // test that search term gets set, search gets started and enough results get returned model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss")); - QSignalSpy searchInProgressChanged( - model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has started - QCOMPARE(searchInProgressChanged.count(), 1); - QVERIFY(model->isSearchInProgress()); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has finished - QVERIFY(!model->isSearchInProgress()); - - QVERIFY(model->rowCount() > 0); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); } void testSetSearchTermResultsNotFound() @@ -421,21 +475,9 @@ private Q_SLOTS: // test that search term gets set, search gets started and enough results get returned model->setSearchTerm(model->searchTerm() + QStringLiteral("[empty]")); - QSignalSpy searchInProgressChanged( - model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has started - QCOMPARE(searchInProgressChanged.count(), 1); - QVERIFY(model->isSearchInProgress()); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has finished - QVERIFY(!model->isSearchInProgress()); - - QVERIFY(model->rowCount() == 0); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress(), 2000); + QCOMPARE(model->rowCount(), 0); } void testFetchMoreClicked() @@ -460,12 +502,30 @@ private Q_SLOTS: // make sure search has finished QVERIFY(!model->isSearchInProgress()); + QString providerIdFetchMoreTriggered; + for (auto row = 0; row < model->rowCount(); ++row) { + if (model->data(model->index(row), OCC::UnifiedSearchResultsListModel::TypeRole) + == OCC::UnifiedSearchResult::Type::ProviderHeader + && model->data(model->index(row), OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) { + providerIdFetchMoreTriggered = model->data(model->index(row), OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerIdFetchMoreTriggered.isEmpty()); + model->openProviderDetail(providerIdFetchMoreTriggered); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); const auto numRowsInModelPrev = model->rowCount(); + QStringList stableKeysBeforePaging; + for (auto row = 0; row < numRowsInModelPrev - 1; ++row) { + stableKeysBeforePaging.push_back(model->data(model->index(row), + OCC::UnifiedSearchResultsListModel::StableKeyRole).toString()); + } + QSignalSpy modelReset(model.data(), &QAbstractItemModel::modelReset); + QSignalSpy rowsInserted(model.data(), &QAbstractItemModel::rowsInserted); + QSignalSpy rowsRemoved(model.data(), &QAbstractItemModel::rowsRemoved); - // test fetch more results QSignalSpy currentFetchMoreInProgressProviderIdChanged( model.data(), &OCC::UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderIdChanged); - QSignalSpy rowsInserted(model.data(), &OCC::UnifiedSearchResultsListModel::rowsInserted); for (int i = 0; i < model->rowCount(); ++i) { const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole); @@ -479,47 +539,24 @@ private Q_SLOTS: } // make sure the currentFetchMoreInProgressProviderId was set back and forth and correct number fows has been inserted - QCOMPARE(currentFetchMoreInProgressProviderIdChanged.count(), 1); - - const auto providerIdFetchMoreTriggered = model->currentFetchMoreInProgressProviderId(); - - QVERIFY(!providerIdFetchMoreTriggered.isEmpty()); - - QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait()); - - QVERIFY(model->currentFetchMoreInProgressProviderId().isEmpty()); - - QCOMPARE(rowsInserted.count(), 1); - - const auto arguments = rowsInserted.takeFirst(); - - QVERIFY(arguments.size() > 0); - - const auto first = arguments.at(0).toInt(); - const auto last = arguments.at(1).toInt(); - - const int numInsertedExpected = last - first; - - QCOMPARE(model->rowCount() - numRowsInModelPrev, numInsertedExpected); + QVERIFY(currentFetchMoreInProgressProviderIdChanged.count() > 0); + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); + QVERIFY(model->rowCount() > numRowsInModelPrev); + QCOMPARE(modelReset.count(), 0); + QVERIFY(rowsInserted.count() > 0); + QCOMPARE(rowsRemoved.count(), 0); + for (auto row = 0; row < stableKeysBeforePaging.size(); ++row) { + QCOMPARE(model->data(model->index(row), OCC::UnifiedSearchResultsListModel::StableKeyRole).toString(), + stableKeysBeforePaging[row]); + } // make sure the FetchMoreTrigger gets removed when no more results available if (!providerIdFetchMoreTriggered.isEmpty()) { currentFetchMoreInProgressProviderIdChanged.clear(); - rowsInserted.clear(); - - QSignalSpy rowsRemoved(model.data(), &OCC::UnifiedSearchResultsListModel::rowsRemoved); - for (int i = 0; i < 10; ++i) { model->fetchMoreTriggerClicked(providerIdFetchMoreTriggered); - - QVERIFY(currentFetchMoreInProgressProviderIdChanged.wait()); - - if (rowsRemoved.count() > 0) { - break; - } + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); } - - QCOMPARE(rowsRemoved.count(), 1); bool isFetchMoreTriggerFound = false; @@ -538,6 +575,178 @@ private Q_SLOTS: } } + void testSmallPaginatedFirstPageCanOpenAndLoadMore() + { + model->setSearchTerm(QStringLiteral("[small-page]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + auto providerId = QString(); + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt() + == OCC::UnifiedSearchResult::Type::ProviderHeader + && model->data(index, OCC::UnifiedSearchResultsListModel::HasOverflowRole).toBool()) { + providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerId.isEmpty()); + + model->openProviderDetail(providerId); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); + const auto rowsBeforeLoading = model->rowCount(); + QVERIFY(rowsBeforeLoading <= 3); + model->loadMore(providerId); + QTRY_VERIFY_WITH_TIMEOUT(model->currentFetchMoreInProgressProviderId().isEmpty(), 1000); + QVERIFY(model->rowCount() > rowsBeforeLoading); + } + + void testNonConformingProviderPageIsCapped() + { + model->setSearchTerm(QStringLiteral("[oversized-page]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + auto providerId = QString(); + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + if (model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt() + == OCC::UnifiedSearchResult::Type::ProviderHeader) { + providerId = model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString(); + break; + } + } + QVERIFY(!providerId.isEmpty()); + model->openProviderDetail(providerId); + QCOMPARE(model->viewMode(), OCC::UnifiedSearchResultsListModel::ViewMode::ProviderDetail); + QCOMPARE(model->rowCount(), 10); + } + + void testAggregateProjectionAndKeyboardSelection() + { + model->setSearchTerm(QStringLiteral("projection")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + QHash resultsByProvider; + auto loadMoreFound = false; + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + const auto type = model->data(index, OCC::UnifiedSearchResultsListModel::TypeRole).toInt(); + if (type == OCC::UnifiedSearchResult::Type::Default) { + ++resultsByProvider[model->data(index, OCC::UnifiedSearchResultsListModel::ProviderIdRole).toString()]; + } else if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) { + loadMoreFound = true; + } + } + for (const auto count : std::as_const(resultsByProvider)) QVERIFY(count <= 3); + QVERIFY(!loadMoreFound); + QVERIFY(model->selectedRow() >= 0); + const auto firstSelected = model->selectedRow(); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next); + QVERIFY(model->selectedRow() >= firstSelected); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Last); + const auto lastSelected = model->selectedRow(); + model->moveSelection(OCC::UnifiedSearchResultsListModel::SelectionDirection::Next); + QCOMPARE(model->selectedRow(), lastSelected); + } + + void testAppsProviderUsesNavigationAppIcons() + { + const auto navigationApps = QVariantList{ + QVariantMap{{QStringLiteral("name"), QStringLiteral("Dashboard")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/dashboard/")}, + {QStringLiteral("id"), QStringLiteral("dashboard")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")}}, + QVariantMap{{QStringLiteral("name"), QStringLiteral("Talk")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/call/")}, + {QStringLiteral("id"), QStringLiteral("spreed")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")}}, + QVariantMap{{QStringLiteral("name"), QStringLiteral("Activity")}, + {QStringLiteral("href"), QStringLiteral("http://example.de/index.php/apps/activity/")}, + {QStringLiteral("id"), QStringLiteral("activity")}, + {QStringLiteral("icon"), QStringLiteral("http://example.de/apps/activity/img/app.svg")}}, + }; + const auto navigationResponse = QJsonDocument::fromVariant(QVariantMap{ + {QStringLiteral("ocs"), QVariantMap{{QStringLiteral("data"), navigationApps}}}, + }); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), + "slotNavigationAppsFetched", + Qt::DirectConnection, + Q_ARG(QJsonDocument, navigationResponse), + Q_ARG(int, 200))); + + model->setSearchTerm(QString()); + model->closeProviderDetail(); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + model->setSearchTerm(QStringLiteral("[app-icons]")); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); + + model->openProviderDetail(QStringLiteral("settings_apps")); + QCOMPARE(model->rowCount(), 4); + + const auto expectedIconUrls = QHash{ + {QStringLiteral("Dashboard"), QStringLiteral("http://example.de/apps/dashboard/img/app.svg")}, + {QStringLiteral("Talk"), QStringLiteral("http://example.de/apps/spreed/img/app.svg")}, + {QStringLiteral("Activity"), QStringLiteral("http://example.de/apps/activity/img/app.svg")}, + }; + for (auto row = 0; row < model->rowCount(); ++row) { + const auto index = model->index(row); + const auto title = model->data(index, OCC::UnifiedSearchResultsListModel::TitleRole).toString(); + if (expectedIconUrls.contains(title)) { + QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole).toString(), + expectedIconUrls.value(title) + QStringLiteral("/white")); + QCOMPARE(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole).toString(), + expectedIconUrls.value(title) + QStringLiteral("/black")); + } else if (title == QStringLiteral("Unknown app")) { + QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::DarkIconsRole) + .toString() + .endsWith(QStringLiteral("change.svg"))); + QVERIFY(model->data(index, OCC::UnifiedSearchResultsListModel::LightIconsRole) + .toString() + .endsWith(QStringLiteral("change.svg"))); + } + } + } + + void testFiltersAndRequestParameters() + { + model->setSearchTerm(QString()); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + requestedUrls.clear(); + + model->toggleProviderFilter(QStringLiteral("files")); + model->setDatePreset(QStringLiteral("last7days")); + model->setPersonFilter(QStringLiteral("ada"), QStringLiteral("Ada Lovelace")); + model->setSearchTerm(QStringLiteral("filtered")); + QTRY_VERIFY_WITH_TIMEOUT(!model->isSearchInProgress() && !model->waitingForSearchTermEditEnd(), 2000); + + QList searchRequests; + for (const auto &url : std::as_const(requestedUrls)) { + if (url.path().endsWith(QStringLiteral("/search"))) searchRequests.push_back(url); + } + QCOMPARE(searchRequests.size(), 1); + QVERIFY(searchRequests.constFirst().path().contains(QStringLiteral("/files/search"))); + const QUrlQuery query(searchRequests.constFirst()); + QCOMPARE(query.queryItemValue(QStringLiteral("term")), QStringLiteral("filtered")); + QCOMPARE(query.queryItemValue(QStringLiteral("limit")), QStringLiteral("10")); + QCOMPARE(query.queryItemValue(QStringLiteral("person")), QStringLiteral("ada")); + QVERIFY(!query.queryItemValue(QStringLiteral("since")).isEmpty()); + QVERIFY(!query.queryItemValue(QStringLiteral("until")).isEmpty()); + QVERIFY(!query.hasQueryItem(QStringLiteral("from"))); + + model->setSearchTerm(QString()); + model->clearTypeFilters(); + model->clearDateFilter(); + model->clearPersonFilter(); + } + void testSearchResultlicked() { // make sure the model is empty @@ -547,21 +756,8 @@ private Q_SLOTS: // test that search term gets set, search gets started and enough results get returned model->setSearchTerm(model->searchTerm() + QStringLiteral("discuss")); - QSignalSpy searchInProgressChanged( - model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has started - QCOMPARE(searchInProgressChanged.count(), 1); - QVERIFY(model->isSearchInProgress()); - - QVERIFY(searchInProgressChanged.wait()); - - // make sure search has finished and some results has been received - QVERIFY(!model->isSearchInProgress()); - - QVERIFY(model->rowCount() != 0); + QTRY_VERIFY_WITH_TIMEOUT(!model->waitingForSearchTermEditEnd() + && !model->isSearchInProgress() && model->rowCount() > 0, 2000); QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked"); QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked"); @@ -737,6 +933,32 @@ private Q_SLOTS: model->setSearchTerm(QStringLiteral("")); } + void testDisconnectAndRediscoveryResetProviderState() + { + model->setSearchTerm(QString()); + model->clearTypeFilters(); + QVERIFY(model->providersReady()); + model->toggleProviderFilter(QStringLiteral("files")); + QCOMPARE(model->activeFilters().size(), 1); + QSignalSpy providersReadyChanged(model.data(), &OCC::UnifiedSearchResultsListModel::providersReadyChanged); + + accountState->setStateForTesting(OCC::AccountState::Disconnected); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection)); + QCOMPARE(model->providersReady(), false); + QVERIFY(providersReadyChanged.count() > 0); + + FakeSearchResultsStorage::instance()->initProvidersResponse(QStringLiteral("files")); + accountState->setStateForTesting(OCC::AccountState::Connected); + QVERIFY(QMetaObject::invokeMethod(accountState.data(), "isConnectedChanged", Qt::DirectConnection)); + QTRY_VERIFY_WITH_TIMEOUT(model->providersReady(), 1000); + QCOMPARE(model->activeFilters().size(), 0); + for (const auto &providerValue : model->providers()) { + QVERIFY(providerValue.toMap().value(QStringLiteral("id")).toString() != QStringLiteral("files")); + } + + FakeSearchResultsStorage::instance()->initProvidersResponse(); + } + void cleanupTestCase() { FakeSearchResultsStorage::destroy(); diff --git a/test/testunifiedsearchpeoplemodel.cpp b/test/testunifiedsearchpeoplemodel.cpp new file mode 100644 index 0000000000000..f8a6607c9311b --- /dev/null +++ b/test/testunifiedsearchpeoplemodel.cpp @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include "gui/search/unifiedsearchpeoplemodel.h" + +#include "account.h" +#include "accountstate.h" +#include "syncenginetestutils.h" +#include "testhelper.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { +const auto response = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[{"label":"Ada Lovelace","value":{"shareWith":"ada","shareType":0}}]},"users":[{"label":"Ada duplicate","value":{"shareWith":"ada","shareType":0}},{"label":"Alan Turing","value":{"shareWith":"alan","shareType":0}}]}}})"); +const auto errorResponse = QByteArrayLiteral(R"({"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Failure"},"data":{}}})"); +} + +class TestUnifiedSearchPeopleModel : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void emptyQueryShowsSignedInUserWithoutRequest() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + auto requestCount = 0; + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + ++requestCount; + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + QCOMPARE(requestCount, 0); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user")); + QVERIFY(model.errorString().isEmpty()); + } + + void parsesUsersAndUsesDocumentedParameters() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("me")); + auto state = std::make_unique(account); + QUrl requestedUrl; + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + requestedUrl = request.url(); + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("a")); + QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && model.rowCount() == 2, 1000); + + QCOMPARE(requestedUrl.path(), QStringLiteral("/ocs/v2.php/apps/files_sharing/api/v1/sharees")); + const QUrlQuery query(requestedUrl); + QCOMPARE(query.queryItemValue(QStringLiteral("shareType")), QStringLiteral("0")); + QCOMPARE(query.queryItemValue(QStringLiteral("lookup")), QStringLiteral("false")); + QCOMPARE(query.queryItemValue(QStringLiteral("page")), QStringLiteral("1")); + QCOMPARE(query.queryItemValue(QStringLiteral("perPage")), QStringLiteral("50")); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("ada")); + QCOMPARE(model.data(model.index(1), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("alan")); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::AvatarUrlRole).toString(), + QStringLiteral("https://cloud.example.test/index.php/avatar/ada/64")); + } + + void injectsMatchingSignedInUser() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + return static_cast(new FakePayloadReply(operation, request, QByteArrayLiteral(R"({"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"exact":{"users":[]},"users":[]}}})"), qnam.get())); + }); + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("current")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + QCOMPARE(model.data(model.index(0), OCC::UnifiedSearchPeopleModel::UserIdRole).toString(), QStringLiteral("current-user")); + } + + void queryReplacementAndFailureClearOldPeople() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + auto state = std::make_unique(account); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + const auto query = QUrlQuery(request.url()).queryItemValue(QStringLiteral("search")); + if (query == QStringLiteral("broken")) { + return static_cast(new FakeErrorReply(operation, request, qnam.get(), 500, errorResponse)); + } + return static_cast(new FakePayloadReply(operation, request, response, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("a")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 2, 1000); + + model.setSearchTerm(QStringLiteral("broken")); + QCOMPARE(model.rowCount(), 0); + QTRY_VERIFY_WITH_TIMEOUT(!model.busy() && !model.errorString().isEmpty(), 1000); + QCOMPARE(model.rowCount(), 0); + } + + void accountDestructionClearsPeopleAndPointer() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("current-user")); + auto state = std::make_unique(account); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + QAbstractItemModelTester tester(&model); + model.setAccountState(state.get()); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 1, 1000); + + state->setStateForTesting(OCC::AccountState::Disconnected); + QVERIFY(QMetaObject::invokeMethod(state.get(), "isConnectedChanged", Qt::DirectConnection)); + QCOMPARE(model.rowCount(), 0); + QVERIFY(!model.errorString().isEmpty()); + + state.reset(); + QCOMPARE(model.accountState(), nullptr); + QCOMPARE(model.rowCount(), 0); + QVERIFY(!model.errorString().isEmpty()); + } + + void capsNonConformingResponses() + { + auto qnam = std::unique_ptr(new FakeQNAM({})); + auto account = OCC::Account::create(); + account->setCredentials(new FakeCredentials(qnam.get())); + account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + auto state = std::make_unique(account); + + auto users = QJsonArray(); + for (auto index = 0; index < 75; ++index) { + users.push_back(QJsonObject{ + {QStringLiteral("label"), QStringLiteral("User %1").arg(index)}, + {QStringLiteral("value"), QJsonObject{{QStringLiteral("shareWith"), QStringLiteral("user-%1").arg(index)}}}, + }); + } + const auto oversizedResponse = QJsonDocument(QJsonObject{ + {QStringLiteral("ocs"), QJsonObject{ + {QStringLiteral("meta"), QJsonObject{{QStringLiteral("statuscode"), 200}}}, + {QStringLiteral("data"), QJsonObject{ + {QStringLiteral("exact"), QJsonObject{{QStringLiteral("users"), QJsonArray()}}}, + {QStringLiteral("users"), users}, + }}, + }}, + }).toJson(QJsonDocument::Compact); + qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { + return static_cast(new FakePayloadReply(operation, request, oversizedResponse, qnam.get())); + }); + + OCC::UnifiedSearchPeopleModel model(nullptr, 0); + model.setAccountState(state.get()); + model.setSearchTerm(QStringLiteral("user")); + QTRY_COMPARE_WITH_TIMEOUT(model.rowCount(), 50, 1000); + } +}; + +QTEST_MAIN(TestUnifiedSearchPeopleModel) +#include "testunifiedsearchpeoplemodel.moc" diff --git a/theme.qrc.in b/theme.qrc.in index 501cae84d3d83..408d8a16e34cf 100644 --- a/theme.qrc.in +++ b/theme.qrc.in @@ -192,6 +192,7 @@ theme/black/caret-down.svg theme/black/change.svg theme/black/clear.svg + theme/black/filter.svg theme/black/close.svg theme/black/comment.svg theme/black/confirm.svg @@ -269,6 +270,8 @@ theme/white/error.svg + theme/arrow-left.svg + theme/arrow-right.svg theme/sync-arrow.svg theme/close.svg theme/files.svg diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index 84adf5c075e70..f5eb384ef9244 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -49,6 +49,7 @@ QtObject { readonly property color wizardErrorText: darkMode ? "#ff7b86" : "#b00020" readonly property color wizardAvatarPlaceholder: darkMode ? "#3b464d" : "#dfe8ee" readonly property color wizardSelectedText: "#ffffff" + readonly property color listItemHoverBackground: darkMode ? "#3a3a3a" : "#f2f2f2" // ErrorBox colors readonly property color errorBoxBackgroundColor: Qt.rgba(0.89, 0.18, 0.18, 1) @@ -126,6 +127,7 @@ QtObject { readonly property int wizardWindowMargin: 24 readonly property int wizardWindowTopMargin: standardSpacing readonly property int wizardFooterButtonHeight: iconButtonWidth + readonly property int wizardChipButtonHeight: variableSize(26) readonly property int wizardFooterSpacing: trayAccountPopupActionVerticalPadding readonly property int wizardSectionSpacing: trayAccountPopupRowPadding readonly property int wizardDialogMaximumWidth: 420 @@ -149,7 +151,7 @@ QtObject { readonly property int assistantWindowWidth: 640 readonly property int assistantWindowHeight: 620 readonly property int searchWindowWidth: 640 - readonly property int searchWindowHeight: 620 + readonly property int searchWindowHeight: 670 readonly property int userStatusWindowWidth: 560 readonly property int userStatusWindowHeight: 700 readonly property int userStatusWindowMinimumHeight: 560 @@ -221,14 +223,16 @@ QtObject { property bool hoverEffectsEnabled: true // unified search constants - readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight + readonly property int unifiedSearchItemHeight: Math.max(44, + unifiedSearchResultTitleFontSize * 2 + unifiedSearchResultTextSpacing + 8) readonly property int unifiedSearchResultTextLeftMargin: 18 readonly property int unifiedSearchResultTextRightMargin: 16 readonly property int unifiedSearchResultIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction) readonly property int unifiedSearchResultSmallIconWidth: trayListItemIconSize * (1 - thumbnailImageSizeReduction * 2) readonly property int unifiedSearchResultIconLeftMargin: 12 - readonly property int unifiedSearchResultTitleFontSize: topLinePixelSize - readonly property int unifiedSearchResultSublineFontSize: subLinePixelSize + readonly property int unifiedSearchResultTitleFontSize: pixelSize + extraSmallSpacing + readonly property int unifiedSearchResultSublineFontSize: unifiedSearchResultTitleFontSize + readonly property int unifiedSearchResultTextSpacing: extraSmallSpacing readonly property int unifiedSearchResultSectionItemLeftPadding: 16 readonly property int unifiedSearchResultSectionItemVerticalPadding: 8 readonly property int unifiedSearchResultNothingFoundHorizontalMargin: 10 diff --git a/theme/arrow-left.svg b/theme/arrow-left.svg new file mode 100644 index 0000000000000..d2992de683874 --- /dev/null +++ b/theme/arrow-left.svg @@ -0,0 +1,7 @@ + + + + diff --git a/theme/arrow-right.svg b/theme/arrow-right.svg new file mode 100644 index 0000000000000..b93e479f6161c --- /dev/null +++ b/theme/arrow-right.svg @@ -0,0 +1,7 @@ + + + + diff --git a/theme/black/filter.svg b/theme/black/filter.svg new file mode 100644 index 0000000000000..75cbe818fa6c2 --- /dev/null +++ b/theme/black/filter.svg @@ -0,0 +1,3 @@ + + + From d7d038b5c729b43498e1f7b32ebe0c5b75bf00d5 Mon Sep 17 00:00:00 2001 From: Rello Date: Sat, 22 Aug 2026 16:12:17 +0200 Subject: [PATCH 2/9] fix(search): improve filter and loading interactions Assisted-by: Codex:GPT-5 Signed-off-by: Rello --- resources.qrc | 1 + src/gui/search/SearchWindow.qml | 42 +++-- .../search/UnifiedSearchInputContainer.qml | 16 +- .../search/UnifiedSearchResultDelegate.qml | 20 +-- src/gui/search/UnifiedSearchResultItem.qml | 5 +- .../search/unifiedsearchresultslistmodel.cpp | 5 +- src/gui/wizard/qml/WizardMenu.qml | 34 ++++ .../com/nextcloud/desktopclient/qmldir | 3 + test/qml/search/testsearch.qml | 146 +++++++++++++++--- test/testunifiedsearchpeoplemodel.cpp | 1 + 10 files changed, 216 insertions(+), 57 deletions(-) create mode 100644 src/gui/wizard/qml/WizardMenu.qml diff --git a/resources.qrc b/resources.qrc index 1aa1474620b7f..1e411df911a56 100644 --- a/resources.qrc +++ b/resources.qrc @@ -75,6 +75,7 @@ src/gui/wizard/qml/WizardChipButton.qml src/gui/wizard/qml/WizardComboBox.qml src/gui/wizard/qml/WizardDialogFrame.qml + src/gui/wizard/qml/WizardMenu.qml src/gui/wizard/qml/WizardMenuItem.qml src/gui/wizard/qml/WizardTextField.qml src/gui/macOS/ui/FileProviderFileDelegate.qml diff --git a/src/gui/search/SearchWindow.qml b/src/gui/search/SearchWindow.qml index 7bdb228a60de1..819831f7165f2 100644 --- a/src/gui/search/SearchWindow.qml +++ b/src/gui/search/SearchWindow.qml @@ -10,6 +10,7 @@ import QtQuick.Layouts import Style import com.nextcloud.desktopclient import "qrc:/qml/src/gui" +import "qrc:/qml/src/gui/tray" import "qrc:/qml/src/gui/wizard/qml" WizardStyledWindow { @@ -17,10 +18,8 @@ WizardStyledWindow { property var account: null property var searchModel: null - property bool filtersRevealed: false readonly property bool aggregateView: searchModel && searchModel.viewMode === UnifiedSearchResultsListModel.Aggregate readonly property bool filtersVisible: aggregateView && searchModel && searchModel.providersReady - && (filtersRevealed || searchModel.searchTerm.length > 0 || searchModel.activeFilters.length > 0) readonly property int searchState: searchModel ? searchModel.searchState : UnifiedSearchResultsListModel.Placeholder title: "" @@ -102,13 +101,14 @@ WizardStyledWindow { readOnly: !root.searchModel || !root.searchModel.canEditSearch text: root.searchModel ? root.searchModel.searchTerm : "" isSearchInProgress: root.searchModel - && (root.searchModel.isSearchInProgress || root.searchModel.waitingForSearchTermEditEnd) + && (root.searchModel.isSearchInProgress + || root.searchModel.waitingForSearchTermEditEnd + || root.searchModel.isFetchMoreInProgress) placeholderText: root.searchModel && !root.searchModel.isAccountConnected ? qsTr("Search is available when this account is connected") : qsTr("Search files, messages, events …") onTextEdited: if (root.searchModel) root.searchModel.searchTerm = text onClearText: if (root.searchModel) root.searchModel.searchTerm = "" - onToggleFilters: root.filtersRevealed = !root.filtersRevealed onMoveSelection: direction => root.searchModel.moveSelection(direction) onActivateSelection: root.searchModel.activateSelected() } @@ -140,7 +140,7 @@ WizardStyledWindow { root.focusSearchInput() } } - Label { + EnforcedPlainTextLabel { objectName: "searchDetailProviderTitle" anchors.left: parent.left anchors.right: parent.right @@ -157,12 +157,15 @@ WizardStyledWindow { Flow { id: filterFlow + objectName: "categoryFilterFlow" Layout.fillWidth: true Layout.preferredHeight: visible ? childrenRect.height : 0 visible: root.filtersVisible spacing: Style.smallSpacing WizardButton { + id: typeFilterButton + objectName: "typeFilterButton" width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) text: qsTr("Type") @@ -173,10 +176,12 @@ WizardStyledWindow { + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) primary: root.hasActiveFilter("provider") Accessible.name: qsTr("Filter by type") - onClicked: typeMenu.open() - Menu { + onClicked: typeMenu.toggle() + WizardMenu { id: typeMenu - width: parent.width * 1.5 + objectName: "typeFilterMenu" + anchorItem: typeFilterButton + width: anchorItem.width * 1.5 Repeater { model: root.searchModel ? root.searchModel.providers : [] delegate: WizardMenuItem { @@ -191,6 +196,8 @@ WizardStyledWindow { } } WizardButton { + id: dateFilterButton + objectName: "dateFilterButton" width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) text: qsTr("Date") @@ -203,9 +210,11 @@ WizardStyledWindow { enabled: root.searchModel && root.searchModel.dateFilterAvailable Accessible.name: qsTr("Filter by date") Accessible.description: enabled ? "" : qsTr("No search source supports date filtering") - onClicked: dateMenu.open() - Menu { + onClicked: dateMenu.toggle() + WizardMenu { id: dateMenu + objectName: "dateFilterMenu" + anchorItem: dateFilterButton WizardMenuItem { objectName: "dateTodayMenuItem" text: qsTr("Today") @@ -245,6 +254,7 @@ WizardStyledWindow { } Flow { + objectName: "activeFilterFlow" Layout.fillWidth: true Layout.preferredHeight: visible ? implicitHeight : 0 visible: root.filtersVisible && root.searchModel && root.searchModel.activeFilters.length > 0 @@ -400,7 +410,7 @@ WizardStyledWindow { objectName: "partialFailureFooter" Layout.fillWidth: true visible: root.aggregateView && root.searchModel && root.searchModel.hasPartialFailure - Label { Layout.fillWidth: true; text: qsTr("Some sources unavailable"); color: palette.placeholderText } + EnforcedPlainTextLabel { Layout.fillWidth: true; text: qsTr("Some sources unavailable"); color: palette.placeholderText } WizardButton { text: qsTr("Retry"); onClicked: root.searchModel.retryFailedProviders() } } @@ -432,7 +442,7 @@ WizardStyledWindow { placeholderText: qsTr("Search people") onTextEdited: peopleModel.searchTerm = text } - Label { visible: peopleModel.errorString.length > 0; text: peopleModel.errorString; wrapMode: Text.Wrap } + EnforcedPlainTextLabel { visible: peopleModel.errorString.length > 0; text: peopleModel.errorString; wrapMode: Text.Wrap } WizardButton { visible: peopleModel.errorString.length > 0 text: qsTr("Retry") @@ -473,7 +483,7 @@ WizardStyledWindow { ? "image://tray-image-provider/" + personDelegate.avatarUrl : "" Accessible.ignored: true } - Label { Layout.fillWidth: true; text: personDelegate.displayName; elide: Text.ElideRight } + EnforcedPlainTextLabel { Layout.fillWidth: true; text: personDelegate.displayName; elide: Text.ElideRight } } onClicked: { root.searchModel.setPersonFilter(userId, displayName, avatarUrl) @@ -517,11 +527,11 @@ WizardStyledWindow { } ColumnLayout { - Label { text: qsTr("Start date (YYYY-MM-DD)") } + EnforcedPlainTextLabel { text: qsTr("Start date (YYYY-MM-DD)") } TextField { id: customSince; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } - Label { text: qsTr("End date (YYYY-MM-DD)") } + EnforcedPlainTextLabel { text: qsTr("End date (YYYY-MM-DD)") } TextField { id: customUntil; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } - Label { + EnforcedPlainTextLabel { visible: customRangeDialog.validationError text: qsTr("Enter valid dates with the start date before the end date.") color: palette.accent diff --git a/src/gui/search/UnifiedSearchInputContainer.qml b/src/gui/search/UnifiedSearchInputContainer.qml index b8da32ac87807..a276f1d82e3d2 100644 --- a/src/gui/search/UnifiedSearchInputContainer.qml +++ b/src/gui/search/UnifiedSearchInputContainer.qml @@ -12,7 +12,6 @@ TextField { id: root signal clearText() - signal toggleFilters() signal moveSelection(int direction) signal activateSelection() @@ -21,7 +20,7 @@ TextField { readonly property int controlSize: Math.max(40, height - 4) leftPadding: 8 + controlSize - rightPadding: 8 + controlSize + rightPadding: root.text.length > 0 ? 8 + controlSize : 8 verticalAlignment: Qt.AlignVCenter placeholderText: qsTr("Search files, messages, events …") @@ -57,6 +56,7 @@ TextField { } BusyIndicator { + objectName: "searchProgressIndicator" anchors.left: parent.left anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter @@ -68,16 +68,16 @@ TextField { } ToolButton { + objectName: "clearSearchButton" anchors.right: parent.right anchors.rightMargin: 2 anchors.verticalCenter: parent.verticalCenter width: root.controlSize height: root.controlSize - icon.source: root.text.length > 0 ? "image://svgimage-custom-color/clear.svg/" + root.iconColor - : "image://svgimage-custom-color/filter.svg/" + root.iconColor - visible: root.text.length > 0 || root.activeFocus - Accessible.name: root.text.length > 0 ? qsTr("Clear search") : qsTr("Show search filters") - Accessible.description: root.text.length > 0 ? qsTr("Keeps the active filters") : "" - onClicked: root.text.length > 0 ? root.clearText() : root.toggleFilters() + icon.source: "image://svgimage-custom-color/clear.svg/" + root.iconColor + visible: root.text.length > 0 + Accessible.name: qsTr("Clear search") + Accessible.description: qsTr("Keeps the active filters") + onClicked: root.clearText() } } diff --git a/src/gui/search/UnifiedSearchResultDelegate.qml b/src/gui/search/UnifiedSearchResultDelegate.qml index b6afca10cf6b9..174ab21187ba0 100644 --- a/src/gui/search/UnifiedSearchResultDelegate.qml +++ b/src/gui/search/UnifiedSearchResultDelegate.qml @@ -9,6 +9,7 @@ import QtQuick.Layouts import Style import com.nextcloud.desktopclient +import "qrc:/qml/src/gui/tray" Item { id: root @@ -101,7 +102,7 @@ Item { radius: Style.mediumRoundedButtonRadius } - contentItem: Label { + contentItem: EnforcedPlainTextLabel { text: providerHeaderButton.text color: Style.wizardPrimaryText font: providerHeaderButton.font @@ -110,7 +111,7 @@ Item { verticalAlignment: Text.AlignVCenter } - onClicked: { + onPressed: { if (root.hasOverflow) { root.searchModel.openProviderDetail(root.providerId) } @@ -121,7 +122,7 @@ Item { Component { id: partialHeader - Label { + EnforcedPlainTextLabel { objectName: "partialMatchesHeaderRow" width: root.width height: 40 @@ -154,9 +155,9 @@ Item { Accessible.selected: root.isSelected background: Rectangle { - color: resultDelegateButton.hovered - ? Style.listItemHoverBackground - : (root.isSelected ? Style.wizardSecondaryButtonPressed : "transparent") + color: root.isSelected + ? Style.wizardSecondaryButtonPressed + : (resultDelegateButton.hovered ? Style.listItemHoverBackground : "transparent") radius: Style.mediumRoundedButtonRadius } @@ -227,18 +228,19 @@ Item { Accessible.ignored: true } - Label { + EnforcedPlainTextLabel { + objectName: "searchPagingLabel" Layout.fillWidth: true text: pagingDelegate.text color: Style.wizardPrimaryText - font.bold: true + font.bold: false font.pixelSize: Style.unifiedSearchResultTitleFontSize elide: Text.ElideRight verticalAlignment: Text.AlignVCenter } } - onClicked: root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger + onPressed: root.resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger ? root.searchModel.retryLoadMore(root.providerId) : root.searchModel.loadMore(root.providerId) } diff --git a/src/gui/search/UnifiedSearchResultItem.qml b/src/gui/search/UnifiedSearchResultItem.qml index 7e74805cbdc5e..a3ebafcf58098 100644 --- a/src/gui/search/UnifiedSearchResultItem.qml +++ b/src/gui/search/UnifiedSearchResultItem.qml @@ -10,6 +10,7 @@ import QtQuick.Layouts import Qt5Compat.GraphicalEffects import Style +import "qrc:/qml/src/gui/tray" RowLayout { id: unifiedSearchResultItemDetails @@ -98,7 +99,7 @@ RowLayout { Layout.fillWidth: true Layout.rightMargin: Style.trayHorizontalMargin - Label { + EnforcedPlainTextLabel { objectName: "searchResultTitle" Layout.fillWidth: true text: unifiedSearchResultItemDetails.title.replace(/[\r\n]+/g, " ") @@ -108,7 +109,7 @@ RowLayout { font.pixelSize: unifiedSearchResultItemDetails.titleFontSize } - Label { + EnforcedPlainTextLabel { objectName: "searchResultSubline" Layout.fillWidth: true text: unifiedSearchResultItemDetails.subline.replace(/[\r\n]+/g, " ") diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp index 0db895a50ccac..a53b19bb77cf7 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.cpp +++ b/src/gui/search/unifiedsearchresultslistmodel.cpp @@ -396,10 +396,11 @@ QVariantList UnifiedSearchResultsListModel::activeFilters() const { QVariantList result; for (const auto &providerId : _selectedProviderIds) { - if (!_providers.contains(providerId)) { + const auto providerIt = _providers.constFind(providerId); + if (providerIt == _providers.cend()) { continue; } - const auto &provider = _providers.constFind(providerId).value(); + const auto &provider = providerIt.value(); result.push_back(QVariantMap{{QStringLiteral("type"), QStringLiteral("provider")}, {QStringLiteral("id"), providerId}, {QStringLiteral("label"), provider.name}, diff --git a/src/gui/wizard/qml/WizardMenu.qml b/src/gui/wizard/qml/WizardMenu.qml new file mode 100644 index 0000000000000..8e64daf3b6ccd --- /dev/null +++ b/src/gui/wizard/qml/WizardMenu.qml @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic as BasicControls + +import Style + +BasicControls.Menu { + id: root + + required property Item anchorItem + + function toggle() { + if (opened) { + close() + } else { + popup(anchorItem, 0, anchorItem.height + Style.smallSpacing) + } + } + + width: anchorItem.width + padding: Style.extraSmallSpacing + closePolicy: BasicControls.Menu.CloseOnPressOutsideParent | BasicControls.Menu.CloseOnEscape + + background: Rectangle { + radius: Style.mediumRoundedButtonRadius + color: Style.wizardFieldBackground + border.width: Style.normalBorderWidth + border.color: Style.wizardSecondaryButtonBorder + } +} diff --git a/test/qml/search/imports/com/nextcloud/desktopclient/qmldir b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir index 38a642a9a7ccb..629543ae97754 100644 --- a/test/qml/search/imports/com/nextcloud/desktopclient/qmldir +++ b/test/qml/search/imports/com/nextcloud/desktopclient/qmldir @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: GPL-2.0-or-later + module com.nextcloud.desktopclient singleton Theme 1.0 Theme.qml singleton UserModel 1.0 UserModel.qml diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml index 425e795548b7f..d8dab2b764de3 100644 --- a/test/qml/search/testsearch.qml +++ b/test/qml/search/testsearch.qml @@ -169,7 +169,6 @@ Item { } SignalSpy { id: clearSpy; target: input; signalName: "clearText" } - SignalSpy { id: filterSpy; target: input; signalName: "toggleFilters" } SignalSpy { id: moveSpy; target: input; signalName: "moveSelection" } SignalSpy { id: activateSpy; target: input; signalName: "activateSelection" } @@ -237,7 +236,10 @@ Item { function test_hiddenLoadingFooterDoesNotReserveSpace() { const model = windowSearchModel.createObject(this) - const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) const footer = findChild(searchWindow, "searchResultsLoadingFooter") verify(footer !== null) @@ -250,6 +252,21 @@ Item { model.destroy() } + function test_fetchMoreShowsSearchProgressIndicator() { + const model = windowSearchModel.createObject(this) + const searchWindow = productionSearchWindow.createObject(null, { searchModel: model }) + const progressIndicator = findChild(searchWindow, "searchProgressIndicator") + + verify(progressIndicator !== null) + compare(progressIndicator.visible, false) + model.currentFetchMoreInProgressProviderId = "files" + tryCompare(progressIndicator, "visible", true) + compare(progressIndicator.running, true) + + searchWindow.destroy() + model.destroy() + } + function test_detailHeaderCentersProviderAndShowsBackArrow() { const model = windowSearchModel.createObject(this, { viewMode: UnifiedSearchResultsListModel.ProviderDetail, @@ -311,6 +328,65 @@ Item { model.destroy() } + function test_filterMenusUseStandardDropdownGeometry() { + const model = windowSearchModel.createObject(this, { dateFilterAvailable: true }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + const dateButton = findChild(searchWindow, "dateFilterButton") + const dateMenu = findChild(searchWindow, "dateFilterMenu") + + verify(typeButton !== null) + verify(typeMenu !== null) + verify(dateButton !== null) + verify(dateMenu !== null) + compare(dateMenu.width, dateButton.width) + verify(dateMenu.width > Style.standardPrimaryButtonHeight) + compare(typeMenu.background.radius, Style.mediumRoundedButtonRadius) + compare(dateMenu.background.radius, Style.mediumRoundedButtonRadius) + compare(typeMenu.background.border.width, Style.normalBorderWidth) + compare(dateMenu.background.border.width, Style.normalBorderWidth) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.y, typeButton.height + Style.smallSpacing) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + mouseClick(dateButton) + tryCompare(dateMenu, "opened", true) + compare(dateMenu.y, dateButton.height + Style.smallSpacing) + verify(dateMenu.height > Style.standardPrimaryButtonHeight) + mouseClick(dateButton) + tryCompare(dateMenu, "opened", false) + + searchWindow.destroy() + model.destroy() + } + + function test_categoryFiltersAreVisibleBeforeSearchBegins() { + const model = windowSearchModel.createObject(this, { searchTerm: "" }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const filterFlow = findChild(searchWindow, "categoryFilterFlow") + const typeButton = findChild(searchWindow, "typeFilterButton") + const dateButton = findChild(searchWindow, "dateFilterButton") + const peopleButton = findChild(searchWindow, "peopleFilterButton") + + verify(filterFlow !== null) + compare(filterFlow.visible, true) + compare(typeButton.visible, true) + compare(dateButton.visible, true) + compare(peopleButton.visible, true) + + searchWindow.destroy() + model.destroy() + } + function test_activeFilterUsesCompactPillButton() { const model = windowSearchModel.createObject(this, { activeFilters: [{ @@ -324,10 +400,20 @@ Item { searchModel: model, visible: true }) - const chip = findChild(searchWindow, "activeFilterChip") + const activeFilterFlow = findChild(searchWindow, "activeFilterFlow") const dateButton = findChild(searchWindow, "dateFilterButton") + function activeFilterChip() { + for (let index = 0; index < activeFilterFlow.children.length; ++index) { + if (activeFilterFlow.children[index].objectName === "activeFilterChip") { + return activeFilterFlow.children[index] + } + } + return null + } - verify(chip !== null) + verify(activeFilterFlow !== null) + tryVerify(() => activeFilterChip() !== null) + const chip = activeFilterChip() verify(dateButton !== null) compare(chip.implicitHeight, Style.wizardChipButtonHeight) verify(chip.implicitHeight < dateButton.implicitHeight) @@ -349,7 +435,6 @@ Item { input.text = "" input.forceActiveFocus() clearSpy.clear() - filterSpy.clear() moveSpy.clear() activateSpy.clear() } @@ -380,7 +465,18 @@ Item { keyClick(Qt.Key_R) compare(input.text, "calendar") compare(clearSpy.count, 0) - compare(filterSpy.count, 0) + } + + function test_clearActionReplacesFilterAction() { + const clearButton = findChild(input, "clearSearchButton") + + verify(clearButton !== null) + compare(clearButton.visible, false) + keyClick(Qt.Key_C) + compare(clearButton.visible, true) + verify(clearButton.icon.source.toString().includes("clear.svg")) + mouseClick(clearButton) + compare(clearSpy.count, 1) } } @@ -424,9 +520,11 @@ Item { const trailingIcon = findChild(wizardHoverButton, "wizardButtonTrailingIcon") verify(textItem !== null) verify(trailingIcon !== null) - const textRight = textItem.mapToItem(wizardHoverButton, textItem.width, 0).x - const iconLeft = trailingIcon.mapToItem(wizardHoverButton, 0, 0).x - verify(textRight < iconLeft) + tryVerify(() => { + const textRight = textItem.mapToItem(wizardHoverButton, textItem.width, 0).x + const iconLeft = trailingIcon.mapToItem(wizardHoverButton, 0, 0).x + return textRight < iconLeft + }) } } @@ -480,12 +578,17 @@ Item { compare(findChild(resultDelegate.loadedItem, "providerHeaderIconTint"), null) mouseMove(input, 1, 1) - wait(0) - const restingColor = resultDelegate.loadedItem.background.color.toString() + mouseMove(input, 2, 2) + tryCompare(resultDelegate.loadedItem, "hovered", false) + compare(resultDelegate.loadedItem.background.color.toString(), "#00000000") + mouseMove(resultDelegate.loadedItem, 1, 1) mouseMove(resultDelegate.loadedItem, 2, 2) - tryVerify(() => resultDelegate.loadedItem.background.color.toString() !== restingColor) + tryCompare(resultDelegate.loadedItem, "hovered", true) + compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) - mouseClick(resultDelegate.loadedItem) + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.openedProviderDetails, 1) + mouseRelease(resultDelegate.loadedItem) compare(fakeSearchModel.openedProviderDetails, 1) } @@ -528,15 +631,11 @@ Item { compare(textContainer.spacing, Style.unifiedSearchResultTextSpacing) resultDelegate.isSelected = true - wait(0) - const selectedColor = resultDelegate.loadedItem.background.color.toString() + tryCompare(resultDelegate.loadedItem.background, "color", Style.wizardSecondaryButtonPressed) resultDelegate.isSelected = false - mouseMove(input, 1, 1) - wait(0) mouseMove(resultDelegate.loadedItem, 2, 2) tryCompare(resultDelegate.loadedItem, "hovered", true) compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) - verify(resultDelegate.loadedItem.background.color.toString() !== selectedColor) mouseClick(resultDelegate.loadedItem) compare(fakeSearchModel.activatedResults, 1) @@ -549,6 +648,9 @@ Item { verify(resultDelegate.loadedItem !== null) compare(resultDelegate.loadedItem.objectName, "searchPagingRow") compare(resultDelegate.loadedItem.text, qsTr("Load more results")) + const pagingLabel = findChild(resultDelegate.loadedItem, "searchPagingLabel") + verify(pagingLabel !== null) + compare(pagingLabel.font.bold, false) verify(resultDelegate.loadedItem.icon.source.toString().includes("more.svg")) verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) compare(resultDelegate.loadedItem.height, 44) @@ -560,7 +662,9 @@ Item { tryCompare(resultDelegate.loadedItem, "hovered", true) compare(resultDelegate.loadedItem.background.color.toString(), Style.listItemHoverBackground.toString()) - mouseClick(resultDelegate.loadedItem) + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.loadedPages, 1) + mouseRelease(resultDelegate.loadedItem) compare(fakeSearchModel.loadedPages, 1) } @@ -574,7 +678,9 @@ Item { verify(!resultDelegate.loadedItem.hasOwnProperty("primary")) compare(resultDelegate.loadedItem.height, 44) - mouseClick(resultDelegate.loadedItem) + mousePress(resultDelegate.loadedItem) + compare(fakeSearchModel.retriedPages, 1) + mouseRelease(resultDelegate.loadedItem) compare(fakeSearchModel.retriedPages, 1) } } diff --git a/test/testunifiedsearchpeoplemodel.cpp b/test/testunifiedsearchpeoplemodel.cpp index f8a6607c9311b..fd0382aaf90b9 100644 --- a/test/testunifiedsearchpeoplemodel.cpp +++ b/test/testunifiedsearchpeoplemodel.cpp @@ -110,6 +110,7 @@ private Q_SLOTS: auto account = OCC::Account::create(); account->setCredentials(new FakeCredentials(qnam.get())); account->setUrl(QUrl(QStringLiteral("https://cloud.example.test/"))); + account->setDavUser(QStringLiteral("me")); auto state = std::make_unique(account); qnam->setOverride([&](QNetworkAccessManager::Operation operation, const QNetworkRequest &request, QIODevice *) { const auto query = QUrlQuery(request.url()).queryItemValue(QStringLiteral("search")); From 05439e5c3d33599319df4cdb21508c07ff9f17e7 Mon Sep 17 00:00:00 2001 From: Rello Date: Tue, 25 Aug 2026 13:42:59 +0200 Subject: [PATCH 3/9] fix(search): keep search icon visible while loading Show the loading indicator on the trailing side of the search field without replacing the permanent search icon. Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- .../search/UnifiedSearchInputContainer.qml | 25 ++++--- .../UnifiedSearchResultNothingFound.qml | 73 +++++++++++-------- test/qml/search/testsearch.qml | 72 ++++++++++++++++++ 3 files changed, 131 insertions(+), 39 deletions(-) diff --git a/src/gui/search/UnifiedSearchInputContainer.qml b/src/gui/search/UnifiedSearchInputContainer.qml index a276f1d82e3d2..ecdba98208352 100644 --- a/src/gui/search/UnifiedSearchInputContainer.qml +++ b/src/gui/search/UnifiedSearchInputContainer.qml @@ -7,6 +7,7 @@ import QtQuick import QtQuick.Controls import Style import com.nextcloud.desktopclient +import "qrc:/qml/src/gui/tray" TextField { id: root @@ -17,10 +18,13 @@ TextField { property bool isSearchInProgress: false readonly property color iconColor: palette.placeholderText + readonly property int iconSize: 24 readonly property int controlSize: Math.max(40, height - 4) leftPadding: 8 + controlSize - rightPadding: root.text.length > 0 ? 8 + controlSize : 8 + rightPadding: 8 + + (root.text.length > 0 ? controlSize : 0) + + (root.isSearchInProgress ? 8 + iconSize : 0) verticalAlignment: Qt.AlignVCenter placeholderText: qsTr("Search files, messages, events …") @@ -43,31 +47,34 @@ TextField { } Image { + objectName: "searchLeadingIcon" anchors.left: parent.left anchors.leftMargin: 8 anchors.verticalCenter: parent.verticalCenter - width: 24 - height: 24 + width: root.iconSize + height: root.iconSize sourceSize.width: width sourceSize.height: height source: "image://svgimage-custom-color/search.svg/" + root.iconColor - visible: !root.isSearchInProgress Accessible.ignored: true } - BusyIndicator { + NCBusyIndicator { objectName: "searchProgressIndicator" - anchors.left: parent.left - anchors.leftMargin: 8 + anchors.right: clearSearchButton.visible ? clearSearchButton.left : parent.right + anchors.rightMargin: clearSearchButton.visible ? 0 : 8 anchors.verticalCenter: parent.verticalCenter - width: 24 - height: 24 + width: root.iconSize + height: root.iconSize + color: root.iconColor visible: root.isSearchInProgress running: visible Accessible.ignored: true } ToolButton { + id: clearSearchButton + objectName: "clearSearchButton" anchors.right: parent.right anchors.rightMargin: 2 diff --git a/src/gui/search/UnifiedSearchResultNothingFound.qml b/src/gui/search/UnifiedSearchResultNothingFound.qml index fe61aa1149127..46d9c80fdce89 100644 --- a/src/gui/search/UnifiedSearchResultNothingFound.qml +++ b/src/gui/search/UnifiedSearchResultNothingFound.qml @@ -10,42 +10,55 @@ import QtQuick.Layouts import Style import "qrc:/qml/src/gui/tray" -ColumnLayout { +Item { id: unifiedSearchResultNothingFoundContainer + objectName: "nothingFoundView" + required property string text - spacing: Style.standardSpacing - anchors.leftMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin - anchors.rightMargin: Style.unifiedSearchResultNothingFoundHorizontalMargin + ColumnLayout { + id: content - Image { - id: unifiedSearchResultsNoResultsLabelIcon - source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}` - sourceSize.width: Style.trayWindowHeaderHeight / 2 - sourceSize.height: Style.trayWindowHeaderHeight / 2 - Layout.alignment: Qt.AlignHCenter - } + objectName: "nothingFoundContent" + anchors.centerIn: parent + width: parent.width - 2 * Style.unifiedSearchResultNothingFoundHorizontalMargin + spacing: Style.standardSpacing - EnforcedPlainTextLabel { - id: unifiedSearchResultsNoResultsLabel - text: qsTr("No results for") - font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize - wrapMode: Text.Wrap - Layout.fillWidth: true - Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 - horizontalAlignment: Text.AlignHCenter - } + Image { + id: unifiedSearchResultsNoResultsLabelIcon + + objectName: "nothingFoundIcon" + source: `image://svgimage-custom-color/magnifying-glass.svg/${palette.windowText}` + sourceSize.width: Style.trayWindowHeaderHeight / 2 + sourceSize.height: Style.trayWindowHeaderHeight / 2 + Layout.alignment: Qt.AlignHCenter + } + + EnforcedPlainTextLabel { + id: unifiedSearchResultsNoResultsLabel + + objectName: "nothingFoundMessage" + text: qsTr("No results for") + font.pixelSize: Style.unifiedSearchPlaceholderViewSublineFontPixelSize + wrapMode: Text.Wrap + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } + + EnforcedPlainTextLabel { + id: unifiedSearchResultsNoResultsLabelDetails - EnforcedPlainTextLabel { - id: unifiedSearchResultsNoResultsLabelDetails - text: unifiedSearchResultNothingFoundContainer.text - font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize - wrapMode: Text.Wrap - maximumLineCount: 2 - elide: Text.ElideRight - Layout.fillWidth: true - Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 - horizontalAlignment: Text.AlignHCenter + objectName: "nothingFoundQuery" + text: unifiedSearchResultNothingFoundContainer.text + font.pixelSize: Style.unifiedSearchPlaceholderViewTitleFontPixelSize + wrapMode: Text.Wrap + maximumLineCount: 2 + elide: Text.ElideRight + Layout.fillWidth: true + Layout.preferredHeight: Style.trayWindowHeaderHeight / 2 + horizontalAlignment: Text.AlignHCenter + } } } diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml index d8dab2b764de3..b59f487d22108 100644 --- a/test/qml/search/testsearch.qml +++ b/test/qml/search/testsearch.qml @@ -192,6 +192,47 @@ Item { searchWindow.destroy() } + function test_nothingFoundContentIsGroupedInTheCenter() { + const model = windowSearchModel.createObject(this, { + searchState: UnifiedSearchResultsListModel.NothingFound, + searchTerm: "Nextcloud" + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const nothingFoundView = findChild(searchWindow, "nothingFoundView") + const content = findChild(searchWindow, "nothingFoundContent") + const icon = findChild(searchWindow, "nothingFoundIcon") + const message = findChild(searchWindow, "nothingFoundMessage") + const query = findChild(searchWindow, "nothingFoundQuery") + + verify(nothingFoundView !== null) + verify(content !== null) + verify(icon !== null) + verify(message !== null) + verify(query !== null) + tryVerify(() => nothingFoundView.height > 0 && content.height > 0) + compare(query.text, "Nextcloud") + verify(content.height < nothingFoundView.height / 2) + + const contentCenter = content.mapToItem(nothingFoundView, + content.width / 2, + content.height / 2) + fuzzyCompare(contentCenter.x, nothingFoundView.width / 2, 1) + fuzzyCompare(contentCenter.y, nothingFoundView.height / 2, 1) + + const iconBottom = icon.mapToItem(content, 0, icon.height).y + const messageTop = message.mapToItem(content, 0, 0).y + const messageBottom = message.mapToItem(content, 0, message.height).y + const queryTop = query.mapToItem(content, 0, 0).y + fuzzyCompare(messageTop - iconBottom, Style.standardSpacing, 1) + fuzzyCompare(queryTop - messageBottom, Style.standardSpacing, 1) + + searchWindow.destroy() + model.destroy() + } + function test_selectionBindingSurvivesViewAndModelChanges() { const firstModel = windowSearchModel.createObject(this, { selectedRow: 0 }) const secondModel = windowSearchModel.createObject(this, { selectedRow: 1 }) @@ -433,6 +474,7 @@ Item { function init() { input.text = "" + input.isSearchInProgress = false input.forceActiveFocus() clearSpy.clear() moveSpy.clear() @@ -454,6 +496,36 @@ Item { compare(input.background.border.color, Style.wizardFieldBorder) } + function test_progressIndicatorDoesNotReplaceSearchIcon() { + const searchIcon = findChild(input, "searchLeadingIcon") + const progressIndicator = findChild(input, "searchProgressIndicator") + const clearButton = findChild(input, "clearSearchButton") + + verify(searchIcon !== null) + verify(progressIndicator !== null) + verify(clearButton !== null) + verify(progressIndicator.imageSource.includes("change.svg")) + input.text = "loading" + compare(searchIcon.visible, true) + compare(progressIndicator.visible, false) + + input.isSearchInProgress = true + tryCompare(progressIndicator, "visible", true) + compare(progressIndicator.running, true) + compare(searchIcon.visible, true) + const searchIconPosition = searchIcon.mapToItem(input, 0, 0) + const progressPosition = progressIndicator.mapToItem(input, 0, 0) + const clearButtonPosition = clearButton.mapToItem(input, 0, 0) + verify(progressPosition.x > searchIconPosition.x) + verify(progressPosition.x + progressIndicator.width <= clearButtonPosition.x) + verify(input.width - input.rightPadding < progressPosition.x) + + input.isSearchInProgress = false + tryCompare(progressIndicator, "visible", false) + compare(progressIndicator.running, false) + compare(searchIcon.visible, true) + } + function test_textEditingSignalIsIndependentFromClear() { keyClick(Qt.Key_C) keyClick(Qt.Key_A) From c9cb570c077d9ee2016a960c0c6d68067be3055d Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 27 Aug 2026 09:58:27 +0200 Subject: [PATCH 4/9] Update src/gui/search/unifiedsearchpeoplemodel.cpp Co-authored-by: Matthieu Gallien Signed-off-by: Rello --- src/gui/search/unifiedsearchpeoplemodel.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp index 0d2ce68340367..c4e2e4e52d82b 100644 --- a/src/gui/search/unifiedsearchpeoplemodel.cpp +++ b/src/gui/search/unifiedsearchpeoplemodel.cpp @@ -32,7 +32,7 @@ UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() { cancel(); } QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const { - if (!checkIndex(index, CheckIndexOption::IndexIsValid)) return {}; + Q_ASSERT(!checkIndex(index, CheckIndexOption::IndexIsValid)); const auto &person = _people.at(index.row()); switch (role) { case UserIdRole: return person.id; From bf3b445d00ba95109093b53b7e5860189f0db249 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 27 Aug 2026 09:58:53 +0200 Subject: [PATCH 5/9] Update src/gui/search/unifiedsearchresultslistmodel.h Co-authored-by: Matthieu Gallien Signed-off-by: Rello --- src/gui/search/unifiedsearchresultslistmodel.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h index 2ebaf327372d4..9e3f48dd81af8 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.h +++ b/src/gui/search/unifiedsearchresultslistmodel.h @@ -118,9 +118,9 @@ class UnifiedSearchResultsListModel : public QAbstractListModel }; explicit UnifiedSearchResultsListModel(AccountState *accountState, - QObject *parent = nullptr, int debounceInterval = 300, - int revealInterval = 1000); + int revealInterval = 1000, + QObject *parent = nullptr); ~UnifiedSearchResultsListModel() override; [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; From aac84ba6de80ab9d0836e005923255e6cd6477d8 Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 27 Aug 2026 09:59:11 +0200 Subject: [PATCH 6/9] Update src/gui/search/unifiedsearchresultslistmodel.cpp Co-authored-by: Matthieu Gallien Signed-off-by: Rello --- src/gui/search/unifiedsearchresultslistmodel.cpp | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp index a53b19bb77cf7..fbb4bcd2c24c6 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.cpp +++ b/src/gui/search/unifiedsearchresultslistmodel.cpp @@ -254,9 +254,7 @@ UnifiedSearchResultsListModel::~UnifiedSearchResultsListModel() QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const { - if (!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)) { - return {}; - } + Q_ASSERT(!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); const auto &result = _results.at(index.row()); switch (role) { case ProviderNameRole: From ab50941de8a8b52181bffdb005bf399d5f71c46a Mon Sep 17 00:00:00 2001 From: Rello Date: Thu, 27 Aug 2026 14:01:08 +0200 Subject: [PATCH 7/9] fix(search): address review feedback Replace deprecated QML effects, rely on JsonApiJob self-deletion, and expose people-search retry as a slot. Align the reviewed constructor signature and correct model index assertions exposed by the focused tests. Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- src/gui/search/unifiedsearchpeoplemodel.cpp | 6 +++--- src/gui/search/unifiedsearchpeoplemodel.h | 2 +- src/gui/search/unifiedsearchresultslistmodel.cpp | 9 ++------- src/gui/systray.cpp | 3 ++- src/gui/wizard/qml/WizardButton.qml | 8 ++++---- src/gui/wizard/qml/WizardMenuItem.qml | 8 ++++---- test/qml/search/testsearch.qml | 6 ++++-- test/testunifiedsearchpeoplemodel.cpp | 6 ++++++ 8 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp index c4e2e4e52d82b..d441ce2d67cfd 100644 --- a/src/gui/search/unifiedsearchpeoplemodel.cpp +++ b/src/gui/search/unifiedsearchpeoplemodel.cpp @@ -32,7 +32,7 @@ UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() { cancel(); } QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const { - Q_ASSERT(!checkIndex(index, CheckIndexOption::IndexIsValid)); + Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid)); const auto &person = _people.at(index.row()); switch (role) { case UserIdRole: return person.id; @@ -186,8 +186,8 @@ void UnifiedSearchPeopleModel::cancel() ++_generation; if (_job) { disconnect(_job, nullptr, this, nullptr); - if (const auto job = qobject_cast(_job.data()); job && job->reply() && job->reply()->isRunning()) job->reply()->abort(); - _job->deleteLater(); + if (const auto job = qobject_cast(_job.data()); job && job->reply() && job->reply()->isRunning()) + job->reply()->abort(); _job.clear(); } setBusy(false); diff --git a/src/gui/search/unifiedsearchpeoplemodel.h b/src/gui/search/unifiedsearchpeoplemodel.h index c763a94023bc4..b81174d8f4188 100644 --- a/src/gui/search/unifiedsearchpeoplemodel.h +++ b/src/gui/search/unifiedsearchpeoplemodel.h @@ -34,11 +34,11 @@ class UnifiedSearchPeopleModel : public QAbstractListModel [[nodiscard]] QString searchTerm() const; [[nodiscard]] bool busy() const; [[nodiscard]] QString errorString() const; - Q_INVOKABLE void retry(); public Q_SLOTS: void setAccountState(AccountState *accountState); void setSearchTerm(const QString &searchTerm); + void retry(); Q_SIGNALS: void accountStateChanged(); diff --git a/src/gui/search/unifiedsearchresultslistmodel.cpp b/src/gui/search/unifiedsearchresultslistmodel.cpp index fbb4bcd2c24c6..69d3f88e9fc7a 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.cpp +++ b/src/gui/search/unifiedsearchresultslistmodel.cpp @@ -177,10 +177,7 @@ QString navigationAppIconForResult(const OCC::AccountState *accountState, namespace OCC { Q_LOGGING_CATEGORY(lcUnifiedSearch, "nextcloud.gui.unifiedsearch", QtInfoMsg) -UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, - QObject *parent, - int debounceInterval, - int revealInterval) +UnifiedSearchResultsListModel::UnifiedSearchResultsListModel(AccountState *accountState, int debounceInterval, int revealInterval, QObject *parent) : QAbstractListModel(parent) , _accountState(accountState) { @@ -254,7 +251,7 @@ UnifiedSearchResultsListModel::~UnifiedSearchResultsListModel() QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const { - Q_ASSERT(!checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); + Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid)); const auto &result = _results.at(index.row()); switch (role) { case ProviderNameRole: @@ -1071,7 +1068,6 @@ void UnifiedSearchResultsListModel::abortSearchJobs() if (const auto job = qobject_cast(jobObject.data()); job && job->reply() && job->reply()->isRunning()) { job->reply()->abort(); } - jobObject->deleteLater(); } _activeSearchJobs.clear(); _inFlightSearchRequests = 0; @@ -1090,7 +1086,6 @@ void UnifiedSearchResultsListModel::abortProviderDiscovery() if (const auto job = qobject_cast(_providerDiscoveryJob.data()); job && job->reply() && job->reply()->isRunning()) { job->reply()->abort(); } - _providerDiscoveryJob->deleteLater(); _providerDiscoveryJob.clear(); _providersLoading = false; } diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index e40778acf9153..62c13cde2d68d 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -461,7 +461,8 @@ void Systray::showSearchWindow(int userIndex) return; } - auto *const searchModel = new UnifiedSearchResultsListModel(accountState.data(), accountState.data()); + auto *const searchModel = new UnifiedSearchResultsListModel(accountState.data()); + searchModel->setParent(accountState.data()); const QVariantMap initialProperties{ {"account", QVariantMap{ {"avatar", user->avatarUrl()}, diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index 4adfc4852d900..a9fd995caacb9 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -5,8 +5,8 @@ import QtQuick import QtQuick.Controls.Basic as BasicControls +import QtQuick.Effects import QtQuick.Layouts -import Qt5Compat.GraphicalEffects import Style BasicControls.Button { @@ -56,13 +56,13 @@ BasicControls.Button { Accessible.ignored: true } - ColorOverlay { + MultiEffect { objectName: "wizardButtonLeadingIconTint" anchors.fill: leadingIconImage visible: root.tintIcon source: leadingIconImage - color: root.iconTintColor - cached: true + colorization: 1.0 + colorizationColor: root.iconTintColor Accessible.ignored: true } } diff --git a/src/gui/wizard/qml/WizardMenuItem.qml b/src/gui/wizard/qml/WizardMenuItem.qml index 2598da697534e..dfe23a51742a0 100644 --- a/src/gui/wizard/qml/WizardMenuItem.qml +++ b/src/gui/wizard/qml/WizardMenuItem.qml @@ -5,8 +5,8 @@ import QtQuick import QtQuick.Controls.Basic as BasicControls +import QtQuick.Effects import QtQuick.Layouts -import Qt5Compat.GraphicalEffects import Style @@ -42,13 +42,13 @@ BasicControls.MenuItem { Accessible.ignored: true } - ColorOverlay { + MultiEffect { objectName: "wizardMenuItemIconTint" anchors.fill: menuIconImage visible: root.tintIcon source: menuIconImage - color: root.iconTintColor - cached: true + colorization: 1.0 + colorizationColor: root.iconTintColor Accessible.ignored: true } diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml index b59f487d22108..cdf2829f8a20c 100644 --- a/test/qml/search/testsearch.qml +++ b/test/qml/search/testsearch.qml @@ -578,7 +578,8 @@ Item { const iconTint = findChild(wizardHoverButton, "wizardButtonLeadingIconTint") verify(iconTint !== null) compare(iconTint.visible, true) - compare(iconTint.color.toString(), Style.wizardPrimaryText.toString()) + compare(iconTint.colorization, 1.0) + compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString()) } function test_trailingIconCannotOverlapLongText() { @@ -618,7 +619,8 @@ Item { const iconTint = findChild(wizardMenuHoverItem, "wizardMenuItemIconTint") verify(iconTint !== null) compare(iconTint.visible, true) - compare(iconTint.color.toString(), Style.wizardPrimaryText.toString()) + compare(iconTint.colorization, 1.0) + compare(iconTint.colorizationColor.toString(), Style.wizardPrimaryText.toString()) } } diff --git a/test/testunifiedsearchpeoplemodel.cpp b/test/testunifiedsearchpeoplemodel.cpp index fd0382aaf90b9..b4006cb8257df 100644 --- a/test/testunifiedsearchpeoplemodel.cpp +++ b/test/testunifiedsearchpeoplemodel.cpp @@ -30,6 +30,12 @@ class TestUnifiedSearchPeopleModel : public QObject Q_OBJECT private Q_SLOTS: + void retryIsExposedAsSlot() + { + const auto methodIndex = OCC::UnifiedSearchPeopleModel::staticMetaObject.indexOfSlot("retry()"); + QVERIFY(methodIndex >= 0); + } + void emptyQueryShowsSignedInUserWithoutRequest() { auto qnam = std::unique_ptr(new FakeQNAM({})); From f376c4e9748c43a9f91849a8036c49508fac12f9 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 28 Aug 2026 10:07:56 +0200 Subject: [PATCH 8/9] fix(test): use Qt slot macro for custom state icons Allow the test to compile when Qt keyword macros are disabled. Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- test/testcustomstateicons.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/testcustomstateicons.cpp b/test/testcustomstateicons.cpp index 74aa22ff7866b..591eb77ecd2fd 100644 --- a/test/testcustomstateicons.cpp +++ b/test/testcustomstateicons.cpp @@ -41,7 +41,7 @@ class TestCustomStateIcons : public QObject { Q_OBJECT -private slots: +private Q_SLOTS: void testSvgContrast_data() { QTest::addColumn("fileName"); From ceb7601a2eb9aa6b5726acc70903cdc08280d019 Mon Sep 17 00:00:00 2001 From: Rello Date: Fri, 28 Aug 2026 17:22:15 +0200 Subject: [PATCH 9/9] fix(search): address review feedback Signed-off-by: Rello Assisted-by: Codex:GPT-5 --- src/gui/activity/qml/ActivityItem.qml | 2 +- src/gui/activity/qml/ActivityList.qml | 2 +- src/gui/search/CMakeLists.txt | 4 + src/gui/search/SearchWindow.qml | 318 ++++-------------- .../UnifiedSearchCustomDateRangeDialog.qml | 79 +++++ src/gui/search/UnifiedSearchDetailHeader.qml | 55 +++ src/gui/search/UnifiedSearchFilterBar.qml | 162 +++++++++ src/gui/search/UnifiedSearchPeoplePopup.qml | 119 +++++++ .../search/UnifiedSearchResultDelegate.qml | 12 +- src/gui/search/unifiedsearchpeoplemodel.cpp | 127 +++++-- .../search/unifiedsearchresultslistmodel.h | 4 + src/gui/systray.cpp | 2 +- src/gui/wizard/qml/WizardButton.qml | 22 +- src/gui/wizard/qml/WizardChipButton.qml | 2 - src/gui/wizard/qml/WizardMenuItem.qml | 4 +- test/qml/search/testsearch.qml | 65 ++++ theme/Style/Style.qml | 21 +- 17 files changed, 689 insertions(+), 311 deletions(-) create mode 100644 src/gui/search/UnifiedSearchCustomDateRangeDialog.qml create mode 100644 src/gui/search/UnifiedSearchDetailHeader.qml create mode 100644 src/gui/search/UnifiedSearchFilterBar.qml create mode 100644 src/gui/search/UnifiedSearchPeoplePopup.qml diff --git a/src/gui/activity/qml/ActivityItem.qml b/src/gui/activity/qml/ActivityItem.qml index 4af0671c2c424..34d5176bb5f0d 100644 --- a/src/gui/activity/qml/ActivityItem.qml +++ b/src/gui/activity/qml/ActivityItem.qml @@ -45,7 +45,7 @@ ItemDelegate { ActivityItemContent { id: activityContent - adaptiveTextColor: palette.text + adaptiveTextColor: root.activeFocus ? palette.highlightedText : palette.text Layout.fillWidth: true Layout.minimumHeight: Style.minActivityHeight diff --git a/src/gui/activity/qml/ActivityList.qml b/src/gui/activity/qml/ActivityList.qml index 5c3924ed977f7..37dee50d50139 100644 --- a/src/gui/activity/qml/ActivityList.qml +++ b/src/gui/activity/qml/ActivityList.qml @@ -79,7 +79,7 @@ ScrollView { highlight: Rectangle { id: activityHover anchors.fill: activityList.currentItem - color: Style.listItemHoverBackground + color: palette.highlight radius: Style.mediumRoundedButtonRadius visible: activityList.activeFocus } diff --git a/src/gui/search/CMakeLists.txt b/src/gui/search/CMakeLists.txt index fe62878b0816b..0320e76d51ffa 100644 --- a/src/gui/search/CMakeLists.txt +++ b/src/gui/search/CMakeLists.txt @@ -18,7 +18,11 @@ ecm_add_qml_module(nextcloudGuiSearch GENERATE_PLUGIN_SOURCE QML_FILES SearchWindow.qml + UnifiedSearchCustomDateRangeDialog.qml + UnifiedSearchDetailHeader.qml + UnifiedSearchFilterBar.qml UnifiedSearchInputContainer.qml + UnifiedSearchPeoplePopup.qml UnifiedSearchResultDelegate.qml UnifiedSearchResultFetchMoreTrigger.qml UnifiedSearchResultItem.qml diff --git a/src/gui/search/SearchWindow.qml b/src/gui/search/SearchWindow.qml index 819831f7165f2..782bc8289ff09 100644 --- a/src/gui/search/SearchWindow.qml +++ b/src/gui/search/SearchWindow.qml @@ -21,6 +21,8 @@ WizardStyledWindow { readonly property bool aggregateView: searchModel && searchModel.viewMode === UnifiedSearchResultsListModel.Aggregate readonly property bool filtersVisible: aggregateView && searchModel && searchModel.providersReady readonly property int searchState: searchModel ? searchModel.searchState : UnifiedSearchResultsListModel.Placeholder + readonly property bool peoplePopupOpened: peoplePopupLoader.status === Loader.Ready && peoplePopupLoader.item.opened + readonly property bool customRangeDialogOpened: customRangeDialogLoader.status === Loader.Ready && customRangeDialogLoader.item.opened title: "" width: Style.searchWindowWidth @@ -32,27 +34,30 @@ WizardStyledWindow { if (visible && searchInput.enabled) searchInput.forceActiveFocus() } - function hasActiveFilter(type) { - if (!searchModel) { - return false + function openPeoplePopup() { + if (peoplePopupLoader.status === Loader.Ready) { + peoplePopupLoader.item.open() + return } - const filters = searchModel.activeFilters - for (let index = 0; index < filters.length; ++index) { - if (filters[index].type === type) { - return true - } + peoplePopupLoader.active = true + } + + function openCustomRangeDialog() { + if (customRangeDialogLoader.status === Loader.Ready) { + customRangeDialogLoader.item.open() + return } - return false + customRangeDialogLoader.active = true } Shortcut { sequences: [StandardKey.Cancel] - enabled: !typeMenu.opened && !dateMenu.opened && !peoplePopup.opened && !customRangeDialog.opened + enabled: !filterBar.opened && !root.peoplePopupOpened && !root.customRangeDialogOpened onActivated: root.close() } UnifiedSearchPeopleModel { - id: peopleModel + id: peopleSuggestionsModel accountState: root.searchModel ? root.searchModel.accountState : null } @@ -113,144 +118,23 @@ WizardStyledWindow { onActivateSelection: root.searchModel.activateSelected() } - Item { - id: detailHeader - - objectName: "searchDetailHeader" + UnifiedSearchDetailHeader { Layout.fillWidth: true - Layout.preferredHeight: 40 + Layout.preferredHeight: implicitHeight visible: root.searchModel && !root.aggregateView - - ToolButton { - id: backButton - - objectName: "searchDetailBackButton" - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter - text: qsTr("Back") - icon.source: "image://svgimage-custom-color/" - + (root.LayoutMirroring.enabled ? "arrow-right.svg/" : "arrow-left.svg/") - + Style.wizardPrimaryText - icon.width: Style.smallIconSize - icon.height: Style.smallIconSize - display: AbstractButton.TextBesideIcon - Accessible.name: qsTr("Back to all search results") - onClicked: { - root.searchModel.closeProviderDetail() - root.focusSearchInput() - } - } - EnforcedPlainTextLabel { - objectName: "searchDetailProviderTitle" - anchors.left: parent.left - anchors.right: parent.right - anchors.leftMargin: backButton.width + Style.smallSpacing - anchors.rightMargin: backButton.width + Style.smallSpacing - anchors.verticalCenter: parent.verticalCenter - text: root.searchModel ? root.searchModel.detailProviderName : "" - font.bold: true - font.pixelSize: Style.wizardHeaderTitleFontPixelSize - elide: Text.ElideRight - horizontalAlignment: Text.AlignHCenter - } + searchModel: root.searchModel + onNavigateBack: root.focusSearchInput() } - Flow { - id: filterFlow - objectName: "categoryFilterFlow" + UnifiedSearchFilterBar { + id: filterBar + Layout.fillWidth: true - Layout.preferredHeight: visible ? childrenRect.height : 0 + Layout.preferredHeight: visible ? implicitHeight : 0 visible: root.filtersVisible - spacing: Style.smallSpacing - - WizardButton { - id: typeFilterButton - - objectName: "typeFilterButton" - width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) - text: qsTr("Type") - trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - iconBeforeText: true - iconSource: "image://svgimage-custom-color/folder.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - primary: root.hasActiveFilter("provider") - Accessible.name: qsTr("Filter by type") - onClicked: typeMenu.toggle() - WizardMenu { - id: typeMenu - objectName: "typeFilterMenu" - anchorItem: typeFilterButton - width: anchorItem.width * 1.5 - Repeater { - model: root.searchModel ? root.searchModel.providers : [] - delegate: WizardMenuItem { - required property var modelData - text: (modelData.selected ? "✓ " : "") + modelData.name - icon.source: modelData.icon ? "image://tray-image-provider/" + modelData.icon : "" - tintIcon: true - iconTintColor: Style.wizardPrimaryText - onTriggered: root.searchModel.toggleProviderFilter(modelData.id) - } - } - } - } - WizardButton { - id: dateFilterButton - - objectName: "dateFilterButton" - width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) - text: qsTr("Date") - trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - iconBeforeText: true - iconSource: "image://svgimage-custom-color/calendar.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - primary: root.hasActiveFilter("date") - enabled: root.searchModel && root.searchModel.dateFilterAvailable - Accessible.name: qsTr("Filter by date") - Accessible.description: enabled ? "" : qsTr("No search source supports date filtering") - onClicked: dateMenu.toggle() - WizardMenu { - id: dateMenu - objectName: "dateFilterMenu" - anchorItem: dateFilterButton - WizardMenuItem { - objectName: "dateTodayMenuItem" - text: qsTr("Today") - onTriggered: root.searchModel.setDatePreset("today") - } - WizardMenuItem { text: qsTr("Last 7 days"); onTriggered: root.searchModel.setDatePreset("last7days") } - WizardMenuItem { text: qsTr("Last 30 days"); onTriggered: root.searchModel.setDatePreset("last30days") } - WizardMenuItem { text: qsTr("This year"); onTriggered: root.searchModel.setDatePreset("thisyear") } - WizardMenuItem { text: qsTr("Last year"); onTriggered: root.searchModel.setDatePreset("lastyear") } - MenuSeparator {} - WizardMenuItem { - text: qsTr("Custom range …") - onTriggered: { - customRangeDialog.validationError = false - customRangeDialog.open() - } - } - WizardMenuItem { text: qsTr("Clear date"); onTriggered: root.searchModel.clearDateFilter() } - } - } - WizardButton { - id: peopleButton - objectName: "peopleFilterButton" - width: Math.max(140, (filterFlow.width - 2 * filterFlow.spacing) / 3) - text: qsTr("People") - trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - iconBeforeText: true - iconSource: "image://svgimage-custom-color/account-group.svg/" - + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) - primary: root.hasActiveFilter("person") - enabled: root.searchModel && root.searchModel.peopleFilterAvailable - Accessible.name: qsTr("Filter by person") - Accessible.description: enabled ? "" : qsTr("No search source supports people filtering") - onClicked: peoplePopup.open() - } + searchModel: root.searchModel + onCustomDateRangeRequested: root.openCustomRangeDialog() + onPeopleRequested: root.openPeoplePopup() } Flow { @@ -289,7 +173,7 @@ WizardStyledWindow { ColumnLayout { anchors.centerIn: parent - width: Math.min(parent.width, 420) + width: Math.min(parent.width, Style.wizardDialogMaximumWidth) visible: root.searchState === UnifiedSearchResultsListModel.SearchError ErrorBox { Layout.fillWidth: true; text: root.searchModel ? root.searchModel.errorString : "" } WizardButton { @@ -391,14 +275,15 @@ WizardStyledWindow { visible: root.searchModel && root.searchModel.isSearchInProgress Accessible.ignored: true Repeater { - model: 3 + model: Style.unifiedSearchLoadingPlaceholderCount Rectangle { required property int index - width: resultsList.width * (0.72 + index * 0.07) - height: 44 - radius: 8 + width: resultsList.width * (Style.unifiedSearchLoadingPlaceholderInitialWidthRatio + + index * Style.unifiedSearchLoadingPlaceholderWidthStep) + height: Style.unifiedSearchProviderHeaderHeight + radius: Style.mediumRoundedButtonRadius color: palette.alternateBase - opacity: 0.55 + opacity: Style.unifiedSearchLoadingPlaceholderOpacity } } } @@ -423,118 +308,47 @@ WizardStyledWindow { } } - Popup { - id: peoplePopup - parent: Overlay.overlay - width: Math.min(root.width - 40, 420) - height: 340 - x: (root.width - width) / 2 - y: 150 - modal: true - closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside - onOpened: peopleSearch.forceActiveFocus() - ColumnLayout { - anchors.fill: parent - anchors.margins: Style.smallSpacing - TextField { - id: peopleSearch - Layout.fillWidth: true - placeholderText: qsTr("Search people") - onTextEdited: peopleModel.searchTerm = text - } - EnforcedPlainTextLabel { visible: peopleModel.errorString.length > 0; text: peopleModel.errorString; wrapMode: Text.Wrap } - WizardButton { - visible: peopleModel.errorString.length > 0 - text: qsTr("Retry") - onClicked: peopleModel.retry() - } - ListView { - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - model: peopleModel - delegate: ItemDelegate { - id: personDelegate - required property string userId - required property string displayName - required property string avatarUrl - width: ListView.view.width - height: 44 - text: displayName - hoverEnabled: true - Accessible.description: userId - background: Rectangle { - color: personDelegate.hovered || personDelegate.down - ? Style.listItemHoverBackground - : "transparent" - radius: Style.mediumRoundedButtonRadius - } - HoverHandler { - cursorShape: Qt.PointingHandCursor - } - contentItem: RowLayout { - Image { - Layout.preferredWidth: 32 - Layout.preferredHeight: 32 - sourceSize.width: 32 - sourceSize.height: 32 - asynchronous: true - source: personDelegate.avatarUrl.length > 0 - ? "image://tray-image-provider/" + personDelegate.avatarUrl : "" - Accessible.ignored: true - } - EnforcedPlainTextLabel { Layout.fillWidth: true; text: personDelegate.displayName; elide: Text.ElideRight } - } - onClicked: { - root.searchModel.setPersonFilter(userId, displayName, avatarUrl) - peoplePopup.close() - root.focusSearchInput() - } - } - } + Component { + id: peoplePopupComponent + + UnifiedSearchPeoplePopup { + searchModel: root.searchModel + peopleModel: peopleSuggestionsModel + windowWidth: root.width + onClosed: peoplePopupLoader.active = false + onPersonSelected: root.focusSearchInput() } } - Dialog { - id: customRangeDialog - property bool validationError: false - anchors.centerIn: parent - title: qsTr("Custom date range") - modal: true - - footer: RowLayout { - spacing: Style.wizardFooterSpacing + Loader { + id: peoplePopupLoader - Item { - Layout.fillWidth: true + active: false + sourceComponent: peoplePopupComponent + onLoaded: { + if (status === Loader.Ready) { + item.open() } + } + } - WizardButton { - text: qsTr("Cancel") - onClicked: customRangeDialog.close() - } + Component { + id: customRangeDialogComponent - WizardButton { - primary: true - text: qsTr("Apply") - onClicked: { - customRangeDialog.validationError = !root.searchModel.setCustomDateRange(customSince.text, customUntil.text) - if (!customRangeDialog.validationError) { - customRangeDialog.close() - } - } - } + UnifiedSearchCustomDateRangeDialog { + searchModel: root.searchModel + onClosed: customRangeDialogLoader.active = false } + } + + Loader { + id: customRangeDialogLoader - ColumnLayout { - EnforcedPlainTextLabel { text: qsTr("Start date (YYYY-MM-DD)") } - TextField { id: customSince; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } - EnforcedPlainTextLabel { text: qsTr("End date (YYYY-MM-DD)") } - TextField { id: customUntil; Layout.fillWidth: true; placeholderText: "YYYY-MM-DD" } - EnforcedPlainTextLabel { - visible: customRangeDialog.validationError - text: qsTr("Enter valid dates with the start date before the end date.") - color: palette.accent + active: false + sourceComponent: customRangeDialogComponent + onLoaded: { + if (status === Loader.Ready) { + item.open() } } } diff --git a/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml new file mode 100644 index 0000000000000..ffaf568fbd6fc --- /dev/null +++ b/src/gui/search/UnifiedSearchCustomDateRangeDialog.qml @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" + +Dialog { + id: root + + required property var searchModel + + property bool validationError: false + + anchors.centerIn: parent + title: qsTr("Custom date range") + modal: true + onOpened: validationError = false + + footer: RowLayout { + spacing: Style.wizardFooterSpacing + + Item { + Layout.fillWidth: true + } + + WizardButton { + text: qsTr("Cancel") + onClicked: root.close() + } + + WizardButton { + primary: true + text: qsTr("Apply") + onClicked: { + root.validationError = !root.searchModel.setCustomDateRange(customSince.text, customUntil.text) + if (!root.validationError) { + root.close() + } + } + } + } + + ColumnLayout { + EnforcedPlainTextLabel { + text: qsTr("Start date (YYYY-MM-DD)") + } + + TextField { + id: customSince + + Layout.fillWidth: true + placeholderText: qsTr("YYYY-MM-DD") + } + + EnforcedPlainTextLabel { + text: qsTr("End date (YYYY-MM-DD)") + } + + TextField { + id: customUntil + + Layout.fillWidth: true + placeholderText: qsTr("YYYY-MM-DD") + } + + EnforcedPlainTextLabel { + visible: root.validationError + text: qsTr("Enter valid dates with the start date before the end date.") + color: palette.accent + } + } +} diff --git a/src/gui/search/UnifiedSearchDetailHeader.qml b/src/gui/search/UnifiedSearchDetailHeader.qml new file mode 100644 index 0000000000000..4bb2596bf407a --- /dev/null +++ b/src/gui/search/UnifiedSearchDetailHeader.qml @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +import QtQuick +import QtQuick.Controls.Basic + +import Style +import "qrc:/qml/src/gui/tray" + +Item { + id: root + + required property var searchModel + + signal navigateBack() + + objectName: "searchDetailHeader" + implicitHeight: Style.unifiedSearchDetailHeaderHeight + + ToolButton { + id: backButton + + objectName: "searchDetailBackButton" + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: qsTr("Back") + icon.source: "image://svgimage-custom-color/" + + (root.LayoutMirroring.enabled ? "arrow-right.svg/" : "arrow-left.svg/") + + Style.wizardPrimaryText + icon.width: Style.smallIconSize + icon.height: Style.smallIconSize + display: AbstractButton.TextBesideIcon + Accessible.name: qsTr("Back to all search results") + onClicked: { + root.searchModel.closeProviderDetail() + root.navigateBack() + } + } + + EnforcedPlainTextLabel { + objectName: "searchDetailProviderTitle" + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: backButton.width + Style.smallSpacing + anchors.rightMargin: backButton.width + Style.smallSpacing + anchors.verticalCenter: parent.verticalCenter + text: root.searchModel ? root.searchModel.detailProviderName : "" + font.bold: true + font.pixelSize: Style.wizardHeaderTitleFontPixelSize + elide: Text.ElideRight + horizontalAlignment: Text.AlignHCenter + } +} diff --git a/src/gui/search/UnifiedSearchFilterBar.qml b/src/gui/search/UnifiedSearchFilterBar.qml new file mode 100644 index 0000000000000..79c339fc2af09 --- /dev/null +++ b/src/gui/search/UnifiedSearchFilterBar.qml @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic + +import Style +import "qrc:/qml/src/gui/wizard/qml" + +Flow { + id: root + + required property var searchModel + + readonly property bool opened: typeMenu.opened || dateMenu.opened + + signal customDateRangeRequested() + signal peopleRequested() + + objectName: "categoryFilterFlow" + spacing: Style.smallSpacing + + function hasActiveFilter(type) { + if (!searchModel) { + return false + } + const filters = searchModel.activeFilters + for (let index = 0; index < filters.length; ++index) { + if (filters[index].type === type) { + return true + } + } + return false + } + + WizardButton { + id: typeFilterButton + + objectName: "typeFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("Type") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/folder.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("provider") + Accessible.name: qsTr("Filter by type") + onClicked: typeMenu.toggle() + + WizardMenu { + id: typeMenu + + objectName: "typeFilterMenu" + anchorItem: typeFilterButton + width: anchorItem.width * Style.unifiedSearchFilterMenuWidthFactor + height: Math.min(implicitHeight, + Style.unifiedSearchProviderMenuMaximumVisibleRows + * Style.standardPrimaryButtonHeight + + topPadding + bottomPadding) + + Repeater { + model: root.searchModel ? root.searchModel.providers : [] + + delegate: WizardMenuItem { + id: providerMenuItem + + required property var modelData + + text: (providerMenuItem.modelData.selected ? "✓ " : "") + providerMenuItem.modelData.name + icon.source: providerMenuItem.modelData.icon + ? "image://tray-image-provider/" + providerMenuItem.modelData.icon + : "" + tintIcon: true + iconTintColor: Style.wizardPrimaryText + onTriggered: root.searchModel.toggleProviderFilter(providerMenuItem.modelData.id) + } + } + } + } + + WizardButton { + id: dateFilterButton + + objectName: "dateFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("Date") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/calendar.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("date") + enabled: root.searchModel && root.searchModel.dateFilterAvailable + Accessible.name: qsTr("Filter by date") + Accessible.description: enabled ? "" : qsTr("No search source supports date filtering") + onClicked: dateMenu.toggle() + + WizardMenu { + id: dateMenu + + objectName: "dateFilterMenu" + anchorItem: dateFilterButton + + WizardMenuItem { + objectName: "dateTodayMenuItem" + text: qsTr("Today") + onTriggered: root.searchModel.setDatePreset("today") + } + WizardMenuItem { + text: qsTr("Last 7 days") + onTriggered: root.searchModel.setDatePreset("last7days") + } + WizardMenuItem { + text: qsTr("Last 30 days") + onTriggered: root.searchModel.setDatePreset("last30days") + } + WizardMenuItem { + text: qsTr("This year") + onTriggered: root.searchModel.setDatePreset("thisyear") + } + WizardMenuItem { + text: qsTr("Last year") + onTriggered: root.searchModel.setDatePreset("lastyear") + } + MenuSeparator {} + WizardMenuItem { + text: qsTr("Custom range …") + onTriggered: root.customDateRangeRequested() + } + WizardMenuItem { + text: qsTr("Clear date") + onTriggered: root.searchModel.clearDateFilter() + } + } + } + + WizardButton { + id: peopleButton + + objectName: "peopleFilterButton" + width: Math.max(Style.unifiedSearchFilterButtonMinimumWidth, + (root.width - 2 * root.spacing) / 3) + text: qsTr("People") + trailingIconSource: "image://svgimage-custom-color/caret-down.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + iconBeforeText: true + iconSource: "image://svgimage-custom-color/account-group.svg/" + + (primary ? Style.wizardSelectedText : Style.wizardPrimaryText) + primary: root.hasActiveFilter("person") + enabled: root.searchModel && root.searchModel.peopleFilterAvailable + Accessible.name: qsTr("Filter by person") + Accessible.description: enabled ? "" : qsTr("No search source supports people filtering") + onClicked: root.peopleRequested() + } +} diff --git a/src/gui/search/UnifiedSearchPeoplePopup.qml b/src/gui/search/UnifiedSearchPeoplePopup.qml new file mode 100644 index 0000000000000..f8ff9cfe20f0e --- /dev/null +++ b/src/gui/search/UnifiedSearchPeoplePopup.qml @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +import Style +import "qrc:/qml/src/gui/tray" +import "qrc:/qml/src/gui/wizard/qml" + +Popup { + id: root + + required property var searchModel + required property var peopleModel + required property real windowWidth + + signal personSelected() + + parent: Overlay.overlay + width: Math.min(windowWidth - Style.unifiedSearchPeoplePopupHorizontalMargin, + Style.wizardDialogMaximumWidth) + height: Style.unifiedSearchPeoplePopupHeight + x: (windowWidth - width) / 2 + y: Style.unifiedSearchPeoplePopupTopMargin + modal: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + onOpened: peopleSearch.forceActiveFocus() + + ColumnLayout { + anchors.fill: parent + anchors.margins: Style.smallSpacing + + TextField { + id: peopleSearch + + Layout.fillWidth: true + placeholderText: qsTr("Search people") + onTextEdited: root.peopleModel.searchTerm = text + } + + EnforcedPlainTextLabel { + visible: root.peopleModel.errorString.length > 0 + text: root.peopleModel.errorString + wrapMode: Text.Wrap + } + + WizardButton { + visible: root.peopleModel.errorString.length > 0 + text: qsTr("Retry") + onClicked: root.peopleModel.retry() + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.peopleModel + + delegate: ItemDelegate { + id: personDelegate + + required property string userId + required property string displayName + required property string avatarUrl + + width: ListView.view.width + height: Style.unifiedSearchProviderHeaderHeight + text: displayName + hoverEnabled: true + Accessible.description: userId + + background: Rectangle { + color: personDelegate.hovered || personDelegate.down + ? Style.listItemHoverBackground + : "transparent" + radius: Style.mediumRoundedButtonRadius + } + + HoverHandler { + cursorShape: Qt.PointingHandCursor + } + + contentItem: RowLayout { + Image { + Layout.preferredWidth: Style.accountAvatarSize + Layout.preferredHeight: Style.accountAvatarSize + sourceSize.width: Style.accountAvatarSize + sourceSize.height: Style.accountAvatarSize + asynchronous: true + source: personDelegate.avatarUrl.length > 0 + ? "image://tray-image-provider/" + personDelegate.avatarUrl + : "" + Accessible.ignored: true + } + + EnforcedPlainTextLabel { + Layout.fillWidth: true + text: personDelegate.displayName + elide: Text.ElideRight + } + } + + onClicked: { + root.searchModel.setPersonFilter(personDelegate.userId, + personDelegate.displayName, + personDelegate.avatarUrl) + root.close() + root.personSelected() + } + } + } + } +} diff --git a/src/gui/search/UnifiedSearchResultDelegate.qml b/src/gui/search/UnifiedSearchResultDelegate.qml index 174ab21187ba0..af549099446a7 100644 --- a/src/gui/search/UnifiedSearchResultDelegate.qml +++ b/src/gui/search/UnifiedSearchResultDelegate.qml @@ -38,14 +38,14 @@ Item { implicitHeight: { if (resultType === UnifiedSearchResultsListModel.ProviderHeader) { - return 44 + return Style.unifiedSearchProviderHeaderHeight } if (resultType === UnifiedSearchResultsListModel.PartialMatchesHeader) { - return 40 + return Style.unifiedSearchPartialMatchesHeaderHeight } if (resultType === UnifiedSearchResultsListModel.FetchMoreTrigger || resultType === UnifiedSearchResultsListModel.RetryFetchMoreTrigger) { - return 44 + return Style.unifiedSearchPagingRowHeight } return Style.unifiedSearchItemHeight } @@ -78,7 +78,7 @@ Item { objectName: "providerHeaderRow" width: root.width - height: 44 + height: Style.unifiedSearchProviderHeaderHeight flat: true text: root.hasOverflow ? qsTr("More from %1 →").arg(root.providerName) : root.providerName font.bold: false @@ -125,7 +125,7 @@ Item { EnforcedPlainTextLabel { objectName: "partialMatchesHeaderRow" width: root.width - height: 40 + height: Style.unifiedSearchPartialMatchesHeaderHeight verticalAlignment: Text.AlignVCenter text: qsTr("Partial matches") color: Style.wizardSecondaryText @@ -147,7 +147,7 @@ Item { topPadding: 0 bottomPadding: 0 activeFocusOnTab: false - opacity: root.isPartialMatch ? 0.72 : 1.0 + opacity: root.isPartialMatch ? Style.unifiedSearchPartialMatchOpacity : 1.0 hoverEnabled: true Accessible.role: Accessible.ListItem Accessible.name: root.resultTitle diff --git a/src/gui/search/unifiedsearchpeoplemodel.cpp b/src/gui/search/unifiedsearchpeoplemodel.cpp index d441ce2d67cfd..4a04cc1a87d02 100644 --- a/src/gui/search/unifiedsearchpeoplemodel.cpp +++ b/src/gui/search/unifiedsearchpeoplemodel.cpp @@ -15,11 +15,20 @@ #include #include -namespace { +namespace +{ constexpr auto maximumPeopleResults = 50; + +QString avatarUrl(const OCC::AccountPtr &account, const QString &userId) +{ + const auto encodedUserId = QString::fromUtf8(QUrl::toPercentEncoding(userId)); + const auto avatarPath = QStringLiteral("index.php/avatar/%1/64").arg(encodedUserId); + return account->url().resolved(QUrl(avatarPath)).toString(); +} } -namespace OCC { +namespace OCC +{ UnifiedSearchPeopleModel::UnifiedSearchPeopleModel(QObject *parent, int debounceInterval) : QAbstractListModel(parent) { @@ -28,21 +37,31 @@ UnifiedSearchPeopleModel::UnifiedSearchPeopleModel(QObject *parent, int debounce connect(&_debounceTimer, &QTimer::timeout, this, &UnifiedSearchPeopleModel::startSearch); } -UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() { cancel(); } +UnifiedSearchPeopleModel::~UnifiedSearchPeopleModel() +{ + cancel(); +} QVariant UnifiedSearchPeopleModel::data(const QModelIndex &index, int role) const { Q_ASSERT(checkIndex(index, CheckIndexOption::IndexIsValid)); const auto &person = _people.at(index.row()); switch (role) { - case UserIdRole: return person.id; - case DisplayNameRole: return person.displayName; - case AvatarUrlRole: return person.avatarUrl; + case UserIdRole: + return person.id; + case DisplayNameRole: + return person.displayName; + case AvatarUrlRole: + return person.avatarUrl; } return {}; } -int UnifiedSearchPeopleModel::rowCount(const QModelIndex &parent) const { return parent.isValid() ? 0 : _people.size(); } +int UnifiedSearchPeopleModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : _people.size(); +} + QHash UnifiedSearchPeopleModel::roleNames() const { static const auto roles = QHash{ @@ -52,17 +71,37 @@ QHash UnifiedSearchPeopleModel::roleNames() const }; return roles; } -AccountState *UnifiedSearchPeopleModel::accountState() const { return _accountState.data(); } -QString UnifiedSearchPeopleModel::searchTerm() const { return _searchTerm; } -bool UnifiedSearchPeopleModel::busy() const { return _busy; } -QString UnifiedSearchPeopleModel::errorString() const { return _errorString; } + +AccountState *UnifiedSearchPeopleModel::accountState() const +{ + return _accountState.data(); +} + +QString UnifiedSearchPeopleModel::searchTerm() const +{ + return _searchTerm; +} + +bool UnifiedSearchPeopleModel::busy() const +{ + return _busy; +} + +QString UnifiedSearchPeopleModel::errorString() const +{ + return _errorString; +} void UnifiedSearchPeopleModel::setAccountState(AccountState *accountState) { - if (_accountState == accountState) return; + if (_accountState == accountState) { + return; + } cancel(); clearPeople(); - if (_accountState) disconnect(_accountState, nullptr, this, nullptr); + if (_accountState) { + disconnect(_accountState, nullptr, this, nullptr); + } _accountState = accountState; if (_accountState) { connect(_accountState, &AccountState::isConnectedChanged, this, [this] { @@ -93,7 +132,9 @@ void UnifiedSearchPeopleModel::setAccountState(AccountState *accountState) void UnifiedSearchPeopleModel::setSearchTerm(const QString &searchTerm) { - if (_searchTerm == searchTerm) return; + if (_searchTerm == searchTerm) { + return; + } _searchTerm = searchTerm; Q_EMIT searchTermChanged(); cancel(); @@ -102,7 +143,10 @@ void UnifiedSearchPeopleModel::setSearchTerm(const QString &searchTerm) _debounceTimer.start(); } -void UnifiedSearchPeopleModel::retry() { startSearch(); } +void UnifiedSearchPeopleModel::retry() +{ + startSearch(); +} void UnifiedSearchPeopleModel::startSearch() { @@ -113,13 +157,11 @@ void UnifiedSearchPeopleModel::startSearch() } const auto account = _accountState->account(); if (_searchTerm.trimmed().isEmpty()) { - QVector people; + auto people = QVector{}; const auto selfId = account->davUser(); if (!selfId.isEmpty()) { const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); - const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64") - .arg(QString::fromUtf8(QUrl::toPercentEncoding(selfId))))).toString(); - people.push_back({selfId, selfName, avatar}); + people.push_back({selfId, selfName, avatarUrl(account, selfId)}); } replacePeople(std::move(people)); setErrorString({}); @@ -127,18 +169,20 @@ void UnifiedSearchPeopleModel::startSearch() } const auto generation = ++_generation; - auto *const job = new JsonApiJob(account, QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/sharees")); - QUrlQuery query; + const auto job = new JsonApiJob(account, QStringLiteral("ocs/v2.php/apps/files_sharing/api/v1/sharees")); + auto query = QUrlQuery{}; query.addQueryItem(QStringLiteral("search"), _searchTerm); query.addQueryItem(QStringLiteral("shareType"), QStringLiteral("0")); query.addQueryItem(QStringLiteral("lookup"), QStringLiteral("false")); query.addQueryItem(QStringLiteral("page"), QStringLiteral("1")); - query.addQueryItem(QStringLiteral("perPage"), QStringLiteral("50")); + query.addQueryItem(QStringLiteral("perPage"), QString::number(maximumPeopleResults)); job->addQueryParams(query); _job = job; setBusy(true); connect(job, &JsonApiJob::jsonReceived, this, [this, generation, job, account](const QJsonDocument &reply, int statusCode) { - if (generation != _generation || _job != job) return; + if (generation != _generation || _job != job) { + return; + } _job.clear(); setBusy(false); if (statusCode != 200) { @@ -146,9 +190,10 @@ void UnifiedSearchPeopleModel::startSearch() setErrorString(tr("Could not load people. Try again.")); return; } - QVector people; - QSet seen; - const auto data = reply.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject(); + auto people = QVector{}; + auto seen = QSet{}; + const auto ocs = reply.object().value(QStringLiteral("ocs")).toObject(); + const auto data = ocs.value(QStringLiteral("data")).toObject(); const auto appendUsers = [&people, &seen, &account](const QJsonArray &users) { for (const auto &value : users) { if (people.size() >= maximumPeopleResults) { @@ -156,20 +201,21 @@ void UnifiedSearchPeopleModel::startSearch() } const auto object = value.toObject(); const auto id = object.value(QStringLiteral("value")).toObject().value(QStringLiteral("shareWith")).toString(); - if (id.isEmpty() || seen.contains(id)) continue; + if (id.isEmpty() || seen.contains(id)) { + continue; + } seen.insert(id); - const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64").arg(QString::fromUtf8(QUrl::toPercentEncoding(id))))).toString(); - people.push_back({id, object.value(QStringLiteral("label")).toString(id), avatar}); + people.push_back({id, object.value(QStringLiteral("label")).toString(id), avatarUrl(account, id)}); } }; appendUsers(data.value(QStringLiteral("exact")).toObject().value(QStringLiteral("users")).toArray()); appendUsers(data.value(QStringLiteral("users")).toArray()); const auto selfId = account->davUser(); const auto selfName = account->prettyName().isEmpty() ? selfId : account->prettyName(); - if (!selfId.isEmpty() && !seen.contains(selfId) - && (_searchTerm.isEmpty() || selfId.contains(_searchTerm, Qt::CaseInsensitive) || selfName.contains(_searchTerm, Qt::CaseInsensitive))) { - const auto avatar = account->url().resolved(QUrl(QStringLiteral("index.php/avatar/%1/64").arg(QString::fromUtf8(QUrl::toPercentEncoding(selfId))))).toString(); - people.prepend({selfId, selfName, avatar}); + const auto selfMatchesSearch = + _searchTerm.isEmpty() || selfId.contains(_searchTerm, Qt::CaseInsensitive) || selfName.contains(_searchTerm, Qt::CaseInsensitive); + if (!selfId.isEmpty() && !seen.contains(selfId) && selfMatchesSearch) { + people.prepend({selfId, selfName, avatarUrl(account, selfId)}); if (people.size() > maximumPeopleResults) { people.removeLast(); } @@ -186,8 +232,11 @@ void UnifiedSearchPeopleModel::cancel() ++_generation; if (_job) { disconnect(_job, nullptr, this, nullptr); - if (const auto job = qobject_cast(_job.data()); job && job->reply() && job->reply()->isRunning()) - job->reply()->abort(); + const auto job = qobject_cast(_job.data()); + const auto reply = job ? job->reply() : nullptr; + if (reply && reply->isRunning()) { + reply->abort(); + } _job.clear(); } setBusy(false); @@ -212,14 +261,18 @@ void UnifiedSearchPeopleModel::replacePeople(QVector people) void UnifiedSearchPeopleModel::setBusy(bool busy) { - if (_busy == busy) return; + if (_busy == busy) { + return; + } _busy = busy; Q_EMIT busyChanged(); } void UnifiedSearchPeopleModel::setErrorString(const QString &errorString) { - if (_errorString == errorString) return; + if (_errorString == errorString) { + return; + } _errorString = errorString; Q_EMIT errorStringChanged(); } diff --git a/src/gui/search/unifiedsearchresultslistmodel.h b/src/gui/search/unifiedsearchresultslistmodel.h index 9e3f48dd81af8..8dbff91f395d4 100644 --- a/src/gui/search/unifiedsearchresultslistmodel.h +++ b/src/gui/search/unifiedsearchresultslistmodel.h @@ -60,6 +60,7 @@ class UnifiedSearchResultsListModel : public QAbstractListModel Q_PROPERTY(AccountState *accountState READ accountState CONSTANT) public: + /** @brief The top-level presentation state of the search results view. */ enum class SearchState { None, Placeholder, @@ -70,12 +71,14 @@ class UnifiedSearchResultsListModel : public QAbstractListModel }; Q_ENUM(SearchState) + /** @brief Selects between results from all providers and one provider. */ enum class ViewMode { Aggregate, ProviderDetail, }; Q_ENUM(ViewMode) + /** @brief Describes how keyboard navigation moves the current selection. */ enum class SelectionDirection { Previous, Next, @@ -84,6 +87,7 @@ class UnifiedSearchResultsListModel : public QAbstractListModel }; Q_ENUM(SelectionDirection) + /** @brief Identifies how a result-model row is rendered in QML. */ enum class ResultType { Default = static_cast(UnifiedSearchResult::Type::Default), ProviderHeader = static_cast(UnifiedSearchResult::Type::ProviderHeader), diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index 62c13cde2d68d..50d4aafcfb949 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -461,7 +461,7 @@ void Systray::showSearchWindow(int userIndex) return; } - auto *const searchModel = new UnifiedSearchResultsListModel(accountState.data()); + const auto searchModel = new UnifiedSearchResultsListModel(accountState.data()); searchModel->setParent(accountState.data()); const QVariantMap initialProperties{ {"account", QVariantMap{ diff --git a/src/gui/wizard/qml/WizardButton.qml b/src/gui/wizard/qml/WizardButton.qml index a9fd995caacb9..f86d038dcaac5 100644 --- a/src/gui/wizard/qml/WizardButton.qml +++ b/src/gui/wizard/qml/WizardButton.qml @@ -29,15 +29,15 @@ BasicControls.Button { readonly property color disabledBorderColor: Style.wizardDisabledButtonBorder implicitHeight: Style.wizardFooterButtonHeight - leftPadding: 18 - rightPadding: 18 - font.pixelSize: Style.pixelSize + 3 + leftPadding: Style.wizardButtonHorizontalPadding + rightPadding: Style.wizardButtonHorizontalPadding + font.pixelSize: Style.wizardButtonFontPixelSize font.weight: Font.Medium Accessible.role: Accessible.Button Accessible.name: textSuffix === "" ? text : text + " " + textSuffix contentItem: RowLayout { - spacing: 6 + spacing: Style.wizardButtonContentSpacing Item { visible: root.iconSource !== "" && root.iconBeforeText @@ -72,9 +72,15 @@ BasicControls.Button { Layout.fillWidth: true text: root.textSuffix === "" ? root.text : root.text + " " + root.textSuffix font: root.font - color: root.enabled - ? (root.primary ? Style.wizardSelectedText : root.palette.buttonText) - : Style.wizardDisabledText + color: { + if (!root.enabled) { + return Style.wizardDisabledText + } + if (root.primary) { + return Style.wizardSelectedText + } + return root.palette.buttonText + } horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter elide: Text.ElideRight @@ -108,7 +114,7 @@ BasicControls.Button { background: Rectangle { radius: root.cornerRadius - border.width: root.primary ? 0 : 1 + border.width: root.primary ? 0 : Style.normalBorderWidth border.color: root.enabled ? root.secondaryBorderColor : root.disabledBorderColor color: { if (!root.enabled) { diff --git a/src/gui/wizard/qml/WizardChipButton.qml b/src/gui/wizard/qml/WizardChipButton.qml index 11d692cbeaba0..91ad50b30cbde 100644 --- a/src/gui/wizard/qml/WizardChipButton.qml +++ b/src/gui/wizard/qml/WizardChipButton.qml @@ -7,7 +7,5 @@ import Style WizardButton { implicitHeight: Style.wizardChipButtonHeight - leftPadding: Style.standardSpacing - rightPadding: Style.standardSpacing cornerRadius: Style.veryRoundedButtonRadius } diff --git a/src/gui/wizard/qml/WizardMenuItem.qml b/src/gui/wizard/qml/WizardMenuItem.qml index dfe23a51742a0..4ac379ccbdf3c 100644 --- a/src/gui/wizard/qml/WizardMenuItem.qml +++ b/src/gui/wizard/qml/WizardMenuItem.qml @@ -18,8 +18,8 @@ BasicControls.MenuItem { hoverEnabled: true implicitHeight: Style.standardPrimaryButtonHeight - leftPadding: 12 - rightPadding: 12 + leftPadding: Style.wizardMenuItemHorizontalPadding + rightPadding: Style.wizardMenuItemHorizontalPadding font.pixelSize: Style.pixelSize + Style.extraSmallSpacing contentItem: RowLayout { diff --git a/test/qml/search/testsearch.qml b/test/qml/search/testsearch.qml index cdf2829f8a20c..4f94bdcde8cee 100644 --- a/test/qml/search/testsearch.qml +++ b/test/qml/search/testsearch.qml @@ -407,6 +407,71 @@ Item { model.destroy() } + function providerEntries(count) { + const providers = [] + for (let index = 0; index < count; ++index) { + providers.push({ + id: "provider-" + index, + name: "Provider " + index, + icon: "", + selected: false + }) + } + return providers + } + + function test_typeFilterMenuShowsEightRowsWithoutScrolling() { + const model = windowSearchModel.createObject(this, { + providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows) + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + + verify(typeButton !== null) + verify(typeMenu !== null) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.height, typeMenu.implicitHeight) + compare(typeMenu.contentItem.interactive, false) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + searchWindow.destroy() + model.destroy() + } + + function test_typeFilterMenuScrollsBeyondEightRows() { + const model = windowSearchModel.createObject(this, { + providers: providerEntries(Style.unifiedSearchProviderMenuMaximumVisibleRows + 1) + }) + const searchWindow = productionSearchWindow.createObject(null, { + searchModel: model, + visible: true + }) + const typeButton = findChild(searchWindow, "typeFilterButton") + const typeMenu = findChild(searchWindow, "typeFilterMenu") + const maximumHeight = Style.unifiedSearchProviderMenuMaximumVisibleRows + * Style.standardPrimaryButtonHeight + typeMenu.topPadding + typeMenu.bottomPadding + + verify(typeButton !== null) + verify(typeMenu !== null) + mouseClick(typeButton) + tryCompare(typeMenu, "opened", true) + compare(typeMenu.height, maximumHeight) + verify(typeMenu.implicitHeight > typeMenu.height) + verify(typeMenu.contentItem.contentHeight > typeMenu.contentItem.height) + compare(typeMenu.contentItem.interactive, true) + + mouseClick(typeButton) + tryCompare(typeMenu, "opened", false) + searchWindow.destroy() + model.destroy() + } + function test_categoryFiltersAreVisibleBeforeSearchBegins() { const model = windowSearchModel.createObject(this, { searchTerm: "" }) const searchWindow = productionSearchWindow.createObject(null, { diff --git a/theme/Style/Style.qml b/theme/Style/Style.qml index f5eb384ef9244..a941c8db3accb 100644 --- a/theme/Style/Style.qml +++ b/theme/Style/Style.qml @@ -127,7 +127,11 @@ QtObject { readonly property int wizardWindowMargin: 24 readonly property int wizardWindowTopMargin: standardSpacing readonly property int wizardFooterButtonHeight: iconButtonWidth + readonly property int wizardButtonHorizontalPadding: 18 + readonly property int wizardButtonContentSpacing: 6 + readonly property int wizardButtonFontPixelSize: pixelSize + 3 readonly property int wizardChipButtonHeight: variableSize(26) + readonly property int wizardMenuItemHorizontalPadding: wizardSectionSpacing readonly property int wizardFooterSpacing: trayAccountPopupActionVerticalPadding readonly property int wizardSectionSpacing: trayAccountPopupRowPadding readonly property int wizardDialogMaximumWidth: 420 @@ -222,7 +226,22 @@ QtObject { // Visual behaviour property bool hoverEffectsEnabled: true - // unified search constants + // unified search constants + readonly property int unifiedSearchDetailHeaderHeight: standardPrimaryButtonHeight + readonly property int unifiedSearchFilterButtonMinimumWidth: 140 + readonly property real unifiedSearchFilterMenuWidthFactor: 1.5 + readonly property int unifiedSearchProviderMenuMaximumVisibleRows: 8 + readonly property int unifiedSearchPeoplePopupHorizontalMargin: standardPrimaryButtonHeight + readonly property int unifiedSearchPeoplePopupHeight: 340 + readonly property int unifiedSearchPeoplePopupTopMargin: 150 + readonly property int unifiedSearchProviderHeaderHeight: 44 + readonly property int unifiedSearchPartialMatchesHeaderHeight: standardPrimaryButtonHeight + readonly property int unifiedSearchPagingRowHeight: unifiedSearchProviderHeaderHeight + readonly property int unifiedSearchLoadingPlaceholderCount: 3 + readonly property real unifiedSearchLoadingPlaceholderInitialWidthRatio: 0.72 + readonly property real unifiedSearchLoadingPlaceholderWidthStep: 0.07 + readonly property real unifiedSearchLoadingPlaceholderOpacity: 0.55 + readonly property real unifiedSearchPartialMatchOpacity: 0.72 readonly property int unifiedSearchItemHeight: Math.max(44, unifiedSearchResultTitleFontSize * 2 + unifiedSearchResultTextSpacing + 8) readonly property int unifiedSearchResultTextLeftMargin: 18