One printer picker for machines and PCs, and one default per asset

The assignment belongs to the MACHINE, and until now there was no way to set it
except the generic relationships card or the API - the form for the thing the
feature is about did not exist. MachineForm now carries the picker, and PCForm
uses the SAME component rather than its own copy: the PC's set overrides the
machine's, and two implementations of that would drift, with the two ends of an
override disagreeing being exactly the bug nobody would spot.

The shared picker also fixes what PCForm did on save. It wrote row at a time
through the generic relationship endpoints, which is a non-atomic reconcile: an
HTTP failure part way left a PC half-assigned with nothing recording what was
meant. It now calls the reconcile endpoint, which validates the default before
writing anything.

A relationship type can now say it allows one active row per asset
(relationshiptypes.issingular, migration 7d34), and defaultprinter says it.
Cardinality belongs to the type rather than the printers plugin: core's create
path is where every hand-made link passes, and the next type meaning "exactly
one" gets the rule for free. Setting a second default REPLACES the first instead
of refusing, because "make this the default" means that - and a card answering
409 would leave the user hunting for the old row.

Without it the schema was happy to hold two defaults: the unique constraint is
(source, target, type), so two different targets are two valid rows, and the
resolver takes the OLDEST - the new default silently lost. Proven by disabling
the new rule and watching the tests fail.

FOUND WHILE TESTING IN A BROWSER, and it was not mine: MachineForm read
.data.data off computersApi.listAll(), which resolves to the ARRAY - fetchAllPages
has already unwrapped every page. The whole parallel load threw into the catch,
so every dropdown on the machine edit form came up empty and the machine's own
values never loaded. A build cannot see this; only opening the page can.

GET /api/printers/assignments/for-asset/<id> returns an asset's OWN assignment,
without inheritance, because the editor must show what this asset's rows say -
otherwise a machine's printers appear ticked on the PC that inherits them and
unticking one silently creates an override.
This commit is contained in:
cproudlock
2026-08-19 11:22:48 -04:00
parent 72b3904f71
commit e2c45d33bc
12 changed files with 498 additions and 253 deletions

View File

@@ -0,0 +1,168 @@
<template>
<template v-if="enabled">
<h4 class="printer-heading">Printers</h4>
<div class="form-group">
<label :for="`printersearch-${uid}`">Assigned printers</label>
<input
:id="`printersearch-${uid}`"
v-model="search"
type="text"
class="form-control"
placeholder="Filter printers..."
/>
<div class="printer-list">
<label v-for="option in filtered" :key="option.assetid" class="printer-item">
<input
type="checkbox"
:checked="assigned.includes(option.assetid)"
@change="toggle(option.assetid, $event.target.checked)"
/>
<span>{{ label(option) }}</span>
<span v-if="option.printer?.modelname" class="printer-meta">
{{ option.printer.modelname }}
</span>
</label>
<span v-if="!filtered.length" class="muted">No printers match.</span>
</div>
<small class="form-hint">{{ hint }}</small>
</div>
<div class="form-group">
<label :for="`defaultprinter-${uid}`">Default printer</label>
<select :id="`defaultprinter-${uid}`" v-model="defaultAssetId" class="form-control">
<option :value="null">No default</option>
<!-- Only what is assigned: a default the bay was never told to install
fails to apply, and nothing in ShopDB shows why. -->
<option v-for="option in assignedOptions" :key="option.assetid" :value="option.assetid">
{{ label(option) }}
</option>
</select>
<small class="form-hint">
Optional. Applied per user at logon, because a default printer is a
per-user setting that SYSTEM cannot set for somebody else.
</small>
</div>
</template>
</template>
<script setup>
// One picker, used by the machine form and the PC form.
//
// The assignment belongs to the MACHINE - that is what makes a reimaged PC come
// back with the bay's printers - and the PC form writes the same shape as an
// override. Two copies of this UI would drift, and the two ends of an override
// disagreeing is exactly the bug nobody would spot.
import { ref, computed, watch, onMounted } from 'vue'
import { printersApi } from '@/api'
const props = defineProps({
// The asset being edited. Null while creating: save(assetid) is called with
// the new id once it exists.
assetid: { type: Number, default: null },
// Wording only. What it means to assign here differs: a machine's set is the
// bay's, a PC's set overrides the machine it controls.
scope: { type: String, default: 'machine' }
})
const uid = Math.random().toString(36).slice(2, 8)
const enabled = ref(false)
const printers = ref([])
const assigned = ref([])
const defaultAssetId = ref(null)
const search = ref('')
const filtered = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return printers.value
return printers.value.filter(option => label(option).toLowerCase().includes(term))
})
const assignedOptions = computed(() =>
printers.value.filter(option => assigned.value.includes(option.assetid)))
const hint = computed(() => {
const count = assigned.value.length
if (props.scope === 'pc') {
return `${count} assigned. Printers ticked here belong to this PC and REPLACE `
+ 'whatever the machine it controls is assigned - the whole set, not added to it.'
}
return `${count} assigned. These belong to the machine, so whichever PC controls `
+ 'it installs them - including a replacement PC after a reimage.'
})
function label(option) {
// A real fleet has printers whose name is the literal string 'NONE' - an
// import artefact - and showing that as the label makes two different
// printers indistinguishable in the list.
const name = option.name && option.name.toUpperCase() !== 'NONE' ? option.name : ''
return name || option.assetnumber || `Printer ${option.assetid}`
}
function toggle(assetid, checked) {
if (checked) {
if (!assigned.value.includes(assetid)) assigned.value.push(assetid)
} else {
assigned.value = assigned.value.filter(id => id !== assetid)
// Unassigning the default clears it rather than leaving a row pointing at a
// printer the bay is no longer told to install.
if (defaultAssetId.value === assetid) defaultAssetId.value = null
}
}
async function loadOptions() {
try {
// listAll: perpage is clamped server-side, and a picker that stops at 100
// silently hides printers sorting late in the alphabet.
printers.value = await printersApi.listAll()
enabled.value = true
} catch (error) {
// A site without the printers plugin has no section at all.
enabled.value = false
}
}
async function loadAssignment(assetid) {
if (!enabled.value || !assetid) return
try {
const response = await printersApi.assignment.get(assetid)
const data = response.data.data || {}
assigned.value = data.printerassetids || []
defaultAssetId.value = data.defaultprinterassetid ?? null
} catch (error) {
assigned.value = []
defaultAssetId.value = null
}
}
// Called by the parent AFTER the asset exists, so a new record can be assigned
// in the same save.
async function save(assetid) {
if (!enabled.value || !assetid) return
await printersApi.assignment.set(assetid, assigned.value, defaultAssetId.value)
}
defineExpose({ save })
onMounted(async () => {
await loadOptions()
await loadAssignment(props.assetid)
})
watch(() => props.assetid, assetid => loadAssignment(assetid))
</script>
<style scoped>
.printer-heading { margin-top: 1.5rem; margin-bottom: 1rem; }
.printer-list {
max-height: 12rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.35rem 0.5rem;
margin-top: 0.35rem;
}
.printer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0; }
.printer-meta { color: var(--text-light); font-size: 0.85em; }
.muted { color: var(--text-light); }
</style>