diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index fb11ffba..bf508a6f 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -48,7 +48,7 @@ from pyramid_deform import SessionFileUploadTempStore
from pyramid.renderers import render
from webhelpers2.html import tags, HTML
-from tailbone.util import raw_datetime
+from tailbone.util import raw_datetime, get_form_data
from . import types
from .widgets import ReadonlyWidget, PlainDateWidget, JQueryDateWidget, JQueryTimeWidget
from tailbone.exceptions import TailboneJSONFieldError
@@ -1071,17 +1071,15 @@ class Form(object):
if self.request.method != 'POST':
return False
- # use POST or JSON body, whichever is present
- # TODO: per docs, some JS libraries may not set this flag?
- # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
- if self.request.is_xhr and not self.request.POST:
- controls = self.request.json_body.items()
+ controls = get_form_data(self.request).items()
- # unfortunately the normal form logic (i.e. peppercorn) is
- # expecting all values to be strings, whereas the JSON body we
- # just parsed, may have given us some Pythonic objects. so
- # here we must convert them *back* to strings...
- # TODO: this seems like a hack, i must be missing something
+ # unfortunately the normal form logic (i.e. peppercorn) is
+ # expecting all values to be strings, whereas if our data
+ # came from JSON body, may have given us some Pythonic
+ # objects. so here we must convert them *back* to strings
+ # TODO: this seems like a hack, i must be missing something
+ # TODO: also this uses same "JSON" check as get_form_data()
+ if self.request.is_xhr and not self.request.POST:
controls = [[key, val] for key, val in controls]
for i in range(len(controls)):
key, value = controls[i]
@@ -1094,9 +1092,6 @@ class Form(object):
elif not isinstance(value, six.string_types):
controls[i][1] = six.text_type(value)
- else:
- controls = self.request.POST.items()
-
dform = self.make_deform_form()
try:
self.validated = dform.validate(controls)
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index db976432..2f11f094 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -68,11 +68,49 @@ class FieldList(list):
class Grid(object):
"""
Core grid class. In sore need of documentation.
+
+ .. attribute:: raw_renderers
+
+ Dict of "raw" field renderers. See also
+ :meth:`set_raw_renderer()`.
+
+ When present, these are rendered "as-is" into the grid
+ template, whereas the more typical scenario involves rendering
+ each field "into" a span element, like:
+
+ .. code-block:: html
+
+
+
+ So instead of injecting into a span, any "raw" fields defined
+ via this dict, will be injected as-is, like:
+
+ .. code-block:: html
+
+ RENDERED-FIELD
+
+ Note that each raw renderer is called only once, and *without*
+ any arguments. Likely the only use case for this, is to inject
+ a Vue component into the field. A basic example::
+
+ from webhelpers2.html import HTML
+
+ def myrender():
+ return HTML.tag('my-component', **{'v-model': 'props.row.myfield'})
+
+ grid = Grid(
+ # ..normal constructor args here..
+
+ raw_renderers={
+ 'myfield': myrender,
+ },
+ )
"""
def __init__(self, key, data, columns=None, width='auto', request=None,
model_class=None, model_title=None, model_title_plural=None,
enums={}, labels={}, assume_local_times=False, renderers={}, invisible=[],
+ raw_renderers={},
extra_row_class=None, linked_columns=[], url='#',
joiners={}, filterable=False, filters={}, use_byte_string_filters=False,
searchable={},
@@ -109,6 +147,7 @@ class Grid(object):
self.labels = labels or {}
self.assume_local_times = assume_local_times
self.renderers = self.make_default_renderers(renderers or {})
+ self.raw_renderers = raw_renderers or {}
self.invisible = invisible or []
self.extra_row_class = extra_row_class
self.linked_columns = linked_columns or []
@@ -286,6 +325,21 @@ class Grid(object):
def set_renderer(self, key, renderer):
self.renderers[key] = renderer
+ def set_raw_renderer(self, key, renderer):
+ """
+ Set or remove the "raw" renderer for the given field.
+
+ See :attr:`raw_renderers` for more about these.
+
+ :param key: Field name.
+
+ :param renderer: Either a renderer callable, or ``None``.
+ """
+ if renderer:
+ self.raw_renderers[key] = renderer
+ else:
+ self.raw_renderers.pop(key, None)
+
def set_type(self, key, type_):
if type_ == 'boolean':
self.set_renderer(key, self.render_boolean)
@@ -1313,7 +1367,10 @@ class Grid(object):
# iterate over data rows
for i in range(count):
rowobj = raw_data[i]
- row = {}
+
+ # nb. cache 0-based index on the row, in case client-side
+ # logic finds it useful
+ row = {'_index': i}
# sometimes we need to include some "raw" data columns in our
# result set, even though the column is not displayed as part of
diff --git a/tailbone/templates/grids/buefy.mako b/tailbone/templates/grids/buefy.mako
index ec1a4875..9d8359d9 100644
--- a/tailbone/templates/grids/buefy.mako
+++ b/tailbone/templates/grids/buefy.mako
@@ -221,8 +221,14 @@
% if grid.is_searchable(column['field']):
searchable
% endif
+ cell-class="${column['field']}"
+ % if grid.has_click_handler(column['field']):
+ @click.native="${grid.click_handlers[column['field']]}"
+ % endif
:visible="${json.dumps(column['visible'])}">
- % if grid.is_linked(column['field']):
+ % if column['field'] in grid.raw_renderers:
+ ${grid.raw_renderers[column['field']]()}
+ % elif grid.is_linked(column['field']):
% else:
@@ -349,6 +355,18 @@
methods: {
+ addRowClass(index, className) {
+
+ // TODO: this may add duplicated name to class string
+ // (not a serious problem i think, but could be improved)
+ this.rowStatusMap[index] = (this.rowStatusMap[index] || '')
+ + ' ' + className
+
+ // nb. for some reason b-table does not always "notice"
+ // when we update status; so we force it to refresh
+ this.$forceUpdate()
+ },
+
getRowClass(row, index) {
return this.rowStatusMap[index]
},
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index f4a697f4..9d06d811 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -115,12 +115,23 @@
-
- Try to auto-correct "case vs. unit" mistakes from invoice parser
-
+
+
+ Try to auto-correct "case vs. unit" mistakes from invoice parser
+
+
+
+
+
+ Allow edit of Catalog Unit Cost
+
+
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index eb1e476e..d7a2a287 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -3,7 +3,7 @@
<%def name="extra_javascript()">
${parent.extra_javascript()}
- % if master.has_perm('edit_row'):
+ % if not use_buefy and master.has_perm('edit_row'):
${h.javascript_link(request.static_url('tailbone:static/js/numeric.js'))}
+ % endif
+%def>
+
<%def name="object_helpers()">
${self.render_status_breakdown()}
${self.render_po_vs_invoice_helper()}
@@ -418,13 +452,128 @@
% endif
+ % if allow_edit_catalog_unit_cost:
+
+ let ReceivingCostEditor = {
+ template: '#receiving-cost-editor-template',
+ props: {
+ row: Object,
+ value: String,
+ },
+ data() {
+ return {
+ inputValue: this.value,
+ editing: false,
+ }
+ },
+ methods: {
+
+ startEdit() {
+ this.inputValue = this.value
+ this.editing = true
+ this.$nextTick(() => {
+ this.$refs.input.focus()
+ })
+ },
+
+ inputKeyDown(event) {
+
+ // when user presses Enter while editing cost value, submit
+ // value to server for immediate persistence
+ if (event.which == 13) {
+ this.submitEdit()
+
+ // when user presses Escape, cancel the edit
+ } else if (event.which == 27) {
+ this.cancelEdit()
+ }
+ },
+
+ inputBlur(event) {
+ // always assume user meant to cancel
+ this.cancelEdit()
+ },
+
+ cancelEdit() {
+ // reset input to discard any user entry
+ this.inputValue = this.value
+ this.editing = false
+ this.$emit('cancel-edit')
+ },
+
+ submitEdit() {
+ let url = '${url('{}.update_row_cost'.format(route_prefix), uuid=batch.uuid)}'
+
+ // TODO: should get csrf token from parent component?
+ let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+ let headers = {'${csrf_header_name}': csrftoken}
+
+ let params = {
+ row_uuid: this.$props.row.uuid,
+ catalog_unit_cost: this.inputValue,
+ }
+
+ this.$http.post(url, params, {headers: headers}).then(response => {
+ if (!response.data.error) {
+
+ // let parent know cost value has changed
+ // (this in turn will update data in *this*
+ // component, and display will refresh)
+ this.$emit('input', response.data.row.catalog_unit_cost,
+ this.$props.row._index)
+
+ // and hide the input box
+ this.editing = false
+
+ } else {
+ this.$buefy.toast.open({
+ message: "Submit failed: " + response.data.error,
+ type: 'is-warning',
+ duration: 4000, // 4 seconds
+ })
+ }
+
+ }, response => {
+ this.$buefy.toast.open({
+ message: "Submit failed: (unknown error)",
+ type: 'is-warning',
+ duration: 4000, // 4 seconds
+ })
+ })
+ },
+ },
+ }
+
+ Vue.component('receiving-cost-editor', ReceivingCostEditor)
+
+ ${rows_grid.component_studly}.methods.catalogUnitCostClicked = function(row) {
+
+ // start edit for clicked cell
+ this.$refs['catalogUnitCost_' + row.uuid].startEdit()
+ }
+
+ ${rows_grid.component_studly}.methods.catalogCostConfirmed = function(amount, index) {
+
+ // update display to indicate cost was confirmed
+ this.addRowClass(index, 'catalog_cost_confirmed')
+
+ // start editing next row, unless there are no more
+ let nextRow = index + 1
+ if (this.data.length > nextRow) {
+ nextRow = this.data[nextRow]
+ this.$refs['catalogUnitCost_' + nextRow.uuid].startEdit()
+ }
+ }
+
+ % endif
+
%def>
${parent.body()}
-% if master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'):
+% if not use_buefy and master.handler.allow_truck_dump_receiving() and master.has_perm('edit_row'):
${h.form(url('{}.transform_unit_row'.format(route_prefix), uuid=batch.uuid), name='transform-unit-form')}
${h.csrf_token(request)}
${h.hidden('row_uuid')}
diff --git a/tailbone/util.py b/tailbone/util.py
index cd6c9237..5dee997f 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -64,6 +64,21 @@ def csrf_token(request, name='_csrf'):
return HTML.tag("div", tags.hidden(name, value=token), style="display:none;")
+def get_form_data(request):
+ """
+ Returns the effective form data for the given request. Mostly
+ this is a convenience, to return either POST or JSON depending on
+ the type of request.
+ """
+ # nb. we prefer JSON only if no POST is present
+ # TODO: this seems to work for our use case at least, but perhaps
+ # there is a better way? see also
+ # https://docs.pylonsproject.org/projects/pyramid/en/latest/api/request.html#pyramid.request.Request.is_xhr
+ if request.is_xhr and not request.POST:
+ return request.json_body
+ return request.POST
+
+
def should_use_buefy(request):
"""
Returns a flag indicating whether or not the current theme supports (and
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index 78136ef3..09a28099 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -46,6 +46,7 @@ from pyramid import httpexceptions
from webhelpers2.html import tags, HTML
from tailbone import forms, grids
+from tailbone.util import get_form_data
from tailbone.views.purchasing import PurchasingBatchView
@@ -715,6 +716,11 @@ class ReceivingBatchView(PurchasingBatchView):
return breakdown
+ def allow_edit_catalog_unit_cost(self, batch):
+ return (not batch.executed
+ and self.has_perm('edit_row')
+ and self.batch_handler.allow_receiving_edit_catalog_unit_cost())
+
def template_kwargs_view(self, **kwargs):
kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)
batch = kwargs['instance']
@@ -739,6 +745,8 @@ class ReceivingBatchView(PurchasingBatchView):
data=breakdown,
columns=['title', 'count'])
+ kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch)
+
return kwargs
def get_context_credits(self, row):
@@ -933,6 +941,7 @@ class ReceivingBatchView(PurchasingBatchView):
def configure_row_grid(self, g):
super(ReceivingBatchView, self).configure_row_grid(g)
+ use_buefy = self.get_use_buefy()
batch = self.get_instance()
# vendor_code
@@ -943,6 +952,10 @@ class ReceivingBatchView(PurchasingBatchView):
if (self.handler.has_purchase_order(batch)
or self.handler.has_invoice_file(batch)):
g.remove('catalog_unit_cost')
+ elif use_buefy and self.allow_edit_catalog_unit_cost(batch):
+ g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost)
+ g.set_click_handler('catalog_unit_cost',
+ 'catalogUnitCostClicked(props.row)')
# po_unit_cost
if self.handler.has_invoice_file(batch):
@@ -1001,6 +1014,14 @@ class ReceivingBatchView(PurchasingBatchView):
else:
g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
+ def render_catalog_unit_cost(self):
+ return HTML.tag('receiving-cost-editor', **{
+ 'v-model': 'props.row.catalog_unit_cost',
+ ':ref': "'catalogUnitCost_' + props.row.uuid",
+ ':row': 'props.row',
+ '@input': 'catalogCostConfirmed',
+ })
+
def row_grid_extra_class(self, row, i):
css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i)
@@ -1790,10 +1811,10 @@ class ReceivingBatchView(PurchasingBatchView):
def update_row_cost(self):
"""
- AJAX view for updating the invoice (actual) unit cost for a row.
+ AJAX view for updating various cost fields in a data row.
"""
batch = self.get_instance()
- data = dict(self.request.POST)
+ data = dict(get_form_data(self.request))
# validate row
uuid = data.get('row_uuid')
@@ -1939,6 +1960,9 @@ class ReceivingBatchView(PurchasingBatchView):
{'section': 'rattail.batch',
'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit',
'type': bool},
+ {'section': 'rattail.batch',
+ 'option': 'purchase.receiving.allow_edit_catalog_unit_cost',
+ 'type': bool},
# mobile interface
{'section': 'rattail.batch',