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:
@@ -245,65 +245,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer assignment. Written as plain asset relationships, so this
|
||||
section is absent at a site without the printers plugin. -->
|
||||
<template v-if="printersEnabled">
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="printersearch">Assigned Printers</label>
|
||||
<input
|
||||
id="printersearch"
|
||||
v-model="printerSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Filter printers..."
|
||||
/>
|
||||
<div class="printer-list">
|
||||
<label
|
||||
v-for="printerAsset in filteredPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
class="printer-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isPrinterAssigned(printerAsset.assetid)"
|
||||
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
|
||||
/>
|
||||
<span>{{ printerLabel(printerAsset) }}</span>
|
||||
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
|
||||
{{ printerAsset.printer.modelname }}
|
||||
</span>
|
||||
</label>
|
||||
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
|
||||
</div>
|
||||
<small class="form-hint">
|
||||
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
|
||||
and take the place of any assigned to the machine it controls.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="defaultprinterassetid">Default Printer</label>
|
||||
<select
|
||||
id="defaultprinterassetid"
|
||||
v-model="defaultPrinterAssetId"
|
||||
class="form-control"
|
||||
>
|
||||
<option :value="null">No default</option>
|
||||
<option
|
||||
v-for="printerAsset in assignedPrinters"
|
||||
:key="printerAsset.assetid"
|
||||
:value="printerAsset.assetid"
|
||||
>
|
||||
{{ printerLabel(printerAsset) }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">
|
||||
Optional, and only ever one of the printers assigned above.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Printers assigned to this PC ITSELF, which replace whatever the
|
||||
machine it controls is assigned. Same component the machine form
|
||||
uses: two copies of this UI would drift, and the two ends of an
|
||||
override disagreeing is the bug nobody would spot. -->
|
||||
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="pc" />
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
@@ -384,6 +330,7 @@ import { loadMapConfig, levelOptions, levelName, state as mapConfig }
|
||||
from '@/composables/mapConfig'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
||||
import PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
|
||||
import { currentTheme } from '@/stores/theme'
|
||||
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
@@ -402,6 +349,7 @@ const isEdit = computed(() => !!route.params.id)
|
||||
// values are keyed by the underlying asset id, captured on load / create.
|
||||
const COMPUTER_ASSETTYPEID = 2
|
||||
const customFieldsRef = ref(null)
|
||||
const printerPickerRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
// PC Number (assetnumber) defaults to the serial number while the user hasn't
|
||||
@@ -476,190 +424,6 @@ const models = ref([])
|
||||
const locations = ref([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Printer assignment. The rows are ordinary asset relationships - usesprinter
|
||||
// for "installed here", defaultprinter for which of them wins - so the picker
|
||||
// reads and writes them through the generic relationship endpoints.
|
||||
const printersEnabled = ref(false)
|
||||
const printers = ref([])
|
||||
const printerSearch = ref('')
|
||||
const assignedPrinterAssetIds = ref([])
|
||||
const defaultPrinterAssetId = ref(null)
|
||||
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
|
||||
// The rows as loaded, so saving writes only what actually changed.
|
||||
const existingPrinterRelationships = ref([])
|
||||
|
||||
function printerLabel(printerAsset) {
|
||||
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
|
||||
? printerAsset.name
|
||||
: null
|
||||
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|
||||
|| `Asset ${printerAsset.assetid}`
|
||||
}
|
||||
|
||||
const sortedPrinters = computed(() =>
|
||||
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
const term = printerSearch.value.trim().toLowerCase()
|
||||
if (!term) return sortedPrinters.value
|
||||
return sortedPrinters.value.filter(printerAsset => {
|
||||
const haystack = [
|
||||
printerLabel(printerAsset),
|
||||
printerAsset.assetnumber || '',
|
||||
printerAsset.printer?.modelname || ''
|
||||
].join(' ').toLowerCase()
|
||||
return haystack.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
// Drives the default dropdown, so the default can only ever be one of the
|
||||
// assigned printers. A printer filtered out of the list above is still here.
|
||||
const assignedPrinters = computed(() =>
|
||||
assignedPrinterAssetIds.value
|
||||
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
|
||||
|
||||
function isPrinterAssigned(assetid) {
|
||||
return assignedPrinterAssetIds.value.includes(assetid)
|
||||
}
|
||||
|
||||
function togglePrinter(assetid, on) {
|
||||
if (on) {
|
||||
if (!isPrinterAssigned(assetid)) {
|
||||
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
|
||||
}
|
||||
} else {
|
||||
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Unassigning the printer that is currently default clears the default rather
|
||||
// than leaving one pointing at a printer this PC no longer has.
|
||||
watch(assignedPrinterAssetIds, (assetids) => {
|
||||
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
|
||||
defaultPrinterAssetId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// Printer list + the two relationship type ids. Both types are seed data
|
||||
// (flask seed reference-data); without them there is nothing to write, so the
|
||||
// section stays hidden rather than offering a control that cannot save.
|
||||
async function loadPrinterOptions() {
|
||||
try {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled('printers')) return
|
||||
const [printerRows, typeResponse] = await Promise.all([
|
||||
printersApi.listAll(),
|
||||
relationshipTypesApi.list()
|
||||
])
|
||||
const types = typeResponse.data.data || []
|
||||
const typeIdFor = (name) =>
|
||||
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
|
||||
printerRelationshipTypes.value = {
|
||||
usesprinter: typeIdFor('usesprinter'),
|
||||
defaultprinter: typeIdFor('defaultprinter')
|
||||
}
|
||||
printers.value = printerRows || []
|
||||
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
|
||||
&& printerRelationshipTypes.value.defaultprinter)
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printers:', printerError)
|
||||
printersEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrinterAssignments(assetid) {
|
||||
if (!printersEnabled.value || !assetid) return
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(assetid)
|
||||
const types = printerRelationshipTypes.value
|
||||
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
|
||||
rel => rel.relationshiptypeid === types.usesprinter
|
||||
|| rel.relationshiptypeid === types.defaultprinter
|
||||
)
|
||||
assignedPrinterAssetIds.value = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
.map(rel => rel.targetassetid)
|
||||
const currentDefault = existingPrinterRelationships.value
|
||||
.find(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
|
||||
// Defaults set before this form existed have no usesprinter row. Show that
|
||||
// printer as assigned: a default missing from the list reads as data loss,
|
||||
// and saving then writes the row that was never there.
|
||||
if (defaultPrinterAssetId.value
|
||||
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
|
||||
assignedPrinterAssetIds.value = [
|
||||
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
|
||||
]
|
||||
}
|
||||
// /printers lists active printers only, so a retired one that is still
|
||||
// assigned would be missing from every control on this form - unable to be
|
||||
// unticked, and blank in the default box. The relationship carries the
|
||||
// asset, so add it to the list it fell out of.
|
||||
for (const rel of existingPrinterRelationships.value) {
|
||||
const target = rel.targetasset
|
||||
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
|
||||
printers.value = [...printers.value, target]
|
||||
}
|
||||
}
|
||||
} catch (printerError) {
|
||||
console.error('Error loading printer assignment:', printerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile the PC's own printer rows against the picker. Row at a time
|
||||
// through the generic relationship endpoints - there is no single assignment
|
||||
// endpoint yet - so the order matters: the outgoing default goes before the
|
||||
// incoming one lands, because the unique key is (source, target, type) and
|
||||
// would let two different defaults sit side by side. Re-creating a row that
|
||||
// was removed earlier is safe; the create path reactivates the soft-deleted
|
||||
// one instead of inserting a duplicate.
|
||||
async function savePrinterAssignments(assetid) {
|
||||
const types = printerRelationshipTypes.value
|
||||
const assigned = assignedPrinterAssetIds.value
|
||||
const wanteddefault = defaultPrinterAssetId.value
|
||||
|
||||
const assignedRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.usesprinter)
|
||||
const defaultRows = existingPrinterRelationships.value
|
||||
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
|
||||
|
||||
try {
|
||||
// Removing an assignment removes the row and nothing else. It never
|
||||
// uninstalls a queue anywhere.
|
||||
for (const rel of assignedRows) {
|
||||
if (!assigned.includes(rel.targetassetid)) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
for (const printerassetid of assigned) {
|
||||
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: printerassetid,
|
||||
relationshiptypeid: types.usesprinter
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const rel of defaultRows) {
|
||||
if (rel.targetassetid !== wanteddefault) {
|
||||
await assetsApi.deleteRelationship(rel.relationshipid)
|
||||
}
|
||||
}
|
||||
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: assetid,
|
||||
targetassetid: wanteddefault,
|
||||
relationshiptypeid: types.defaultprinter
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Part of the reconcile may have landed, so what the form believes is
|
||||
// stored has to come from the server before anyone saves again.
|
||||
await loadPrinterAssignments(assetid)
|
||||
}
|
||||
}
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
@@ -689,7 +453,6 @@ onMounted(async () => {
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list(),
|
||||
// Handles its own failure: a site without printers still gets a form.
|
||||
loadPrinterOptions()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -733,8 +496,6 @@ onMounted(async () => {
|
||||
levelid: pc.levelid ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
|
||||
await loadPrinterAssignments(currentAssetId.value)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
@@ -823,13 +584,16 @@ async function savePC() {
|
||||
}
|
||||
}
|
||||
|
||||
// Printer assignment through the shared picker, which reconciles the whole
|
||||
// set in one call rather than row at a time.
|
||||
//
|
||||
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
|
||||
// whose Save would create a second PC is the worse failure - but a printer
|
||||
// whose Save would create a second PC is the worse failure - but an
|
||||
// assignment that quietly did not happen is the bug this feature exists to
|
||||
// stop, so it has to be said out loud.
|
||||
if (assetId && printersEnabled.value) {
|
||||
if (assetId && printerPickerRef.value) {
|
||||
try {
|
||||
await savePrinterAssignments(assetId)
|
||||
await printerPickerRef.value.save(assetId)
|
||||
} catch (printerError) {
|
||||
console.error('Error saving printer assignment:', printerError)
|
||||
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
|
||||
|
||||
Reference in New Issue
Block a user