Refactor "send new message" form, esp. recipients field, per Vue.js

This commit is contained in:
Lance Edgar 2019-11-04 17:30:00 -06:00
parent e153e530a8
commit 86695c9dc7
8 changed files with 274 additions and 20 deletions

View 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)

View 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>

View file

@ -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>

View file

@ -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()}

View 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>

View file

@ -34,6 +34,7 @@
}
.tailbone-message-body {
margin: 1rem auto;
min-height: 10rem;
}
.tailbone-message-body p {
margin-bottom: 1rem;

View file

@ -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>&raquo;</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>

View file

@ -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")