diff --git a/tailbone/static/css/jquery.tagit.css b/tailbone/static/css/jquery.tagit.css new file mode 100644 index 00000000..f18650d9 --- /dev/null +++ b/tailbone/static/css/jquery.tagit.css @@ -0,0 +1,69 @@ +ul.tagit { + padding: 1px 5px; + overflow: auto; + margin-left: inherit; /* usually we don't want the regular ul margins. */ + margin-right: inherit; +} +ul.tagit li { + display: block; + float: left; + margin: 2px 5px 2px 0; +} +ul.tagit li.tagit-choice { + position: relative; + line-height: inherit; +} +input.tagit-hidden-field { + display: none; +} +ul.tagit li.tagit-choice-read-only { + padding: .2em .5em .2em .5em; +} + +ul.tagit li.tagit-choice-editable { + padding: .2em 18px .2em .5em; +} + +ul.tagit li.tagit-new { + padding: .25em 4px .25em 0; +} + +ul.tagit li.tagit-choice a.tagit-label { + cursor: pointer; + text-decoration: none; +} +ul.tagit li.tagit-choice .tagit-close { + cursor: pointer; + position: absolute; + right: .1em; + top: 50%; + margin-top: -8px; + line-height: 17px; +} + +/* used for some custom themes that don't need image icons */ +ul.tagit li.tagit-choice .tagit-close .text-icon { + display: none; +} + +ul.tagit li.tagit-choice input { + display: block; + float: left; + margin: 2px 5px 2px 0; +} +ul.tagit input[type="text"] { + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + box-sizing: border-box; + + -moz-box-shadow: none; + -webkit-box-shadow: none; + box-shadow: none; + + border: none; + margin: 0; + padding: 0; + width: inherit; + background-color: inherit; + outline: none; +} diff --git a/tailbone/static/js/lib/tag-it.min.js b/tailbone/static/js/lib/tag-it.min.js new file mode 100644 index 00000000..fd6140c8 --- /dev/null +++ b/tailbone/static/js/lib/tag-it.min.js @@ -0,0 +1,17 @@ +(function(b){b.widget("ui.tagit",{options:{allowDuplicates:!1,caseSensitive:!0,fieldName:"tags",placeholderText:null,readOnly:!1,removeConfirmation:!1,tagLimit:null,availableTags:[],autocomplete:{},showAutocompleteOnFocus:!1,allowSpaces:!1,singleField:!1,singleFieldDelimiter:",",singleFieldNode:null,animate:!0,tabIndex:null,beforeTagAdded:null,afterTagAdded:null,beforeTagRemoved:null,afterTagRemoved:null,onTagClicked:null,onTagLimitExceeded:null,onTagAdded:null,onTagRemoved:null,tagSource:null},_create:function(){var a= +this;this.element.is("input")?(this.tagList=b("").insertAfter(this.element),this.options.singleField=!0,this.options.singleFieldNode=this.element,this.element.addClass("tagit-hidden-field")):this.tagList=this.element.find("ul, ol").andSelf().last();this.tagInput=b('').addClass("ui-widget-content");this.options.readOnly&&this.tagInput.attr("disabled","disabled");this.options.tabIndex&&this.tagInput.attr("tabindex",this.options.tabIndex);this.options.placeholderText&&this.tagInput.attr("placeholder", +this.options.placeholderText);this.options.autocomplete.source||(this.options.autocomplete.source=function(a,e){var d=a.term.toLowerCase(),c=b.grep(this.options.availableTags,function(a){return 0===a.toLowerCase().indexOf(d)});this.options.allowDuplicates||(c=this._subtractArray(c,this.assignedTags()));e(c)});this.options.showAutocompleteOnFocus&&(this.tagInput.focus(function(b,d){a._showAutocomplete()}),"undefined"===typeof this.options.autocomplete.minLength&&(this.options.autocomplete.minLength= +0));b.isFunction(this.options.autocomplete.source)&&(this.options.autocomplete.source=b.proxy(this.options.autocomplete.source,this));b.isFunction(this.options.tagSource)&&(this.options.tagSource=b.proxy(this.options.tagSource,this));this.tagList.addClass("tagit").addClass("ui-widget ui-widget-content ui-corner-all").append(b('
  • ').append(this.tagInput)).click(function(d){var c=b(d.target);c.hasClass("tagit-label")?(c=c.closest(".tagit-choice"),c.hasClass("removed")||a._trigger("onTagClicked", +d,{tag:c,tagLabel:a.tagLabel(c)})):a.tagInput.focus()});var c=!1;if(this.options.singleField)if(this.options.singleFieldNode){var d=b(this.options.singleFieldNode),f=d.val().split(this.options.singleFieldDelimiter);d.val("");b.each(f,function(b,d){a.createTag(d,null,!0);c=!0})}else this.options.singleFieldNode=b(''),this.tagList.after(this.options.singleFieldNode);c||this.tagList.children("li").each(function(){b(this).hasClass("tagit-new")|| +(a.createTag(b(this).text(),b(this).attr("class"),!0),b(this).remove())});this.tagInput.keydown(function(c){if(c.which==b.ui.keyCode.BACKSPACE&&""===a.tagInput.val()){var d=a._lastTag();!a.options.removeConfirmation||d.hasClass("remove")?a.removeTag(d):a.options.removeConfirmation&&d.addClass("remove ui-state-highlight")}else a.options.removeConfirmation&&a._lastTag().removeClass("remove ui-state-highlight");if(c.which===b.ui.keyCode.COMMA&&!1===c.shiftKey||c.which===b.ui.keyCode.ENTER||c.which== +b.ui.keyCode.TAB&&""!==a.tagInput.val()||c.which==b.ui.keyCode.SPACE&&!0!==a.options.allowSpaces&&('"'!=b.trim(a.tagInput.val()).replace(/^s*/,"").charAt(0)||'"'==b.trim(a.tagInput.val()).charAt(0)&&'"'==b.trim(a.tagInput.val()).charAt(b.trim(a.tagInput.val()).length-1)&&0!==b.trim(a.tagInput.val()).length-1))c.which===b.ui.keyCode.ENTER&&""===a.tagInput.val()||c.preventDefault(),a.options.autocomplete.autoFocus&&a.tagInput.data("autocomplete-open")||(a.tagInput.autocomplete("close"),a.createTag(a._cleanedInput()))}).blur(function(b){a.tagInput.data("autocomplete-open")|| +a.createTag(a._cleanedInput())});if(this.options.availableTags||this.options.tagSource||this.options.autocomplete.source)d={select:function(b,c){a.createTag(c.item.value);return!1}},b.extend(d,this.options.autocomplete),d.source=this.options.tagSource||d.source,this.tagInput.autocomplete(d).bind("autocompleteopen.tagit",function(b,c){a.tagInput.data("autocomplete-open",!0)}).bind("autocompleteclose.tagit",function(b,c){a.tagInput.data("autocomplete-open",!1)}),this.tagInput.autocomplete("widget").addClass("tagit-autocomplete")}, +destroy:function(){b.Widget.prototype.destroy.call(this);this.element.unbind(".tagit");this.tagList.unbind(".tagit");this.tagInput.removeData("autocomplete-open");this.tagList.removeClass("tagit ui-widget ui-widget-content ui-corner-all tagit-hidden-field");this.element.is("input")?(this.element.removeClass("tagit-hidden-field"),this.tagList.remove()):(this.element.children("li").each(function(){b(this).hasClass("tagit-new")?b(this).remove():(b(this).removeClass("tagit-choice ui-widget-content ui-state-default ui-state-highlight ui-corner-all remove tagit-choice-editable tagit-choice-read-only"), +b(this).text(b(this).children(".tagit-label").text()))}),this.singleFieldNode&&this.singleFieldNode.remove());return this},_cleanedInput:function(){return b.trim(this.tagInput.val().replace(/^"(.*)"$/,"$1"))},_lastTag:function(){return this.tagList.find(".tagit-choice:last:not(.removed)")},_tags:function(){return this.tagList.find(".tagit-choice:not(.removed)")},assignedTags:function(){var a=this,c=[];this.options.singleField?(c=b(this.options.singleFieldNode).val().split(this.options.singleFieldDelimiter), +""===c[0]&&(c=[])):this._tags().each(function(){c.push(a.tagLabel(this))});return c},_updateSingleTagsField:function(a){b(this.options.singleFieldNode).val(a.join(this.options.singleFieldDelimiter)).trigger("change")},_subtractArray:function(a,c){for(var d=[],f=0;f=this.options.tagLimit)return this._trigger("onTagLimitExceeded",null,{duringInitialization:d}),!1;var g=b(this.options.onTagClicked?'':'').text(a),e=b("
  • ").addClass("tagit-choice ui-widget-content ui-state-default ui-corner-all").addClass(c).append(g); +this.options.readOnly?e.addClass("tagit-choice-read-only"):(e.addClass("tagit-choice-editable"),c=b("").addClass("ui-icon ui-icon-close"),c=b('\u00d7').addClass("tagit-close").append(c).click(function(a){f.removeTag(e)}),e.append(c));this.options.singleField||(g=g.html(),e.append(''));!1!==this._trigger("beforeTagAdded",null,{tag:e,tagLabel:this.tagLabel(e), +duringInitialization:d})&&(this.options.singleField&&(g=this.assignedTags(),g.push(a),this._updateSingleTagsField(g)),this._trigger("onTagAdded",null,e),this.tagInput.val(""),this.tagInput.parent().before(e),this._trigger("afterTagAdded",null,{tag:e,tagLabel:this.tagLabel(e),duringInitialization:d}),this.options.showAutocompleteOnFocus&&!d&&setTimeout(function(){f._showAutocomplete()},0))},removeTag:function(a,c){c="undefined"===typeof c?this.options.animate:c;a=b(a);this._trigger("onTagRemoved", +null,a);if(!1!==this._trigger("beforeTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})){if(this.options.singleField){var d=this.assignedTags(),f=this.tagLabel(a),d=b.grep(d,function(a){return a!=f});this._updateSingleTagsField(d)}if(c){a.addClass("removed");var d=this._effectExists("blind")?["blind",{direction:"horizontal"},"fast"]:["fast"],g=this;d.push(function(){a.remove();g._trigger("afterTagRemoved",null,{tag:a,tagLabel:g.tagLabel(a)})});a.fadeOut("fast").hide.apply(a,d).dequeue()}else a.remove(), +this._trigger("afterTagRemoved",null,{tag:a,tagLabel:this.tagLabel(a)})}},removeTagByLabel:function(a,b){var d=this._findTagByLabel(a);if(!d)throw"No such tag exists with the name '"+a+"'";this.removeTag(d,b)},removeAll:function(){var a=this;this._tags().each(function(b,d){a.removeTag(d,!1)})}})})(jQuery); diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako new file mode 100644 index 00000000..f592ed71 --- /dev/null +++ b/tailbone/templates/messages/create.mako @@ -0,0 +1,59 @@ +## -*- coding: utf-8 -*- +<%inherit file="/master/create.mako" /> + +<%def name="head_tags()"> + ${parent.head_tags()} + ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} + ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} + + + + +<%def name="context_menu_items()"> + % if request.has_perm('messages.list'): +
  • ${h.link_to("Go to my Message Inbox", url('messages.inbox'))}
  • +
  • ${h.link_to("Go to my Message Archive", url('messages.archive'))}
  • +
  • ${h.link_to("Go to my Sent Messages", url('messages.sent'))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako index cfcf21d1..aebd91e1 100644 --- a/tailbone/templates/messages/index.mako +++ b/tailbone/templates/messages/index.mako @@ -58,4 +58,10 @@ ## +<%def name="context_menu_items()"> + % if request.has_perm('messages.create'): +
  • ${h.link_to("Send a new Message", url('messages.create'))}
  • + % endif + + ${parent.body()} diff --git a/tailbone/templates/messages/sent/index.mako b/tailbone/templates/messages/sent/index.mako index 6c96d85a..0b5d5b60 100644 --- a/tailbone/templates/messages/sent/index.mako +++ b/tailbone/templates/messages/sent/index.mako @@ -1,5 +1,5 @@ ## -*- coding: utf-8 -*- -<%inherit file="/master/index.mako" /> +<%inherit file="/messages/index.mako" /> <%def name="title()">Sent Messages diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako index 69cc8f94..d101d1ef 100644 --- a/tailbone/templates/messages/view.mako +++ b/tailbone/templates/messages/view.mako @@ -2,6 +2,9 @@ <%inherit file="/master/view.mako" /> <%def name="context_menu_items()"> + % if request.has_perm('messages.create'): +
  • ${h.link_to("Send a new Message", url('messages.create'))}
  • + % endif % if recipient: % if recipient.status == rattail.enum.MESSAGE_STATUS_INBOX:
  • ${h.link_to("Back to Message Inbox", url('messages.inbox'))}
  • diff --git a/tailbone/views/autocomplete.py b/tailbone/views/autocomplete.py index 26fb47db..ef389d12 100644 --- a/tailbone/views/autocomplete.py +++ b/tailbone/views/autocomplete.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2014 Lance Edgar +# Copyright © 2010-2016 Lance Edgar # # This file is part of Rattail. # @@ -20,11 +20,12 @@ # along with Rattail. If not, see . # ################################################################################ - """ Autocomplete View """ +from __future__ import unicode_literals, absolute_import + from tailbone.views.core import View from tailbone.db import Session @@ -73,4 +74,4 @@ class AutocompleteView(View): if not term: return [] results = self.query(term).all() - return [{u'label': self.display(x), u'value': self.value(x)} for x in results] + return [{'label': self.display(x), 'value': self.value(x)} for x in results] diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c5763e35..f55c5d82 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -103,6 +103,7 @@ class MasterView(View): form = self.make_form(self.model_class) if self.request.method == 'POST': if form.validate(): + self.before_create(form) form.save() instance = form.fieldset.model self.after_create(instance) @@ -549,6 +550,12 @@ class MasterView(View): """ fieldset.configure() + def before_create(self, form): + """ + Event hook, called just after the form to create a new instance has + been validated, but prior to the form itself being saved. + """ + def after_create(self, instance): """ Event hook, called just after a new instance is saved. diff --git a/tailbone/views/messages.py b/tailbone/views/messages.py index 018beafa..f9df4985 100644 --- a/tailbone/views/messages.py +++ b/tailbone/views/messages.py @@ -30,12 +30,13 @@ from rattail import enum from rattail.db import model import formalchemy +from formalchemy.helpers import hidden_field from pyramid import httpexceptions -from webhelpers.html import tags +from webhelpers.html import tags, HTML from tailbone import forms from tailbone.db import Session -from tailbone.views import MasterView +from tailbone.views import MasterView, AutocompleteView class SubjectFieldRenderer(formalchemy.FieldRenderer): @@ -56,13 +57,31 @@ class SenderFieldRenderer(forms.renderers.UserFieldRenderer): return super(SenderFieldRenderer, self).render_readonly(**kwargs) +class RecipientsField(formalchemy.Field): + """ + Custom field for recipients, used when sending new messages. + """ + is_collection = True + + def sync(self): + if not self.is_readonly(): + message = self.parent.model + for uuid in set(self._deserialize()): + user = Session.query(model.User).get(uuid) + if user: + message.add_recipient(user, status=enum.MESSAGE_STATUS_INBOX) + + class RecipientsFieldRenderer(formalchemy.FieldRenderer): + def render(self, **kwargs): + return HTML.tag('ul') + def render_readonly(self, **kwargs): recipients = self.raw_value if not recipients: return '' - recips = filter(lambda r: r.recipient is not self.request.user, recipients) + recips = [r for r in recipients if r.recipient is not self.request.user] recips = sorted([r.recipient.display_name for r in recips]) if len(recips) < len(recipients): recips.insert(0, 'you') @@ -87,7 +106,6 @@ class MessagesView(MasterView): Base class for message views. """ model_class = model.Message - creatable = False editable = False deletable = False checkboxes = True @@ -151,16 +169,27 @@ class MessagesView(MasterView): return {} def configure_fieldset(self, fs): - fs.configure( - include=[ + if self.creating: + fs.append(RecipientsField('recipients', label="To", renderer=RecipientsFieldRenderer)) + fs.configure(include=[ + fs.recipients, fs.subject, + fs.body.textarea(size='50x10'), + ]) + else: + fs.configure(include=[ fs.sender.with_renderer(SenderFieldRenderer).label("From"), fs.recipients.with_renderer(RecipientsFieldRenderer).label("To"), fs.sent.with_renderer(forms.renderers.DateTimeFieldRenderer(self.rattail_config)), - fs.body, + fs.subject, + fs.body.textarea(size='50x10'), ]) if self.viewing: - del fs.body + del fs.body # ..really? + + def before_create(self, form): + message = form.fieldset.model + message.sender = self.request.user def get_recipient(self, message): for recip in message.recipients: @@ -265,6 +294,22 @@ class SentView(MessagesView): g.filters['sender'].default_active = False +class RecipientsAutocomplete(AutocompleteView): + """ + Autocomplete AJAX view for the recipients field when sending a new message. + """ + + def query(self, term): + return Session.query(model.User)\ + .join(model.Person)\ + .filter(model.User.active == True)\ + .filter(model.Person.display_name.ilike('%{}%'.format(term)))\ + .order_by(model.Person.display_name) + + def display(self, user): + return user.person.display_name + + def includeme(config): config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") @@ -279,6 +324,10 @@ def includeme(config): config.add_view(ArchiveView, attr='index', route_name='messages.archive', permission='messages.list') + # move (single) + config.add_route('messages.move', '/messages/{uuid}/move') + config.add_view(MessagesView, attr='move', route_name='messages.move') + # move bulk config.add_route('messages.move_bulk', '/messages/move-bulk') config.add_view(MessagesView, attr='move_bulk', route_name='messages.move_bulk') @@ -288,10 +337,9 @@ def includeme(config): config.add_view(SentView, attr='index', route_name='messages.sent', permission='messages.list') - # view - config.add_route('messages.view', '/messages/{uuid}') - config.add_view(MessagesView, attr='view', route_name='messages.view') + # recipients autocomplete + config.add_route('messages.recipients', '/messages/recipients') + config.add_view(RecipientsAutocomplete, route_name='messages.recipients', + renderer='json', permission='messages.create') - # move (single) - config.add_route('messages.move', '/messages/{uuid}/move') - config.add_view(MessagesView, attr='move', route_name='messages.move') + MessagesView.defaults(config)