Skip to content

Add tariffs from the meter form via a modal - #9

Open
tescalada wants to merge 11 commits into
mainfrom
tariff-modal-form
Open

Add tariffs from the meter form via a modal#9
tescalada wants to merge 11 commits into
mainfrom
tariff-modal-form

Conversation

@tescalada

Copy link
Copy Markdown
Contributor

Adds a tariff from inside the customer meter form, without navigating away
and losing the in-progress meter.

The tariff select starts blank and carries an <Add New> option. Choosing it
opens a modal containing the tariff form, loaded from a new endpoint. On save
the created tariff is inserted into the select and selected; on validation
failure the modal re-renders with inline field errors. Cancelling restores the
previous selection.

Shared form fields

The tariff form fields move into tariff/_tariff-form-fields.html, included by
both the standalone tariff page and the modal template, so the two stay in step.
The form_tariff_error macro is defined once, in the partial that calls it.

Endpoint

GET|POST /tariff/add-modal, guarded by the tariff:add permission.

  • GET returns the form fragment with no page chrome.
  • A valid POST creates the tariff and returns {"message", "tariff": {"id", "name"}}.
  • An invalid POST returns 400, re-renders the fragment with inline errors, and
    sets X-Form-Errors to a JSON map of field to messages.

X-Form-Errors is now built by one helper shared with BaseForm.render, which
coerces error values to strings. The tariff collection validators store raw
ValueError objects, which are not JSON-serializable, so serializing them
directly returned a 500 instead of the 400.

Modal behavior

The behavior lives in sparkmeter/meter/js/meter-tariff-modal.js, registered
through the existing page-loader mechanism and covered by a jest spec.

The tariff form's own JavaScript is initialized against the injected fragment,
so the block-rate, time-of-use, scheduled-load-limit, monthly-plan and daily-
energy-limit controls work in the modal as they do on the standalone page. Its
delegated handlers are namespaced and rebound rather than stacked when the
fragment re-renders after a 400.

Bootstrap 3 gives every modal the same z-index and drops modal-open off
<body> whenever any modal closes. The block-rate, time-of-use and load-limit
editors are relocated out of the modal body, stacked above it, and the body
scroll lock is held while any modal is still open.

Submitting is guarded against double-submit, and a response arriving after the
modal is dismissed is ignored.

Meter form validation

A customer meter now requires a tariff, reported as "Please select a tariff or
add a new one." The <Add New> sentinel is rejected by the field itself so it
surfaces one accurate message rather than also collecting "Not a valid choice".

Test tooling

scripts/run_coverage.sh gives the test image a version carrying a
+g<short hash> local segment. Without it sparkmeter.__version__.git_version
is empty, and the page tests scrub GIT_VERSION from snapshots with
str.replace() — replacing the empty string inserts the marker between every
character of every snapshot and fails the suite.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown

Coverage

Coverage report
FileStmtsMissCoverMissing
sparkmeter
   __version__.py16288%32, 38
   _wsgi_mount_smoketest.py97970%19–152
   app.py3151197%19–20, 111, 368–375, 552–554, 675, 682–683
   asgi.py74740%19–166
   cli.py42686%59–70
   cli_app.py880%4–20
   cli_prompts.py151220%8–12, 17–21, 26–28
   conftest.py181995%51–52, 85–86, 108–120, 209
   controller.py2444283%183, 207–208, 531, 585–642
   debug_memory.py32320%6–63
   periodic.py563145%60–66, 71–75, 80–84, 93–98, 103–111, 124–128
   sentry_proxy.py342235%38, 44–47, 56–65, 73–82
   servercommand.py1127236%47, 56–60, 64–69, 73–78, 82–85, 89–90, 101–103, 112–181
sparkmeter/alembic/versions
   0.32_merge_sms_tables.py46393%61–67
   0.77_snapshot_support.py128298%340, 401
sparkmeter/api
   customerviews0.py283199%301
   historyviews0.py1149219%49–55, 69–72, 81–131, 144–175, 185–216, 230–257
sparkmeter/config
   configparameter.py57296%80, 111
sparkmeter/database
   alchemy.py54493%61–63, 74
   database.py232399%497, 579–580
   ormobject.py55198%65
   session.py21210%12–62
   types.py54394%91–92, 125
sparkmeter/ground
   groundcommand.py201050%20–35
sparkmeter/history
   historyview.py351654%30–31, 41–54, 65–72
sparkmeter/meter
   meterdomain.py7722797%892, 1805–1806, 1817–1868, 1911
sparkmeter/metering
   api.py27967%66–76
   cli.py2275874%158, 174–175, 184, 193, 198–201, 203, 234, 238–244, 281–297, 301–335, 456–458
   dispatch.py741678%78–80, 85–97, 185
   events.py1053468%56, 75–77, 109–112, 118–193, 240
   lifespan.py695225%37–98, 109–125
   reconcile.py1134362%84–107, 119–120, 129, 135–138, 140–143, 169, 259–262, 266–269, 273–276, 287
sparkmeter/metering/tools
   cli_client.py825829%46–52, 56–61, 65–70, 83–107, 115–148, 153–166
sparkmeter/misc
   datetimeutils.py65395%115–117
sparkmeter/system
   systemcommand.py67593%35, 130–132, 134–135
sparkmeter/tariff
   tariffcommand.py821582%27–44
sparkmeter/transaction
   transactionview.py167199%71
sparkmeter/user
   userdomain.py1871294%416–434
sparkmeter/web
   unittestutils.py1941194%73–81, 323, 332, 398, 400
   views.py72199%71
TOTAL1269492193% 

Tests Skipped Failures Errors Time
1289 0 💤 0 ❌ 0 🔥 3m 43s ⏱️

@github-actions

Copy link
Copy Markdown

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • sparkmeter/meter/meterform.py (100%)
  • sparkmeter/tariff/tariffview.py (100%)
  • sparkmeter/web/forms.py (100%)

Summary

  • Total: 37 lines
  • Missing: 0 lines
  • Coverage: 100%

@tescalada
tescalada force-pushed the tariff-modal-form branch 2 times, most recently from 7322d49 to f4ad95b Compare July 22, 2026 19:43
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Coverage

Coverage report
FileStmtsMissCoverMissing
sparkmeter
   __version__.py16288%32, 38
   _wsgi_mount_smoketest.py97970%19–152
   app.py3151197%19–20, 111, 368–375, 552–554, 675, 682–683
   asgi.py74740%19–166
   cli.py42686%59–70
   cli_app.py880%4–20
   cli_prompts.py151220%8–12, 17–21, 26–28
   conftest.py181995%51–52, 85–86, 108–120, 209
   controller.py2444283%183, 207–208, 531, 585–642
   debug_memory.py32320%6–63
   periodic.py563145%60–66, 71–75, 80–84, 93–98, 103–111, 124–128
   sentry_proxy.py342235%38, 44–47, 56–65, 73–82
   servercommand.py1127236%47, 56–60, 64–69, 73–78, 82–85, 89–90, 101–103, 112–181
sparkmeter/alembic/versions
   0.32_merge_sms_tables.py46393%61–67
   0.77_snapshot_support.py128298%340, 401
sparkmeter/api
   customerviews0.py283199%301
   historyviews0.py1149219%49–55, 69–72, 81–131, 144–175, 185–216, 230–257
sparkmeter/config
   configparameter.py57296%80, 111
sparkmeter/database
   alchemy.py54493%61–63, 74
   database.py232399%497, 579–580
   ormobject.py55198%65
   session.py21210%12–62
   types.py54394%91–92, 125
sparkmeter/ground
   groundcommand.py201050%20–35
sparkmeter/history
   historyview.py351654%30–31, 41–54, 65–72
sparkmeter/meter
   meterdomain.py7722797%892, 1805–1806, 1817–1868, 1911
sparkmeter/metering
   api.py27967%66–76
   cli.py2275874%158, 174–175, 184, 193, 198–201, 203, 234, 238–244, 281–297, 301–335, 456–458
   dispatch.py741678%78–80, 85–97, 185
   events.py1053468%56, 75–77, 109–112, 118–193, 240
   lifespan.py695225%37–98, 109–125
   reconcile.py1134362%84–107, 119–120, 129, 135–138, 140–143, 169, 259–262, 266–269, 273–276, 287
sparkmeter/metering/tools
   cli_client.py825829%46–52, 56–61, 65–70, 83–107, 115–148, 153–166
sparkmeter/misc
   datetimeutils.py65395%115–117
sparkmeter/system
   systemcommand.py67593%35, 130–132, 134–135
sparkmeter/tariff
   tariffcommand.py821582%27–44
sparkmeter/transaction
   transactionview.py167199%71
sparkmeter/user
   userdomain.py1871294%416–434
sparkmeter/web
   unittestutils.py1941194%73–81, 323, 332, 398, 400
   views.py72199%71
TOTAL1269492193% 

Tests Skipped Failures Errors Time
1289 0 💤 0 ❌ 0 🔥 2m 23s ⏱️

@tescalada
tescalada force-pushed the tariff-modal-form branch from f4ad95b to b5a7b54 Compare July 31, 2026 11:47
tescalada added 11 commits July 31, 2026 14:14
Extract the tariff form fields into a shared partial included by both the
standalone tariff page and a new modal-only template.

Add GET|POST /tariff/add-modal, guarded by the tariff:add permission. GET
returns the form fragment with no page chrome. A valid POST creates the
tariff and returns its id and name as JSON; an invalid POST returns 400 and
re-renders the fragment with inline field errors.

On the customer meter form, the tariff select starts blank and carries an
<Add New> option that loads the modal form, submits it, and inserts the
created tariff into the dropdown.
A worktree's .git is a file rather than a directory, so hatch-vcs cannot
read it from the build context and the version was pinned to a bare
0.0.0. sparkmeter.__version__ derives git_version from the PEP 440 local
segment, which 0.0.0 does not have, leaving it empty. The page tests
scrub GIT_VERSION out of every snapshot with str.replace(), and replacing
the empty string inserts the marker between every character, so every
page test failed.
The block rate, TOU and scheduled load limit validators store the raw
ValueError rather than its message. render_modal_form serialized
form.errors directly, and the JSON encoder has no branch for exceptions,
so any of those three validation failures raised a TypeError and turned
the 400 into a 500.

BaseForm.render already coerced every error to str and logged the result.
Move that into set_form_errors_header and use it from both, so the modal
and the standalone page report errors identically.

Build the modal form from request.form on GET as well, matching
/tariff/add. With no formdata the JSON collection fields keep None, which
renders as null in the params element and as an empty value in the hidden
inputs, and posting an untouched modal back then fails to parse.

Use http.client.OK for render_modal_form's default status, consistent
with the rest of the module.
The behavior lived in a ~190 line inline <script> in meter-form.html: not
linted, not covered by the jest suite, not bundled, and baked into 19
page snapshots. Move it to meter/js/meter-tariff-modal.js, registered as
the meter-form page loader, and cover it with a spec.

Because the module can require tariff/js/tariff-form.js, the tariff form
behavior now runs inside the modal. Previously TariffForm was registered
only for the tariff-form page, so in the modal the block rate and
scheduled load limit toggles, the plan, TOU and daily energy limit
checkboxes, and the three table editors all did nothing. Namespace
TariffForm's delegated handlers so re-rendering the fragment after a 400
rebinds them instead of stacking a second copy on document.

The fragment is a real <form> and the save control sits outside it in the
modal footer, so pressing Enter in any field did a full page POST and
navigated away from the half-filled meter form. Handle submit as well as
the save button click.

The shared partial emits the block rate, TOU and load limit editors as
top-level modals. Injected into the modal body they became nested modals,
which Bootstrap 3 does not support, and their hidden.bs.modal bubbled to
the outer handler. Relocate them to <body>, stack their z-indexes, keep
the body scroll lock while a modal is still open, and have the outer
handler react only to its own modal.

An expired session redirects to the login page, and jQuery follows the
redirect, so the login page arrived as a 200 and was injected into the
modal body; on POST it reached the success path and threw on
data.tariff.id. Check for the tariff form and for the tariff payload, and
tell the user to reload and sign in.

Cancelling restored the select with `this.previousValue || ''`, and ''
matches no option, so the select rendered empty. Restore the blank
option's actual value, __None.

Guard save against a second request while one is in flight, since
tariff.name has no unique constraint, and drop a response that arrives
after the modal was dismissed.

Define form_tariff_error once, in the partial that calls it, instead of
in each of the two including templates.

Move the modal's layout rules from a <style> block in the fetched
fragment, which accumulated an element per open and per re-render, into
modal.scss, and let .modal-lg set the dialog width.

Carry the modal's strings in data attributes rather than interpolating
them into JavaScript string literals, wrap <Add New> in _(), and extract
the new strings into messages.pot. flask-babel's LazyString defines
__html__, so translated text is emitted unescaped; forceescape is needed
for the attribute values.

Reject __add_new__ server-side. It is a client-side sentinel with no
server counterpart, and allow_blank made a posted __add_new__ report
"Please select a tariff or add a new one." rather than an invalid choice.
Drop validate_tariff's meter_type check, which cannot be false: __init__
deletes the field for totalizers.

Add tests for the meter form consumer, validate_tariff, the blank option
and its text, the tariff error markup, and the tariff:add permission
boundary on /tariff/add-modal.
base.flash builds its alert with jQuery's .html(), so the message the
server returns in the add-modal JSON reached the page as markup. The
inline implementation the module replaced used .text().

Add base.flashText, which builds the same alert from .text(), and have
the tariff modal use it for all four of its messages. base.flash is
shared by the rest of the application and keeps its behavior.
The <style> block that moved into modal.scss set the dialog to 900px
with a max-width of calc(100vw - 40px) at every viewport width.
`.modal-lg` applies its 900px only from 992px up, so between 768px and
991px the dialog fell back to Bootstrap's 600px -- where the column
overrides already assume the wider dialog -- and the max-width clamp had
no replacement at all. Set the width in modal.scss and drop `.modal-lg`
from the dialog, leaving one source for it.

Drop ensureModal's adopt branch as well. It returned before setting the
modal title and the cancel and save labels, so reaching it would have
shipped an unlabelled modal; MeterTariffModal is constructed once per
page, so nothing does.
The editor ids come from a shared partial, so clearing them out of <body>
by selector on every render could take out same-id elements owned by
anything else on the page. Keep the elements that were relocated and
remove those, and reach for their time pickers the same way.

BLANK_VALUE's comment described a selectedIndex of -1 the code cannot
produce: a rendered select's val() returns the blank option's value, so
the `|| BLANK_VALUE` fallback could only fire for a null val(). Drop the
fallback and say what the constant is for.
The stacking the module does on show.bs.modal had no test coverage at
all: deleting the binding left the whole suite green. Open an editor
modal on top of the tariff modal and assert the resulting dialog and
backdrop order.

Bootstrap unwinds the body scroll lock as it hides a modal, without
regard for another one still being open: it drops modal-open and hands
back the padding that compensates for the scrollbar the lock hides. The
handler put modal-open back but not the padding, so closing a block
rate, TOU or load limit editor with the tariff modal still open could
shift the page sideways by the scrollbar width. Record the padding on
hide.bs.modal and restore it together with the class.

Drop the event.target check in the tariff modal's own hidden.bs.modal
handler. The three editor modals are relocated to <body> as siblings and
they are the only modals the tariff form partial emits, so nothing
inside the tariff modal can raise the event. The test that appeared to
cover the check asserted that DOM layout rather than the check, and
passed with the check deleted.
QuerySelectField.pre_validate reports every primary key it cannot
resolve as "Not a valid choice", and WTForms carries on running the
validation chain after a pre_validate ValidationError. A posted
__add_new__ therefore collected that message and validate_tariff's
accurate one, and the field rendered both stacked together.

Reject the sentinel in the field's own pre_validate with StopValidation,
which carries the accurate message and ends the chain.
The vendored Bootstrap is 3.0.0, whose only body handling is adding and
removing the `modal-open` class. Both the scroll lock `overflow: hidden`
and the `margin-right: 15px` that stands in for the hidden scrollbar are
stylesheet rules keyed on that class, so nothing ever wrote an inline
`padding-right` on <body>. `rememberScrollbarPad` therefore always read
the empty string, and re-applying it was a no-op.

Re-adding `modal-open` when an inner editor closes over a still-open
tariff modal restores both rules, so that stays.

The removed test manufactured the padding reset it then asserted was
undone, so it exercised its own handler rather than Bootstrap.
@tescalada
tescalada force-pushed the tariff-modal-form branch from b5a7b54 to 5f42228 Compare July 31, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant