Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ function App() {
onUpdateRelationshipLineStyle,
onResetRelationshipRouting,
onToggleAssociationComposition,
onUpdateAssociationWhole,
onHighlightAssociation,
onHighlightNote,
onHighlightArea,
Expand Down Expand Up @@ -977,6 +978,7 @@ function App() {
onResetRelationshipRouting={onResetRelationshipRouting}
showCompositionAggregation={showCompositionAggregation}
onToggleAssociationComposition={onToggleAssociationComposition}
onUpdateAssociationWhole={onUpdateAssociationWhole}
onHighlightAssociation={onHighlightAssociation}
onAddNote={onAddNote}
onRenameNote={onRenameNote}
Expand Down
49 changes: 48 additions & 1 deletion src/components/flow/edges/__tests__/parallelLabels.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Association } from '../Association.jsx'
import { Composition } from '../Composition.jsx'
import { AssociativeAssociation } from '../AssociativeAssociation.jsx'
import { getAssociationEndpointAllocations, getAssociationLayout } from '../../utils/associationUtils.js'
import { normalizeEdges, reverseAssociationEndpoints } from '../../../../model/edgeUtils.js'

const flow = vi.hoisted(() => ({ edges: [], nodes: {
a: { position: { x: 0, y: 0 }, measured: { width: 188, height: 80 } },
Expand All @@ -17,7 +18,53 @@ vi.mock('@xyflow/react', async importOriginal => ({
useReactFlow: () => ({}),
EdgeLabelRenderer: ({ children }) => createPortal(children, document.body),
}))
afterEach(cleanup)
afterEach(() => {
cleanup()
flow.edges = []
flow.nodes = {
a: { position: { x: 0, y: 0 }, measured: { width: 188, height: 80 } },
b: { position: { x: 400, y: 0 }, measured: { width: 188, height: 80 } },
}
})

describe.each(['straight', 'orthogonal', 'manual'])('changing the whole with %s routing', lineStyle => {
it.each([1, 3])('keeps geometry and labels in place with %i association(s)', count => {
flow.nodes = {
a: { position: { x: 0, y: 0 }, measured: { width: 188, height: 80 } },
b: { position: { x: 430, y: 170 }, measured: { width: 188, height: 120 } },
}
flow.edges = normalizeEdges(Array.from({ length: count }, (_, index) => ({
id: `composition-${index}`, source: 'a', target: 'b', type: 'composition',
data: { name: 'contains', lineStyle, multiplicityA: '0..*', multiplicityB: '1..1',
controlPoints: lineStyle === 'manual' ? [{ x: 270, y: 40 }, { x: 350, y: 240 }] : [] },
})))
const first = flow.edges[0]
const { container, rerender } = render(<svg><Composition {...first} /></svg>)
const multiplicityTransforms = ['0..*', '1..1'].map(label => screen.getByText(label).style.transform)
const coordinates = () => container.querySelector('.react-flow__edge-path').getAttribute('d')
.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/gi).map(Number)
.reduce((pairs, number, index) => {
if (index % 2 === 0) pairs.push([number])
else pairs[pairs.length - 1].push(number)
return pairs
}, [])
const beforeCoordinates = coordinates()
const diamondCenter = () => container.querySelector('polygon').getAttribute('transform').match(/translate\(([^)]+)\)/)[1].split(',').map(Number)
const originalDiamond = diamondCenter()
const originalTarget = beforeCoordinates.at(-1)
expect(Math.hypot(originalDiamond[0] - originalTarget[0], originalDiamond[1] - originalTarget[1])).toBeCloseTo(7)

const reversed = reverseAssociationEndpoints(first)
flow.edges = [reversed, ...flow.edges.slice(1)]
rerender(<svg><Composition {...reversed} /></svg>)
const afterCoordinates = coordinates().reverse()
expect(afterCoordinates).toHaveLength(beforeCoordinates.length)
afterCoordinates.forEach((pair, index) => pair.forEach((number, axis) => expect(number).toBeCloseTo(beforeCoordinates[index][axis])))
expect(['0..*', '1..1'].map(label => screen.getByText(label).style.transform)).toEqual(multiplicityTransforms)
const newDiamond = diamondCenter()
expect(Math.hypot(newDiamond[0] - beforeCoordinates[0][0], newDiamond[1] - beforeCoordinates[0][1])).toBeCloseTo(7)
})
})

it('attaches a dashed association-class link to the allocated path despite stale helper coordinates', () => {
const nodes = { ...flow.nodes, upper: { position: { x: 0, y: -150 }, measured: { width: 188, height: 80 } } }
Expand Down
2 changes: 2 additions & 0 deletions src/components/layout/InfoPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ function InfoPanel({
onResetRelationshipRouting,
showCompositionAggregation,
onToggleAssociationComposition,
onUpdateAssociationWhole,
onHighlightAssociation,
onAddNote,
onRenameNote,
Expand Down Expand Up @@ -165,6 +166,7 @@ function InfoPanel({
onUpdateRelationshipLineStyle={onUpdateRelationshipLineStyle}
onResetRelationshipRouting={onResetRelationshipRouting}
onToggleAssociationComposition={onToggleAssociationComposition}
onUpdateAssociationWhole={onUpdateAssociationWhole}
onHighlightAssociation={onHighlightAssociation}
openAssociationId={openAssociationId}
onOpenAssociationIdChange={onOpenAssociationIdChange}
Expand Down
2 changes: 2 additions & 0 deletions src/components/panels/refs/AssociationsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function AssociationsPanel({
onUpdateAssociationLineStyle,
onResetAssociationRouting,
onToggleAssociationComposition,
onUpdateAssociationWhole,
onHighlightAssociation,
openAssociationId: controlledOpenAssociationId,
onOpenAssociationIdChange,
Expand Down Expand Up @@ -119,6 +120,7 @@ export default function AssociationsPanel({
onResetAssociationRouting={onResetAssociationRouting}
showCompositionAggregation={showCompositionAggregation}
onToggleAssociationComposition={onToggleAssociationComposition}
onUpdateAssociationWhole={onUpdateAssociationWhole}
onHighlightAssociation={onHighlightAssociation}
/>
)
Expand Down
131 changes: 76 additions & 55 deletions src/components/panels/refs/AssociationsPanelItem.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default function AssociationsPanelItem({
onResetAssociationRouting,
showCompositionAggregation = false,
onToggleAssociationComposition,
onUpdateAssociationWhole,
onHighlightAssociation,
isOpen = false,
onToggleOpen,
Expand All @@ -59,7 +60,20 @@ export default function AssociationsPanelItem({
const isComposition = edge.type === 'composition'
const isReflexive = edge.source === edge.target
const label = edge.data?.name ?? ''
const endpointSummary = isReflexive ? `${sourceLabel} (self)` : `${sourceLabel} and ${targetLabel}`
const firstLabel = isComposition ? targetLabel : sourceLabel
const secondLabel = isComposition ? sourceLabel : targetLabel
const endpointSummary = isReflexive ? `${sourceLabel} (self)`
: isComposition ? `${firstLabel} (whole) and ${secondLabel} (part)`
: `${sourceLabel} and ${targetLabel}`
const endpointLabels = <>
<span className="truncate">{firstLabel}</span>
{isReflexive ? <span>(self)</span> : <>
<EndpointConnector composition={isComposition} />
<span className="sr-only">{isComposition ? ' (whole) and ' : 'and'}</span>
<span className="truncate">{secondLabel}</span>
{isComposition && <span className="sr-only"> (part)</span>}
</>}
</>
const multiplicityA = edge.data?.multiplicityA ?? ''
const multiplicityB = edge.data?.multiplicityB ?? ''
const roleA = edge.data?.roleA ?? ''
Expand Down Expand Up @@ -109,6 +123,11 @@ export default function AssociationsPanelItem({
multiplicityAValue || UNDEFINED_MULTIPLICITY_VALUE
const selectMultiplicityBValue =
multiplicityBValue || UNDEFINED_MULTIPLICITY_VALUE
const endpoints = [
{ side: 'A', name: sourceLabel, isAssociation: sourceIsAssociation, multiplicity: selectMultiplicityAValue, role: roleA },
{ side: 'B', name: targetLabel, isAssociation: targetIsAssociation, multiplicity: selectMultiplicityBValue, role: roleB },
]
if (isComposition) endpoints.reverse()

useEffect(() => {
if (isEditing) {
Expand Down Expand Up @@ -199,28 +218,15 @@ export default function AssociationsPanelItem({
}}
/>
) : (
<span className="flex w-full min-w-0 items-center gap-1 text-sm leading-5">
<span className="flex w-full min-w-0 items-center gap-1 text-sm leading-5" title={label ? undefined : endpointSummary}>
{label ? (
<span className="truncate">{label}</span>
) : isReflexive ? (
<span className="truncate">{`${sourceLabel} (self)`}</span>
) : (
<>
<span className="truncate">{sourceLabel}</span>
<EndpointConnector />
<span className="truncate">{targetLabel}</span>
</>
)}
) : endpointLabels}
</span>
)}
{!isEditing && label && (
<span className="flex w-full min-w-0 items-center gap-1 text-[11px] font-normal leading-4 text-muted-foreground" title={endpointSummary}>
<span className="truncate">{sourceLabel}</span>
{isReflexive ? <span>(self)</span> : <>
<EndpointConnector />
<span className="sr-only">and</span>
<span className="truncate">{targetLabel}</span>
</>}
{endpointLabels}
</span>
)}
</div>
Expand Down Expand Up @@ -296,45 +302,45 @@ export default function AssociationsPanelItem({
<div className="w-full">
<div className="rounded-b-md border-b border-base-content/20 px-3 pb-3 pt-1">
<div className="grid grid-cols-2 gap-3">
{[
{ side: 'A', name: sourceLabel, isAssociation: sourceIsAssociation, multiplicity: selectMultiplicityAValue, role: roleA },
{ side: 'B', name: targetLabel, isAssociation: targetIsAssociation, multiplicity: selectMultiplicityBValue, role: roleB },
].map(({ side, name, isAssociation, multiplicity, role }) => (
<section key={side} aria-label={`Endpoint ${side}: ${name}`} className="min-w-0 space-y-2">
<div>
<div className="text-[11px] text-base-content/65">{isAssociation ? 'Association' : edge.type === 'associativeAssociation' ? 'Class' : `Class ${side}`}</div>
<div className="mt-0.5 truncate text-sm font-medium" title={name}>{name}</div>
</div>
{canEditMultiplicity && (
<div className="space-y-1">
<label htmlFor={`${fieldId}-multiplicity-${side}`} className="text-[11px] text-base-content/65">Multiplicity</label>
<SelectField
id={`${fieldId}-multiplicity-${side}`}
aria-label={`Multiplicity ${side} (${name})`}
value={multiplicity}
items={MULTIPLICITY_OPTIONS}
placeholder="Select"
onValueChange={value => onUpdateAssociationMultiplicity?.(
edge.id, side, value === UNDEFINED_MULTIPLICITY_VALUE ? '' : value,
)}
/>
</div>
)}
{canEditRole && (
<div className="space-y-1">
<label htmlFor={`${fieldId}-role-${side}`} className="text-[11px] text-base-content/65">Role</label>
<Input
id={`${fieldId}-role-${side}`}
aria-label={`Role ${side} (${name})`}
size="xs" value={role} placeholder="Role"
onFocus={() => window.dispatchEvent(new CustomEvent('model-text-edit-start'))}
onBlur={() => window.dispatchEvent(new CustomEvent('model-text-edit-end'))}
onChange={event => onUpdateAssociationRole?.(edge.id, side, event.target.value)}
/>
{endpoints.map(({ side, name, isAssociation, multiplicity, role }) => {
const endpointLabel = isComposition ? (side === 'A' ? 'Part' : 'Whole (composite)') : `Endpoint ${side}`
return (
<section key={side} aria-label={`${endpointLabel}: ${name}`} className="min-w-0 space-y-2">
<div>
<div className="text-[11px] text-base-content/65">{isComposition ? endpointLabel : isAssociation ? 'Association' : edge.type === 'associativeAssociation' ? 'Class' : `Class ${side}`}</div>
<div className="mt-0.5 truncate text-sm font-medium" title={name}>{name}</div>
</div>
)}
</section>
))}
{canEditMultiplicity && (
<div className="space-y-1">
<label htmlFor={`${fieldId}-multiplicity-${side}`} className="text-[11px] text-base-content/65">Multiplicity</label>
<SelectField
id={`${fieldId}-multiplicity-${side}`}
aria-label={`Multiplicity ${isComposition ? endpointLabel : side} (${name})`}
value={multiplicity}
items={MULTIPLICITY_OPTIONS}
placeholder="Select"
onValueChange={value => onUpdateAssociationMultiplicity?.(
edge.id, side, value === UNDEFINED_MULTIPLICITY_VALUE ? '' : value,
)}
/>
</div>
)}
{canEditRole && (
<div className="space-y-1">
<label htmlFor={`${fieldId}-role-${side}`} className="text-[11px] text-base-content/65">Role</label>
<Input
id={`${fieldId}-role-${side}`}
aria-label={`Role ${side} (${name})`}
size="xs" value={role} placeholder="Role"
onFocus={() => window.dispatchEvent(new CustomEvent('model-text-edit-start'))}
onBlur={() => window.dispatchEvent(new CustomEvent('model-text-edit-end'))}
onChange={event => onUpdateAssociationRole?.(edge.id, side, event.target.value)}
/>
</div>
)}
</section>
)
})}
</div>
{canToggleComposition ? (
<div className="mt-3">
Expand All @@ -353,6 +359,21 @@ export default function AssociationsPanelItem({
/>
<span>Composite aggregation</span>
</label>
{isComposition && (
<div className="mt-2 space-y-1">
<label htmlFor={`${fieldId}-whole`} className="text-[11px] text-base-content/65">Whole (composite)</label>
<SelectField
id={`${fieldId}-whole`}
aria-label="Whole (composite)"
value={edge.target}
items={[
{ value: edge.source, label: sourceLabel === targetLabel ? `${sourceLabel} (Part)` : sourceLabel },
{ value: edge.target, label: sourceLabel === targetLabel ? `${targetLabel} (Whole)` : targetLabel },
]}
onValueChange={nodeId => onUpdateAssociationWhole?.(edge.id, nodeId)}
/>
</div>
)}
</div>
) : null}
{canEditLineStyle ? (
Expand Down
7 changes: 5 additions & 2 deletions src/components/panels/refs/EndpointConnector.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export default function EndpointConnector({ directional = false }) {
export default function EndpointConnector({ directional = false, composition = false }) {
return (
<svg viewBox="0 0 20 16" fill="none" stroke="currentColor" strokeWidth="1.25"
className="h-3.5 w-4 shrink-0 opacity-70" aria-hidden="true">
{directional ? (
{composition ? <>
<path d="M9 8h10" />
<polygon points="1,8 5,4 9,8 5,12" fill="currentColor" strokeLinejoin="round" />
</> : directional ? (
<path d="M2 8h16m-5-5 5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
) : <>
<circle cx="3" cy="8" r="2" />
Expand Down
2 changes: 2 additions & 0 deletions src/components/panels/refs/RefsPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function RefsPanel({
onUpdateRelationshipLineStyle,
onResetRelationshipRouting,
onToggleAssociationComposition,
onUpdateAssociationWhole,
onHighlightAssociation,
openAssociationId,
onOpenAssociationIdChange,
Expand All @@ -45,6 +46,7 @@ export default function RefsPanel({
onUpdateAssociationLineStyle={onUpdateAssociationLineStyle}
onResetAssociationRouting={onResetAssociationRouting}
onToggleAssociationComposition={onToggleAssociationComposition}
onUpdateAssociationWhole={onUpdateAssociationWhole}
onHighlightAssociation={onHighlightAssociation}
openAssociationId={openAssociationId}
onOpenAssociationIdChange={onOpenAssociationIdChange}
Expand Down
Loading
Loading