Per-type board rows with shared categories, a network device IP field, and two dark-mode dialogs

This commit is contained in:
2026-08-07 10:43:32 -04:00
parent bcf7a009f8
commit 7c5b1bc74e
12 changed files with 1154 additions and 829 deletions

View File

@@ -96,6 +96,22 @@ Use `PrintersList.vue` as the reference for new list pages. It has NO scoped sty
Dark mode is automatic via `@media (prefers-color-scheme: dark)`. Using CSS variables ensures colors adapt automatically - no extra work needed per page.
## Data Entry: Page or Modal
Both patterns are in use on purpose. Pick by what the record IS, not by how big the form feels today.
**Routed form page** (`views/<Thing>Form.vue` + a route) when the record has its own detail page and its own URL: machines, PCs, printers, network devices, measuring tools, USB devices, applications, knowledge-base articles, notifications, printed items. These forms are long, often carry an image upload, a map-position picker or relationship editing, and someone will want to link straight to one.
**Modal over its list** when the record is reference data that only exists inside the list it belongs to: every `*TypesList`, plus locations, vendors, models, VLANs, subnets, support teams, custom fields, API tokens, printer drivers, dashboard defaults. The form is a handful of fields, and keeping the list visible behind the dialog is the point.
Rule of thumb: **a thing with a detail page gets a form page; a lookup row gets a modal.**
Modal rules (from the overlay-close fix, commit d8fe0a4):
- A modal holding typed input must NOT close on overlay click or Escape. Losing a part-filled form to a stray click is not an acceptable failure.
- A confirmation dialog MAY close on overlay click (`@click.self`), since it holds nothing to lose.
- The panel background is `var(--bg-card-solid)`, never `var(--bg-card)`: the card variable is translucent in dark mode, which leaves a dialog see-through over the overlay. The shared `.modal` in `style.css` already does this; hand-rolled `.modal-panel` rules must too.
## Component Organization
- **Global styles**: `src/assets/style.css`

File diff suppressed because it is too large Load Diff

View File

@@ -192,7 +192,9 @@ onMounted(load)
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
/* Solid: var(--bg-card) is translucent in dark mode, which made this
dialog see-through. */
background: var(--bg-card-solid);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;

View File

@@ -24,9 +24,13 @@ def _upsert_primary_ip(asset, ip):
"""Create/update/clear the asset's primary IP Communication from an
ipaddress string. No-op if the IP CommunicationType is not seeded."""
ip = (ip or '').strip()
if not ip:
return
comm = Communication.query.filter_by(assetid=asset.assetid, isprimary=True).first()
if not ip:
# Clearing the field clears the row's IP - it used to return early, so
# an address could be typed but never taken back out.
if comm:
comm.ipaddress = None
return
if comm:
comm.ipaddress = ip
return

View File

@@ -112,6 +112,16 @@
placeholder="e.g., sw-bldg1-floor2"
/>
</div>
<div class="form-group">
<label for="ipaddress">IP Address</label>
<input
id="ipaddress"
v-model="form.ipaddress"
type="text"
class="form-control"
placeholder="e.g., 192.168.1.10"
/>
</div>
<div class="form-group">
<label for="networkdevicetypeid">Device Type</label>
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
@@ -318,6 +328,7 @@ const form = ref({
locationid: '',
businessunitid: '',
hostname: '',
ipaddress: '',
networkdevicetypeid: '',
vendorid: '',
modelnumberid: '',
@@ -419,6 +430,9 @@ async function loadDevice() {
form.value.mapx = data.mapx
form.value.mapy = data.mapy
form.value.notes = data.notes || ''
// The IP lives in a Communication row, not on the extension table; the API
// flattens it onto the response as ipaddress.
form.value.ipaddress = data.ipaddress || ''
// Network device specific
if (data.networkdevice) {
@@ -453,6 +467,7 @@ async function submitForm() {
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
hostname: form.value.hostname || null,
ipaddress: form.value.ipaddress || null,
networkdevicetypeid: form.value.networkdevicetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,

View File

@@ -157,6 +157,13 @@ def _apply_display_fields(t, data):
if ds not in _DISPLAY_STYLES:
return "displaystyle must be one of: %s" % ", ".join(_DISPLAY_STYLES)
t.displaystyle = ds
if 'boardcategory' in data:
category = (data.get('boardcategory') or '').strip()
if len(category) > 50:
return "boardcategory must be 50 characters or fewer"
# Blank stores as NULL: "no category" is the absence of one, not the
# empty-string category that every uncategorised type would share.
t.boardcategory = category or None
if 'gracewindowminutes' in data:
raw = data.get('gracewindowminutes')
raw = 0 if raw in (None, '') else raw
@@ -178,11 +185,11 @@ def _config_version():
reach pages that are already open."""
types = NotificationType.query.order_by(NotificationType.notificationtypeid).all()
parts = [
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d" % (
"%s|%s|%s|%d|%d|%s|%s|%s|%d|%d|%s" % (
t.notificationtypeid, t.typecolor, t.displaystyle,
int(bool(t.splitperemployee)), int(bool(t.showemployeephoto)),
t.expirymode, t.expirydays, t.expiryhour, int(bool(t.isactive)),
int(t.gracewindowminutes or 0),
int(t.gracewindowminutes or 0), t.boardcategory or '',
)
for t in types
]
@@ -779,8 +786,11 @@ def get_shopfloor_notifications():
'resolved': is_resolved,
'typename': ntype.typename if ntype else None,
'typecolor': ntype.typecolor if ntype else None,
# Per-type display behavior the dashboard groups/renders by.
# Per-type display behavior the dashboard groups/renders by. A
# boardcategory puts several types in one row under that name;
# blank gives the type a row of its own.
'displaystyle': (ntype.displaystyle or 'standard') if ntype else 'standard',
'boardcategory': (ntype.boardcategory or '') if ntype else '',
}
# Employee info (photo only when the type wants it)

View File

@@ -32,7 +32,10 @@
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
<span class="mono">{{ t.typecolor }}</span>
</td>
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
<td>
<span class="badge">{{ t.displaystyle || 'standard' }}</span>
<span v-if="t.boardcategory" class="badge badge-secondary">{{ t.boardcategory }}</span>
</td>
<td>
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
@@ -87,6 +90,17 @@
</select>
</label>
<label class="field">
<span>Board category (optional)</span>
<input v-model="form.boardcategory" type="text" maxlength="50"
placeholder="its own row" />
<small class="muted">
Types sharing a category share one row on the shopfloor board,
under the category name. Blank gives this type a row of its own.
Only types with the same display style can share a row.
</small>
</label>
<label class="field">
<span>Keep showing after end (minutes)</span>
<input v-model.number="form.gracewindowminutes" type="number"
@@ -215,6 +229,7 @@ function openNew() {
expirydays: null,
expiryhour: null,
gracewindowminutes: 0,
boardcategory: '',
isactive: true
}
editing.value = true
@@ -234,6 +249,7 @@ function openEdit(t) {
expirydays: t.expirydays ?? null,
expiryhour: t.expiryhour ?? null,
gracewindowminutes: t.gracewindowminutes ?? 0,
boardcategory: t.boardcategory || '',
isactive: t.isactive !== false
}
editing.value = true
@@ -300,7 +316,10 @@ onMounted(load)
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
/* Solid, not var(--bg-card): the card variable is translucent in dark mode
(rgba(0,0,61,0.4)) so cards glass over the page, which left this dialog
see-through with the table legible behind it. */
background: var(--bg-card-solid);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;

View File

@@ -0,0 +1,47 @@
"""Add notificationtypes.boardcategory (shared heading on the shopfloor board).
The board grouped cards by display style alone, so every type set to grid landed
inside the Recertification row and every carousel type inside Recognition's,
under a heading that named someone else's type. Each type now gets a row of its
own; this column is the opt-in for the other direction - several types that
belong together (Change, Awareness, Incident) share one row under a category
name instead of taking three.
Idempotent; downgrade drops the column.
Revision ID: notifications0004category
Revises: notifications0003grace
"""
from alembic import op
import sqlalchemy as sa
revision = 'notifications0004category'
down_revision = 'notifications0003grace'
branch_labels = None
depends_on = None
_TABLE = 'notificationtypes'
_COLUMN = 'boardcategory'
def _column_names(insp, table):
return {c['name'] for c in insp.get_columns(table)}
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN not in _column_names(insp, _TABLE):
op.add_column(_TABLE, sa.Column(_COLUMN, sa.String(50), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if _TABLE not in insp.get_table_names():
return
if _COLUMN in _column_names(insp, _TABLE):
op.drop_column(_TABLE, _COLUMN)

View File

@@ -83,6 +83,12 @@ class NotificationType(db.Model):
gracewindowminutes = db.Column(db.Integer, nullable=False,
server_default='0', default=0)
# Optional shared heading on the shopfloor board. Blank (the default) gives
# the type a row of its own under its own name; types sharing a category
# share one row under that category name, provided they also share a
# displaystyle - a banner and a tile row cannot be the same row.
boardcategory = db.Column(db.String(50), nullable=True)
def __repr__(self):
return f"<NotificationType {self.typename}>"
@@ -100,7 +106,8 @@ class NotificationType(db.Model):
'splitperemployee': bool(self.splitperemployee),
'showemployeephoto': bool(self.showemployeephoto),
'displaystyle': self.displaystyle or 'standard',
'gracewindowminutes': int(self.gracewindowminutes or 0)
'gracewindowminutes': int(self.gracewindowminutes or 0),
'boardcategory': self.boardcategory or ''
}

View File

@@ -64,8 +64,9 @@ EXPECTED_HEAD_REVISION['network'] = 'network0002model'
# printedparts is post-cutover: its 0001 really creates its tables; 0004 adds
# the per-transaction revision column.
EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev'
# notifications indexes businessunitid, then adds the per-type grace window.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0003grace'
# notifications indexes businessunitid, then adds the per-type grace window and
# the shared board category.
EXPECTED_HEAD_REVISION['notifications'] = 'notifications0004category'
# Plugins built after the cutover: their 0001 baseline really creates tables the
# core chain never owned.

View File

@@ -0,0 +1,117 @@
"""Tests for notificationtypes.boardcategory (shared row on the shopfloor board).
The board grouped cards by display style alone, so every type set to grid landed
in the Recertification row and every carousel type in Recognition's, under a
heading naming someone else's type. Each type now gets a row of its own, and
this column is the opt-in for the other direction: Change, Awareness and
Incident share one row under a category name.
The grouping itself is frontend (ShopfloorDashboard.vue); what is pinned here is
the contract it groups on - the feed must carry boardcategory per card, and the
type API must round-trip it.
"""
from plugins.notifications.models import Notification, NotificationType
def _make_type(db, typename, displaystyle='grid', boardcategory=None):
t = NotificationType(typename=typename, typecolor='#17a2b8', isactive=True,
displaystyle=displaystyle, boardcategory=boardcategory)
db.session.add(t)
db.session.commit()
return t
def _make_note(db, ntype, text):
n = Notification(notificationtypeid=ntype.notificationtypeid,
notification=text, businessunitid=None,
isactive=True, isshopfloor=True)
db.session.add(n)
db.session.commit()
return n
def _current(client):
resp = client.get('/api/notifications/shopfloor')
assert resp.status_code == 200, resp.get_json()
return resp.get_json()['data']['current']
def test_feed_carries_the_category_per_card(client, db):
"""The dashboard groups on this field, so it has to reach the card."""
ntype = _make_type(db, 'Awareness', boardcategory='Alerts')
_make_note(db, ntype, 'watch your step')
card = _current(client)[0]
assert card['boardcategory'] == 'Alerts'
assert card['typename'] == 'Awareness'
assert card['displaystyle'] == 'grid'
def test_uncategorised_type_reports_an_empty_category(client, db):
"""Blank, not null: the dashboard falls back to the type name for a row."""
ntype = _make_type(db, 'Recertification')
_make_note(db, ntype, 'forklift cert due')
assert _current(client)[0]['boardcategory'] == ''
def test_types_sharing_a_category_keep_their_own_names(client, db):
"""Grouping is the dashboard's job; the feed must not flatten the types."""
change = _make_type(db, 'Change', boardcategory='Alerts')
incident = _make_type(db, 'Incident', boardcategory='Alerts')
_make_note(db, change, 'line 3 retooling')
_make_note(db, incident, 'press 12 down')
cards = _current(client)
assert {c['typename'] for c in cards} == {'Change', 'Incident'}
assert {c['boardcategory'] for c in cards} == {'Alerts'}
def test_api_round_trips_the_category(client, db, auth_headers):
create = client.post('/api/notifications/types',
json={'typename': 'Awareness', 'displaystyle': 'grid',
'boardcategory': 'Alerts'},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
assert create.get_json()['data']['boardcategory'] == 'Alerts'
listed = client.get('/api/notifications/types', headers=auth_headers)
row = next(t for t in listed.get_json()['data']
if t['notificationtypeid'] == typeid)
assert row['boardcategory'] == 'Alerts'
def test_blank_category_stores_as_null_not_an_empty_group(client, db, auth_headers):
"""Every uncategorised type sharing the empty string would be one big row."""
create = client.post('/api/notifications/types',
json={'typename': 'Solo', 'boardcategory': ' '},
headers=auth_headers)
assert create.status_code in (200, 201), create.get_json()
typeid = create.get_json()['data']['notificationtypeid']
stored = db.session.get(NotificationType, typeid)
assert stored.boardcategory is None
assert create.get_json()['data']['boardcategory'] == ''
def test_api_rejects_an_overlong_category(client, db, auth_headers):
resp = client.post('/api/notifications/types',
json={'typename': 'Bad', 'boardcategory': 'x' * 51},
headers=auth_headers)
assert resp.status_code == 400
assert 'boardcategory' in resp.get_data(as_text=True)
def test_category_change_moves_the_config_version(client, db, auth_headers):
"""Open kiosks reload on a layout change; a re-grouped board is one."""
ntype = _make_type(db, 'Awareness')
before = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
client.put(f'/api/notifications/types/{ntype.notificationtypeid}',
json={'boardcategory': 'Alerts'}, headers=auth_headers)
after = client.get('/api/notifications/shopfloor').get_json()['data']['configversion']
assert before != after

View File

@@ -57,6 +57,39 @@ def test_update_network_device_ipaddress_upserts(client, db, auth_headers, netwo
assert len(comms) == 1 and comms[0].ipaddress == '10.0.0.9'
def test_clearing_the_ip_takes_it_back_out(client, db, auth_headers, network_setup):
"""Blanking the form field must clear the stored IP.
The upsert helper returned early on a blank value, so an address could be
typed and never removed - invisible until the form grew an IP field.
"""
from shopdb.core.models import Communication
created = client.post('/api/network', json={
'assetnumber': 'SW-09', 'name': 'Switch', 'ipaddress': '10.0.0.5',
}, headers=auth_headers).get_json()['data']
devid = created['networkdevice']['networkdeviceid']
cleared = client.put(f'/api/network/{devid}', json={'ipaddress': ''},
headers=auth_headers)
assert cleared.status_code == 200, cleared.get_json()
assert not cleared.get_json()['data']['ipaddress']
comms = Communication.query.filter_by(assetid=created['assetid']).all()
assert len(comms) == 1 and comms[0].ipaddress is None
def test_omitting_ipaddress_leaves_it_alone(client, db, auth_headers, network_setup):
"""A PUT that never mentions the field must not wipe the stored IP."""
created = client.post('/api/network', json={
'assetnumber': 'SW-10', 'name': 'Switch', 'ipaddress': '10.0.0.7',
}, headers=auth_headers).get_json()['data']
devid = created['networkdevice']['networkdeviceid']
updated = client.put(f'/api/network/{devid}', json={'hostname': 'sw-10'},
headers=auth_headers)
assert updated.get_json()['data']['ipaddress'] == '10.0.0.7'
def test_seed_reference_data_creates_ip_commtype(app, db):
"""The reference-data seed now creates the IP communication type the asset
import depends on."""