Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replybox richtext placeholder with modified behavior #597

Merged
merged 10 commits into from
Nov 8, 2019
101 changes: 79 additions & 22 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
from gettext import gettext as _
from typing import Dict, List # noqa: F401
from uuid import uuid4
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, QObject
from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QEvent, QTimer, QSize, pyqtBoundSignal, \
QObject, QPoint
from PyQt5.QtGui import QIcon, QPalette, QBrush, QColor, QFont, QLinearGradient
from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \
QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, QMessageBox, \
Expand Down Expand Up @@ -2251,20 +2252,8 @@ class ReplyBoxWidget(QWidget):
#replybox::disabled {
background-color: #efefef;
}
QPlainTextEdit {
font-family: 'Montserrat';
font-weight: 400;
font-size: 18px;
border: none;
margin-left: 32.6px;
margin-top: 19px;
margin-bottom: 18px;
margin-right: 30.2px;
}
QPushButton {
border: none;
margin-right: 27.3px;
margin-bottom: 18px;
}
QWidget#horizontal_line {
min-height: 2px;
Expand Down Expand Up @@ -2304,14 +2293,12 @@ def __init__(self, source: Source, controller: Controller) -> None:
self.replybox = QWidget()
self.replybox.setObjectName('replybox')
replybox_layout = QHBoxLayout(self.replybox)
replybox_layout.setContentsMargins(0, 0, 0, 0)
replybox_layout.setContentsMargins(32.6, 19, 27.3, 18)
replybox_layout.setSpacing(0)

# Create reply text box
self.text_edit = QPlainTextEdit()
self.text_edit = ReplyTextEdit(self.source, self.controller)
self.text_edit.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.text_edit.setPlaceholderText("Compose a reply to %s" %
self.source.journalist_designation)

# Create reply send button (airplane)
self.send_button = QPushButton()
Expand Down Expand Up @@ -2339,14 +2326,12 @@ def __init__(self, source: Source, controller: Controller) -> None:
self.controller.authentication_state.connect(self._on_authentication_changed)

def enable(self):
self.text_edit.clear()
self.text_edit.setEnabled(True)
self.text_edit.set_logged_in()
self.replybox.setEnabled(True)
self.send_button.show()

def disable(self):
self.text_edit.setPlainText(_('You need to log in to send replies.'))
self.text_edit.setEnabled(False)
self.text_edit.set_logged_out()
self.replybox.setEnabled(False)
self.send_button.hide()

Expand All @@ -2360,7 +2345,7 @@ def send_reply(self) -> None:
reply_uuid = str(uuid4())
self.controller.send_reply(self.source.uuid, reply_uuid, reply_text)
self.reply_sent.emit(self.source.uuid, reply_uuid, reply_text)
self.text_edit.clear()
self.text_edit.setText('')

def _on_authentication_changed(self, authenticated: bool) -> None:
if authenticated:
Expand All @@ -2369,6 +2354,78 @@ def _on_authentication_changed(self, authenticated: bool) -> None:
self.disable()


class ReplyTextEdit(QPlainTextEdit):
"""
A plaintext textbox with placeholder that disapears when clicked and
a richtext lable on top to replace the placeholder functionality
"""

CSS = '''
#reply_textedit {
font-family: 'Montserrat';
font-weight: 400;
font-size: 18px;
border: none;
margin-right: 30.2px;
}
#reply_placeholder {
font-family: 'Montserrat';
font-weight: 400;
font-size: 18px;
color: #404040;
}
#reply_placeholder::disabled {
color: rgba(42, 49, 157, 0.6);
}
'''

def __init__(self, source, controller):
super().__init__()
self.controller = controller
self.source = source

self.setObjectName('reply_textedit')
self.setStyleSheet(self.CSS)

self.placeholder = QLabel()
self.placeholder.setObjectName("reply_placeholder")
self.placeholder.setParent(self)
self.placeholder.move(QPoint(3, 4)) # make label match text below
self.set_logged_in()

def focusInEvent(self, e):
# override default behavior: when reply text box is focused, the placeholder
# disappears instead of only doing so when text is typed
if self.toPlainText() == "":
self.placeholder.hide()
super(ReplyTextEdit, self).focusInEvent(e)

def focusOutEvent(self, e):
if self.toPlainText() == "":
self.placeholder.show()
super(ReplyTextEdit, self).focusOutEvent(e)

def set_logged_in(self):
source_name = "<strong><font color=\"#24276d\">%s</font></strong>" % \
self.source.journalist_designation
placeholder = _("Compose a reply to ") + source_name
self.placeholder.setText(placeholder)
self.setEnabled(True)

def set_logged_out(self):
text = "<strong><font color=\"#2a319d\">" + _("Sign in") + " </font></strong>" + \
_("to compose or send a reply")
self.placeholder.setText(text)
self.setEnabled(False)

def setText(self, text):
if text == "":
self.placeholder.show()
else:
self.placeholder.hide()
super(ReplyTextEdit, self).setPlainText(text)


class DeleteSourceAction(QAction):
"""Use this action to delete the source record."""

Expand Down
175 changes: 139 additions & 36 deletions tests/gui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@
"""
import html

from PyQt5.QtWidgets import QWidget, QApplication, QVBoxLayout, QMessageBox, QMainWindow, QTextEdit
from PyQt5.QtCore import Qt, QEvent
from PyQt5.QtGui import QFocusEvent
from PyQt5.QtWidgets import QWidget, QApplication, QVBoxLayout, QMessageBox, QMainWindow
from sqlalchemy.orm import scoped_session, sessionmaker

from tests import factory
from securedrop_client import db, logic
from securedrop_client.export import ExportError, ExportStatus
from securedrop_client.gui.widgets import MainView, SourceList, SourceWidget, LoginDialog, \
SpeechBubble, MessageWidget, ReplyWidget, FileWidget, ConversationView, \
DeleteSourceMessageBox, DeleteSourceAction, SourceMenu, TopPane, LeftPane, RefreshButton, \
ErrorStatusBar, ActivityStatusBar, UserProfile, UserButton, UserMenu, LoginButton, \
ReplyBoxWidget, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, LoginErrorBar, \
EmptyConversationView, ExportDialog
ReplyBoxWidget, ReplyTextEdit, SourceConversationWrapper, StarToggleButton, LoginOfflineLink, \
LoginErrorBar, EmptyConversationView, ExportDialog
from tests import factory


app = QApplication([])
Expand Down Expand Up @@ -2189,31 +2190,11 @@ def test_ReplyBoxWidget_placeholder_show_currently_selected_source(mocker):
designation for the correct source. #sanity-check
"""
controller = mocker.MagicMock()
sl = SourceList()
sl.setup(controller)

source_1 = factory.Source()
source_1.journalist_designation = "source one"
source_2 = factory.Source()
source_2.journalist_designation = "source two"

# add sources to sources list
sl.update([source_1, source_2])

source_1_item = sl.item(0)
source_2_item = sl.item(1)

# select source 1
sl.setCurrentItem(source_1_item)
assert sl.currentItem() == source_1_item

# select source other source
sl.setCurrentItem(source_2_item)
assert sl.currentItem() == source_2_item
source = factory.Source()
source.journalist_designation = "source name"

selected_source = sl.itemWidget(sl.currentItem()).source
rb = ReplyBoxWidget(selected_source, controller)
assert rb.text_edit.placeholderText().find(source_2.journalist_designation) != -1
rb = ReplyBoxWidget(source, controller)
assert rb.text_edit.placeholder.text().find(source.journalist_designation) != -1


def test_ReplyBoxWidget_send_reply(mocker):
Expand All @@ -2233,23 +2214,42 @@ def test_ReplyBoxWidget_send_reply(mocker):
on_reply_sent_fn = mocker.MagicMock()
scw.conversation_view.on_reply_sent = on_reply_sent_fn
scw.reply_box.reply_sent = mocker.MagicMock()
scw.reply_box.text_edit = QTextEdit('Alles für Alle')
scw.reply_box.text_edit = ReplyTextEdit(source, controller)
scw.reply_box.text_edit.setText = mocker.MagicMock()
scw.reply_box.text_edit.setPlainText('Alles für Alle')

scw.reply_box.send_reply()

scw.reply_box.reply_sent.emit.assert_called_once_with('abc123', '456xyz', 'Alles für Alle')
assert scw.reply_box.text_edit.toPlainText() == ''
scw.reply_box.text_edit.setText.assert_called_once_with('')
controller.send_reply.assert_called_once_with('abc123', '456xyz', 'Alles für Alle')


def test_ReplyBoxWidget_send_reply_calls_setText_after_send(mocker):
"""
Ensure sending a reply from the reply box emits signal, clears text box, and sends the reply
details to the controller.
"""
source = factory.Source()
controller = mocker.MagicMock()
rb = ReplyBoxWidget(source, controller)
rb.text_edit = ReplyTextEdit(source, controller)
setText = mocker.patch.object(rb.text_edit, 'setText')
rb.text_edit.setPlainText('Alles für Alle')

rb.send_reply()

setText.assert_called_once_with('')


def test_ReplyBoxWidget_send_reply_does_not_send_empty_string(mocker):
"""
Ensure sending a reply from the reply box does not send empty string.
"""
source = mocker.MagicMock()
controller = mocker.MagicMock()
rb = ReplyBoxWidget(source, controller)
rb.text_edit = QTextEdit()
rb.text_edit = ReplyTextEdit(source, controller)
assert not rb.text_edit.toPlainText()

rb.send_reply()
Expand Down Expand Up @@ -2347,30 +2347,133 @@ def test_ReplyBoxWidget_enable(mocker):
source = mocker.MagicMock()
controller = mocker.MagicMock()
rb = ReplyBoxWidget(source, controller)
rb.text_edit = QTextEdit()
rb.text_edit = ReplyTextEdit(source, controller)
rb.text_edit.set_logged_in = mocker.MagicMock()
rb.send_button = mocker.MagicMock()

rb.enable()

assert rb.text_edit.isEnabled()
assert rb.text_edit.toPlainText() == ''
rb.text_edit.set_logged_in.assert_called_once_with()
rb.send_button.show.assert_called_once_with()


def test_ReplyBoxWidget_disable(mocker):
source = mocker.MagicMock()
controller = mocker.MagicMock()
rb = ReplyBoxWidget(source, controller)
rb.text_edit = QTextEdit()
rb.text_edit = ReplyTextEdit(source, controller)
rb.text_edit.set_logged_out = mocker.MagicMock()
rb.send_button = mocker.MagicMock()

rb.disable()

assert not rb.text_edit.isEnabled()
assert rb.text_edit.toPlainText() == 'You need to log in to send replies.'
assert rb.text_edit.toPlainText() == ''
rb.text_edit.set_logged_out.assert_called_once_with()
rb.send_button.hide.assert_called_once_with()


def test_ReplyTextEdit_focus_change_no_text(mocker):
"""
Tests if placeholder text in reply box disappears when it's focused (clicked)
and reappears when it's no longer on focus
"""
source = mocker.MagicMock()
controller = mocker.MagicMock()
rt = ReplyTextEdit(source, controller)

focus_in_event = QFocusEvent(QEvent.FocusIn)
focus_out_event = QFocusEvent(QEvent.FocusOut)

rt.focusInEvent(focus_in_event)
assert rt.placeholder.isHidden()
assert rt.toPlainText() == ''

rt.focusOutEvent(focus_out_event)
assert not rt.placeholder.isHidden()
assert rt.toPlainText() == ''


def test_ReplyTextEdit_focus_change_with_text_typed(mocker):
"""
Test that the placeholder does not appear when there is text in the ReplyTextEdit widget and
that the text remains in the ReplyTextEdit regardless of focus.
"""
source = mocker.MagicMock()
controller = mocker.MagicMock()
rt = ReplyTextEdit(source, controller)
reply_text = 'mocked reply text'
rt.setText(reply_text)

focus_in_event = QFocusEvent(QEvent.FocusIn)
focus_out_event = QFocusEvent(QEvent.FocusOut)

rt.focusInEvent(focus_in_event)
assert rt.placeholder.isHidden()
assert rt.toPlainText() == reply_text

rt.focusOutEvent(focus_out_event)
assert rt.placeholder.isHidden()
assert rt.toPlainText() == reply_text


def test_ReplyTextEdit_setText(mocker):
"""
Checks that a non-empty string parameter causes placeholder to hide and that super's
setPlainText method is called (to ensure cursor is hidden).
"""
rt = ReplyTextEdit(mocker.MagicMock(), mocker.MagicMock())
mocker.patch('securedrop_client.gui.widgets.QPlainTextEdit.setPlainText')

rt.setText('mocked reply text')

assert rt.placeholder.isHidden()
rt.setPlainText.assert_called_once_with('mocked reply text')


def test_ReplyTextEdit_setText_empty_string(mocker):
"""
Checks that plain string parameter causes placeholder to show and that super's setPlainText
method is called (to ensure cursor is hidden).
"""
rt = ReplyTextEdit(mocker.MagicMock(), mocker.MagicMock())
mocker.patch('securedrop_client.gui.widgets.QPlainTextEdit.setPlainText')

rt.setText('')

assert not rt.placeholder.isHidden()
rt.setPlainText.assert_called_once_with('')


def test_ReplyTextEdit_set_logged_out(mocker):
"""
Checks the placeholder text for reply box is correct for offline mode
"""
source = mocker.MagicMock()
controller = mocker.MagicMock()
rt = ReplyTextEdit(source, controller)

rt.set_logged_out()

assert 'Sign in' in rt.placeholder.text()
assert 'to compose or send a reply' in rt.placeholder.text()


def test_ReplyTextEdit_set_logged_in(mocker):
"""
Checks the placeholder text for reply box is correct for online mode
"""
source = mocker.MagicMock()
source.journalist_designation = 'journalist designation'
controller = mocker.MagicMock()
rt = ReplyTextEdit(source, controller)

rt.set_logged_in()

assert 'Compose a reply to' in rt.placeholder.text()
assert source.journalist_designation in rt.placeholder.text()


def test_update_conversation_maintains_old_items(mocker, session):
"""
Calling update_conversation deletes and adds old items back to layout
Expand Down