diff --git a/setup.py b/setup.py index c497eed..8e8293c 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ 'BeautifulSoup', 'strainer', 'WebTest', + 'sieve' ], entry_points=""" [tw2.widgets] diff --git a/tw2/dynforms/static/dynforms.js b/tw2/dynforms/static/dynforms.js index 616ce2c..be83ed4 100644 --- a/tw2/dynforms/static/dynforms.js +++ b/tw2/dynforms/static/dynforms.js @@ -45,11 +45,27 @@ function twd_hiding_onchange(ctrl) for(a in mapping) for(b in mapping[a]) { - var display = visible[mapping[a][b]] ? '' : 'none'; + var is_visible = visible[mapping[a][b]] + var display = is_visible ? '' : 'none'; + var req_from = is_visible ? '_twd_hidden_required' : 'required'; + var req_to = is_visible ? 'required' : '_twd_hidden_required'; var x = document.getElementById(parent_id+mapping[a][b]+':container'); if(x.style.display != display) { x.style.display = display; + + // Set/hide required attribute on children where applicable + var children = x.getElementsByTagName('*'); + for(var i = 0; i < children.length; i++) + { + c = children[i]; + if(c.hasAttribute(req_from)) + { + c.setAttribute(req_to, c.getAttribute(req_from)); + c.removeAttribute(req_from); + } + } + var x = document.getElementById(parent_id+mapping[a][b]); if(x && x.id && twd_mapping_store[x.id]) twd_hiding_onchange(x); diff --git a/tw2/dynforms/widgets.py b/tw2/dynforms/widgets.py index e3cf8f5..f9f076e 100755 --- a/tw2/dynforms/widgets.py +++ b/tw2/dynforms/widgets.py @@ -1,264 +1,311 @@ -import tw2.core as twc, tw2.forms as twf, datetime as dt - -#-- -# Growing -#-- -class DeleteButton(twf.ImageButton): - """A button to delete a row in a growing grid. This is created automatically and would not usually be used directly.""" - attrs = { - 'onclick': 'twd_grow_del(this); return false;', - } - modname = __name__ - filename = 'static/del.png' - alt = 'Delete row' - validator = twc.BlankValidator - - -class GrowingGridLayout(twf.GridLayout): - """A GridLayout that can dynamically grow on the client, with delete and undo functionality. This is useful for allowing users to enter a list of items that can vary in length. To function correctly, the widget must appear inside a CustomisedForm.""" - resources = [ - twc.Link(id='undo', modname=__name__, filename="static/undo.png"), - twc.JSLink(modname=__name__, filename="static/dynforms.js"), - ] - template = 'genshi:tw2.dynforms.templates.growing_grid_layout' - - # TBD: support these properly & min/max - repetitions = twc.Variable() - extra_reps = twc.Variable(default=1) - mix_reps = twc.Variable() - max_reps = twc.Variable() - - @classmethod - def post_define(cls): - if hasattr(cls.child, 'children'): - if not hasattr(cls.child.children, 'del'): # TBD: 'del' in ... - cls.child = cls.child(children = list(cls.child.children) + [DeleteButton(id='del', label='')]) - - def prepare(self): - if not hasattr(self, '_validated'): - self.value = [None] + (self.value or []) - super(GrowingGridLayout, self).prepare() - # First and last rows have delete hidden (and hidingbutton) and get onchange - for r in (self.children[0], self.children[self.repetitions-1]): - for c in r.children: - c.safe_modify('attrs') - if c.id == 'del': - c.attrs['style'] = 'display:none;' + c.attrs.get('style', '') - else: - c.attrs['onchange'] = 'twd_grow_add(this);' + c.attrs.get('onchange', '') - # First row is hidden - hidden_row = self.children[0] - hidden_row.safe_modify('attrs') - hidden_row.attrs['style'] = 'display:none;' + hidden_row.attrs.get('style', '') - - def _validate(self, value, state=None): - value = [v for v in value if not ('del.x' in v and 'del.y' in v)] - return twc.RepeatingWidget._validate(self, [None] + twf.StripBlanks().to_python(value), state)[1:] - - -#-- -# Hiding -#-- -class HidingComponentMixin(twc.Widget): - """This widget is a $$ with additional functionality to hide or show other widgets in the form, depending on the value selected. The widget must be used inside a hiding container, e.g. HidingTableLayout.""" - resources = [twc.JSLink(modname=__name__, filename='static/dynforms.js')] - - mapping = twc.Param('A dictionary that maps selection values to visible controls', request_local=False) - - def prepare(self): - super(HidingComponentMixin, self).prepare() - self.safe_modify('resources') - self.add_call(twc.js_function('twd_hiding_init')( - self.compound_id, self.mapping)) - -class HidingSingleSelectField(HidingComponentMixin, twf.SingleSelectField): - __doc__ = HidingComponentMixin.__doc__.replace('$$', 'SingleSelectField') - attrs = {'onchange': 'twd_hiding_onchange(this)'} - -class HidingCheckBox(HidingComponentMixin, twf.CheckBox): - __doc__ = HidingComponentMixin.__doc__.replace('$$', 'CheckBox') - attrs = {'onclick': 'twd_hiding_onchange(this)'} - -class HidingSelectionList(HidingComponentMixin, twf.widgets.SelectionList): - def prepare(self): - super(HidingSelectionList, self).prepare() - for opt in self.options: - opt[0]['onclick'] = 'twd_hiding_listitem_onchange(this);' + opt[0].get('onclick', '') - -class HidingCheckBoxList(HidingSelectionList, twf.CheckBoxList): - __doc__ = HidingComponentMixin.__doc__.replace('$$', 'CheckBoxList') - -class HidingRadioButtonList(HidingSelectionList, twf.RadioButtonList): - __doc__ = HidingComponentMixin.__doc__.replace('$$', 'RadioButtonList') - -class HidingContainerMixin(object): - """Mixin to add hiding functionality to a container widget. The developer can use multiple inheritence to combine this class with a container widget, e.g. ListFieldSet. For this to work correctly, the container must make use of the container_attrs parameter on child widgets.""" - - @classmethod - def post_define(cls): - """ - Verify the mapping - check all controls exist and generate cls.hiding_ctrls - """ - cls.hiding_ctrls = set() - seen = set() - for c in getattr(cls, 'children', []): - seen.add(c.id) - if issubclass(c, HidingComponentMixin): - dep_ctrls = set() - for m in c.mapping.values(): - dep_ctrls.update(m) - cls.hiding_ctrls.update(dep_ctrls) - for d in dep_ctrls: - if not hasattr(cls.children, d): - raise twc.ParameterError('Widget referenced in mapping does not exist: ' + d) - if d in seen: - raise twc.ParameterError('Widget mapping references a preceding widget: ' + d) - - def prepare(self): - super(HidingContainerMixin, self).prepare() - show = set() - for c in self.children: - if isinstance(c, HidingComponentMixin): - if isinstance(c.value, list): - for v in c.value: - show.update(c.mapping.get(v, [])) - else: - show.update(c.mapping.get(c.value, [])) - if c.id in self.hiding_ctrls and c.id not in show: - c.safe_modify('container_attrs') - c.container_attrs['style'] = 'display:none;' + c.container_attrs.get('style', '') - - @twc.validation.catch_errors - def _validate(self, value, state=None): - self._validated = True - value = value or {} - if not isinstance(value, dict): - raise vd.ValidationError('corrupt', self.validator) - self.value = value - any_errors = False - data = {} - show = set() - for c in self.children: - if c.id in self.hiding_ctrls and c.id not in show: - data[c.id] = None - else: - try: - if c._sub_compound: - data.update(c._validate(value, data)) - else: - val = c._validate(value.get(c.id), data) - if val is not twc.EmptyField: - data[c.id] = val - if isinstance(c, HidingComponentMixin): - show.update(c.mapping.get(data[c.id], [])) - except twc.ValidationError: - data[c.id] = twc.Invalid - any_errors = True - if self.validator: - data = self.validator.to_python(data, state) - self.validator.validate_python(data, state) - if any_errors: - raise twc.ValidationError('childerror', self.validator) - return data - - -class HidingTableLayout(HidingContainerMixin, twf.TableLayout): - """A TableLayout that can contain hiding widgets.""" - -class HidingListLayout(HidingContainerMixin, twf.ListLayout): - """A ListLayout that can contain hiding widgets.""" - -#-- -# Miscellaneous widgets -#-- -class CalendarDatePicker(twf.widgets.InputField): - """ - A JavaScript calendar system for picking dates. The date format can be configured on the validator. - """ - resources = [ - twc.CSSLink(modname='tw2.dynforms', filename='static/calendar/calendar-system.css'), - twc.JSLink(modname='tw2.dynforms', filename='static/calendar/calendar.js'), - twc.JSLink(modname='tw2.dynforms', filename='static/calendar/calendar-setup.js'), - twc.Link(id='cal', modname='tw2.dynforms', filename='static/office-calendar.png'), - ] - language = twc.Param('Short country code for language to use, e.g. fr, de', default='en') - show_time = twc.Variable('Whether to display the time', default=False) - value = twc.Param('The default value is the current date/time', default=None) - validator = twc.DateValidator - template = "genshi:tw2.dynforms.templates.calendar" - type = 'text' - - def prepare(self): - - if not self.value: - # XXX -- Doing this instead of twc.Deferred consciously. - # twc.Deferred is/was nice, but the execution in post_define(...) of - # cls._deferred = [k for k, v in cls.__dict__.iteritems() - # if isinstance(v, pm.Deferred)] - # with dir(..) instead of vars(..) is too costly. This is the only - # place I'm aware of that actually uses deferred params. - threebean - self.value = dt.datetime.now() - - super(CalendarDatePicker, self).prepare() - - self.safe_modify('resources') - self.resources.extend([ - twc.JSLink(parent=self.__class__, modname='tw2.dynforms', filename='static/calendar/lang/calendar-%s.js' % self.language), - ]) - self.add_call(twc.js_function('Calendar.setup')(dict( - inputField = self.compound_id, - ifFormat = self.validator.format, - button = self.compound_id + ':trigger', - showsTime = self.show_time - ))) - - -class CalendarDateTimePicker(CalendarDatePicker): - """ - A JavaScript calendar system for picking dates and times. - """ - validator = twc.DateTimeValidator - show_time = True - - -class LinkContainer(twc.DisplayOnlyWidget): - """This widget provides a "View" link adjacent to any other widget required. This link is visible only when a value is selected, and allows the user to view detailed information on the current selection.""" - template = "genshi:tw2.dynforms.templates.link_container" - resources = [twc.JSLink(modname=__name__, filename='static/dynforms.js')] - - link = twc.Param('The link target. If a $ character is present in the URL, it is replaced with the current value of the widget.') - view_text = twc.Param('Text to appear in the link', default='View') - id_suffix = 'view' - - def prepare(self): - super(LinkContainer, self).prepare() - self.child.safe_modify('attrs') - self.child.attrs['onchange'] = (('twd_link_onchange(this, "%s");' % self.link) + - self.child.attrs.get('onchange', '')) - if not self.child.value: - self.attrs['style'] = 'display:none;' + self.attrs.get('style', '') - - -class CustomisedForm(twf.Form): - """A form that allows specification of several useful client-side behaviours.""" - blank_deleted = twc.Param('Blank out any invisible form fields before submitting. This is needed for GrowingGrid.', default=True) - disable_enter = twc.Param('Disable the enter button (except with textarea fields). This reduces the chance of users accidentally submitting the form.', default=True) - prevent_multi_submit = twc.Param('When the user clicks the submit button, disable it, to prevent the user causing multiple submissions.', default=True) - - resources = [twc.JSLink(modname=__name__, filename="static/dynforms.js")] - - def prepare(self): - super(CustomisedForm, self).prepare() - if self.blank_deleted: - self.safe_modify('attrs') - self.attrs['onsubmit'] = 'twd_blank_deleted()' - if self.disable_enter: - self.safe_modify('resources') - self.resources.append(twc.JSSource(src='document.onkeypress = twd_suppress_enter;')) - if self.prevent_multi_submit: - self.submit.safe_modify('attrs') - self.submit.attrs['onclick'] = 'return twd_no_multi_submit(this);' - - -class CustomisedTableForm(CustomisedForm, twf.TableForm): - pass +import tw2.core as twc, tw2.forms as twf, datetime as dt + +#-- +# Growing +#-- +class DeleteButton(twf.ImageButton): + """A button to delete a row in a growing grid. This is created automatically and would not usually be used directly.""" + attrs = { + 'onclick': 'twd_grow_del(this); return false;', + } + modname = __name__ + filename = 'static/del.png' + alt = 'Delete row' + validator = twc.BlankValidator + + +class GrowingGridLayout(twf.GridLayout): + """A GridLayout that can dynamically grow on the client, with delete and undo functionality. This is useful for allowing users to enter a list of items that can vary in length. To function correctly, the widget must appear inside a CustomisedForm.""" + resources = [ + twc.Link(id='undo', modname=__name__, filename="static/undo.png"), + twc.JSLink(modname=__name__, filename="static/dynforms.js"), + ] + template = 'genshi:tw2.dynforms.templates.growing_grid_layout' + + # TBD: support these properly & min/max + repetitions = twc.Variable() + extra_reps = twc.Variable(default=1) + mix_reps = twc.Variable() + max_reps = twc.Variable() + + @classmethod + def post_define(cls): + if hasattr(cls.child, 'children') and len(cls.child.children): + # don't let the hidden template child cause validation to fail + cls.child.children[0].validator = None + if not hasattr(cls.child.children, 'del'): # TBD: 'del' in ... + cls.child = cls.child(children = list(cls.child.children) + [DeleteButton(id='del', label='')]) + + def prepare(self): + if not hasattr(self, '_validated'): + self.value = [None] + (self.value or []) + super(GrowingGridLayout, self).prepare() + # First and last rows have delete hidden (and hidingbutton) and get onchange + for r in (self.children[0], self.children[self.repetitions-1]): + for c in r.children: + c.safe_modify('attrs') + if c.id == 'del': + c.attrs['style'] = 'display:none;' + c.attrs.get('style', '') + else: + c.attrs['onchange'] = 'twd_grow_add(this);' + c.attrs.get('onchange', '') + # First row is hidden + hidden_row = self.children[0] + hidden_row.safe_modify('attrs') + hidden_row.attrs['style'] = 'display:none;' + hidden_row.attrs.get('style', '') + + def _validate(self, value, state=None): + value = [v for v in value if not ('del.x' in v and 'del.y' in v)] + return twc.RepeatingWidget._validate(self, [None] + twf.StripBlanks().to_python(value), state)[1:] + + +#-- +# Hiding +#-- +class HidingComponentMixin(twc.Widget): + """This widget is a $$ with additional functionality to hide or show other widgets in the form, depending on the value selected. The widget must be used inside a hiding container, e.g. HidingTableLayout.""" + resources = [twc.JSLink(modname=__name__, filename='static/dynforms.js')] + + mapping = twc.Param('A dictionary that maps selection values to visible controls', request_local=False) + + def prepare(self): + super(HidingComponentMixin, self).prepare() + self.safe_modify('resources') + self.add_call(twc.js_function('twd_hiding_init')( + self.compound_id, self.mapping)) + +class HidingSingleSelectField(HidingComponentMixin, twf.SingleSelectField): + __doc__ = HidingComponentMixin.__doc__.replace('$$', 'SingleSelectField') + attrs = {'onchange': 'twd_hiding_onchange(this)'} + +class HidingCheckBox(HidingComponentMixin, twf.CheckBox): + __doc__ = HidingComponentMixin.__doc__.replace('$$', 'CheckBox') + attrs = {'onclick': 'twd_hiding_onchange(this)'} + + _falsevals = (0, False, 'false') + _truevals = (1, True, 'true') + _truthvals = _falsevals + _truevals + + @classmethod + def normalize_bool(cls, value): + if value in cls._truevals: + return cls._truevals[0] + elif value in cls._falsevals: + return cls._falsevals[0] + else: + raise ValueError("Can't normalize {!r} to boolean".format(value)) + + @classmethod + def post_define(cls): + if not hasattr(cls, 'mapping'): + return + new_mapping = {} + for k, v in cls.mapping.items(): + if k not in cls._truthvals: + new_k = k + else: + new_k = cls.normalize_bool(k) + if new_k in new_mapping: + raise twc.ParameterError( + "duplicate normalized mapping key: {}".format(k)) + new_mapping[new_k] = v + cls.mapping = new_mapping + +class HidingSelectionList(HidingComponentMixin, twf.widgets.SelectionList): + def prepare(self): + super(HidingSelectionList, self).prepare() + for opt in self.options: + opt[0]['onclick'] = 'twd_hiding_listitem_onchange(this);' + opt[0].get('onclick', '') + +class HidingCheckBoxList(HidingSelectionList, twf.CheckBoxList): + __doc__ = HidingComponentMixin.__doc__.replace('$$', 'CheckBoxList') + +class HidingRadioButtonList(HidingSelectionList, twf.RadioButtonList): + __doc__ = HidingComponentMixin.__doc__.replace('$$', 'RadioButtonList') + +class HidingContainerMixin(object): + """Mixin to add hiding functionality to a container widget. The developer can use multiple inheritence to combine this class with a container widget, e.g. ListFieldSet. For this to work correctly, the container must make use of the container_attrs parameter on child widgets.""" + + @classmethod + def post_define(cls): + """ + Verify the mapping - check all controls exist and generate cls.hiding_ctrls + """ + cls.hiding_ctrls = set() + seen = set() + for c in getattr(cls, 'children', []): + seen.add(c.id) + if issubclass(c, HidingComponentMixin): + dep_ctrls = set() + for m in c.mapping.values(): + dep_ctrls.update(m) + cls.hiding_ctrls.update(dep_ctrls) + for d in dep_ctrls: + if not hasattr(cls.children, d): + raise twc.ParameterError('Widget referenced in mapping does not exist: ' + d) + if d in seen: + raise twc.ParameterError('Widget mapping references a preceding widget: ' + d) + + def prepare(self): + super(HidingContainerMixin, self).prepare() + show = set() + for c in self.children: + if isinstance(c, HidingComponentMixin): + if isinstance(c, HidingCheckBox): + show.update(c.mapping.get(c.normalize_bool(c.value), [])) + elif isinstance(c.value, list): + for v in c.value: + show.update(c.mapping.get(v, [])) + else: + show.update(c.mapping.get(c.value, [])) + if c.id in self.hiding_ctrls and c.id not in show: + c.safe_modify('container_attrs') + c.container_attrs['style'] = 'display:none;' + c.container_attrs.get('style', '') + + # Hide required attribute on children where applicable + children = [c] + while children: + new_children = [] + for cc in children: + new_children.extend(getattr(cc, 'children', [])) + if cc.attrs.get('required', None): + cc.safe_modify('attrs') + del cc.attrs['required'] + cc.attrs['_twd_hidden_required'] = 'required' + children = new_children + + @twc.validation.catch_errors + def _validate(self, value, state=None): + self._validated = True + value = value or {} + if not isinstance(value, dict): + raise twc.ValidationError('corrupt', self.validator) + self.value = value + any_errors = False + data = {} + state = twc.util.clone_object( + state, full_dict=value, validated_values=data) + show = set() + for c in self.children: + if c.id in self.hiding_ctrls and c.id not in show: + data[c.id] = None + else: + try: + if c._sub_compound: + data.update(c._validate(value, state)) + else: + val = c._validate(value.get(c.id), state) + if val is not twc.EmptyField: + data[c.id] = val + if isinstance(c, HidingComponentMixin): + show.update(c.mapping.get(data[c.id], [])) + except twc.ValidationError: + data[c.id] = twc.Invalid + any_errors = True + if self.validator: + data = self.validator.to_python(data, state) + self.validator.validate_python(data, state) + if any_errors: + raise twc.ValidationError('childerror', self.validator) + return data + + +class HidingTableLayout(HidingContainerMixin, twf.TableLayout): + """A TableLayout that can contain hiding widgets.""" + +class HidingListLayout(HidingContainerMixin, twf.ListLayout): + """A ListLayout that can contain hiding widgets.""" + +#-- +# Miscellaneous widgets +#-- +class CalendarDatePicker(twf.widgets.InputField): + """ + A JavaScript calendar system for picking dates. The date format can be configured on the validator. + """ + resources = [ + twc.CSSLink(modname='tw2.dynforms', filename='static/calendar/calendar-system.css'), + twc.JSLink(modname='tw2.dynforms', filename='static/calendar/calendar.js'), + twc.JSLink(modname='tw2.dynforms', filename='static/calendar/calendar-setup.js'), + twc.Link(id='cal', modname='tw2.dynforms', filename='static/office-calendar.png'), + ] + language = twc.Param('Short country code for language to use, e.g. fr, de', default='en') + show_time = twc.Variable('Whether to display the time', default=False) + value = twc.Param('The default value is the current date/time', default=None) + validator = twc.DateValidator + template = "genshi:tw2.dynforms.templates.calendar" + type = 'text' + + def prepare(self): + + if not self.value: + # XXX -- Doing this instead of twc.Deferred consciously. + # twc.Deferred is/was nice, but the execution in post_define(...) of + # cls._deferred = [k for k, v in cls.__dict__.iteritems() + # if isinstance(v, pm.Deferred)] + # with dir(..) instead of vars(..) is too costly. This is the only + # place I'm aware of that actually uses deferred params. - threebean + self.value = dt.datetime.now() + + super(CalendarDatePicker, self).prepare() + + self.safe_modify('resources') + self.resources.extend([ + twc.JSLink(parent=self.__class__, modname='tw2.dynforms', filename='static/calendar/lang/calendar-%s.js' % self.language), + ]) + self.add_call(twc.js_function('Calendar.setup')(dict( + inputField = self.compound_id, + ifFormat = self.validator.format, + button = self.compound_id + ':trigger', + showsTime = self.show_time + ))) + + +class CalendarDateTimePicker(CalendarDatePicker): + """ + A JavaScript calendar system for picking dates and times. + """ + validator = twc.DateTimeValidator + show_time = True + + +class LinkContainer(twc.DisplayOnlyWidget): + """This widget provides a "View" link adjacent to any other widget required. This link is visible only when a value is selected, and allows the user to view detailed information on the current selection.""" + template = "genshi:tw2.dynforms.templates.link_container" + resources = [twc.JSLink(modname=__name__, filename='static/dynforms.js')] + + link = twc.Param('The link target. If a $ character is present in the URL, it is replaced with the current value of the widget.') + view_text = twc.Param('Text to appear in the link', default='View') + id_suffix = 'view' + + def prepare(self): + super(LinkContainer, self).prepare() + self.child.safe_modify('attrs') + self.child.attrs['onchange'] = (('twd_link_onchange(this, "%s");' % self.link) + + self.child.attrs.get('onchange', '')) + if not self.child.value: + self.attrs['style'] = 'display:none;' + self.attrs.get('style', '') + + +class CustomisedForm(twf.Form): + """A form that allows specification of several useful client-side behaviours.""" + blank_deleted = twc.Param('Blank out any invisible form fields before submitting. This is needed for GrowingGrid.', default=True) + disable_enter = twc.Param('Disable the enter button (except with textarea fields). This reduces the chance of users accidentally submitting the form.', default=True) + prevent_multi_submit = twc.Param('When the user clicks the submit button, disable it, to prevent the user causing multiple submissions.', default=True) + + resources = [twc.JSLink(modname=__name__, filename="static/dynforms.js")] + + def prepare(self): + super(CustomisedForm, self).prepare() + if self.blank_deleted: + self.safe_modify('attrs') + self.attrs['onsubmit'] = 'twd_blank_deleted()' + if self.disable_enter: + self.safe_modify('resources') + self.resources.append(twc.JSSource(src='document.onkeypress = twd_suppress_enter;')) + if self.prevent_multi_submit: + self.submit.safe_modify('attrs') + self.submit.attrs['onclick'] = 'return twd_no_multi_submit(this);' + + +class CustomisedTableForm(CustomisedForm, twf.TableForm): + pass