Philipp Hörist pushed to branch master at gajim / gajim
Commits: 44d56ac2 by Philipp Hörist at 2018-11-03T22:09:12Z Tests: Add a DataFormWidget test window
- - - - - 6b65a233 by Philipp Hörist at 2018-11-03T23:58:34Z DataForms: Make validation possible
- Show orange warning icon if a field is required - Show red error icon if validation of the field failed - Set validation error text as tooltip of the icon - DataFormWidget emits a signal 'is-valid' on every change
- - - - - 4753e601 by Philipp Hörist at 2018-11-04T00:01:39Z ServiceRegistration: Validate form before sending
- - - - -
6 changed files:
- gajim/common/modules/dataforms.py - gajim/data/style/gajim.css - gajim/gtk/dataform.py - gajim/gtk/service_registration.py - + test/gtk/dataform.py - + test/gtk/util.py
Changes:
===================================== gajim/common/modules/dataforms.py ===================================== @@ -239,7 +239,7 @@ class DataField(ExtendedNode):
@staticmethod def is_valid(): - return True + return True, ''
class Uri(nbxmpp.Node): @@ -353,6 +353,13 @@ class StringField(DataField): except ValueError: # if there already were no value tag pass
+ def is_valid(self): + if not self.required: + return True, '' + if not self.value: + return False, '' + return True, '' +
class ListField(DataField): """ @@ -404,10 +411,10 @@ class ListSingleField(ListField, StringField): """ def is_valid(self): if not self.required: - return True + return True, '' if not self.value: - return False - return True + return False, '' + return True, ''
class JidSingleField(ListSingleField): @@ -418,12 +425,12 @@ class JidSingleField(ListSingleField): if self.value: try: helpers.parse_jid(self.value) - return True - except Exception: - return False + return True, '' + except Exception as error: + return False, error if self.required: - return False - return True + return False, '' + return True, ''
class ListMultiField(ListField): @@ -458,10 +465,10 @@ class ListMultiField(ListField):
def is_valid(self): if not self.required: - return True + return True, '' if not self.values: - return False - return True + return False, '' + return True, ''
class JidMultiField(ListMultiField): @@ -473,12 +480,12 @@ class JidMultiField(ListMultiField): for value in self.values: try: helpers.parse_jid(value) - except Exception: - return False - return True + except Exception as error: + return False, error + return True, '' if self.required: - return False - return True + return False, '' + return True, ''
class TextMultiField(DataField): @@ -505,6 +512,13 @@ class TextMultiField(DataField): for element in self.getTags('value'): self.delChild(element)
+ def is_valid(self): + if not self.required: + return True, '' + if not self.value: + return False, '' + return True, '' +
class DataRecord(ExtendedNode): """ @@ -573,7 +587,7 @@ class DataRecord(ExtendedNode):
def is_valid(self): for field in self.iter_fields(): - if not field.is_valid(): + if not field.is_valid()[0]: return False return True
===================================== gajim/data/style/gajim.css ===================================== @@ -147,7 +147,7 @@ list.settings > row > box { #StartChatListBox > row:not(.activatable) label { color: @insensitive_fg_color }
/* GroupchatConfig */ -#GroupchatConfig buttonbox { margin: 0px 12px 12px 12px; } +#GroupchatConfig > box > buttonbox { margin: 0px 12px 12px 12px; } #GroupchatConfig stack { border-bottom: 1px solid; border-color: @borders;} #GroupchatConfig stacksidebar > scrolledwindow { background-color:@theme_base_color; @@ -249,8 +249,4 @@ button.flat.link { padding: 0; border: 0; } .data-form-title { font-size: 16px; font-weight: bold; } .data-form-widget grid { margin: 18px; } .data-form-widget treeview { padding: 5px; } -.field-required { border: 1px solid; border-color: red; } -.field-normal { border: 1px solid; border-color:@unfocused_borders; } - -.entry-field-required { border: 1px solid; border-color: red; } -.entry-field-required:focus { box-shadow: inset 0 0 0 1px red; border-color: red; } \ No newline at end of file +.data-form-widget scrolledwindow { border: 1px solid; border-color:@unfocused_borders; }
===================================== gajim/gtk/dataform.py ===================================== @@ -13,10 +13,17 @@ # along with Gajim. If not, see http://www.gnu.org/licenses/.
from gi.repository import Gtk +from gi.repository import GLib +from gi.repository import GObject from gi.repository import Pango
+from gajim.common.i18n import _ +
class DataFormWidget(Gtk.ScrolledWindow): + + __gsignals__ = {'is-valid': (GObject.SignalFlags.RUN_LAST, None, (bool,))} + def __init__(self, form_node, options=None): Gtk.ScrolledWindow.__init__(self) self.set_hexpand(True) @@ -39,6 +46,9 @@ class DataFormWidget(Gtk.ScrolledWindow): def instructions(self): return self._form_grid.instructions
+ def validate(self): + return self._form_grid.validate(True) + def get_submit_form(self): self._form_node.type_ = 'submit' return self._form_node @@ -109,10 +119,11 @@ class FormGrid(Gtk.Grid): continue
widget = self._fields[field.type_] - self.add_row(widget(field, options)) + self.add_row(widget(field, self, options))
- def is_valid(self): - return self._data_form.is_valid() + def validate(self, is_valid): + value = self._data_form.is_valid() if is_valid else False + self.get_parent().get_parent().emit('is-valid', value)
class Title: @@ -135,8 +146,10 @@ class Instructions:
class Field: - def __init__(self, field, options): + def __init__(self, field, form_grid, options): self._field = field + self._form_grid = form_grid + self._validate_source_id = None
self._label = Gtk.Label(label=field.label) self._label.set_single_line_mode(False) @@ -147,15 +160,64 @@ class Field: self._label.set_xalign(bool(options.get('right_align'))) self._label.set_tooltip_text(field.description)
+ self._warning_image = Gtk.Image.new_from_icon_name( + 'dialog-warning-symbolic', Gtk.IconSize.MENU) + self._warning_image.get_style_context().add_class('warning-color') + self._warning_image.set_no_show_all(True) + self._warning_image.set_valign(Gtk.Align.CENTER) + self._warning_image.set_tooltip_text(_('Required')) + self._warning_box = Gtk.Box() + self._warning_box.set_size_request(16, -1) + self._warning_box.add(self._warning_image) + def add(self, form_grid, row_number): form_grid.attach(self._label, 0, row_number, 1, 1) - form_grid.attach_next_to( - self._widget, self._label, Gtk.PositionType.RIGHT, 1, 1) + form_grid.attach_next_to(self._widget, + self._label, + Gtk.PositionType.RIGHT, 1, 1) + if self._field.type_ in ('jid-single', + 'jid-multi', + 'text-single', + 'text-private', + 'text-multi'): + form_grid.attach_next_to(self._warning_box, + self._widget, + Gtk.PositionType.RIGHT, 1, 1) + self._set_warning(False, '') + + def _set_warning(self, is_valid, error): + if not self._field.required and not is_valid and not error: + # If its not valid and no error is given, its the inital call + # to show all icons on required fields. + return + + style = self._warning_image.get_style_context() + if error: + style.remove_class('warning-color') + style.add_class('error-color') + else: + error = _('Required') + style.remove_class('error-color') + style.add_class('warning-color') + self._warning_image.set_tooltip_text(str(error)) + self._warning_image.set_visible(not is_valid) + + def _validate(self): + if self._validate_source_id is not None: + GLib.source_remove(self._validate_source_id) + + def _start_validation(): + is_valid, error = self._field.is_valid() + self._set_warning(is_valid, error) + self._form_grid.validate(is_valid) + self._validate_source_id = None + + self._validate_source_id = GLib.timeout_add(500, _start_validation)
class BooleanField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options)
self._widget = Gtk.CheckButton() self._widget.set_active(field.value) @@ -166,8 +228,8 @@ class BooleanField(Field):
class FixedField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options)
self._label.set_text(field.value)
@@ -186,8 +248,8 @@ class FixedField(Field):
class ListSingleField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options)
self._widget = Gtk.ComboBoxText() for value, label in field.iter_options(): @@ -203,8 +265,8 @@ class ListSingleField(Field):
class ListMultiField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options) self._label.set_valign(Gtk.Align.START)
self._treeview = ListMutliTreeView(field) @@ -214,7 +276,6 @@ class ListMultiField(Field): self._widget.set_min_content_height(100) self._widget.set_max_content_height(300) self._widget.add(self._treeview) - self._widget.get_style_context().add_class('field-normal')
class ListMutliTreeView(Gtk.TreeView): @@ -264,32 +325,26 @@ class ListMutliTreeView(Gtk.TreeView):
class JidMultiField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options) self._label.set_valign(Gtk.Align.START)
- self._treeview = JidMutliTreeView(field) + self._treeview = JidMutliTreeView(field, self)
- self._add_button = Gtk.Button.new_from_icon_name( - 'list-add-symbolic', Gtk.IconSize.MENU) + self._add_button = Gtk.ToolButton(icon_name='list-add-symbolic') self._add_button.connect('clicked', self._add_clicked) - self._add_button.set_halign(Gtk.Align.START)
- self._remove_button = Gtk.Button.new_from_icon_name( - 'list-remove-symbolic', Gtk.IconSize.MENU) + self._remove_button = Gtk.ToolButton(icon_name='list-remove-symbolic') self._remove_button.connect('clicked', self._remove_clicked) - self._remove_button.set_halign(Gtk.Align.START)
- self._button_box = Gtk.ButtonBox( - orientation=Gtk.Orientation.HORIZONTAL) - self._button_box.set_layout(Gtk.ButtonBoxStyle.START) - self._button_box.add(self._add_button) - self._button_box.add(self._remove_button) - self._button_box.set_child_non_homogeneous(self._add_button, True) - self._button_box.set_child_non_homogeneous(self._remove_button, True) + self._toolbar = Gtk.Toolbar() + self._toolbar.set_icon_size(Gtk.IconSize.MENU) + self._toolbar.set_style(Gtk.ToolbarStyle.ICONS) + self._toolbar.get_style_context().add_class('inline-toolbar') + self._toolbar.add(self._add_button) + self._toolbar.add(self._remove_button)
self._widget = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self._widget.set_spacing(6)
self._scrolled_window = Gtk.ScrolledWindow() self._scrolled_window.set_propagate_natural_height(True) @@ -298,9 +353,7 @@ class JidMultiField(Field): self._scrolled_window.add(self._treeview)
self._widget.pack_start(self._scrolled_window, True, True, 0) - self._widget.pack_end(self._button_box, False, False, 0) - - self._treeview.update_required_css() + self._widget.pack_end(self._toolbar, False, False, 0)
def _add_clicked(self, _widget): self._treeview.get_model().append(['']) @@ -317,14 +370,18 @@ class JidMultiField(Field): continue jids.append(row[0]) self._field.values = jids - self._treeview.update_required_css() + self._validate() + + def validate(self): + self._validate()
class JidMutliTreeView(Gtk.TreeView): - def __init__(self, field): + def __init__(self, field, multi_field): Gtk.TreeView.__init__(self)
self._field = field + self._multi_field = multi_field
self._store = Gtk.ListStore(str)
@@ -348,7 +405,7 @@ class JidMutliTreeView(Gtk.TreeView): iter_ = self._store.get_iter(path) self._store.set_value(iter_, 0, new_text) self._set_values() - self.update_required_css() + self._multi_field.validate()
def _set_values(self): jids = [] @@ -358,58 +415,35 @@ class JidMutliTreeView(Gtk.TreeView): jids.append(row[0]) self._field.values = jids
- def update_required_css(self): - style = self.get_parent().get_style_context() - if not self._field.required: - style.add_class('field-normal') - return - - if self._field.values: - style.remove_class('field-required') - style.add_class('field-normal') - else: - style.remove_class('field-normal') - style.add_class('field-required') -
class TextSingleField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options)
self._widget = Gtk.Entry() self._widget.set_text(field.value) self._widget.connect('changed', self._changed) - self._update_required_css()
def _changed(self, _widget): self._field.value = self._widget.get_text() - self._update_required_css() - - def _update_required_css(self): - if not self._field.required: - return - style = self._widget.get_style_context() - if self._field.value: - style.remove_class('entry-field-required') - else: - style.add_class('entry-field-required') + self._validate()
class TextPrivateField(TextSingleField): - def __init__(self, field, options): - TextSingleField.__init__(self, field, options) + def __init__(self, field, form_grid, options): + TextSingleField.__init__(self, field, form_grid, options) self._widget.set_input_purpose(Gtk.InputPurpose.PASSWORD) self._widget.set_visibility(False)
class JidSingleField(TextSingleField): - def __init__(self, field, options): - TextSingleField.__init__(self, field, options) + def __init__(self, field, form_grid, options): + TextSingleField.__init__(self, field, form_grid, options)
class TextMultiField(Field): - def __init__(self, field, options): - Field.__init__(self, field, options) + def __init__(self, field, form_grid, options): + Field.__init__(self, field, form_grid, options) self._label.set_valign(Gtk.Align.START)
self._widget = Gtk.ScrolledWindow() @@ -422,17 +456,7 @@ class TextMultiField(Field): self._textview.get_buffer().connect('changed', self._changed)
self._widget.add(self._textview) - self._update_required_css()
def _changed(self, widget): self._field.value = widget.get_text(*widget.get_bounds(), False) - self._update_required_css() - - def _update_required_css(self): - if not self._field.required: - return - style = self._widget.get_style_context() - if self._field.value: - style.remove_class('field-required') - else: - style.add_class('field-required') + self._validate()
===================================== gajim/gtk/service_registration.py ===================================== @@ -89,6 +89,8 @@ class ServiceRegistration(Gtk.Assistant): if is_form: dataform = dataforms.extend_form(node=form) self._data_form_widget = DataFormWidget(dataform) + self._data_form_widget.connect('is-valid', self._on_is_valid) + self._data_form_widget.validate() else: from gajim import config self._data_form_widget = config.FakeDataForm(form) @@ -98,6 +100,9 @@ class ServiceRegistration(Gtk.Assistant): self._data_form_widget.show_all() self.set_current_page(Page.FORM)
+ def _on_is_valid(self, _widget, is_valid): + self.set_page_complete(self.get_nth_page(Page.FORM), is_valid) + def _on_error(self, error_text): log.info('Show Error page') page = self.get_nth_page(Page.ERROR)
===================================== test/gtk/dataform.py ===================================== @@ -0,0 +1,83 @@ +from gi.repository import Gtk +import nbxmpp + +from gajim.gtk.dataform import DataFormWidget +from gajim.common.modules.dataforms import extend_form +from gajim.common.const import CSSPriority + +from test.gtk import util +util.load_style('gajim.css', CSSPriority.APPLICATION) + +FORM = ''' +<x xmlns='jabber:x:data' type='form'> + <title>Bot Configuration</title> + <instructions>Fill out this form to configure your new bot!</instructions> + <field type='hidden' + var='FORM_TYPE'> + <value>jabber:bot</value> + </field> + <field type='fixed'><value>Section 1: Bot Info</value></field> + <field type='text-single' + label='The name of your bot' + var='botname'> + <required/> + </field> + <field type='text-multi' + label='Helpful description of your bot' + var='description'> + <required/> + </field> + <field type='boolean' + label='Public bot?' + var='public'/> + <field type='text-private' + label='Password for special access' + var='password'> + <required/> + </field> + <field type='fixed'><value>Section 2: Features</value></field> + <field type='list-multi' + label='What features will the bot support?' + var='features'> + <option label='Contests'><value>contests</value></option> + <option label='News'><value>news</value></option> + <option label='Polls'><value>polls</value></option> + <option label='Reminders'><value>reminders</value></option> + <option label='Search'><value>search</value></option> + <value>news</value> + <value>search</value> + </field> + <field type='fixed'><value>Section 3: Subscriber List</value></field> + <field type='list-single' + label='Maximum number of subscribers' + var='maxsubs'> + <value>20</value> + <option label='10'><value>10</value></option> + <option label='20'><value>20</value></option> + <option label='30'><value>30</value></option> + <option label='50'><value>50</value></option> + <option label='100'><value>100</value></option> + <option label='None'><value>none</value></option> + </field> + <field type='fixed'><value>Section 4: Invitations</value></field> + <field type='jid-multi' + label='People to invite' + var='invitelist'> + <desc>Tell all your friends about your new bot!</desc> + <required/> + </field> +</x> +''' + +class DataFormWindow(Gtk.Window): + def __init__(self): + Gtk.Window.__init__(self, title="Data Form Test") + self.set_default_size(600, 600) + self._widget = DataFormWidget(extend_form(node=nbxmpp.Node(node=FORM))) + self.add(self._widget) + self.show() + +win = DataFormWindow() +win.connect("destroy", Gtk.main_quit) +win.show_all() +Gtk.main()
===================================== test/gtk/util.py ===================================== @@ -0,0 +1,23 @@ +from pathlib import Path + +from gi.repository import Gdk +from gi.repository import Gtk + + +def get_gajim_dir(): + gajim_path = Path(__file__) / '..' / '..' / '..' / 'gajim' + return gajim_path.resolve() + +def load_style(filename, priority): + path = get_gajim_dir() / 'data' / 'style' / filename + try: + with open(str(path), "r") as file: + css = file.read() + except Exception as exc: + print(exc) + return + provider = Gtk.CssProvider() + provider.load_from_data(bytes(css.encode('utf-8'))) + Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), + provider, + priority)
View it on GitLab: https://dev.gajim.org/gajim/gajim/compare/09f0835fbd02f4586181d1ea08310f1765...