Refactor "send new message" form, esp. recipients field, per Vue.js
This commit is contained in:
		
							parent
							
								
									e153e530a8
								
							
						
					
					
						commit
						86695c9dc7
					
				
					 8 changed files with 274 additions and 20 deletions
				
			
		
							
								
								
									
										108
									
								
								tailbone/static/js/tailbone.buefy.message_recipients.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								tailbone/static/js/tailbone.buefy.message_recipients.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,108 @@ | |||
| 
 | ||||
| const MessageRecipients = { | ||||
|     template: '#message-recipients-template', | ||||
| 
 | ||||
|     props: { | ||||
|         name: String, | ||||
|         value: Array, | ||||
|         possibleRecipients: Array, | ||||
|         recipientDisplayMap: Object, | ||||
|     }, | ||||
| 
 | ||||
|     data() { | ||||
|         return { | ||||
|             autocompleteValue: null, | ||||
|             actualValue: this.value, | ||||
|         } | ||||
|     }, | ||||
| 
 | ||||
|     computed: { | ||||
| 
 | ||||
|         filteredData() { | ||||
|             // this is the logic responsible for "matching" user's autocomplete
 | ||||
|             // input, with possible recipients.  we return all matches as list.
 | ||||
|             let filtered = [] | ||||
|             if (this.autocompleteValue) { | ||||
|                 let term = this.autocompleteValue.toLowerCase() | ||||
|                 this.possibleRecipients.forEach(function(value, key, map) { | ||||
| 
 | ||||
|                     // first check to see if value is simple string, if so then
 | ||||
|                     // will attempt to match it directly
 | ||||
|                     if (value.toLowerCase !== undefined) { | ||||
|                         if (value.toLowerCase().indexOf(term) >= 0) { | ||||
|                             filtered.push({value: key, label: value}) | ||||
|                         } | ||||
| 
 | ||||
|                     } else { | ||||
|                         // value is not a string, which means it must be a
 | ||||
|                         // grouping object, which must have a name property
 | ||||
|                         if (value.name.toLowerCase().indexOf(term) >= 0) { | ||||
|                             filtered.push({ | ||||
|                                 value: key, | ||||
|                                 label: value.name, | ||||
|                                 moreValues: value.uuids, | ||||
|                             }) | ||||
|                         } | ||||
|                     } | ||||
|                 }) | ||||
|             } | ||||
|             return filtered | ||||
|         }, | ||||
|     }, | ||||
| 
 | ||||
|     methods: { | ||||
| 
 | ||||
|         addRecipient(uuid) { | ||||
| 
 | ||||
|             // add selected user to "actual" value
 | ||||
|             if (!this.actualValue.includes(uuid)) { | ||||
|                 this.actualValue.push(uuid) | ||||
|             } | ||||
|         }, | ||||
| 
 | ||||
|         removeRecipient(uuid) { | ||||
| 
 | ||||
|             // locate and remove user uuid from "actual" value
 | ||||
|             for (let i = 0; i < this.actualValue.length; i++) { | ||||
|                 if (this.actualValue[i] == uuid) { | ||||
|                     this.actualValue.splice(i, 1) | ||||
|                     break | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
| 
 | ||||
|         selectionMade(option) { | ||||
| 
 | ||||
|             // apparently option can be null sometimes..?
 | ||||
|             if (option) { | ||||
| 
 | ||||
|                 // add all newly-selected users to "actual" value
 | ||||
|                 if (option.moreValues) { | ||||
|                     // grouping object; add all its "contained" values
 | ||||
|                     option.moreValues.forEach(function(uuid) { | ||||
|                         this.addRecipient(uuid) | ||||
|                     }, this) | ||||
|                 } else { | ||||
|                     // normal object, just add its value
 | ||||
|                     this.addRecipient(option.value) | ||||
|                 } | ||||
| 
 | ||||
|                 // let parent know we changed value
 | ||||
|                 this.$emit('input', this.actualValue) | ||||
|             } | ||||
| 
 | ||||
|             // clear out the *visible* autocomplete value
 | ||||
|             this.$nextTick(function() { | ||||
|                 this.autocompleteValue = null | ||||
| 
 | ||||
|                 // TODO: wtf, sometimes we have to clear this out twice?!
 | ||||
|                 this.$nextTick(function() { | ||||
|                     this.autocompleteValue = null | ||||
|                 }) | ||||
|             }) | ||||
|         }, | ||||
|     }, | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| Vue.component('message-recipients', MessageRecipients) | ||||
							
								
								
									
										13
									
								
								tailbone/templates/deform/message_recipients_buefy.pt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								tailbone/templates/deform/message_recipients_buefy.pt
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| <div tal:define="name name|field.name; | ||||
|                  possible_recipients possible_recipients|'possibleRecipients'; | ||||
|                  recipient_display_map recipient_display_map|'recipientDisplayMap';" | ||||
|      tal:omit-tag=""> | ||||
|   <div tal:define="vmodel vmodel|'field_model_' + name;" | ||||
|        tal:omit-tag=""> | ||||
|     <message-recipients name="${name}" | ||||
|                         v-model="${vmodel}" | ||||
|                         tal:attributes=":possible-recipients possible_recipients; | ||||
|                                         :recipient-display-map recipient_display_map;"> | ||||
|     </message-recipients> | ||||
|   </div> | ||||
| </div> | ||||
|  | @ -4,13 +4,16 @@ | |||
|                   mask mask|field.widget.mask; | ||||
|                   mask_placeholder mask_placeholder|field.widget.mask_placeholder; | ||||
|                   style style|field.widget.style; | ||||
|                   use_buefy use_buefy|0;" | ||||
|                   use_buefy use_buefy|0; | ||||
|                   placeholder placeholder|getattr(field.widget, 'placeholder', ''); | ||||
|                   autocomplete autocomplete|getattr(field.widget, 'autocomplete', 'on');" | ||||
|       tal:omit-tag=""> | ||||
|   <div tal:condition="not use_buefy" tal:omit-tag=""> | ||||
|     <input type="text" name="${name}" value="${cstruct}"  | ||||
|            tal:attributes="class string: form-control ${css_class or ''}; | ||||
|                            style style; | ||||
|                            attributes|field.widget.attributes|{};" | ||||
|            autocomplete="${autocomplete}" | ||||
|            id="${oid}"/> | ||||
|     <script tal:condition="mask" type="text/javascript"> | ||||
|       deform.addCallback( | ||||
|  | @ -26,7 +29,9 @@ | |||
|        tal:define="vmodel vmodel|'field_model_' + name;" | ||||
|        tal:omit-tag=""> | ||||
|     <b-input name="${name}" | ||||
|              v-model="${vmodel}"> | ||||
|              v-model="${vmodel}" | ||||
|              placeholder="${placeholder}" | ||||
|              autocomplete="${autocomplete}"> | ||||
|     </b-input> | ||||
|   </div> | ||||
| </span> | ||||
|  |  | |||
|  | @ -1,8 +1,14 @@ | |||
| ## -*- coding: utf-8 -*- | ||||
| ## -*- coding: utf-8; -*- | ||||
| <%inherit file="/master/create.mako" /> | ||||
| <%namespace file="/messages/recipients.mako" import="message_recipients_template" /> | ||||
| 
 | ||||
| <%def name="content_title()">${parent.content_title() if not use_buefy else ''}</%def> | ||||
| 
 | ||||
| <%def name="extra_javascript()"> | ||||
|   ${parent.extra_javascript()} | ||||
|   % if use_buefy: | ||||
|       ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.message_recipients.js'))} | ||||
|   % else: | ||||
|   ${h.javascript_link(request.static_url('tailbone:static/js/lib/tag-it.min.js'))} | ||||
|   <script type="text/javascript"> | ||||
| 
 | ||||
|  | @ -18,9 +24,9 @@ | |||
|     function validate_message_form() { | ||||
|         var form = $('#deform'); | ||||
| 
 | ||||
|         if (! form.find('input[name="recipients_"]').val()) { | ||||
|         if (! form.find('input[name="set_recipients"]').val()) { | ||||
|             alert("You must specify some recipient(s) for the message."); | ||||
|             $('.recipients_ input').data('ui-tagit').tagInput.focus(); | ||||
|             $('.set_recipients input').data('ui-tagit').tagInput.focus(); | ||||
|             return false; | ||||
|         } | ||||
| 
 | ||||
|  | @ -35,7 +41,7 @@ | |||
| 
 | ||||
|     $(function() { | ||||
| 
 | ||||
|         var recipients = $('.recipients_ input'); | ||||
|         var recipients = $('.set_recipients input'); | ||||
| 
 | ||||
|         recipients.tagit({ | ||||
| 
 | ||||
|  | @ -81,6 +87,7 @@ | |||
| 
 | ||||
|   </script> | ||||
|   ${self.validate_message_js()} | ||||
|   % endif | ||||
| </%def> | ||||
| 
 | ||||
| <%def name="validate_message_js()"> | ||||
|  | @ -95,6 +102,19 @@ | |||
| 
 | ||||
| <%def name="extra_styles()"> | ||||
|   ${parent.extra_styles()} | ||||
|   % if use_buefy: | ||||
|       <style type="text/css"> | ||||
| 
 | ||||
|         .this-page-content { | ||||
|           width: 100%; | ||||
|         } | ||||
| 
 | ||||
|         .this-page-content .buttons { | ||||
|             margin-left: 20rem; | ||||
|         } | ||||
| 
 | ||||
|       </style> | ||||
|   % else: | ||||
|   ${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.tagit.css'))} | ||||
|   <style type="text/css"> | ||||
| 
 | ||||
|  | @ -111,6 +131,7 @@ | |||
|     } | ||||
| 
 | ||||
|   </style> | ||||
|   % endif | ||||
| </%def> | ||||
| 
 | ||||
| <%def name="before_tag_added()"> | ||||
|  | @ -133,4 +154,20 @@ | |||
|   % endif | ||||
| </%def> | ||||
| 
 | ||||
| <%def name="render_this_page_template()"> | ||||
|   ${parent.render_this_page_template()} | ||||
|   ${message_recipients_template()} | ||||
| </%def> | ||||
| 
 | ||||
| <%def name="modify_this_page_vars()"> | ||||
|   ${parent.modify_this_page_vars()} | ||||
|   <script type="text/javascript"> | ||||
| 
 | ||||
|     TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n}) | ||||
|     TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n} | ||||
| 
 | ||||
|   </script> | ||||
| </%def> | ||||
| 
 | ||||
| 
 | ||||
| ${parent.body()} | ||||
|  |  | |||
							
								
								
									
										36
									
								
								tailbone/templates/messages/recipients.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								tailbone/templates/messages/recipients.mako
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,36 @@ | |||
| ## -*- coding: utf-8; -*- | ||||
| 
 | ||||
| <%def name="message_recipients_template()"> | ||||
|   <script type="text/x-template" id="message-recipients-template"> | ||||
|     <div> | ||||
| 
 | ||||
|       <input type="hidden" :name="name" v-model="actualValue" /> | ||||
| 
 | ||||
|       <b-field grouped group-multiline> | ||||
|         <div v-for="uuid in actualValue" | ||||
|              :key="uuid" | ||||
|              class="control"> | ||||
|           <b-tag type="is-primary" | ||||
|                  attached | ||||
|                  aria-close-label="Remove recipient" | ||||
|                  closable | ||||
|                  @close="removeRecipient(uuid)"> | ||||
|             {{ recipientDisplayMap[uuid] }} | ||||
|           </b-tag> | ||||
|         </div> | ||||
|       </b-field> | ||||
| 
 | ||||
|       <b-autocomplete v-model="autocompleteValue" | ||||
|                       placeholder="add recipient" | ||||
|                       :data="filteredData" | ||||
|                       field="uuid" | ||||
|                       @select="selectionMade" | ||||
|                       keep-first> | ||||
|         <template slot-scope="props"> | ||||
|           {{ props.option.label }} | ||||
|         </template> | ||||
|         <template slot="empty">No results found</template> | ||||
|       </b-autocomplete> | ||||
|     </div> | ||||
|   </script> | ||||
| </%def> | ||||
|  | @ -34,6 +34,7 @@ | |||
|         } | ||||
|         .tailbone-message-body { | ||||
|             margin: 1rem auto; | ||||
|             min-height: 10rem; | ||||
|         } | ||||
|         .tailbone-message-body p { | ||||
|             margin-bottom: 1rem; | ||||
|  |  | |||
|  | @ -246,7 +246,7 @@ | |||
|               % if master: | ||||
|                   % if master.listing: | ||||
|                       <span>${index_title}</span> | ||||
|                   % else: | ||||
|                   % elif index_url: | ||||
|                       ${h.link_to(index_title, index_url)} | ||||
|                       % if parent_url is not Undefined: | ||||
|                           <span>»</span> | ||||
|  | @ -258,6 +258,8 @@ | |||
|                       % if master.viewing and grid_index: | ||||
|                           ${grid_index_nav()} | ||||
|                       % endif | ||||
|                   % else: | ||||
|                       <span>${index_title}</span> | ||||
|                   % endif | ||||
|               % elif index_title: | ||||
|                   <span>${index_title}</span> | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ | |||
| ################################################################################ | ||||
| # | ||||
| #  Rattail -- Retail Software Framework | ||||
| #  Copyright © 2010-2018 Lance Edgar | ||||
| #  Copyright © 2010-2019 Lance Edgar | ||||
| # | ||||
| #  This file is part of Rattail. | ||||
| # | ||||
|  | @ -36,6 +36,7 @@ from deform import widget as dfwidget | |||
| from pyramid import httpexceptions | ||||
| from webhelpers2.html import tags, HTML | ||||
| 
 | ||||
| # from tailbone import forms | ||||
| from tailbone.db import Session | ||||
| from tailbone.views import MasterView | ||||
| from tailbone.util import raw_datetime | ||||
|  | @ -140,6 +141,8 @@ class MessagesView(MasterView): | |||
|         return six.text_type(sender) | ||||
| 
 | ||||
|     def render_subject_bold(self, message, field): | ||||
|         if not message.subject: | ||||
|             return "" | ||||
|         return HTML.tag('span', c=message.subject, style='font-weight: bold;') | ||||
| 
 | ||||
|     def render_recipients(self, message, column_name): | ||||
|  | @ -212,6 +215,9 @@ class MessagesView(MasterView): | |||
|         super(MessagesView, self).configure_form(f) | ||||
|         use_buefy = self.get_use_buefy() | ||||
| 
 | ||||
|         f.submit_label = "Send Message" | ||||
| 
 | ||||
|         if not use_buefy: | ||||
|             # we have custom logic to disable submit button | ||||
|             f.auto_disable = False | ||||
|             f.auto_disable_save = False | ||||
|  | @ -223,22 +229,34 @@ class MessagesView(MasterView): | |||
| 
 | ||||
|         f.set_type('sent', 'datetime') | ||||
| 
 | ||||
|         # recipients | ||||
|         f.set_renderer('recipients', self.render_recipients_full) | ||||
|         f.set_label('recipients', "To") | ||||
| 
 | ||||
|         # subject | ||||
|         if use_buefy: | ||||
|             f.set_renderer('subject', self.render_subject_bold) | ||||
|             if self.creating: | ||||
|                 f.set_widget('subject', dfwidget.TextInputWidget( | ||||
|                     placeholder="please enter a subject", | ||||
|                     autocomplete='off')) | ||||
|                 f.set_required('subject') | ||||
| 
 | ||||
|         # body | ||||
|         f.set_widget('body', dfwidget.TextAreaWidget(cols=50, rows=15)) | ||||
| 
 | ||||
|         if self.creating: | ||||
|             f.remove('sender', 'sent') | ||||
| 
 | ||||
|             f.insert_after('recipients', 'recipients_') | ||||
|             # recipients | ||||
|             f.insert_after('recipients', 'set_recipients') | ||||
|             f.remove('recipients') | ||||
|             f.set_node('recipients_', colander.SchemaNode(colander.Set())) | ||||
|             f.set_widget('recipients_', RecipientsWidget()) | ||||
|             f.set_label('recipients_', "To") | ||||
|             f.set_node('set_recipients', colander.SchemaNode(colander.Set())) | ||||
|             if use_buefy: | ||||
|                 f.set_widget('set_recipients', RecipientsWidgetBuefy()) | ||||
|             else: | ||||
|                 f.set_widget('set_recipients', RecipientsWidget()) | ||||
|             f.set_label('set_recipients', "To") | ||||
| 
 | ||||
|             if self.replying: | ||||
|                 old_message = self.get_instance() | ||||
|  | @ -259,11 +277,11 @@ class MessagesView(MasterView): | |||
|                     value = [r[0] for r in value] | ||||
|                     if old_message.sender is not self.request.user and old_message.sender.active: | ||||
|                         value.insert(0, old_message.sender_uuid) | ||||
|                     f.set_default('recipients_', ','.join(value)) | ||||
|                     f.set_default('set_recipients', ','.join(value)) | ||||
| 
 | ||||
|                 # Just a normal reply, to sender only. | ||||
|                 elif self.filter_reply_recipient(old_message.sender): | ||||
|                     f.set_default('recipients_', old_message.sender.uuid) | ||||
|                     f.set_default('set_recipients', old_message.sender.uuid) | ||||
| 
 | ||||
|                 # TODO? | ||||
|                 # # Set focus to message body instead of recipients, when replying. | ||||
|  | @ -281,7 +299,7 @@ class MessagesView(MasterView): | |||
|             if self.request.user: | ||||
|                 message.sender = self.request.user | ||||
| 
 | ||||
|             for uuid in data['recipients_']: | ||||
|             for uuid in data['set_recipients']: | ||||
|                 user = self.Session.query(model.User).get(uuid) | ||||
|                 if user: | ||||
|                     message.add_recipient(user, status=self.enum.MESSAGE_STATUS_INBOX) | ||||
|  | @ -322,11 +340,21 @@ class MessagesView(MasterView): | |||
|                 return recipient | ||||
| 
 | ||||
|     def template_kwargs_create(self, **kwargs): | ||||
|         recips = list(self.get_available_recipients().items()) | ||||
|         use_buefy = self.get_use_buefy() | ||||
| 
 | ||||
|         recips = self.get_available_recipients() | ||||
|         if use_buefy: | ||||
|             kwargs['recipient_display_map'] = recips | ||||
|         recips = list(recips.items()) | ||||
|         recips.sort(key=self.recipient_sortkey) | ||||
|         kwargs['available_recipients'] = recips | ||||
| 
 | ||||
|         if self.replying: | ||||
|             kwargs['original_message'] = self.get_instance() | ||||
| 
 | ||||
|         if use_buefy: | ||||
|             kwargs['index_url'] = None | ||||
|             kwargs['index_title'] = "New Message" | ||||
|         return kwargs | ||||
| 
 | ||||
|     def recipient_sortkey(self, recip): | ||||
|  | @ -355,7 +383,7 @@ class MessagesView(MasterView): | |||
|         kwargs['message'] = message | ||||
|         kwargs['recipient'] = recipient | ||||
| 
 | ||||
|         if recipient.status == self.enum.MESSAGE_STATUS_ARCHIVE: | ||||
|         if recipient and recipient.status == self.enum.MESSAGE_STATUS_ARCHIVE: | ||||
|             kwargs['index_url'] = self.request.route_url('messages.archive') | ||||
| 
 | ||||
|         return kwargs | ||||
|  | @ -514,6 +542,30 @@ class RecipientsWidget(dfwidget.TextInputWidget): | |||
|         return pstruct.split(',') | ||||
| 
 | ||||
| 
 | ||||
| class RecipientsWidgetBuefy(dfwidget.Widget): | ||||
|     """ | ||||
|     Custom "message recipients" widget, for use with Buefy / Vue.js themes. | ||||
|     """ | ||||
|     template = 'message_recipients_buefy' | ||||
| 
 | ||||
|     def deserialize(self, field, pstruct): | ||||
|         if pstruct is colander.null: | ||||
|             return colander.null | ||||
|         if not isinstance(pstruct, six.string_types): | ||||
|             raise colander.Invalid(field.schema, "Pstruct is not a string") | ||||
|         if not pstruct: | ||||
|             return colander.null | ||||
|         pstruct = pstruct.split(',') | ||||
|         return pstruct | ||||
| 
 | ||||
|     def serialize(self, field, cstruct, **kw): | ||||
|         if cstruct in (colander.null, None): | ||||
|             cstruct = "" | ||||
|         template = self.template | ||||
|         values = self.get_template_values(field, cstruct, kw) | ||||
|         return field.renderer(template, **values) | ||||
| 
 | ||||
| 
 | ||||
| def includeme(config): | ||||
| 
 | ||||
|     config.add_tailbone_permission('messages', 'messages.list', "List/Search Messages") | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Lance Edgar
						Lance Edgar