Wire relationship directionality and dualpath controls propagation
Symmetric relationship types (isdirectional flag, migration 7d19) show
one entry per peer on the relationships card - a Dualpath pair no
longer lists its partner twice - and directional types read naturally
instead of Outgoing/Incoming. Deleting a collapsed entry removes every
underlying direction row.
Propagation is now real (migration 7d20): relationship types declare
propagation-through pairs in relationshiptypepropagations (M:N,
replacing the never-consumed single column); creating a controls link
on either bay of a Dualpath pair auto-creates it on the partner,
mirrored across both endpoints because live data stores controls as
bay -> PC. flask relationships propagate backfills existing data (29
rows fanned out on the WJ dataset, idempotent).
This also completes the tree that commit 1d21bf0 accidentally split
(core/models/__init__ imported RelationshipTypePropagation ahead of the
file that defines it), returning CI to green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,30 +18,32 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Outgoing relationships (this asset controls/connects to...) -->
|
||||
<div v-if="outgoing.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Outgoing</h4>
|
||||
<!-- Symmetric connections: one direction-blind entry per peer -->
|
||||
<div v-if="connectedItems.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Connected</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in outgoing"
|
||||
:key="rel.relationshipid"
|
||||
v-for="item in connectedItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(rel.targetasset?.assettypename || rel.targetasset?.assettype)" :size="16" /></div>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.targetasset)" class="rel-name">
|
||||
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
|
||||
<span class="rel-type-badge">{{ rel.targetasset?.assettypename || rel.targetasset?.assettype }}</span>
|
||||
<div class="rel-line">
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
</div>
|
||||
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteRelationship(rel.relationshipid)"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
@@ -50,30 +52,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Incoming relationships (...controls/connects to this asset) -->
|
||||
<div v-if="incoming.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Incoming</h4>
|
||||
<!-- Directional relationships: natural per-row phrasing, no jargon -->
|
||||
<div v-if="directionalItems.length > 0" class="relationship-group">
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="rel in incoming"
|
||||
:key="rel.relationshipid"
|
||||
v-for="item in directionalItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(rel.sourceasset?.assettypename || rel.sourceasset?.assettype)" :size="16" /></div>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<router-link :to="getAssetRoute(rel.sourceasset)" class="rel-name">
|
||||
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<div class="rel-meta">
|
||||
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
|
||||
<span class="rel-type-badge">{{ rel.sourceasset?.assettypename || rel.sourceasset?.assettype }}</span>
|
||||
<div class="rel-line">
|
||||
<template v-if="item.direction === 'outgoing'">
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-arrow">-></span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rel-arrow"><-</span>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-from">from</span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteRelationship(rel.relationshipid)"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
@@ -225,6 +239,71 @@ let searchTimeout = null
|
||||
|
||||
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
|
||||
|
||||
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
|
||||
// stored direction rows (both directions, plus any legacy duplicates) fold
|
||||
// into one displayed entry; its rowIds carries every collapsed relationshipid
|
||||
// so a delete removes them all.
|
||||
const connectedItems = computed(() => {
|
||||
const byPair = new Map()
|
||||
const rows = [
|
||||
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
|
||||
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
|
||||
]
|
||||
for (const { rel, peer } of rows) {
|
||||
if (rel.isdirectional !== false) continue
|
||||
const selfid = resolvedAssetId.value
|
||||
const peerid = peer?.assetid
|
||||
const lo = Math.min(selfid, peerid)
|
||||
const hi = Math.max(selfid, peerid)
|
||||
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
|
||||
const existing = byPair.get(key)
|
||||
if (existing) {
|
||||
existing.rowIds.push(rel.relationshipid)
|
||||
if (!existing.notes && rel.notes) existing.notes = rel.notes
|
||||
} else {
|
||||
byPair.set(key, {
|
||||
key,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer,
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byPair.values())
|
||||
})
|
||||
|
||||
// Directional types keep one entry per stored row with arrow phrasing.
|
||||
const directionalItems = computed(() => {
|
||||
const items = []
|
||||
for (const rel of outgoing.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `out-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.targetasset,
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
for (const rel of incoming.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `in-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.sourceasset,
|
||||
direction: 'incoming',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
|
||||
})
|
||||
@@ -356,11 +435,12 @@ async function saveRelationship() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRelationship(relationshipId) {
|
||||
// Delete every collapsed direction row behind a displayed entry.
|
||||
async function deleteItem(item) {
|
||||
if (!confirm('Remove this relationship?')) return
|
||||
|
||||
try {
|
||||
await assetsApi.deleteRelationship(relationshipId)
|
||||
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
|
||||
await loadRelationships()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
@@ -499,6 +579,24 @@ function getAssetRoute(asset) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rel-arrow {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-from {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user