From 2e3823364cac6a608b5d14418ef24feaebade9b9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 5 Dec 2022 14:03:03 -0600 Subject: [PATCH] Add support for editing catalog cost in receiving batch, per new theme had to add several "under the hood" features to make this work, to embed a Vue component within grid `` cells, etc. --- tailbone/forms/core.py | 23 ++- tailbone/grids/core.py | 59 +++++++- tailbone/templates/grids/buefy.mako | 20 ++- tailbone/templates/receiving/configure.mako | 23 ++- tailbone/templates/receiving/view.mako | 155 +++++++++++++++++++- tailbone/util.py | 15 ++ tailbone/views/purchasing/receiving.py | 28 +++- 7 files changed, 296 insertions(+), 27 deletions(-) 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 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 + ${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',