mirror of
https://github.com/nextcloud/desktop.git
synced 2024-11-21 20:45:51 +03:00
Unified Search via Tray window
Signed-off-by: alex-z <blackslayer4@gmail.com>
This commit is contained in:
parent
b8e2dc24f3
commit
c1dab7e4cb
35 changed files with 2528 additions and 23 deletions
|
@ -15,5 +15,13 @@
|
|||
<file>src/gui/tray/AutoSizingMenu.qml</file>
|
||||
<file>src/gui/tray/ActivityList.qml</file>
|
||||
<file>src/gui/tray/FileActivityDialog.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchInputContainer.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultItem.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultItemSkeleton.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultListItem.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultNothingFound.qml</file>
|
||||
<file>src/gui/tray/UnifiedSearchResultSectionItem.qml</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
|
|
|
@ -43,6 +43,14 @@ set(client_UI_SRCS
|
|||
tray/ActivityList.qml
|
||||
tray/Window.qml
|
||||
tray/UserLine.qml
|
||||
tray/UnifiedSearchInputContainer.qml
|
||||
tray/UnifiedSearchResultFetchMoreTrigger.qml
|
||||
tray/UnifiedSearchResultItem.qml
|
||||
tray/UnifiedSearchResultItemSkeleton.qml
|
||||
tray/UnifiedSearchResultItemSkeletonContainer.qml
|
||||
tray/UnifiedSearchResultListItem.qml
|
||||
tray/UnifiedSearchResultNothingFound.qml
|
||||
tray/UnifiedSearchResultSectionItem.qml
|
||||
wizard/flow2authwidget.ui
|
||||
wizard/owncloudadvancedsetuppage.ui
|
||||
wizard/owncloudconnectionmethoddialog.ui
|
||||
|
@ -116,6 +124,9 @@ set(client_SRCS
|
|||
tray/syncstatussummary.cpp
|
||||
tray/ActivityData.cpp
|
||||
tray/ActivityListModel.cpp
|
||||
tray/unifiedsearchresult.cpp
|
||||
tray/unifiedsearchresultimageprovider.cpp
|
||||
tray/unifiedsearchresultslistmodel.cpp
|
||||
tray/UserModel.cpp
|
||||
tray/NotificationHandler.cpp
|
||||
tray/NotificationCache.cpp
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
import QtQuick 2.15
|
||||
|
||||
import Style 1.0
|
||||
|
||||
Item {
|
||||
id: errorBox
|
||||
|
||||
property var text: ""
|
||||
|
||||
property color color: Style.errorBoxTextColor
|
||||
property color backgroundColor: Style.errorBoxBackgroundColor
|
||||
property color borderColor: Style.errorBoxBorderColor
|
||||
|
||||
implicitHeight: errorMessage.implicitHeight + 2 * 8
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "red"
|
||||
border.color: "black"
|
||||
color: errorBox.backgroundColor
|
||||
border.color: errorBox.borderColor
|
||||
}
|
||||
|
||||
Text {
|
||||
|
@ -19,7 +25,7 @@ Item {
|
|||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
width: parent.width
|
||||
color: "white"
|
||||
color: errorBox.color
|
||||
wrapMode: Text.WordWrap
|
||||
text: errorBox.text
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
#include "userstatusselectormodel.h"
|
||||
#include "emojimodel.h"
|
||||
#include "tray/syncstatussummary.h"
|
||||
#include "tray/unifiedsearchresultslistmodel.h"
|
||||
|
||||
#if defined(BUILD_UPDATER)
|
||||
#include "updater/updater.h"
|
||||
|
@ -68,6 +69,9 @@ int main(int argc, char **argv)
|
|||
qmlRegisterType<UserStatusSelectorModel>("com.nextcloud.desktopclient", 1, 0, "UserStatusSelectorModel");
|
||||
qmlRegisterType<OCC::ActivityListModel>("com.nextcloud.desktopclient", 1, 0, "ActivityListModel");
|
||||
qmlRegisterType<OCC::FileActivityListModel>("com.nextcloud.desktopclient", 1, 0, "FileActivityListModel");
|
||||
qmlRegisterUncreatableType<OCC::UnifiedSearchResultsListModel>(
|
||||
"com.nextcloud.desktopclient", 1, 0, "UnifiedSearchResultsListModel", "UnifiedSearchResultsListModel");
|
||||
qRegisterMetaType<UnifiedSearchResultsListModel *>("UnifiedSearchResultsListModel*");
|
||||
|
||||
qmlRegisterUncreatableType<OCC::UserStatus>("com.nextcloud.desktopclient", 1, 0, "UserStatus", "Access to Status enum");
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
#include "config.h"
|
||||
#include "common/utility.h"
|
||||
#include "tray/UserModel.h"
|
||||
#include "tray/unifiedsearchresultimageprovider.h"
|
||||
#include "configfile.h"
|
||||
|
||||
#include <QCursor>
|
||||
|
@ -58,6 +59,7 @@ void Systray::setTrayEngine(QQmlApplicationEngine *trayEngine)
|
|||
|
||||
_trayEngine->addImportPath("qrc:/qml/theme");
|
||||
_trayEngine->addImageProvider("avatars", new ImageProvider);
|
||||
_trayEngine->addImageProvider(QLatin1String("unified-search-result-icon"), new UnifiedSearchResultImageProvider);
|
||||
}
|
||||
|
||||
Systray::Systray()
|
||||
|
|
110
src/gui/tray/UnifiedSearchInputContainer.qml
Normal file
110
src/gui/tray/UnifiedSearchInputContainer.qml
Normal file
|
@ -0,0 +1,110 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.3
|
||||
import QtGraphicalEffects 1.0
|
||||
import Style 1.0
|
||||
|
||||
TextField {
|
||||
id: trayWindowUnifiedSearchTextField
|
||||
|
||||
property bool isSearchInProgress: false
|
||||
|
||||
readonly property color textFieldIconsColor: Style.menuBorder
|
||||
|
||||
readonly property int textFieldIconsOffset: 10
|
||||
|
||||
readonly property double textFieldIconsScaleFactor: 0.6
|
||||
|
||||
readonly property int textFieldHorizontalPaddingOffset: 14
|
||||
|
||||
leftPadding: trayWindowUnifiedSearchTextFieldSearchIcon.width + trayWindowUnifiedSearchTextFieldSearchIcon.anchors.leftMargin + textFieldHorizontalPaddingOffset
|
||||
rightPadding: trayWindowUnifiedSearchTextFieldClearTextButton.width + trayWindowUnifiedSearchTextFieldClearTextButton.anchors.rightMargin + textFieldHorizontalPaddingOffset
|
||||
|
||||
placeholderText: qsTr("Search files, messages, events...")
|
||||
|
||||
selectByMouse: true
|
||||
|
||||
background: Rectangle {
|
||||
radius: 5
|
||||
border.color: parent.activeFocus ? Style.ncBlue : Style.menuBorder
|
||||
border.width: 1
|
||||
}
|
||||
|
||||
Image {
|
||||
id: trayWindowUnifiedSearchTextFieldSearchIcon
|
||||
|
||||
anchors {
|
||||
left: parent.left
|
||||
leftMargin: parent.textFieldIconsOffset
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
visible: !trayWindowUnifiedSearchTextField.isSearchInProgress
|
||||
|
||||
smooth: true;
|
||||
antialiasing: true
|
||||
mipmap: true
|
||||
|
||||
source: "qrc:///client/theme/black/search.svg"
|
||||
sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor)
|
||||
|
||||
ColorOverlay {
|
||||
anchors.fill: parent
|
||||
source: parent
|
||||
cached: true
|
||||
color: parent.parent.textFieldIconsColor
|
||||
}
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
id: trayWindowUnifiedSearchTextFieldIconInProgress
|
||||
running: visible
|
||||
visible: trayWindowUnifiedSearchTextField.isSearchInProgress
|
||||
anchors {
|
||||
left: trayWindowUnifiedSearchTextField.left
|
||||
bottom: trayWindowUnifiedSearchTextField.bottom
|
||||
leftMargin: trayWindowUnifiedSearchTextField.textFieldIconsOffset - 4
|
||||
topMargin: 4
|
||||
bottomMargin: 4
|
||||
verticalCenter: trayWindowUnifiedSearchTextField.verticalCenter
|
||||
}
|
||||
width: height
|
||||
}
|
||||
|
||||
Image {
|
||||
id: trayWindowUnifiedSearchTextFieldClearTextButton
|
||||
|
||||
anchors {
|
||||
right: parent.right
|
||||
rightMargin: parent.textFieldIconsOffset
|
||||
verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
smooth: true;
|
||||
antialiasing: true
|
||||
mipmap: true
|
||||
|
||||
visible: parent.text
|
||||
|
||||
source: "qrc:///client/theme/black/clear.svg"
|
||||
sourceSize: Qt.size(parent.height * parent.textFieldIconsScaleFactor, parent.height * parent.textFieldIconsScaleFactor)
|
||||
|
||||
ColorOverlay {
|
||||
anchors.fill: parent
|
||||
cached: true
|
||||
source: parent
|
||||
color: parent.parent.textFieldIconsColor
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: trayWindowUnifiedSearchTextFieldClearTextButtonMouseArea
|
||||
|
||||
anchors.fill: parent
|
||||
|
||||
onClicked: {
|
||||
trayWindowUnifiedSearchTextField.text = ""
|
||||
trayWindowUnifiedSearchTextField.onTextEdited()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
42
src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml
Normal file
42
src/gui/tray/UnifiedSearchResultFetchMoreTrigger.qml
Normal file
|
@ -0,0 +1,42 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import Style 1.0
|
||||
|
||||
ColumnLayout {
|
||||
id: unifiedSearchResultItemFetchMore
|
||||
|
||||
property bool isFetchMoreInProgress: false
|
||||
|
||||
property bool isWihinViewPort: false
|
||||
|
||||
property int fontSize: Style.topLinePixelSize
|
||||
|
||||
property string textColor: "grey"
|
||||
|
||||
Accessible.role: Accessible.ListItem
|
||||
Accessible.name: unifiedSearchResultItemFetchMoreText.text
|
||||
Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
|
||||
|
||||
Label {
|
||||
id: unifiedSearchResultItemFetchMoreText
|
||||
text: qsTr("Load more results")
|
||||
visible: !unifiedSearchResultItemFetchMore.isFetchMoreInProgress
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
font.pixelSize: unifiedSearchResultItemFetchMore.fontSize
|
||||
color: unifiedSearchResultItemFetchMore.textColor
|
||||
}
|
||||
|
||||
BusyIndicator {
|
||||
id: unifiedSearchResultItemFetchMoreIconInProgress
|
||||
running: visible
|
||||
visible: unifiedSearchResultItemFetchMore.isFetchMoreInProgress && unifiedSearchResultItemFetchMore.isWihinViewPort
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
Layout.preferredWidth: parent.height * 0.70
|
||||
Layout.preferredHeight: parent.height * 0.70
|
||||
}
|
||||
}
|
107
src/gui/tray/UnifiedSearchResultItem.qml
Normal file
107
src/gui/tray/UnifiedSearchResultItem.qml
Normal file
|
@ -0,0 +1,107 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.9
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import Style 1.0
|
||||
import QtGraphicalEffects 1.0
|
||||
|
||||
RowLayout {
|
||||
id: unifiedSearchResultItemDetails
|
||||
|
||||
property string title: ""
|
||||
property string subline: ""
|
||||
property string icons: ""
|
||||
property string iconPlaceholder: ""
|
||||
property bool isRounded: false
|
||||
|
||||
|
||||
property int textLeftMargin: 18
|
||||
property int textRightMargin: 16
|
||||
property int iconWidth: 24
|
||||
property int iconLeftMargin: 12
|
||||
|
||||
property int titleFontSize: Style.topLinePixelSize
|
||||
property int sublineFontSize: Style.subLinePixelSize
|
||||
|
||||
property string titleColor: "black"
|
||||
property string sublineColor: "grey"
|
||||
|
||||
Accessible.role: Accessible.ListItem
|
||||
Accessible.name: resultTitle
|
||||
Accessible.onPressAction: unifiedSearchResultMouseArea.clicked()
|
||||
|
||||
ColumnLayout {
|
||||
id: unifiedSearchResultImageContainer
|
||||
visible: true
|
||||
Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth + 10
|
||||
Layout.preferredHeight: unifiedSearchResultItemDetails.height
|
||||
Image {
|
||||
id: unifiedSearchResultThumbnail
|
||||
visible: false
|
||||
asynchronous: true
|
||||
source: "image://unified-search-result-icon/" + icons
|
||||
cache: true
|
||||
sourceSize.width: imageData.width
|
||||
sourceSize.height: imageData.height
|
||||
width: imageData.width
|
||||
height: imageData.height
|
||||
}
|
||||
Rectangle {
|
||||
id: mask
|
||||
visible: false
|
||||
radius: isRounded ? width / 2 : 0
|
||||
width: imageData.width
|
||||
height: imageData.height
|
||||
}
|
||||
OpacityMask {
|
||||
id: imageData
|
||||
visible: !unifiedSearchResultThumbnailPlaceholder.visible && icons
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
Layout.leftMargin: iconLeftMargin
|
||||
Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth
|
||||
Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth
|
||||
source: unifiedSearchResultThumbnail
|
||||
maskSource: mask
|
||||
}
|
||||
Image {
|
||||
id: unifiedSearchResultThumbnailPlaceholder
|
||||
visible: icons && iconPlaceholder && unifiedSearchResultThumbnail.status !== Image.Ready
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
Layout.leftMargin: iconLeftMargin
|
||||
verticalAlignment: Qt.AlignCenter
|
||||
cache: true
|
||||
source: iconPlaceholder
|
||||
sourceSize.height: unifiedSearchResultItemDetails.iconWidth
|
||||
sourceSize.width: unifiedSearchResultItemDetails.iconWidth
|
||||
Layout.preferredWidth: unifiedSearchResultItemDetails.iconWidth
|
||||
Layout.preferredHeight: unifiedSearchResultItemDetails.iconWidth
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: unifiedSearchResultTextContainer
|
||||
Layout.fillWidth: true
|
||||
|
||||
Label {
|
||||
id: unifiedSearchResultTitleText
|
||||
text: title.replace(/[\r\n]+/g, " ")
|
||||
Layout.leftMargin: textLeftMargin
|
||||
Layout.rightMargin: textRightMargin
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: unifiedSearchResultItemDetails.titleFontSize
|
||||
color: unifiedSearchResultItemDetails.titleColor
|
||||
}
|
||||
Label {
|
||||
id: unifiedSearchResultTextSubline
|
||||
text: subline.replace(/[\r\n]+/g, " ")
|
||||
elide: Text.ElideRight
|
||||
font.pixelSize: unifiedSearchResultItemDetails.sublineFontSize
|
||||
Layout.leftMargin: textLeftMargin
|
||||
Layout.rightMargin: textRightMargin
|
||||
Layout.fillWidth: true
|
||||
color: unifiedSearchResultItemDetails.sublineColor
|
||||
}
|
||||
}
|
||||
|
||||
}
|
58
src/gui/tray/UnifiedSearchResultItemSkeleton.qml
Normal file
58
src/gui/tray/UnifiedSearchResultItemSkeleton.qml
Normal file
|
@ -0,0 +1,58 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Layouts 1.2
|
||||
import Style 1.0
|
||||
|
||||
RowLayout {
|
||||
id: unifiedSearchResultSkeletonItemDetails
|
||||
|
||||
property int textLeftMargin: 18
|
||||
property int textRightMargin: 16
|
||||
property int iconWidth: 24
|
||||
property int iconLeftMargin: 12
|
||||
|
||||
property int titleFontSize: Style.topLinePixelSize
|
||||
property int sublineFontSize: Style.subLinePixelSize
|
||||
|
||||
property string titleColor: "black"
|
||||
property string sublineColor: "grey"
|
||||
|
||||
property string iconColor: "#afafaf"
|
||||
|
||||
property int index: 0
|
||||
|
||||
Accessible.role: Accessible.ListItem
|
||||
Accessible.name: qsTr("Search result skeleton.").arg(index)
|
||||
|
||||
Rectangle {
|
||||
id: unifiedSearchResultSkeletonThumbnail
|
||||
color: unifiedSearchResultSkeletonItemDetails.iconColor
|
||||
Layout.preferredWidth: unifiedSearchResultSkeletonItemDetails.iconWidth
|
||||
Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.iconWidth
|
||||
Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.iconLeftMargin
|
||||
Layout.alignment: Qt.AlignVCenter | Qt.AlignHCenter
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: unifiedSearchResultSkeletonTextContainer
|
||||
Layout.fillWidth: true
|
||||
|
||||
Rectangle {
|
||||
id: unifiedSearchResultSkeletonTitleText
|
||||
color: unifiedSearchResultSkeletonItemDetails.titleColor
|
||||
Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.titleFontSize
|
||||
Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin
|
||||
Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: unifiedSearchResultSkeletonTextSubline
|
||||
color: unifiedSearchResultSkeletonItemDetails.sublineColor
|
||||
Layout.preferredHeight: unifiedSearchResultSkeletonItemDetails.sublineFontSize
|
||||
Layout.leftMargin: unifiedSearchResultSkeletonItemDetails.textLeftMargin
|
||||
Layout.rightMargin: unifiedSearchResultSkeletonItemDetails.textRightMargin
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
49
src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml
Normal file
49
src/gui/tray/UnifiedSearchResultItemSkeletonContainer.qml
Normal file
|
@ -0,0 +1,49 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.3
|
||||
import Style 1.0
|
||||
|
||||
Column {
|
||||
id: unifiedSearchResultsListViewSkeletonColumn
|
||||
|
||||
property int textLeftMargin: 18
|
||||
property int textRightMargin: 16
|
||||
property int iconWidth: 24
|
||||
property int iconLeftMargin: 12
|
||||
property int itemHeight: Style.trayWindowHeaderHeight
|
||||
property int titleFontSize: Style.topLinePixelSize
|
||||
property int sublineFontSize: Style.subLinePixelSize
|
||||
property string titleColor: "black"
|
||||
property string sublineColor: "grey"
|
||||
property string iconColor: "#afafaf"
|
||||
|
||||
Repeater {
|
||||
model: 10
|
||||
UnifiedSearchResultItemSkeleton {
|
||||
textLeftMargin: unifiedSearchResultsListViewSkeletonColumn.textLeftMargin
|
||||
textRightMargin: unifiedSearchResultsListViewSkeletonColumn.textRightMargin
|
||||
iconWidth: unifiedSearchResultsListViewSkeletonColumn.iconWidth
|
||||
iconLeftMargin: unifiedSearchResultsListViewSkeletonColumn.iconLeftMargin
|
||||
width: unifiedSearchResultsListViewSkeletonColumn.width
|
||||
height: unifiedSearchResultsListViewSkeletonColumn.itemHeight
|
||||
index: model.index
|
||||
titleFontSize: unifiedSearchResultsListViewSkeletonColumn.titleFontSize
|
||||
sublineFontSize: unifiedSearchResultsListViewSkeletonColumn.sublineFontSize
|
||||
titleColor: unifiedSearchResultsListViewSkeletonColumn.titleColor
|
||||
sublineColor: unifiedSearchResultsListViewSkeletonColumn.sublineColor
|
||||
iconColor: unifiedSearchResultsListViewSkeletonColumn.iconColor
|
||||
}
|
||||
}
|
||||
|
||||
OpacityAnimator {
|
||||
target: unifiedSearchResultsListViewSkeletonColumn;
|
||||
from: 0.5;
|
||||
to: 1;
|
||||
duration: 800
|
||||
running: unifiedSearchResultsListViewSkeletonColumn.visible
|
||||
loops: Animation.Infinite;
|
||||
easing {
|
||||
type: Easing.InOutBounce;
|
||||
}
|
||||
}
|
||||
}
|
87
src/gui/tray/UnifiedSearchResultListItem.qml
Normal file
87
src/gui/tray/UnifiedSearchResultListItem.qml
Normal file
|
@ -0,0 +1,87 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.3
|
||||
import Style 1.0
|
||||
|
||||
MouseArea {
|
||||
id: unifiedSearchResultMouseArea
|
||||
|
||||
property int textLeftMargin: 18
|
||||
property int textRightMargin: 16
|
||||
property int iconWidth: 24
|
||||
property int iconLeftMargin: 12
|
||||
|
||||
property int titleFontSize: Style.topLinePixelSize
|
||||
property int sublineFontSize: Style.subLinePixelSize
|
||||
|
||||
property string titleColor: "black"
|
||||
property string sublineColor: "grey"
|
||||
|
||||
property string currentFetchMoreInProgressProviderId: ""
|
||||
|
||||
readonly property bool isFetchMoreTrigger: model.typeAsString === "FetchMoreTrigger"
|
||||
|
||||
property bool isFetchMoreInProgress: currentFetchMoreInProgressProviderId === model.providerId
|
||||
property bool isSearchInProgress: false
|
||||
|
||||
property bool isPooled: false
|
||||
|
||||
property var fetchMoreTriggerClicked: function(){}
|
||||
property var resultClicked: function(){}
|
||||
|
||||
enabled: !isFetchMoreTrigger || !isSearchInProgress
|
||||
hoverEnabled: enabled
|
||||
|
||||
ToolTip {
|
||||
visible: unifiedSearchResultMouseArea.containsMouse
|
||||
text: isFetchMoreTrigger ? qsTr("Load more results") : model.resultTitle + "\n\n" + model.subline
|
||||
delay: Qt.styleHints.mousePressAndHoldInterval
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: unifiedSearchResultHoverBackground
|
||||
anchors.fill: parent
|
||||
color: (parent.containsMouse ? Style.lightHover : "transparent")
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: !isFetchMoreTrigger
|
||||
sourceComponent: UnifiedSearchResultItem {
|
||||
width: unifiedSearchResultMouseArea.width
|
||||
height: unifiedSearchResultMouseArea.height
|
||||
title: model.resultTitle
|
||||
subline: model.subline
|
||||
icons: model.icons
|
||||
iconPlaceholder: model.imagePlaceholder
|
||||
isRounded: model.isRounded
|
||||
textLeftMargin: unifiedSearchResultMouseArea.textLeftMargin
|
||||
textRightMargin: unifiedSearchResultMouseArea.textRightMargin
|
||||
iconWidth: unifiedSearchResultMouseArea.iconWidth
|
||||
iconLeftMargin: unifiedSearchResultMouseArea.iconLeftMargin
|
||||
titleFontSize: unifiedSearchResultMouseArea.titleFontSize
|
||||
sublineFontSize: unifiedSearchResultMouseArea.sublineFontSize
|
||||
titleColor: unifiedSearchResultMouseArea.titleColor
|
||||
sublineColor: unifiedSearchResultMouseArea.sublineColor
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
active: isFetchMoreTrigger
|
||||
sourceComponent: UnifiedSearchResultFetchMoreTrigger {
|
||||
isFetchMoreInProgress: unifiedSearchResultMouseArea.isFetchMoreInProgress
|
||||
width: unifiedSearchResultMouseArea.width
|
||||
height: unifiedSearchResultMouseArea.height
|
||||
isWihinViewPort: !unifiedSearchResultMouseArea.isPooled
|
||||
fontSize: unifiedSearchResultMouseArea.titleFontSize
|
||||
textColor: unifiedSearchResultMouseArea.sublineColor
|
||||
}
|
||||
}
|
||||
|
||||
onClicked: {
|
||||
if (isFetchMoreTrigger) {
|
||||
unifiedSearchResultMouseArea.fetchMoreTriggerClicked(model.providerId)
|
||||
} else {
|
||||
unifiedSearchResultMouseArea.resultClicked(model.providerId, model.resourceUrlRole)
|
||||
}
|
||||
}
|
||||
}
|
47
src/gui/tray/UnifiedSearchResultNothingFound.qml
Normal file
47
src/gui/tray/UnifiedSearchResultNothingFound.qml
Normal file
|
@ -0,0 +1,47 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import Style 1.0
|
||||
|
||||
ColumnLayout {
|
||||
id: unifiedSearchResultNothingFoundContainer
|
||||
|
||||
required property string text
|
||||
|
||||
spacing: 8
|
||||
anchors.leftMargin: 10
|
||||
anchors.rightMargin: 10
|
||||
|
||||
Image {
|
||||
id: unifiedSearchResultsNoResultsLabelIcon
|
||||
source: "qrc:///client/theme/magnifying-glass.svg"
|
||||
sourceSize.width: Style.trayWindowHeaderHeight / 2
|
||||
sourceSize.height: Style.trayWindowHeaderHeight / 2
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
id: unifiedSearchResultsNoResultsLabel
|
||||
text: qsTr("No results for")
|
||||
color: Style.menuBorder
|
||||
font.pixelSize: Style.subLinePixelSize * 1.25
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
|
||||
Label {
|
||||
id: unifiedSearchResultsNoResultsLabelDetails
|
||||
text: unifiedSearchResultNothingFoundContainer.text
|
||||
color: "black"
|
||||
font.pixelSize: Style.topLinePixelSize * 1.25
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Style.trayWindowHeaderHeight / 2
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
}
|
20
src/gui/tray/UnifiedSearchResultSectionItem.qml
Normal file
20
src/gui/tray/UnifiedSearchResultSectionItem.qml
Normal file
|
@ -0,0 +1,20 @@
|
|||
import QtQml 2.15
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Controls 2.15
|
||||
import QtQuick.Layouts 1.2
|
||||
import Style 1.0
|
||||
|
||||
Label {
|
||||
required property string section
|
||||
|
||||
topPadding: 8
|
||||
bottomPadding: 8
|
||||
leftPadding: 16
|
||||
|
||||
text: section
|
||||
font.pixelSize: Style.topLinePixelSize
|
||||
color: Style.ncBlue
|
||||
|
||||
Accessible.role: Accessible.Separator
|
||||
Accessible.name: qsTr("Search results section %1").arg(section)
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
#include "syncfileitem.h"
|
||||
#include "tray/ActivityListModel.h"
|
||||
#include "tray/NotificationCache.h"
|
||||
#include "tray/unifiedsearchresultslistmodel.h"
|
||||
#include "userstatusconnector.h"
|
||||
|
||||
#include <QDesktopServices>
|
||||
|
@ -38,7 +39,8 @@ User::User(AccountStatePtr &account, const bool &isCurrent, QObject *parent)
|
|||
: QObject(parent)
|
||||
, _account(account)
|
||||
, _isCurrentUser(isCurrent)
|
||||
, _activityModel(new ActivityListModel(_account.data()))
|
||||
, _activityModel(new ActivityListModel(_account.data(), this))
|
||||
, _unifiedSearchResultsModel(new UnifiedSearchResultsListModel(_account.data(), this))
|
||||
, _notificationRequestsRunning(0)
|
||||
{
|
||||
connect(ProgressDispatcher::instance(), &ProgressDispatcher::progressInfo,
|
||||
|
@ -589,6 +591,11 @@ ActivityListModel *User::getActivityModel()
|
|||
return _activityModel;
|
||||
}
|
||||
|
||||
UnifiedSearchResultsListModel *User::getUnifiedSearchResultsListModel() const
|
||||
{
|
||||
return _unifiedSearchResultsModel;
|
||||
}
|
||||
|
||||
void User::openLocalFolder()
|
||||
{
|
||||
const auto folder = getFolder();
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
#include <chrono>
|
||||
|
||||
namespace OCC {
|
||||
class UnifiedSearchResultsListModel;
|
||||
|
||||
class User : public QObject
|
||||
{
|
||||
|
@ -33,6 +34,7 @@ class User : public QObject
|
|||
Q_PROPERTY(bool serverHasTalk READ serverHasTalk NOTIFY serverHasTalkChanged)
|
||||
Q_PROPERTY(QString avatar READ avatarUrl NOTIFY avatarChanged)
|
||||
Q_PROPERTY(bool isConnected READ isConnected NOTIFY accountStateChanged)
|
||||
Q_PROPERTY(UnifiedSearchResultsListModel* unifiedSearchResultsListModel READ getUnifiedSearchResultsListModel CONSTANT)
|
||||
public:
|
||||
User(AccountStatePtr &account, const bool &isCurrent = false, QObject *parent = nullptr);
|
||||
|
||||
|
@ -44,6 +46,7 @@ public:
|
|||
void setCurrentUser(const bool &isCurrent);
|
||||
Folder *getFolder() const;
|
||||
ActivityListModel *getActivityModel();
|
||||
UnifiedSearchResultsListModel *getUnifiedSearchResultsListModel() const;
|
||||
void openLocalFolder();
|
||||
QString name() const;
|
||||
QString server(bool shortened = true) const;
|
||||
|
@ -113,6 +116,7 @@ private:
|
|||
AccountStatePtr _account;
|
||||
bool _isCurrentUser;
|
||||
ActivityListModel *_activityModel;
|
||||
UnifiedSearchResultsListModel *_unifiedSearchResultsModel;
|
||||
ActivityList _blacklistedNotifications;
|
||||
|
||||
QTimer _expiredActivitiesCheckTimer;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import QtQml 2.12
|
||||
import QtQml.Models 2.1
|
||||
import QtQuick 2.9
|
||||
import QtQuick 2.15
|
||||
import QtQuick.Window 2.3
|
||||
import QtQuick.Controls 2.3
|
||||
import QtQuick.Layouts 1.2
|
||||
import QtGraphicalEffects 1.0
|
||||
import "../"
|
||||
|
||||
// Custom qml modules are in /theme (and included by resources.qrc)
|
||||
import Style 1.0
|
||||
|
@ -101,6 +102,11 @@ Window {
|
|||
Rectangle {
|
||||
id: trayWindowBackground
|
||||
|
||||
property bool isUnifiedSearchActive: unifiedSearchResultsListViewSkeleton.visible
|
||||
|| unifiedSearchResultNothingFound.visible
|
||||
|| unifiedSearchResultsErrorLabel.visible
|
||||
|| unifiedSearchResultsListView.visible
|
||||
|
||||
anchors.fill: parent
|
||||
radius: Systray.useNormalWindow ? 0.0 : Style.trayWindowRadius
|
||||
border.width: Style.trayWindowBorderWidth
|
||||
|
@ -496,8 +502,6 @@ Window {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Accessible.role: Accessible.Button
|
||||
Accessible.name: qsTr("Open local folder of current account")
|
||||
}
|
||||
|
@ -566,15 +570,153 @@ Window {
|
|||
}
|
||||
} // Rectangle trayWindowHeaderBackground
|
||||
|
||||
UnifiedSearchInputContainer {
|
||||
id: trayWindowUnifiedSearchInputContainer
|
||||
height: Style.trayWindowHeaderHeight * 0.65
|
||||
|
||||
anchors {
|
||||
top: trayWindowHeaderBackground.bottom
|
||||
left: trayWindowBackground.left
|
||||
right: trayWindowBackground.right
|
||||
|
||||
margins: {
|
||||
top: 10
|
||||
}
|
||||
}
|
||||
|
||||
text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
|
||||
readOnly: !UserModel.currentUser.isConnected || UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId
|
||||
isSearchInProgress: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress
|
||||
onTextEdited: { UserModel.currentUser.unifiedSearchResultsListModel.searchTerm = trayWindowUnifiedSearchInputContainer.text }
|
||||
}
|
||||
|
||||
ErrorBox {
|
||||
id: unifiedSearchResultsErrorLabel
|
||||
visible: UserModel.currentUser.unifiedSearchResultsListModel.errorString && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress && ! UserModel.currentUser.unifiedSearchResultsListModel.currentFetchMoreInProgressProviderId
|
||||
text: UserModel.currentUser.unifiedSearchResultsListModel.errorString
|
||||
color: Style.errorBoxBackgroundColor
|
||||
backgroundColor: Style.errorBoxTextColor
|
||||
borderColor: "transparent"
|
||||
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
anchors.margins: 10
|
||||
}
|
||||
|
||||
UnifiedSearchResultNothingFound {
|
||||
id: unifiedSearchResultNothingFound
|
||||
visible: false
|
||||
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
anchors.topMargin: 10
|
||||
|
||||
text: UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
|
||||
|
||||
property bool isSearchRunning: UserModel.currentUser.unifiedSearchResultsListModel.isSearchInProgress
|
||||
property bool isSearchResultsEmpty: unifiedSearchResultsListView.count === 0
|
||||
property bool nothingFound: text && isSearchResultsEmpty && !UserModel.currentUser.unifiedSearchResultsListModel.errorString
|
||||
|
||||
onIsSearchRunningChanged: {
|
||||
if (unifiedSearchResultNothingFound.isSearchRunning) {
|
||||
visible = false;
|
||||
} else {
|
||||
if (nothingFound) {
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
visible = false;
|
||||
}
|
||||
|
||||
onIsSearchResultsEmptyChanged: {
|
||||
if (!unifiedSearchResultNothingFound.isSearchResultsEmpty) {
|
||||
visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UnifiedSearchResultItemSkeletonContainer {
|
||||
id: unifiedSearchResultsListViewSkeleton
|
||||
visible: !unifiedSearchResultNothingFound.visible && !unifiedSearchResultsListView.visible && ! UserModel.currentUser.unifiedSearchResultsListModel.errorString && UserModel.currentUser.unifiedSearchResultsListModel.searchTerm
|
||||
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
anchors.bottom: trayWindowBackground.bottom
|
||||
textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin
|
||||
textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin
|
||||
iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth
|
||||
iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin
|
||||
itemHeight: trayWindowBackground.Style.unifiedSearchItemHeight
|
||||
titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize
|
||||
sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize
|
||||
titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor
|
||||
sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor
|
||||
iconColor: "#afafaf"
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: unifiedSearchResultsListView
|
||||
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
anchors.bottom: trayWindowBackground.bottom
|
||||
spacing: 4
|
||||
visible: count > 0
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
id: unifiedSearchResultsListViewScrollbar
|
||||
}
|
||||
|
||||
keyNavigationEnabled: true
|
||||
|
||||
reuseItems: true
|
||||
|
||||
Accessible.role: Accessible.List
|
||||
Accessible.name: qsTr("Unified search results list")
|
||||
|
||||
model: UserModel.currentUser.unifiedSearchResultsListModel
|
||||
|
||||
delegate: UnifiedSearchResultListItem {
|
||||
width: unifiedSearchResultsListView.width
|
||||
height: trayWindowBackground.Style.unifiedSearchItemHeight
|
||||
isSearchInProgress: unifiedSearchResultsListView.model.isSearchInProgress
|
||||
textLeftMargin: trayWindowBackground.Style.unifiedSearchResultTextLeftMargin
|
||||
textRightMargin: trayWindowBackground.Style.unifiedSearchResultTextRightMargin
|
||||
iconWidth: trayWindowBackground.Style.unifiedSearchResulIconWidth
|
||||
iconLeftMargin: trayWindowBackground.Style.unifiedSearchResulIconLeftMargin
|
||||
titleFontSize: trayWindowBackground.Style.unifiedSearchResulTitleFontSize
|
||||
sublineFontSize: trayWindowBackground.Style.unifiedSearchResulSublineFontSize
|
||||
titleColor: trayWindowBackground.Style.unifiedSearchResulTitleColor
|
||||
sublineColor: trayWindowBackground.Style.unifiedSearchResulSublineColor
|
||||
currentFetchMoreInProgressProviderId: unifiedSearchResultsListView.model.currentFetchMoreInProgressProviderId
|
||||
fetchMoreTriggerClicked: unifiedSearchResultsListView.model.fetchMoreTriggerClicked
|
||||
resultClicked: unifiedSearchResultsListView.model.resultClicked
|
||||
ListView.onPooled: isPooled = true
|
||||
ListView.onReused: isPooled = false
|
||||
}
|
||||
|
||||
section.property: "providerName"
|
||||
section.criteria: ViewSection.FullString
|
||||
section.delegate: UnifiedSearchResultSectionItem {
|
||||
width: unifiedSearchResultsListView.width
|
||||
}
|
||||
}
|
||||
|
||||
SyncStatus {
|
||||
id: syncStatus
|
||||
|
||||
anchors.top: trayWindowHeaderBackground.bottom
|
||||
visible: !trayWindowBackground.isUnifiedSearchActive
|
||||
|
||||
anchors.top: trayWindowUnifiedSearchInputContainer.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
}
|
||||
|
||||
ActivityList {
|
||||
visible: !trayWindowBackground.isUnifiedSearchActive
|
||||
anchors.top: syncStatus.bottom
|
||||
anchors.left: trayWindowBackground.left
|
||||
anchors.right: trayWindowBackground.right
|
||||
|
|
36
src/gui/tray/unifiedsearchresult.cpp
Normal file
36
src/gui/tray/unifiedsearchresult.cpp
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#include <QtCore>
|
||||
|
||||
#include "unifiedsearchresult.h"
|
||||
|
||||
namespace OCC {
|
||||
|
||||
QString UnifiedSearchResult::typeAsString(UnifiedSearchResult::Type type)
|
||||
{
|
||||
QString result;
|
||||
|
||||
switch (type) {
|
||||
case Default:
|
||||
result = QStringLiteral("Default");
|
||||
break;
|
||||
|
||||
case FetchMoreTrigger:
|
||||
result = QStringLiteral("FetchMoreTrigger");
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
48
src/gui/tray/unifiedsearchresult.h
Normal file
48
src/gui/tray/unifiedsearchresult.h
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include <QtCore>
|
||||
|
||||
namespace OCC {
|
||||
|
||||
/**
|
||||
* @brief The UnifiedSearchResult class
|
||||
* @ingroup gui
|
||||
* Simple data structure that represents single Unified Search result
|
||||
*/
|
||||
|
||||
struct UnifiedSearchResult
|
||||
{
|
||||
enum Type : quint8 {
|
||||
Default,
|
||||
FetchMoreTrigger,
|
||||
};
|
||||
|
||||
static QString typeAsString(UnifiedSearchResult::Type type);
|
||||
|
||||
QString _title;
|
||||
QString _subline;
|
||||
QString _providerId;
|
||||
QString _providerName;
|
||||
bool _isRounded = false;
|
||||
qint32 _order = std::numeric_limits<qint32>::max();
|
||||
QUrl _resourceUrl;
|
||||
QString _icons;
|
||||
Type _type = Type::Default;
|
||||
};
|
||||
}
|
131
src/gui/tray/unifiedsearchresultimageprovider.cpp
Normal file
131
src/gui/tray/unifiedsearchresultimageprovider.cpp
Normal file
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#include "unifiedsearchresultimageprovider.h"
|
||||
|
||||
#include "UserModel.h"
|
||||
|
||||
#include <QImage>
|
||||
#include <QPainter>
|
||||
#include <QSvgRenderer>
|
||||
|
||||
namespace {
|
||||
class AsyncImageResponse : public QQuickImageResponse
|
||||
{
|
||||
public:
|
||||
AsyncImageResponse(const QString &id, const QSize &requestedSize)
|
||||
{
|
||||
if (id.isEmpty()) {
|
||||
setImageAndEmitFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
_imagePaths = id.split(QLatin1Char(';'), Qt::SkipEmptyParts);
|
||||
_requestedImageSize = requestedSize;
|
||||
|
||||
if (_imagePaths.isEmpty()) {
|
||||
setImageAndEmitFinished();
|
||||
} else {
|
||||
processNextImage();
|
||||
}
|
||||
}
|
||||
|
||||
void setImageAndEmitFinished(const QImage &image = {})
|
||||
{
|
||||
_image = image;
|
||||
emit finished();
|
||||
}
|
||||
|
||||
QQuickTextureFactory *textureFactory() const override
|
||||
{
|
||||
return QQuickTextureFactory::textureFactoryForImage(_image);
|
||||
}
|
||||
|
||||
private:
|
||||
void processNextImage()
|
||||
{
|
||||
if (_index < 0 || _index >= _imagePaths.size()) {
|
||||
setImageAndEmitFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_imagePaths.at(_index).startsWith(QStringLiteral(":/client"))) {
|
||||
setImageAndEmitFinished(QIcon(_imagePaths.at(_index)).pixmap(_requestedImageSize).toImage());
|
||||
return;
|
||||
}
|
||||
|
||||
const auto currentUser = OCC::UserModel::instance()->currentUser();
|
||||
if (currentUser && currentUser->account()) {
|
||||
const QUrl iconUrl(_imagePaths.at(_index));
|
||||
if (iconUrl.isValid() && !iconUrl.scheme().isEmpty()) {
|
||||
// fetch the remote resource
|
||||
const auto reply = currentUser->account()->sendRawRequest(QByteArrayLiteral("GET"), iconUrl);
|
||||
connect(reply, &QNetworkReply::finished, this, &AsyncImageResponse::slotProcessNetworkReply);
|
||||
++_index;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setImageAndEmitFinished();
|
||||
}
|
||||
|
||||
private slots:
|
||||
void slotProcessNetworkReply()
|
||||
{
|
||||
const auto reply = qobject_cast<QNetworkReply *>(sender());
|
||||
if (!reply) {
|
||||
setImageAndEmitFinished();
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray imageData = reply->readAll();
|
||||
// server returns "[]" for some some file previews (have no idea why), so, we use another image
|
||||
// from the list if available
|
||||
if (imageData.isEmpty() || imageData == QByteArrayLiteral("[]")) {
|
||||
processNextImage();
|
||||
} else {
|
||||
if (imageData.startsWith(QByteArrayLiteral("<svg"))) {
|
||||
// SVG image needs proper scaling, let's do it with QPainter and QSvgRenderer
|
||||
QSvgRenderer svgRenderer;
|
||||
if (svgRenderer.load(imageData)) {
|
||||
QImage scaledSvg(_requestedImageSize, QImage::Format_ARGB32);
|
||||
scaledSvg.fill("transparent");
|
||||
QPainter painterForSvg(&scaledSvg);
|
||||
svgRenderer.render(&painterForSvg);
|
||||
setImageAndEmitFinished(scaledSvg);
|
||||
return;
|
||||
} else {
|
||||
processNextImage();
|
||||
}
|
||||
} else {
|
||||
setImageAndEmitFinished(QImage::fromData(imageData));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QImage _image;
|
||||
QStringList _imagePaths;
|
||||
QSize _requestedImageSize;
|
||||
int _index = 0;
|
||||
};
|
||||
}
|
||||
|
||||
namespace OCC {
|
||||
|
||||
QQuickImageResponse *UnifiedSearchResultImageProvider::requestImageResponse(const QString &id, const QSize &requestedSize)
|
||||
{
|
||||
return new AsyncImageResponse(id, requestedSize);
|
||||
}
|
||||
|
||||
}
|
33
src/gui/tray/unifiedsearchresultimageprovider.h
Normal file
33
src/gui/tray/unifiedsearchresultimageprovider.h
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QtCore>
|
||||
#include <QQuickImageProvider>
|
||||
|
||||
namespace OCC {
|
||||
|
||||
/**
|
||||
* @brief The UnifiedSearchResultImageProvider
|
||||
* @ingroup gui
|
||||
* Allows to fetch Unified Search result icon from the server or used a local resource
|
||||
*/
|
||||
|
||||
class UnifiedSearchResultImageProvider : public QQuickAsyncImageProvider
|
||||
{
|
||||
public:
|
||||
QQuickImageResponse *requestImageResponse(const QString &id, const QSize &requestedSize) override;
|
||||
};
|
||||
}
|
708
src/gui/tray/unifiedsearchresultslistmodel.cpp
Normal file
708
src/gui/tray/unifiedsearchresultslistmodel.cpp
Normal file
|
@ -0,0 +1,708 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#include "unifiedsearchresultslistmodel.h"
|
||||
|
||||
#include "account.h"
|
||||
#include "accountstate.h"
|
||||
#include "guiutility.h"
|
||||
#include "folderman.h"
|
||||
#include "networkjobs.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QDesktopServices>
|
||||
|
||||
namespace {
|
||||
QString imagePlaceholderUrlForProviderId(const QString &providerId)
|
||||
{
|
||||
if (providerId.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|
||||
|| providerId.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/wizard-talk.svg");
|
||||
} else if (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/edit.svg");
|
||||
} else if (providerId.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/deck.svg");
|
||||
} else if (providerId.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/calendar.svg");
|
||||
} else if (providerId.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/email.svg");
|
||||
} else if (providerId.contains(QStringLiteral("comment"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral("qrc:///client/theme/black/comment.svg");
|
||||
}
|
||||
|
||||
return QStringLiteral("qrc:///client/theme/change.svg");
|
||||
}
|
||||
|
||||
QString localIconPathFromIconPrefix(const QString &iconNameWithPrefix)
|
||||
{
|
||||
if (iconNameWithPrefix.contains(QStringLiteral("message"), Qt::CaseInsensitive)
|
||||
|| iconNameWithPrefix.contains(QStringLiteral("talk"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/wizard-talk.svg");
|
||||
} else if (iconNameWithPrefix.contains(QStringLiteral("folder"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/folder.svg");
|
||||
} else if (iconNameWithPrefix.contains(QStringLiteral("deck"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/deck.svg");
|
||||
} else if (iconNameWithPrefix.contains(QStringLiteral("contacts"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/wizard-groupware.svg");
|
||||
} else if (iconNameWithPrefix.contains(QStringLiteral("calendar"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/calendar.svg");
|
||||
} else if (iconNameWithPrefix.contains(QStringLiteral("mail"), Qt::CaseInsensitive)) {
|
||||
return QStringLiteral(":/client/theme/black/email.svg");
|
||||
}
|
||||
|
||||
return QStringLiteral(":/client/theme/change.svg");
|
||||
}
|
||||
|
||||
QString iconUrlForDefaultIconName(const QString &defaultIconName)
|
||||
{
|
||||
const QUrl urlForIcon{defaultIconName};
|
||||
|
||||
if (urlForIcon.isValid() && !urlForIcon.scheme().isEmpty()) {
|
||||
return defaultIconName;
|
||||
}
|
||||
|
||||
if (defaultIconName.startsWith(QStringLiteral("icon-"))) {
|
||||
const auto parts = defaultIconName.split(QLatin1Char('-'));
|
||||
|
||||
if (parts.size() > 1) {
|
||||
const QString iconFilePath = QStringLiteral(":/client/theme/") + parts[1] + QStringLiteral(".svg");
|
||||
|
||||
if (QFile::exists(iconFilePath)) {
|
||||
return iconFilePath;
|
||||
}
|
||||
|
||||
const QString blackIconFilePath = QStringLiteral(":/client/theme/black/") + parts[1] + QStringLiteral(".svg");
|
||||
|
||||
if (QFile::exists(blackIconFilePath)) {
|
||||
return blackIconFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
const auto iconNameFromIconPrefix = localIconPathFromIconPrefix(defaultIconName);
|
||||
|
||||
if (!iconNameFromIconPrefix.isEmpty()) {
|
||||
return iconNameFromIconPrefix;
|
||||
}
|
||||
}
|
||||
|
||||
return QStringLiteral(":/client/theme/change.svg");
|
||||
}
|
||||
|
||||
QString generateUrlForThumbnail(const QString &thumbnailUrl, const QUrl &serverUrl)
|
||||
{
|
||||
auto serverUrlCopy = serverUrl;
|
||||
auto thumbnailUrlCopy = thumbnailUrl;
|
||||
|
||||
if (thumbnailUrlCopy.startsWith(QLatin1Char('/')) || thumbnailUrlCopy.startsWith(QLatin1Char('\\'))) {
|
||||
// relative image resource URL, just needs some concatenation with current server URL
|
||||
// some icons may contain parameters after (?)
|
||||
const QStringList thumbnailUrlCopySplitted = thumbnailUrlCopy.contains(QLatin1Char('?'))
|
||||
? thumbnailUrlCopy.split(QLatin1Char('?'), Qt::SkipEmptyParts)
|
||||
: QStringList{thumbnailUrlCopy};
|
||||
Q_ASSERT(!thumbnailUrlCopySplitted.isEmpty());
|
||||
serverUrlCopy.setPath(thumbnailUrlCopySplitted[0]);
|
||||
thumbnailUrlCopy = serverUrlCopy.toString();
|
||||
if (thumbnailUrlCopySplitted.size() > 1) {
|
||||
thumbnailUrlCopy += QLatin1Char('?') + thumbnailUrlCopySplitted[1];
|
||||
}
|
||||
}
|
||||
|
||||
return thumbnailUrlCopy;
|
||||
}
|
||||
|
||||
QString generateUrlForIcon(const QString &fallackIcon, const QUrl &serverUrl)
|
||||
{
|
||||
auto serverUrlCopy = serverUrl;
|
||||
|
||||
auto fallackIconCopy = fallackIcon;
|
||||
|
||||
if (fallackIconCopy.startsWith(QLatin1Char('/')) || fallackIconCopy.startsWith(QLatin1Char('\\'))) {
|
||||
// relative image resource URL, just needs some concatenation with current server URL
|
||||
// some icons may contain parameters after (?)
|
||||
const QStringList fallackIconPathSplitted =
|
||||
fallackIconCopy.contains(QLatin1Char('?')) ? fallackIconCopy.split(QLatin1Char('?')) : QStringList{fallackIconCopy};
|
||||
Q_ASSERT(!fallackIconPathSplitted.isEmpty());
|
||||
serverUrlCopy.setPath(fallackIconPathSplitted[0]);
|
||||
fallackIconCopy = serverUrlCopy.toString();
|
||||
if (fallackIconPathSplitted.size() > 1) {
|
||||
fallackIconCopy += QLatin1Char('?') + fallackIconPathSplitted[1];
|
||||
}
|
||||
} else if (!fallackIconCopy.isEmpty()) {
|
||||
// could be one of names for standard icons (e.g. icon-mail)
|
||||
const auto defaultIconUrl = iconUrlForDefaultIconName(fallackIconCopy);
|
||||
if (!defaultIconUrl.isEmpty()) {
|
||||
fallackIconCopy = defaultIconUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return fallackIconCopy;
|
||||
}
|
||||
|
||||
QString iconsFromThumbnailAndFallbackIcon(const QString &thumbnailUrl, const QString &fallackIcon, const QUrl &serverUrl)
|
||||
{
|
||||
if (thumbnailUrl.isEmpty() && fallackIcon.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (serverUrl.isEmpty()) {
|
||||
const QStringList listImages = {thumbnailUrl, fallackIcon};
|
||||
return listImages.join(QLatin1Char(';'));
|
||||
}
|
||||
|
||||
const auto urlForThumbnail = generateUrlForThumbnail(thumbnailUrl, serverUrl);
|
||||
const auto urlForFallackIcon = generateUrlForIcon(fallackIcon, serverUrl);
|
||||
|
||||
if (urlForThumbnail.isEmpty() && !urlForFallackIcon.isEmpty()) {
|
||||
return urlForFallackIcon;
|
||||
}
|
||||
|
||||
if (!urlForThumbnail.isEmpty() && urlForFallackIcon.isEmpty()) {
|
||||
return urlForThumbnail;
|
||||
}
|
||||
|
||||
const QStringList listImages{urlForThumbnail, urlForFallackIcon};
|
||||
return listImages.join(QLatin1Char(';'));
|
||||
}
|
||||
|
||||
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)
|
||||
: QAbstractListModel(parent)
|
||||
, _accountState(accountState)
|
||||
{
|
||||
}
|
||||
|
||||
QVariant UnifiedSearchResultsListModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
Q_ASSERT(checkIndex(index, QAbstractItemModel::CheckIndexOption::IndexIsValid));
|
||||
|
||||
switch (role) {
|
||||
case ProviderNameRole:
|
||||
return _results.at(index.row())._providerName;
|
||||
case ProviderIdRole:
|
||||
return _results.at(index.row())._providerId;
|
||||
case ImagePlaceholderRole:
|
||||
return imagePlaceholderUrlForProviderId(_results.at(index.row())._providerId);
|
||||
case IconsRole:
|
||||
return _results.at(index.row())._icons;
|
||||
case TitleRole:
|
||||
return _results.at(index.row())._title;
|
||||
case SublineRole:
|
||||
return _results.at(index.row())._subline;
|
||||
case ResourceUrlRole:
|
||||
return _results.at(index.row())._resourceUrl;
|
||||
case RoundedRole:
|
||||
return _results.at(index.row())._isRounded;
|
||||
case TypeRole:
|
||||
return _results.at(index.row())._type;
|
||||
case TypeAsStringRole:
|
||||
return UnifiedSearchResult::typeAsString(_results.at(index.row())._type);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
int UnifiedSearchResultsListModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _results.size();
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> UnifiedSearchResultsListModel::roleNames() const
|
||||
{
|
||||
auto roles = QAbstractListModel::roleNames();
|
||||
roles[ProviderNameRole] = "providerName";
|
||||
roles[ProviderIdRole] = "providerId";
|
||||
roles[IconsRole] = "icons";
|
||||
roles[ImagePlaceholderRole] = "imagePlaceholder";
|
||||
roles[TitleRole] = "resultTitle";
|
||||
roles[SublineRole] = "subline";
|
||||
roles[ResourceUrlRole] = "resourceUrlRole";
|
||||
roles[TypeRole] = "type";
|
||||
roles[TypeAsStringRole] = "typeAsString";
|
||||
roles[RoundedRole] = "isRounded";
|
||||
return roles;
|
||||
}
|
||||
|
||||
QString UnifiedSearchResultsListModel::searchTerm() const
|
||||
{
|
||||
return _searchTerm;
|
||||
}
|
||||
|
||||
QString UnifiedSearchResultsListModel::errorString() const
|
||||
{
|
||||
return _errorString;
|
||||
}
|
||||
|
||||
QString UnifiedSearchResultsListModel::currentFetchMoreInProgressProviderId() const
|
||||
{
|
||||
return _currentFetchMoreInProgressProviderId;
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::setSearchTerm(const QString &term)
|
||||
{
|
||||
if (term == _searchTerm) {
|
||||
return;
|
||||
}
|
||||
|
||||
_searchTerm = term;
|
||||
emit searchTermChanged();
|
||||
|
||||
if (!_errorString.isEmpty()) {
|
||||
_errorString.clear();
|
||||
emit errorStringChanged();
|
||||
}
|
||||
|
||||
disconnectAndClearSearchJobs();
|
||||
|
||||
clearCurrentFetchMoreInProgressProviderId();
|
||||
|
||||
disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
||||
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
||||
|
||||
if (_unifiedSearchTextEditingFinishedTimer.isActive()) {
|
||||
_unifiedSearchTextEditingFinishedTimer.stop();
|
||||
}
|
||||
|
||||
if (!_searchTerm.isEmpty()) {
|
||||
_unifiedSearchTextEditingFinishedTimer.setInterval(searchTermEditingFinishedSearchStartDelay);
|
||||
connect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
||||
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
||||
_unifiedSearchTextEditingFinishedTimer.start();
|
||||
}
|
||||
|
||||
if (!_results.isEmpty()) {
|
||||
beginResetModel();
|
||||
_results.clear();
|
||||
endResetModel();
|
||||
}
|
||||
}
|
||||
|
||||
bool UnifiedSearchResultsListModel::isSearchInProgress() const
|
||||
{
|
||||
return !_searchJobConnections.isEmpty();
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::resultClicked(const QString &providerId, const QUrl &resourceUrl) const
|
||||
{
|
||||
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 (providerId.contains(QStringLiteral("file"), Qt::CaseInsensitive) && !dir.isEmpty() && !fileName.isEmpty()) {
|
||||
if (!_accountState || !_accountState->account()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const QString relativePath = dir + QLatin1Char('/') + fileName;
|
||||
const auto localFiles =
|
||||
FolderMan::instance()->findFileInLocalFolders(QFileInfo(relativePath).path(), _accountState->account());
|
||||
|
||||
if (!localFiles.isEmpty()) {
|
||||
QDesktopServices::openUrl(localFiles.constFirst());
|
||||
return;
|
||||
}
|
||||
}
|
||||
Utility::openBrowser(resourceUrl);
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::fetchMoreTriggerClicked(const QString &providerId)
|
||||
{
|
||||
if (isSearchInProgress() || !_currentFetchMoreInProgressProviderId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto providerInfo = _providers.value(providerId, {});
|
||||
|
||||
if (!providerInfo._id.isEmpty() && providerInfo._id == providerId && providerInfo._isPaginated) {
|
||||
// Load more items
|
||||
_currentFetchMoreInProgressProviderId = providerId;
|
||||
emit currentFetchMoreInProgressProviderIdChanged();
|
||||
startSearchForProvider(providerId, providerInfo._cursor);
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::slotSearchTermEditingFinished()
|
||||
{
|
||||
disconnect(&_unifiedSearchTextEditingFinishedTimer, &QTimer::timeout, this,
|
||||
&UnifiedSearchResultsListModel::slotSearchTermEditingFinished);
|
||||
|
||||
if (!_accountState || !_accountState->account()) {
|
||||
qCCritical(lcUnifiedSearch) << QString("Account state is invalid. Could not start search!");
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::slotFetchProvidersFinished(const QJsonDocument &json, int statusCode)
|
||||
{
|
||||
const auto job = qobject_cast<JsonApiJob *>(sender());
|
||||
|
||||
if (!job) {
|
||||
qCCritical(lcUnifiedSearch) << QString("Failed to fetch providers.").arg(_searchTerm);
|
||||
_errorString += tr("Failed to fetch providers.") + QLatin1Char('\n');
|
||||
emit errorStringChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusCode != 200) {
|
||||
qCCritical(lcUnifiedSearch) << QString("%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())
|
||||
+ QLatin1Char('\n');
|
||||
emit errorStringChanged();
|
||||
return;
|
||||
}
|
||||
const auto providerList =
|
||||
json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toVariant().toList();
|
||||
|
||||
for (const auto &provider : providerList) {
|
||||
const auto providerMap = provider.toMap();
|
||||
const auto id = providerMap[QStringLiteral("id")].toString();
|
||||
const auto name = providerMap[QStringLiteral("name")].toString();
|
||||
if (!name.isEmpty() && id != QStringLiteral("talk-message-current")) {
|
||||
UnifiedSearchProvider newProvider;
|
||||
newProvider._name = name;
|
||||
newProvider._id = id;
|
||||
newProvider._order = providerMap[QStringLiteral("order")].toInt();
|
||||
_providers.insert(newProvider._id, newProvider);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_providers.empty()) {
|
||||
startSearch();
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::slotSearchForProviderFinished(const QJsonDocument &json, int statusCode)
|
||||
{
|
||||
Q_ASSERT(_accountState && _accountState->account());
|
||||
|
||||
const auto job = qobject_cast<JsonApiJob *>(sender());
|
||||
|
||||
if (!job) {
|
||||
qCCritical(lcUnifiedSearch) << QString("Search has failed for '%2'.").arg(_searchTerm);
|
||||
_errorString += tr("Search has failed for '%2'.").arg(_searchTerm) + QLatin1Char('\n');
|
||||
emit errorStringChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto providerId = job->property("providerId").toString();
|
||||
|
||||
if (providerId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_searchJobConnections.isEmpty()) {
|
||||
_searchJobConnections.remove(providerId);
|
||||
|
||||
if (_searchJobConnections.isEmpty()) {
|
||||
emit isSearchInProgressChanged();
|
||||
}
|
||||
}
|
||||
|
||||
if (providerId == _currentFetchMoreInProgressProviderId) {
|
||||
clearCurrentFetchMoreInProgressProviderId();
|
||||
}
|
||||
|
||||
if (statusCode != 200) {
|
||||
qCCritical(lcUnifiedSearch) << QString("%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()) + QLatin1Char('\n');
|
||||
emit errorStringChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto data = json.object().value(QStringLiteral("ocs")).toObject().value(QStringLiteral("data")).toObject();
|
||||
if (!data.isEmpty()) {
|
||||
parseResultsForProvider(data, providerId, job->property("appendResults").toBool());
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::startSearch()
|
||||
{
|
||||
Q_ASSERT(_accountState && _accountState->account());
|
||||
|
||||
disconnectAndClearSearchJobs();
|
||||
|
||||
if (!_accountState || !_accountState->account()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_results.isEmpty()) {
|
||||
beginResetModel();
|
||||
_results.clear();
|
||||
endResetModel();
|
||||
}
|
||||
|
||||
for (const auto &provider : _providers) {
|
||||
startSearchForProvider(provider._id);
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::startSearchForProvider(const QString &providerId, qint32 cursor)
|
||||
{
|
||||
Q_ASSERT(_accountState && _accountState->account());
|
||||
|
||||
if (!_accountState || !_accountState->account()) {
|
||||
return;
|
||||
}
|
||||
|
||||
auto job = new JsonApiJob(_accountState->account(),
|
||||
QLatin1String("ocs/v2.php/search/providers/%1/search").arg(providerId));
|
||||
|
||||
QUrlQuery params;
|
||||
params.addQueryItem(QStringLiteral("term"), _searchTerm);
|
||||
if (cursor > 0) {
|
||||
params.addQueryItem(QStringLiteral("cursor"), QString::number(cursor));
|
||||
job->setProperty("appendResults", true);
|
||||
}
|
||||
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) {
|
||||
emit isSearchInProgressChanged();
|
||||
}
|
||||
job->start();
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::parseResultsForProvider(const QJsonObject &data, const QString &providerId, bool fetchedMore)
|
||||
{
|
||||
const auto cursor = data.value(QStringLiteral("cursor")).toInt();
|
||||
const auto entries = data.value(QStringLiteral("entries")).toVariant().toList();
|
||||
|
||||
auto &provider = _providers[providerId];
|
||||
|
||||
if (provider._id.isEmpty() && fetchedMore) {
|
||||
_providers.remove(providerId);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
provider._isPaginated = data.value(QStringLiteral("isPaginated")).toBool();
|
||||
provider._cursor = cursor;
|
||||
|
||||
if (provider._pageSize == -1) {
|
||||
provider._pageSize = cursor;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
QVector<UnifiedSearchResult> newEntries;
|
||||
|
||||
const auto makeResourceUrl = [](const QString &resourceUrl, const QUrl &accountUrl) {
|
||||
QUrl finalResurceUrl(resourceUrl);
|
||||
if (finalResurceUrl.scheme().isEmpty() && accountUrl.scheme().isEmpty()) {
|
||||
finalResurceUrl = accountUrl;
|
||||
finalResurceUrl.setPath(resourceUrl);
|
||||
}
|
||||
return finalResurceUrl;
|
||||
};
|
||||
|
||||
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(QStringLiteral("rounded")).toBool();
|
||||
result._title = entryMap.value(QStringLiteral("title")).toString();
|
||||
result._subline = entryMap.value(QStringLiteral("subline")).toString();
|
||||
|
||||
const auto resourceUrl = entryMap.value(QStringLiteral("resourceUrl")).toString();
|
||||
const auto accountUrl = (_accountState && _accountState->account()) ? _accountState->account()->url() : QUrl();
|
||||
|
||||
result._resourceUrl = makeResourceUrl(resourceUrl, accountUrl);
|
||||
result._icons = iconsFromThumbnailAndFallbackIcon(entryMap.value(QStringLiteral("thumbnailUrl")).toString(),
|
||||
entryMap.value(QStringLiteral("icon")).toString(), accountUrl);
|
||||
|
||||
newEntries.push_back(result);
|
||||
}
|
||||
|
||||
if (fetchedMore) {
|
||||
appendResultsToProvider(newEntries, provider);
|
||||
} else {
|
||||
appendResults(newEntries, provider);
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::appendResults(QVector<UnifiedSearchResult> results, const UnifiedSearchProvider &provider)
|
||||
{
|
||||
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 (_results.isEmpty()) {
|
||||
beginInsertRows({}, 0, results.size() - 1);
|
||||
_results = results;
|
||||
endInsertRows();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const auto first = static_cast<int>(std::distance(std::begin(_results), itToInsertTo));
|
||||
const auto last = first + results.size() - 1;
|
||||
|
||||
beginInsertRows({}, first, last);
|
||||
std::copy(std::begin(results), std::end(results), std::inserter(_results, itToInsertTo));
|
||||
endInsertRows();
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::appendResultsToProvider(const QVector<UnifiedSearchResult> &results, const UnifiedSearchProvider &provider)
|
||||
{
|
||||
if (results.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
if (itLastResultForProviderReverse != std::rend(_results)) {
|
||||
// #1 Insert rows
|
||||
// convert reverse_iterator to iterator
|
||||
const auto itLastResultForProvider = (itLastResultForProviderReverse + 1).base();
|
||||
const auto first = static_cast<int>(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();
|
||||
|
||||
// #2 Remove the FetchMoreTrigger item if there are no more results to load for this provider
|
||||
if (!provider._isPaginated) {
|
||||
removeFetchMoreTrigger(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::removeFetchMoreTrigger(const QString &providerId)
|
||||
{
|
||||
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<int>(std::distance(std::begin(_results), itFetchMoreTriggerForProvider));
|
||||
Q_ASSERT(eraseIndex >= 0 && eraseIndex < static_cast<int>(_results.size()));
|
||||
beginRemoveRows({}, eraseIndex, eraseIndex);
|
||||
_results.erase(itFetchMoreTriggerForProvider);
|
||||
endRemoveRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::disconnectAndClearSearchJobs()
|
||||
{
|
||||
for (const auto &connection : _searchJobConnections) {
|
||||
if (connection) {
|
||||
QObject::disconnect(connection);
|
||||
}
|
||||
}
|
||||
|
||||
if (!_searchJobConnections.isEmpty()) {
|
||||
_searchJobConnections.clear();
|
||||
emit isSearchInProgressChanged();
|
||||
}
|
||||
}
|
||||
|
||||
void UnifiedSearchResultsListModel::clearCurrentFetchMoreInProgressProviderId()
|
||||
{
|
||||
if (!_currentFetchMoreInProgressProviderId.isEmpty()) {
|
||||
_currentFetchMoreInProgressProviderId.clear();
|
||||
emit currentFetchMoreInProgressProviderIdChanged();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
129
src/gui/tray/unifiedsearchresultslistmodel.h
Normal file
129
src/gui/tray/unifiedsearchresultslistmodel.h
Normal file
|
@ -0,0 +1,129 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "unifiedsearchresult.h"
|
||||
|
||||
#include <limits>
|
||||
|
||||
#include <QtCore>
|
||||
|
||||
namespace OCC {
|
||||
class AccountState;
|
||||
|
||||
/**
|
||||
* @brief The UnifiedSearchResultsListModel
|
||||
* @ingroup gui
|
||||
* Simple list model to provide the list view with data for the Unified Search results.
|
||||
*/
|
||||
|
||||
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 errorString READ errorString NOTIFY errorStringChanged)
|
||||
Q_PROPERTY(QString searchTerm READ searchTerm WRITE setSearchTerm NOTIFY searchTermChanged)
|
||||
|
||||
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<qint32>::max(); // sorting order (smaller number has bigger priority)
|
||||
};
|
||||
|
||||
public:
|
||||
enum DataRole {
|
||||
ProviderNameRole = Qt::UserRole + 1,
|
||||
ProviderIdRole,
|
||||
ImagePlaceholderRole,
|
||||
IconsRole,
|
||||
TitleRole,
|
||||
SublineRole,
|
||||
ResourceUrlRole,
|
||||
RoundedRole,
|
||||
TypeRole,
|
||||
TypeAsStringRole,
|
||||
};
|
||||
|
||||
explicit UnifiedSearchResultsListModel(AccountState *accountState, QObject *parent = nullptr);
|
||||
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
|
||||
bool isSearchInProgress() const;
|
||||
|
||||
QString currentFetchMoreInProgressProviderId() const;
|
||||
QString searchTerm() const;
|
||||
QString errorString() const;
|
||||
|
||||
Q_INVOKABLE void resultClicked(const QString &providerId, const QUrl &resourceUrl) const;
|
||||
Q_INVOKABLE void fetchMoreTriggerClicked(const QString &providerId);
|
||||
|
||||
QHash<int, QByteArray> 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<UnifiedSearchResult> results, const UnifiedSearchProvider &provider);
|
||||
|
||||
// append pagination results to existing results from the initial search
|
||||
void appendResultsToProvider(const QVector<UnifiedSearchResult> &results, const UnifiedSearchProvider &provider);
|
||||
|
||||
void removeFetchMoreTrigger(const QString &providerId);
|
||||
|
||||
void disconnectAndClearSearchJobs();
|
||||
|
||||
void clearCurrentFetchMoreInProgressProviderId();
|
||||
|
||||
signals:
|
||||
void currentFetchMoreInProgressProviderIdChanged();
|
||||
void isSearchInProgressChanged();
|
||||
void errorStringChanged();
|
||||
void searchTermChanged();
|
||||
|
||||
public slots:
|
||||
void setSearchTerm(const QString &term);
|
||||
|
||||
private slots:
|
||||
void slotSearchTermEditingFinished();
|
||||
void slotFetchProvidersFinished(const QJsonDocument &json, int statusCode);
|
||||
void slotSearchForProviderFinished(const QJsonDocument &json, int statusCode);
|
||||
|
||||
private:
|
||||
QMap<QString, UnifiedSearchProvider> _providers;
|
||||
QVector<UnifiedSearchResult> _results;
|
||||
|
||||
QString _searchTerm;
|
||||
QString _errorString;
|
||||
|
||||
QString _currentFetchMoreInProgressProviderId;
|
||||
|
||||
QMap<QString, QMetaObject::Connection> _searchJobConnections;
|
||||
|
||||
QTimer _unifiedSearchTextEditingFinishedTimer;
|
||||
|
||||
AccountState *_accountState = nullptr;
|
||||
};
|
||||
}
|
|
@ -841,4 +841,19 @@ bool Theme::showVirtualFilesOption() const
|
|||
return ConfigFile().showExperimentalOptions() || vfsMode == Vfs::WindowsCfApi;
|
||||
}
|
||||
|
||||
QColor Theme::errorBoxTextColor() const
|
||||
{
|
||||
return QColor{"white"};
|
||||
}
|
||||
|
||||
QColor Theme::errorBoxBackgroundColor() const
|
||||
{
|
||||
return QColor{"red"};
|
||||
}
|
||||
|
||||
QColor Theme::errorBoxBorderColor() const
|
||||
{
|
||||
return QColor{"black"};
|
||||
}
|
||||
|
||||
} // end namespace client
|
||||
|
|
|
@ -61,6 +61,10 @@ class OWNCLOUDSYNC_EXPORT Theme : public QObject
|
|||
Q_PROPERTY(QColor wizardHeaderBackgroundColor READ wizardHeaderBackgroundColor CONSTANT)
|
||||
#endif
|
||||
Q_PROPERTY(QString updateCheckUrl READ updateCheckUrl CONSTANT)
|
||||
|
||||
Q_PROPERTY(QColor errorBoxTextColor READ errorBoxTextColor CONSTANT)
|
||||
Q_PROPERTY(QColor errorBoxBackgroundColor READ errorBoxBackgroundColor CONSTANT)
|
||||
Q_PROPERTY(QColor errorBoxBorderColor READ errorBoxBorderColor CONSTANT)
|
||||
public:
|
||||
enum CustomMediaType {
|
||||
oCSetupTop, // ownCloud connect page
|
||||
|
@ -547,6 +551,15 @@ public:
|
|||
*/
|
||||
virtual bool showVirtualFilesOption() const;
|
||||
|
||||
/** @return color for the ErrorBox text. */
|
||||
virtual QColor errorBoxTextColor() const;
|
||||
|
||||
/** @return color for the ErrorBox background. */
|
||||
virtual QColor errorBoxBackgroundColor() const;
|
||||
|
||||
/** @return color for the ErrorBox border. */
|
||||
virtual QColor errorBoxBorderColor() const;
|
||||
|
||||
static constexpr const char *themePrefix = ":/client/theme/";
|
||||
|
||||
protected:
|
||||
|
|
|
@ -60,6 +60,7 @@ nextcloud_add_test(Theme)
|
|||
nextcloud_add_test(IconUtils)
|
||||
nextcloud_add_test(NotificationCache)
|
||||
nextcloud_add_test(SetUserStatusDialog)
|
||||
nextcloud_add_test(UnifiedSearchListmodel)
|
||||
|
||||
if( UNIX AND NOT APPLE )
|
||||
nextcloud_add_test(InotifyWatcher)
|
||||
|
|
|
@ -709,6 +709,12 @@ void FakeChunkMoveReply::abort()
|
|||
}
|
||||
|
||||
FakePayloadReply::FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, QObject *parent)
|
||||
: FakePayloadReply(op, request, body, FakePayloadReply::defaultDelay, parent)
|
||||
{
|
||||
}
|
||||
|
||||
FakePayloadReply::FakePayloadReply(
|
||||
QNetworkAccessManager::Operation op, const QNetworkRequest &request, const QByteArray &body, int delay, QObject *parent)
|
||||
: FakeReply{parent}
|
||||
, _body(body)
|
||||
{
|
||||
|
@ -716,7 +722,7 @@ FakePayloadReply::FakePayloadReply(QNetworkAccessManager::Operation op, const QN
|
|||
setUrl(request.url());
|
||||
setOperation(op);
|
||||
open(QIODevice::ReadOnly);
|
||||
QTimer::singleShot(10, this, &FakePayloadReply::respond);
|
||||
QTimer::singleShot(delay, this, &FakePayloadReply::respond);
|
||||
}
|
||||
|
||||
void FakePayloadReply::respond()
|
||||
|
|
|
@ -316,12 +316,17 @@ public:
|
|||
FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request,
|
||||
const QByteArray &body, QObject *parent);
|
||||
|
||||
FakePayloadReply(QNetworkAccessManager::Operation op, const QNetworkRequest &request,
|
||||
const QByteArray &body, int delay, QObject *parent);
|
||||
|
||||
void respond();
|
||||
|
||||
void abort() override {}
|
||||
qint64 readData(char *buf, qint64 max) override;
|
||||
qint64 bytesAvailable() const override;
|
||||
QByteArray _body;
|
||||
|
||||
static const int defaultDelay = 10;
|
||||
};
|
||||
|
||||
|
||||
|
|
640
test/testunifiedsearchlistmodel.cpp
Normal file
640
test/testunifiedsearchlistmodel.cpp
Normal file
|
@ -0,0 +1,640 @@
|
|||
/*
|
||||
* Copyright (C) by Oleksandr Zolotov <alex@nextcloud.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
#include "gui/tray/unifiedsearchresultslistmodel.h"
|
||||
|
||||
#include "account.h"
|
||||
#include "accountstate.h"
|
||||
#include "syncenginetestutils.h"
|
||||
|
||||
#include <QAbstractItemModelTester>
|
||||
#include <QDesktopServices>
|
||||
#include <QSignalSpy>
|
||||
#include <QTest>
|
||||
|
||||
namespace {
|
||||
/**
|
||||
* @brief The FakeDesktopServicesUrlHandler
|
||||
* overrides QDesktopServices::openUrl
|
||||
**/
|
||||
class FakeDesktopServicesUrlHandler : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
FakeDesktopServicesUrlHandler(QObject *parent = nullptr)
|
||||
: QObject(parent)
|
||||
{}
|
||||
|
||||
public:
|
||||
signals:
|
||||
void resultClicked(const QUrl &url);
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief The FakeProvider
|
||||
* is a simple structure that represents initial list of providers and their properties
|
||||
**/
|
||||
class FakeProvider
|
||||
{
|
||||
public:
|
||||
QString _id;
|
||||
QString _name;
|
||||
qint32 _order = std::numeric_limits<qint32>::max();
|
||||
quint32 _numItemsToInsert = 5; // how many fake resuls to insert
|
||||
};
|
||||
|
||||
// this will be used when initializing fake search results data for each provider
|
||||
static const QVector<FakeProvider> fakeProvidersInitInfo = {
|
||||
{QStringLiteral("settings_apps"), QStringLiteral("Apps"), -50, 10},
|
||||
{QStringLiteral("talk-message"), QStringLiteral("Messages"), -2, 17},
|
||||
{QStringLiteral("files"), QStringLiteral("Files"), 5, 3},
|
||||
{QStringLiteral("deck"), QStringLiteral("Deck"), 10, 5},
|
||||
{QStringLiteral("comments"), QStringLiteral("Comments"), 10, 2},
|
||||
{QStringLiteral("mail"), QStringLiteral("Mails"), 10, 15},
|
||||
{QStringLiteral("calendar"), QStringLiteral("Events"), 30, 11}
|
||||
};
|
||||
|
||||
static QByteArray fake404Response = R"(
|
||||
{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Invalid query, please check the syntax. API specifications are here: http:\/\/www.freedesktop.org\/wiki\/Specifications\/open-collaboration-services.\n"},"data":[]}}
|
||||
)";
|
||||
|
||||
static QByteArray fake400Response = R"(
|
||||
{"ocs":{"meta":{"status":"failure","statuscode":400,"message":"Parameter is incorrect.\n"},"data":[]}}
|
||||
)";
|
||||
|
||||
static QByteArray fake500Response = R"(
|
||||
{"ocs":{"meta":{"status":"failure","statuscode":500,"message":"Internal Server Error.\n"},"data":[]}}
|
||||
)";
|
||||
|
||||
/**
|
||||
* @brief The FakeSearchResultsStorage
|
||||
* emulates the real server storage that contains all the results that UnifiedSearchListmodel will search for
|
||||
**/
|
||||
class FakeSearchResultsStorage
|
||||
{
|
||||
class Provider
|
||||
{
|
||||
public:
|
||||
class SearchResult
|
||||
{
|
||||
public:
|
||||
QString _thumbnailUrl;
|
||||
QString _title;
|
||||
QString _subline;
|
||||
QString _resourceUrl;
|
||||
QString _icon;
|
||||
bool _rounded;
|
||||
};
|
||||
|
||||
QString _id;
|
||||
QString _name;
|
||||
qint32 _order = std::numeric_limits<qint32>::max();
|
||||
qint32 _cursor = 0;
|
||||
bool _isPaginated = false;
|
||||
QVector<SearchResult> _results;
|
||||
};
|
||||
|
||||
FakeSearchResultsStorage() = default;
|
||||
|
||||
public:
|
||||
static FakeSearchResultsStorage *instance()
|
||||
{
|
||||
if (!_instance) {
|
||||
_instance = new FakeSearchResultsStorage();
|
||||
_instance->init();
|
||||
}
|
||||
|
||||
return _instance;
|
||||
};
|
||||
|
||||
static void destroy()
|
||||
{
|
||||
if (_instance) {
|
||||
delete _instance;
|
||||
}
|
||||
|
||||
_instance = nullptr;
|
||||
}
|
||||
|
||||
void init()
|
||||
{
|
||||
if (!_searchResultsData.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_metaSuccess = {{QStringLiteral("status"), QStringLiteral("ok")}, {QStringLiteral("statuscode"), 200},
|
||||
{QStringLiteral("message"), QStringLiteral("OK")}};
|
||||
|
||||
initProvidersResponse();
|
||||
|
||||
initSearchResultsData();
|
||||
}
|
||||
|
||||
// initialize the JSON response containing the fake list of providers and their properties
|
||||
void initProvidersResponse()
|
||||
{
|
||||
QList<QVariant> providersList;
|
||||
|
||||
for (const auto &fakeProviderInitInfo : fakeProvidersInitInfo) {
|
||||
providersList.push_back(QVariantMap{
|
||||
{QStringLiteral("id"), fakeProviderInitInfo._id},
|
||||
{QStringLiteral("name"), fakeProviderInitInfo._name},
|
||||
{QStringLiteral("order"), fakeProviderInitInfo._order},
|
||||
});
|
||||
}
|
||||
|
||||
const QVariantMap ocsMap = {
|
||||
{QStringLiteral("meta"), _metaSuccess},
|
||||
{QStringLiteral("data"), providersList}
|
||||
};
|
||||
|
||||
_providersResponse =
|
||||
QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
// init the map of fake search results for each provider
|
||||
void initSearchResultsData()
|
||||
{
|
||||
for (const auto &fakeProvider : fakeProvidersInitInfo) {
|
||||
auto &providerData = _searchResultsData[fakeProvider._id];
|
||||
providerData._id = fakeProvider._id;
|
||||
providerData._name = fakeProvider._name;
|
||||
providerData._order = fakeProvider._order;
|
||||
if (fakeProvider._numItemsToInsert > pageSize) {
|
||||
providerData._isPaginated = true;
|
||||
}
|
||||
for (quint32 i = 0; i < fakeProvider._numItemsToInsert; ++i) {
|
||||
providerData._results.push_back(
|
||||
{"http://example.de/avatar/john/64", QString(QStringLiteral("John Doe in ") + fakeProvider._name),
|
||||
QString(QStringLiteral("We a discussion about ") + fakeProvider._name
|
||||
+ QStringLiteral(" already. But, let's have a follow up tomorrow afternoon.")),
|
||||
"http://example.de/call/abcde12345#message_12345", QStringLiteral("icon-talk"), true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const QList<QVariant> resultsForProvider(const QString &providerId, int cursor)
|
||||
{
|
||||
QList<QVariant> list;
|
||||
|
||||
const auto results = resultsForProviderAsVector(providerId, cursor);
|
||||
|
||||
if (results.isEmpty()) {
|
||||
return list;
|
||||
}
|
||||
|
||||
for (const auto &result : results) {
|
||||
list.push_back(QVariantMap{
|
||||
{"thumbnailUrl", result._thumbnailUrl},
|
||||
{"title", result._title},
|
||||
{"subline", result._subline},
|
||||
{"resourceUrl", result._resourceUrl},
|
||||
{"icon", result._icon},
|
||||
{"rounded", result._rounded}
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
const QVector<Provider::SearchResult> resultsForProviderAsVector(const QString &providerId, int cursor)
|
||||
{
|
||||
QVector<Provider::SearchResult> results;
|
||||
|
||||
const auto provider = _searchResultsData.value(providerId, Provider());
|
||||
|
||||
if (provider._id.isEmpty() || cursor > provider._results.size()) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const int n = cursor + pageSize > provider._results.size()
|
||||
? 0
|
||||
: cursor + pageSize;
|
||||
|
||||
for (int i = cursor; i < n; ++i) {
|
||||
results.push_back(provider._results[i]);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const QByteArray queryProvider(const QString &providerId, const QString &searchTerm, int cursor)
|
||||
{
|
||||
if (!_searchResultsData.contains(providerId)) {
|
||||
return fake404Response;
|
||||
}
|
||||
|
||||
if (searchTerm == QStringLiteral("[HTTP500]")) {
|
||||
return fake500Response;
|
||||
}
|
||||
|
||||
if (searchTerm == QStringLiteral("[empty]")) {
|
||||
const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
|
||||
{QStringLiteral("isPaginated"), false}, {QStringLiteral("cursor"), 0},
|
||||
{QStringLiteral("entries"), QVariantList{}}};
|
||||
|
||||
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;
|
||||
|
||||
const QVariantMap dataMap = {{QStringLiteral("name"), _searchResultsData[providerId]._name},
|
||||
{QStringLiteral("isPaginated"), _searchResultsData[providerId]._isPaginated},
|
||||
{QStringLiteral("cursor"), nextCursor},
|
||||
{QStringLiteral("entries"), resultsForProvider(providerId, cursor)}};
|
||||
|
||||
const QVariantMap ocsMap = {{QStringLiteral("meta"), _metaSuccess}, {QStringLiteral("data"), dataMap}};
|
||||
|
||||
return QJsonDocument::fromVariant(QVariantMap{{QStringLiteral("ocs"), ocsMap}}).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
const QByteArray &fakeProvidersResponseJson() const { return _providersResponse; }
|
||||
|
||||
private:
|
||||
static FakeSearchResultsStorage *_instance;
|
||||
|
||||
static const int pageSize = 5;
|
||||
|
||||
QMap<QString, Provider> _searchResultsData;
|
||||
|
||||
QByteArray _providersResponse = fake404Response;
|
||||
|
||||
QVariantMap _metaSuccess;
|
||||
};
|
||||
|
||||
FakeSearchResultsStorage *FakeSearchResultsStorage::_instance = nullptr;
|
||||
|
||||
}
|
||||
|
||||
class TestUnifiedSearchListmodel : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
TestUnifiedSearchListmodel() = default;
|
||||
|
||||
QScopedPointer<FakeQNAM> fakeQnam;
|
||||
OCC::AccountPtr account;
|
||||
QScopedPointer<OCC::AccountState> accountState;
|
||||
QScopedPointer<OCC::UnifiedSearchResultsListModel> model;
|
||||
QScopedPointer<QAbstractItemModelTester> modelTester;
|
||||
|
||||
QScopedPointer<FakeDesktopServicesUrlHandler> fakeDesktopServicesUrlHandler;
|
||||
|
||||
static const int searchResultsReplyDelay = 100;
|
||||
|
||||
private slots:
|
||||
void initTestCase()
|
||||
{
|
||||
fakeQnam.reset(new FakeQNAM({}));
|
||||
account = OCC::Account::create();
|
||||
account->setCredentials(new FakeCredentials{fakeQnam.data()});
|
||||
account->setUrl(QUrl(("http://example.de")));
|
||||
|
||||
accountState.reset(new OCC::AccountState(account));
|
||||
|
||||
fakeQnam->setOverride([this](QNetworkAccessManager::Operation op, const QNetworkRequest &req, QIODevice *device) {
|
||||
Q_UNUSED(device);
|
||||
QNetworkReply *reply = nullptr;
|
||||
|
||||
const auto urlQuery = QUrlQuery(req.url());
|
||||
const auto format = urlQuery.queryItemValue(QStringLiteral("format"));
|
||||
const auto cursor = urlQuery.queryItemValue(QStringLiteral("cursor")).toInt();
|
||||
const auto searchTerm = urlQuery.queryItemValue(QStringLiteral("term"));
|
||||
const auto path = req.url().path();
|
||||
|
||||
if (!req.url().toString().startsWith(accountState->account()->url().toString())) {
|
||||
reply = new FakeErrorReply(op, req, this, 404, fake404Response);
|
||||
}
|
||||
if (format != QStringLiteral("json")) {
|
||||
reply = new FakeErrorReply(op, req, this, 400, fake400Response);
|
||||
}
|
||||
|
||||
// handle fetch of providers list
|
||||
if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && searchTerm.isEmpty()) {
|
||||
reply = new FakePayloadReply(op, req,
|
||||
FakeSearchResultsStorage::instance()->fakeProvidersResponseJson(), fakeQnam.data());
|
||||
// handle search for provider
|
||||
} else if (path.startsWith(QStringLiteral("/ocs/v2.php/search/providers")) && !searchTerm.isEmpty()) {
|
||||
const auto pathSplit = path.mid(QString(QStringLiteral("/ocs/v2.php/search/providers")).size())
|
||||
.split(QLatin1Char('/'), Qt::SkipEmptyParts);
|
||||
|
||||
if (!pathSplit.isEmpty() && path.contains(pathSplit.first())) {
|
||||
reply = new FakePayloadReply(op, req,
|
||||
FakeSearchResultsStorage::instance()->queryProvider(pathSplit.first(), searchTerm, cursor),
|
||||
searchResultsReplyDelay, fakeQnam.data());
|
||||
}
|
||||
}
|
||||
|
||||
if (!reply) {
|
||||
return qobject_cast<QNetworkReply*>(new FakeErrorReply(op, req, this, 404, QByteArrayLiteral("{error: \"Not found!\"}")));
|
||||
}
|
||||
|
||||
return reply;
|
||||
});
|
||||
|
||||
model.reset(new OCC::UnifiedSearchResultsListModel(accountState.data()));
|
||||
|
||||
modelTester.reset(new QAbstractItemModelTester(model.data()));
|
||||
|
||||
fakeDesktopServicesUrlHandler.reset(new FakeDesktopServicesUrlHandler);
|
||||
}
|
||||
void testSetSearchTermStartStopSearch()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
// #1 test setSearchTerm actually sets the search term and the signal is emitted
|
||||
QSignalSpy searhTermChanged(model.data(), &OCC::UnifiedSearchResultsListModel::searchTermChanged);
|
||||
model->setSearchTerm(QStringLiteral("dis"));
|
||||
QCOMPARE(searhTermChanged.count(), 1);
|
||||
QCOMPARE(model->searchTerm(), QStringLiteral("dis"));
|
||||
|
||||
// #2 test setSearchTerm actually sets the search term and the signal is emitted
|
||||
searhTermChanged.clear();
|
||||
model->setSearchTerm(model->searchTerm() + QStringLiteral("cuss"));
|
||||
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
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(!model->isSearchInProgress());
|
||||
}
|
||||
|
||||
void testSetSearchTermResultsFound()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
void testSetSearchTermResultsNotFound()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
void testFetchMoreClicked()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
QSignalSpy searchInProgressChanged(
|
||||
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
|
||||
|
||||
// test that search term gets set, search gets started and enough results get returned
|
||||
model->setSearchTerm(model->searchTerm() + QStringLiteral("whatever"));
|
||||
|
||||
QVERIFY(searchInProgressChanged.wait());
|
||||
|
||||
// make sure search has started
|
||||
QVERIFY(model->isSearchInProgress());
|
||||
|
||||
QVERIFY(searchInProgressChanged.wait());
|
||||
|
||||
// make sure search has finished
|
||||
QVERIFY(!model->isSearchInProgress());
|
||||
|
||||
const auto numRowsInModelPrev = model->rowCount();
|
||||
|
||||
// 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);
|
||||
|
||||
if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger) {
|
||||
const auto providerId =
|
||||
model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
|
||||
.toString();
|
||||
model->fetchMoreTriggerClicked(providerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
QCOMPARE(rowsRemoved.count(), 1);
|
||||
|
||||
bool isFetchMoreTriggerFound = false;
|
||||
|
||||
for (int i = 0; i < model->rowCount(); ++i) {
|
||||
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
|
||||
const auto providerId = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
|
||||
.toString();
|
||||
if (type == OCC::UnifiedSearchResult::Type::FetchMoreTrigger
|
||||
&& providerId == providerIdFetchMoreTriggered) {
|
||||
isFetchMoreTriggerFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
QVERIFY(!isFetchMoreTriggerFound);
|
||||
}
|
||||
}
|
||||
|
||||
void testSearchResultlicked()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
// 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);
|
||||
|
||||
QDesktopServices::setUrlHandler("http", fakeDesktopServicesUrlHandler.data(), "resultClicked");
|
||||
QDesktopServices::setUrlHandler("https", fakeDesktopServicesUrlHandler.data(), "resultClicked");
|
||||
|
||||
QSignalSpy resultClicked(fakeDesktopServicesUrlHandler.data(), &FakeDesktopServicesUrlHandler::resultClicked);
|
||||
|
||||
// test click on a result item
|
||||
QString urlForClickedResult;
|
||||
|
||||
for (int i = 0; i < model->rowCount(); ++i) {
|
||||
const auto type = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::TypeRole);
|
||||
|
||||
if (type == OCC::UnifiedSearchResult::Type::Default) {
|
||||
const auto providerId =
|
||||
model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ProviderIdRole)
|
||||
.toString();
|
||||
urlForClickedResult = model->data(model->index(i), OCC::UnifiedSearchResultsListModel::DataRole::ResourceUrlRole).toString();
|
||||
|
||||
if (!providerId.isEmpty() && !urlForClickedResult.isEmpty()) {
|
||||
model->resultClicked(providerId, QUrl(urlForClickedResult));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QCOMPARE(resultClicked.count(), 1);
|
||||
|
||||
const auto arguments = resultClicked.takeFirst();
|
||||
|
||||
const auto urlOpenTriggeredViaDesktopServices = arguments.at(0).toString();
|
||||
|
||||
QCOMPARE(urlOpenTriggeredViaDesktopServices, urlForClickedResult);
|
||||
}
|
||||
|
||||
void testSetSearchTermResultsError()
|
||||
{
|
||||
// make sure the model is empty
|
||||
model->setSearchTerm(QStringLiteral(""));
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
QSignalSpy errorStringChanged(model.data(), &OCC::UnifiedSearchResultsListModel::errorStringChanged);
|
||||
QSignalSpy searchInProgressChanged(
|
||||
model.data(), &OCC::UnifiedSearchResultsListModel::isSearchInProgressChanged);
|
||||
|
||||
model->setSearchTerm(model->searchTerm() + QStringLiteral("[HTTP500]"));
|
||||
|
||||
QVERIFY(searchInProgressChanged.wait());
|
||||
|
||||
// make sure search has started
|
||||
QVERIFY(model->isSearchInProgress());
|
||||
|
||||
QVERIFY(searchInProgressChanged.wait());
|
||||
|
||||
// make sure search has finished
|
||||
QVERIFY(!model->isSearchInProgress());
|
||||
|
||||
// make sure the model is empty and an error string has been set
|
||||
QVERIFY(model->rowCount() == 0);
|
||||
|
||||
QVERIFY(errorStringChanged.count() > 0);
|
||||
|
||||
QVERIFY(!model->errorString().isEmpty());
|
||||
}
|
||||
|
||||
void cleanupTestCase()
|
||||
{
|
||||
FakeSearchResultsStorage::destroy();
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestUnifiedSearchListmodel)
|
||||
#include "testunifiedsearchlistmodel.moc"
|
|
@ -44,6 +44,9 @@
|
|||
<file>theme/white/state-sync-64.png</file>
|
||||
<file>theme/white/state-sync-128.png</file>
|
||||
<file>theme/white/state-sync-256.png</file>
|
||||
<file>theme/black/clear.svg</file>
|
||||
<file>theme/black/comment.svg</file>
|
||||
<file>theme/black/search.svg</file>
|
||||
<file>theme/black/state-error-32.png</file>
|
||||
<file>theme/black/state-error-64.png</file>
|
||||
<file>theme/black/state-error-128.png</file>
|
||||
|
@ -81,6 +84,7 @@
|
|||
<file>theme/colored/state-warning-128.png</file>
|
||||
<file>theme/colored/state-warning-256.png</file>
|
||||
<file>theme/black/folder.png</file>
|
||||
<file>theme/black/folder.svg</file>
|
||||
<file>theme/black/folder@2x.png</file>
|
||||
<file>theme/white/folder.png</file>
|
||||
<file>theme/white/folder@2x.png</file>
|
||||
|
@ -147,6 +151,7 @@
|
|||
<file>theme/black/wizard-talk.png</file>
|
||||
<file>theme/black/wizard-talk@2x.png</file>
|
||||
<file>theme/black/wizard-files.png</file>
|
||||
<file>theme/black/wizard-groupware.svg</file>
|
||||
<file>theme/colored/wizard-files.png</file>
|
||||
<file>theme/colored/wizard-files@2x.png</file>
|
||||
<file>theme/colored/wizard-groupware.png</file>
|
||||
|
@ -173,6 +178,9 @@
|
|||
<file>theme/black/add.svg</file>
|
||||
<file>theme/black/activity.svg</file>
|
||||
<file>theme/black/bell.svg</file>
|
||||
<file>theme/black/wizard-talk.svg</file>
|
||||
<file>theme/black/calendar.svg</file>
|
||||
<file>theme/black/deck.svg</file>
|
||||
<file>theme/black/state-info.svg</file>
|
||||
<file>theme/close.svg</file>
|
||||
<file>theme/files.svg</file>
|
||||
|
|
|
@ -12,6 +12,11 @@ QtObject {
|
|||
property color lightHover: "#f7f7f7"
|
||||
property color menuBorder: "#bdbdbd"
|
||||
|
||||
// ErrorBox colors
|
||||
property color errorBoxTextColor: Theme.errorBoxTextColor
|
||||
property color errorBoxBackgroundColor: Theme.errorBoxBackgroundColor
|
||||
property color errorBoxBorderColor: Theme.errorBoxBorderColor
|
||||
|
||||
// Fonts
|
||||
// We are using pixel size because this is cross platform comparable, point size isn't
|
||||
property int topLinePixelSize: 12
|
||||
|
@ -56,4 +61,15 @@ QtObject {
|
|||
|
||||
// Visual behaviour
|
||||
property bool hoverEffectsEnabled: true
|
||||
|
||||
// unified search constants
|
||||
readonly property int unifiedSearchItemHeight: trayWindowHeaderHeight
|
||||
readonly property int unifiedSearchResultTextLeftMargin: 18
|
||||
readonly property int unifiedSearchResultTextRightMargin: 16
|
||||
readonly property int unifiedSearchResulIconWidth: 24
|
||||
readonly property int unifiedSearchResulIconLeftMargin: 12
|
||||
readonly property int unifiedSearchResulTitleFontSize: topLinePixelSize
|
||||
readonly property int unifiedSearchResulSublineFontSize: subLinePixelSize
|
||||
readonly property string unifiedSearchResulTitleColor: "black"
|
||||
readonly property string unifiedSearchResulSublineColor: "grey"
|
||||
}
|
||||
|
|
1
theme/black/calendar.svg
Normal file
1
theme/black/calendar.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" version="1.1" height="32" viewbox="0 0 32 32"><path fill="#000" d="m8 2c-1.108 0-2 0.892-2 2v4c0 1.108 0.892 2 2 2s2-0.892 2-2v-4c0-1.108-0.892-2-2-2zm16 0c-1.108 0-2 0.892-2 2v4c0 1.108 0.892 2 2 2s2-0.892 2-2v-4c0-1.108-0.892-2-2-2zm-13 4v2c0 1.662-1.338 3-3 3s-3-1.338-3-3v-1.875a3.993 3.993 0 0 0 -3 3.875v16c0 2.216 1.784 4 4 4h20c2.216 0 4-1.784 4-4v-16a3.993 3.993 0 0 0 -3 -3.875v1.875c0 1.662-1.338 3-3 3s-3-1.338-3-3v-2zm-4.906 10h19.812a0.09 0.09 0 0 1 0.094 0.094v9.812a0.09 0.09 0 0 1 -0.094 0.094h-19.812a0.09 0.09 0 0 1 -0.094 -0.094v-9.812a0.09 0.09 0 0 1 0.094 -0.094z"/></svg>
|
After Width: | Height: | Size: 646 B |
1
theme/black/clear.svg
Normal file
1
theme/black/clear.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
|
After Width: | Height: | Size: 259 B |
1
theme/black/comment.svg
Normal file
1
theme/black/comment.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M21.99 4c0-1.1-.89-2-1.99-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4-.01-18zM20 4v13.17L18.83 16H4V4h16zM6 12h12v2H6zm0-3h12v2H6zm0-3h12v2H6z"/></svg>
|
After Width: | Height: | Size: 302 B |
8
theme/black/deck.svg
Normal file
8
theme/black/deck.svg
Normal file
|
@ -0,0 +1,8 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" version="1.1" viewBox="0 0 16 16">
|
||||
<g fill="#000000">
|
||||
<rect ry="1" height="8" width="14" y="7" x="1"/>
|
||||
<rect ry=".5" height="1" width="12" y="5" x="2"/>
|
||||
<rect ry=".5" height="1" width="10" y="3" x="3"/>
|
||||
<rect ry=".5" height="1" width="8" y="1" x="4"/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 367 B |
1
theme/black/search.svg
Normal file
1
theme/black/search.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
After Width: | Height: | Size: 392 B |
Loading…
Reference in a new issue