diff --git a/plugins/DigitalLibrary/resources/images/projects_not_found.svg b/plugins/DigitalLibrary/resources/images/projects_not_found.svg new file mode 100644 index 0000000000..8aee7b797c --- /dev/null +++ b/plugins/DigitalLibrary/resources/images/projects_not_found.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml b/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml index 2de0e78cc7..1a3c1723b3 100644 --- a/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml +++ b/plugins/DigitalLibrary/resources/qml/SelectProjectPage.qml @@ -1,10 +1,12 @@ // Copyright (C) 2021 Ultimaker B.V. +// Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.10 import QtQuick.Window 2.2 import QtQuick.Controls 1.4 as OldControls // TableView doesn't exist in the QtQuick Controls 2.x in 5.10, so use the old one import QtQuick.Controls 2.3 import QtQuick.Controls.Styles 1.4 +import QtQuick.Layouts 1.1 import UM 1.2 as UM import Cura 1.6 as Cura @@ -29,31 +31,43 @@ Item margins: UM.Theme.getSize("default_margin").width } - Label + RowLayout { - id: selectProjectLabel + id: headerRow - text: "Select Project" - font: UM.Theme.getFont("medium") - color: UM.Theme.getColor("small_button_text") - anchors.top: parent.top - anchors.left: parent.left - visible: projectListContainer.visible - } - - Cura.SecondaryButton - { - id: createNewProjectButton - - anchors.verticalCenter: selectProjectLabel.verticalCenter - anchors.right: parent.right - text: "New Library project" - - onClicked: + anchors { - createNewProjectPopup.open() + top: parent.top + left: parent.left + right: parent.right + } + height: childrenRect.height + spacing: UM.Theme.getSize("default_margin").width + + Cura.TextField + { + id: searchBar + Layout.fillWidth: true + implicitHeight: createNewProjectButton.height + + onTextEdited: manager.projectFilter = text //Update the search filter when editing this text field. + + leftIcon: UM.Theme.getIcon("Magnifier") + placeholderText: "Search" + } + + Cura.SecondaryButton + { + id: createNewProjectButton + + text: "New Library project" + + onClicked: + { + createNewProjectPopup.open() + } + busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress } - busy: manager.creatingNewProjectStatus == DF.RetrievalStatus.InProgress } Item @@ -76,7 +90,7 @@ Item { id: digitalFactoryImage anchors.horizontalCenter: parent.horizontalCenter - source: "../images/digital_factory.svg" + source: searchBar.text === "" ? "../images/digital_factory.svg" : "../images/projects_not_found.svg" fillMode: Image.PreserveAspectFit width: parent.width - 2 * UM.Theme.getSize("thick_margin").width sourceSize.width: width @@ -87,8 +101,9 @@ Item { id: noLibraryProjectsLabel anchors.horizontalCenter: parent.horizontalCenter - text: "It appears that you don't have any projects in the Library yet." + text: searchBar.text === "" ? "It appears that you don't have any projects in the Library yet." : "No projects found that match the search query." font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") } Cura.TertiaryButton @@ -97,6 +112,7 @@ Item anchors.horizontalCenter: parent.horizontalCenter text: "Visit Digital Library" onClicked: Qt.openUrlExternally(CuraApplication.ultimakerDigitalFactoryUrl + "/app/library") + visible: searchBar.text === "" //Show the link to Digital Library when there are no projects in the user's Library. } } } @@ -106,7 +122,7 @@ Item id: projectListContainer anchors { - top: selectProjectLabel.bottom + top: headerRow.bottom topMargin: UM.Theme.getSize("default_margin").height bottom: parent.bottom left: parent.left diff --git a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py index e1a62fdd5c..4ebb3cb051 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py @@ -95,7 +95,7 @@ class DigitalFactoryApiClient: error_callback = failed, timeout = self.DEFAULT_REQUEST_TIMEOUT) - def getProjectsFirstPage(self, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None: + def getProjectsFirstPage(self, search_filter: str, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None: """ Retrieves digital factory projects for the user that is currently logged in. @@ -103,13 +103,18 @@ class DigitalFactoryApiClient: according to the limit set in the pagination manager. If there is no projects pagination manager, this function leaves the project limit to the default set on the server side (999999). + :param search_filter: Text to filter the search results. If given an empty string, results are not filtered. :param on_finished: The function to be called after the result is parsed. :param failed: The function to be called if the request fails. """ - url = "{}/projects".format(self.CURA_API_ROOT) + url = f"{self.CURA_API_ROOT}/projects" + query_character = "?" if self._projects_pagination_mgr: self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata - url += "?limit={}".format(self._projects_pagination_mgr.limit) + url += f"{query_character}limit={self._projects_pagination_mgr.limit}" + query_character = "&" + if search_filter != "": + url += f"{query_character}search={search_filter}" self._http.get(url, scope = self._scope, diff --git a/plugins/DigitalLibrary/src/DigitalFactoryController.py b/plugins/DigitalLibrary/src/DigitalFactoryController.py index 368b29219a..0821cc6925 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryController.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryController.py @@ -1,4 +1,6 @@ # Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + import json import math import os @@ -8,7 +10,7 @@ from enum import IntEnum from pathlib import Path from typing import Optional, List, Dict, Any, cast -from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QUrl +from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QTimer, QUrl from PyQt5.QtNetwork import QNetworkReply from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType @@ -116,6 +118,11 @@ class DigitalFactoryController(QObject): self._project_model = DigitalFactoryProjectModel() self._selected_project_idx = -1 self._project_creation_error_text = "Something went wrong while creating a new project. Please try again." + self._project_filter = "" + self._project_filter_change_timer = QTimer() + self._project_filter_change_timer.setInterval(200) + self._project_filter_change_timer.setSingleShot(True) + self._project_filter_change_timer.timeout.connect(self._applyProjectFilter) # Initialize the file model self._file_model = DigitalFactoryFileModel() @@ -176,7 +183,7 @@ class DigitalFactoryController(QObject): if preselected_project_id: self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed) else: - self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None: """ @@ -302,6 +309,38 @@ class DigitalFactoryController(QObject): self._selected_file_indices = file_indices self.selectedFileIndicesChanged.emit(file_indices) + def setProjectFilter(self, new_filter: str) -> None: + """ + Called when the user wants to change the search filter for projects. + + The filter is not immediately applied. There is some delay to allow the user to finish typing. + :param new_filter: The new filter that the user wants to apply. + """ + self._project_filter = new_filter + self._project_filter_change_timer.start() + + """ + Signal to notify Qt that the applied filter has changed. + """ + projectFilterChanged = pyqtSignal() + + @pyqtProperty(str, notify = projectFilterChanged, fset = setProjectFilter) + def projectFilter(self) -> str: + """ + The current search filter being applied to the project list. + :return: The current search filter being applied to the project list. + """ + return self._project_filter + + def _applyProjectFilter(self) -> None: + """ + Actually apply the current filter to search for projects with the user-defined search string. + :return: + """ + self.clear() + self.projectFilterChanged.emit() + self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + @pyqtProperty(QObject, constant = True) def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel": return self._project_model @@ -516,7 +555,7 @@ class DigitalFactoryController(QObject): # false, we also need to clean it from the projects model self._project_model.clearProjects() self.setSelectedProjectIndex(-1) - self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) self._has_preselected_project = new_has_preselected_project self.preselectedProjectChanged.emit() diff --git a/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py b/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py index ba0a0b15b4..9751838ddf 100644 --- a/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py +++ b/plugins/DigitalLibrary/tests/TestDigitalLibraryApiClient.py @@ -1,3 +1,6 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + from unittest.mock import MagicMock import pytest @@ -37,7 +40,7 @@ def test_getProjectsFirstPage(api_client): failed_callback = MagicMock() # Call - api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback) + api_client.getProjectsFirstPage(search_filter = "filter", on_finished = finished_callback, failed = failed_callback) # Asserts pagination_manager.reset.assert_called_once() # Should be called since we asked for new set of projects @@ -45,16 +48,16 @@ def test_getProjectsFirstPage(api_client): args = http_manager.get.call_args_list[0] # Ensure that it's called with the right limit - assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=20" + assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=20&search=filter" # Change the limit & try again http_manager.get.reset_mock() pagination_manager.limit = 80 - api_client.getProjectsFirstPage(on_finished = finished_callback, failed = failed_callback) + api_client.getProjectsFirstPage(search_filter = "filter", on_finished = finished_callback, failed = failed_callback) args = http_manager.get.call_args_list[0] # Ensure that it's called with the right limit - assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=80" + assert args[0][0] == "https://api.ultimaker.com/cura/v1/projects?limit=80&search=filter" def test_getMoreProjects_noNewProjects(api_client): diff --git a/resources/qml/Widgets/TextField.qml b/resources/qml/Widgets/TextField.qml index 28074d4415..c126c8a6e0 100644 --- a/resources/qml/Widgets/TextField.qml +++ b/resources/qml/Widgets/TextField.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2019 Ultimaker B.V. +// Copyright (c) 2021 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.10 @@ -15,6 +15,8 @@ TextField { id: textField + property alias leftIcon: iconLeft.source + UM.I18nCatalog { id: catalog; name: "cura" } hoverEnabled: true @@ -22,6 +24,7 @@ TextField font: UM.Theme.getFont("default") color: UM.Theme.getColor("text") renderType: Text.NativeRendering + leftPadding: iconLeft.visible ? iconLeft.width + UM.Theme.getSize("default_margin").width * 2 : UM.Theme.getSize("thin_margin").width states: [ State @@ -52,7 +55,6 @@ TextField color: UM.Theme.getColor("main_background") - anchors.margins: Math.round(UM.Theme.getSize("default_lining").width) radius: UM.Theme.getSize("setting_control_radius").width border.color: @@ -67,5 +69,23 @@ TextField } return UM.Theme.getColor("setting_control_border") } + + //Optional icon added on the left hand side. + UM.RecolorImage + { + id: iconLeft + + anchors + { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: UM.Theme.getSize("default_margin").width + } + + visible: source != "" + height: UM.Theme.getSize("small_button_icon").height + width: visible ? height : 0 + color: textField.color + } } }