Per-type board rows with shared categories, a network device IP field, and two dark-mode dialogs
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
117
tests/test_plugins/test_board_category.py
Normal file
117
tests/test_plugins/test_board_category.py
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user