diff --git a/src/App.jsx b/src/App.jsx index 4dffba9..8678e99 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -353,6 +353,7 @@ function App() { onUpdateRelationshipLineStyle, onResetRelationshipRouting, onToggleAssociationComposition, + onUpdateAssociationWhole, onHighlightAssociation, onHighlightNote, onHighlightArea, @@ -977,6 +978,7 @@ function App() { onResetRelationshipRouting={onResetRelationshipRouting} showCompositionAggregation={showCompositionAggregation} onToggleAssociationComposition={onToggleAssociationComposition} + onUpdateAssociationWhole={onUpdateAssociationWhole} onHighlightAssociation={onHighlightAssociation} onAddNote={onAddNote} onRenameNote={onRenameNote} diff --git a/src/components/flow/edges/__tests__/parallelLabels.test.jsx b/src/components/flow/edges/__tests__/parallelLabels.test.jsx index 5e1b4e0..a86787d 100644 --- a/src/components/flow/edges/__tests__/parallelLabels.test.jsx +++ b/src/components/flow/edges/__tests__/parallelLabels.test.jsx @@ -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 } }, @@ -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() + 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() + 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 } } } diff --git a/src/components/layout/InfoPanel.jsx b/src/components/layout/InfoPanel.jsx index 1eabeb5..3870f8d 100644 --- a/src/components/layout/InfoPanel.jsx +++ b/src/components/layout/InfoPanel.jsx @@ -54,6 +54,7 @@ function InfoPanel({ onResetRelationshipRouting, showCompositionAggregation, onToggleAssociationComposition, + onUpdateAssociationWhole, onHighlightAssociation, onAddNote, onRenameNote, @@ -165,6 +166,7 @@ function InfoPanel({ onUpdateRelationshipLineStyle={onUpdateRelationshipLineStyle} onResetRelationshipRouting={onResetRelationshipRouting} onToggleAssociationComposition={onToggleAssociationComposition} + onUpdateAssociationWhole={onUpdateAssociationWhole} onHighlightAssociation={onHighlightAssociation} openAssociationId={openAssociationId} onOpenAssociationIdChange={onOpenAssociationIdChange} diff --git a/src/components/panels/refs/AssociationsPanel.jsx b/src/components/panels/refs/AssociationsPanel.jsx index 881f521..cb9d07a 100644 --- a/src/components/panels/refs/AssociationsPanel.jsx +++ b/src/components/panels/refs/AssociationsPanel.jsx @@ -21,6 +21,7 @@ export default function AssociationsPanel({ onUpdateAssociationLineStyle, onResetAssociationRouting, onToggleAssociationComposition, + onUpdateAssociationWhole, onHighlightAssociation, openAssociationId: controlledOpenAssociationId, onOpenAssociationIdChange, @@ -119,6 +120,7 @@ export default function AssociationsPanel({ onResetAssociationRouting={onResetAssociationRouting} showCompositionAggregation={showCompositionAggregation} onToggleAssociationComposition={onToggleAssociationComposition} + onUpdateAssociationWhole={onUpdateAssociationWhole} onHighlightAssociation={onHighlightAssociation} /> ) diff --git a/src/components/panels/refs/AssociationsPanelItem.jsx b/src/components/panels/refs/AssociationsPanelItem.jsx index 009fe5d..f936881 100644 --- a/src/components/panels/refs/AssociationsPanelItem.jsx +++ b/src/components/panels/refs/AssociationsPanelItem.jsx @@ -39,6 +39,7 @@ export default function AssociationsPanelItem({ onResetAssociationRouting, showCompositionAggregation = false, onToggleAssociationComposition, + onUpdateAssociationWhole, onHighlightAssociation, isOpen = false, onToggleOpen, @@ -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 = <> + {firstLabel} + {isReflexive ? (self) : <> + + {isComposition ? ' (whole) and ' : 'and'} + {secondLabel} + {isComposition && (part)} + } + const multiplicityA = edge.data?.multiplicityA ?? '' const multiplicityB = edge.data?.multiplicityB ?? '' const roleA = edge.data?.roleA ?? '' @@ -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) { @@ -199,28 +218,15 @@ export default function AssociationsPanelItem({ }} /> ) : ( - + {label ? ( {label} - ) : isReflexive ? ( - {`${sourceLabel} (self)`} - ) : ( - <> - {sourceLabel} - - {targetLabel} - - )} + ) : endpointLabels} )} {!isEditing && label && ( - {sourceLabel} - {isReflexive ? (self) : <> - - and - {targetLabel} - } + {endpointLabels} )} @@ -296,45 +302,45 @@ export default function AssociationsPanelItem({
- {[ - { 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 }) => ( -
-
-
{isAssociation ? 'Association' : edge.type === 'associativeAssociation' ? 'Class' : `Class ${side}`}
-
{name}
-
- {canEditMultiplicity && ( -
- - onUpdateAssociationMultiplicity?.( - edge.id, side, value === UNDEFINED_MULTIPLICITY_VALUE ? '' : value, - )} - /> -
- )} - {canEditRole && ( -
- - 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 ( +
+
+
{isComposition ? endpointLabel : isAssociation ? 'Association' : edge.type === 'associativeAssociation' ? 'Class' : `Class ${side}`}
+
{name}
- )} -
- ))} + {canEditMultiplicity && ( +
+ + onUpdateAssociationMultiplicity?.( + edge.id, side, value === UNDEFINED_MULTIPLICITY_VALUE ? '' : value, + )} + /> +
+ )} + {canEditRole && ( +
+ + 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)} + /> +
+ )} +
+ ) + })}
{canToggleComposition ? (
@@ -353,6 +359,21 @@ export default function AssociationsPanelItem({ /> Composite aggregation + {isComposition && ( +
+ + onUpdateAssociationWhole?.(edge.id, nodeId)} + /> +
+ )}
) : null} {canEditLineStyle ? ( diff --git a/src/components/panels/refs/EndpointConnector.jsx b/src/components/panels/refs/EndpointConnector.jsx index 32595f7..e729189 100644 --- a/src/components/panels/refs/EndpointConnector.jsx +++ b/src/components/panels/refs/EndpointConnector.jsx @@ -1,8 +1,11 @@ -export default function EndpointConnector({ directional = false }) { +export default function EndpointConnector({ directional = false, composition = false }) { return ( + + + - {item.label} + {item.label} ))} diff --git a/src/content/help.md b/src/content/help.md index 522f48f..009ff95 100644 --- a/src/content/help.md +++ b/src/content/help.md @@ -60,6 +60,8 @@ Connect a class to the handle on an existing association. This creates the dashe Enable **Preferences → Enable composite aggregation**, then expand an ordinary association in Relations and select **Composite aggregation**. Its name, roles, multiplicities, and comment are kept. +Choose **Whole (composite)** to select which class carries the diamond. The endpoint columns show **Whole (composite)** on the left and **Part** on the right; changing the whole exchanges the classes between these columns while keeping each class’s multiplicity and stored role. Unchecking **Composite aggregation** restores the **Class A** and **Class B** headings and ordering. Rechecking it restores the last selected whole. Changing the whole can be undone and is preserved when saving and reopening the model. + This preference enables creation as well as display. Disabling it hides existing composite aggregations without deleting them. Reflexive associations cannot be changed into compositions. ### Create a logical or physical relationship diff --git a/src/hooks/__tests__/useModelState.test.jsx b/src/hooks/__tests__/useModelState.test.jsx index a0ef64f..f3d297e 100644 --- a/src/hooks/__tests__/useModelState.test.jsx +++ b/src/hooks/__tests__/useModelState.test.jsx @@ -2,6 +2,7 @@ import { act, renderHook } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import { useModelState } from '../useModelState.js' +import { normalizeEdges } from '../../model/edgeUtils.js' const renderModelState = () => renderHook(() => @@ -21,6 +22,63 @@ const flushHistoryPush = async () => { }) } +describe('composition whole selection', () => { + const composition = normalizeEdges([{ + id: 'composition', type: 'composition', source: 'part', target: 'whole', + sourceHandle: 'right-source', targetHandle: 'left-target', + data: { type: 'composition', name: 'contains', roleA: 'items', roleB: 'owner', + multiplicityA: '0..*', multiplicityB: '1..1', lineStyle: 'manual', + controlPoints: [{ x: 240, y: 120 }, { x: 360, y: 120 }] }, + }])[0] + const setup = (edge = composition) => { + const hook = renderModelState() + const nodes = ['part', 'whole'].map((id, index) => ({ id, type: 'class', position: { x: index * 400, y: 0 }, data: { label: id } })) + act(() => hook.result.current.setModel(nodes, [edge])) + return hook + } + + it('updates panel and model atomically and undoes/redoes in one step', async () => { + const { result } = setup() + act(() => result.current.onUpdateAssociationWhole('composition', 'part')) + await flushHistoryPush() + const changed = result.current.edges[0] + expect(changed).toMatchObject({ source: 'whole', target: 'part', data: { roleA: 'owner', roleB: 'items', multiplicityA: '1..1', multiplicityB: '0..*' } }) + expect(result.current.panelEdges).toEqual(result.current.edges) + act(() => result.current.onUndo()) + expect(result.current.edges).toEqual([composition]) + expect(result.current.canUndo).toBe(false) + act(() => result.current.onUpdateAssociationWhole('composition', 'whole')) + await flushHistoryPush() + expect(result.current.canRedo).toBe(true) + act(() => result.current.onRedo()) + expect(result.current.edges).toEqual([changed]) + }) + + it.each([ + ['current target', composition, 'composition', 'whole'], + ['unrelated node', composition, 'composition', 'other'], + ['missing edge', composition, 'missing', 'part'], + ['ordinary association', { ...composition, type: 'association' }, 'composition', 'part'], + ['reflexive composition', { ...composition, target: 'part' }, 'composition', 'part'], + ])('ignores %s without adding history', async (_name, edge, id, whole) => { + const { result } = setup(edge) + const before = result.current.edges + act(() => result.current.onUpdateAssociationWhole(id, whole)) + await flushHistoryPush() + expect(result.current.edges).toBe(before) + expect(result.current.canUndo).toBe(false) + }) + + it('retains the selected whole and roles when composition is disabled and reenabled', () => { + const { result } = setup() + act(() => result.current.onUpdateAssociationWhole('composition', 'part')) + act(() => result.current.onToggleAssociationComposition('composition', false)) + expect(result.current.edges[0]).toMatchObject({ type: 'association', target: 'part', data: { roleB: 'items' } }) + act(() => result.current.onToggleAssociationComposition('composition', true)) + expect(result.current.edges[0]).toMatchObject({ type: 'composition', target: 'part', data: { roleB: 'items' } }) + }) +}) + describe.each(['conceptual', 'logical', 'physical'])('exclusive focus in %s view', (activeView) => { const setup = () => { const reactFlowInstance = { getInternalNode: vi.fn(), setCenter: vi.fn() } diff --git a/src/hooks/useModelState.js b/src/hooks/useModelState.js index e891c0f..9353c5b 100644 --- a/src/hooks/useModelState.js +++ b/src/hooks/useModelState.js @@ -15,7 +15,7 @@ import { getReflexiveAssociationLayout, getReflexiveSide, } from '../components/flow/utils/reflexiveAssociationUtils.js' -import { normalizeEdges } from '../model/edgeUtils.js' +import { normalizeEdges, reverseAssociationEndpoints } from '../model/edgeUtils.js' import { ASSOCIATION_EDGE_TYPE, ASSOCIATION_LINE_STYLE_ORTHOGONAL, @@ -2151,6 +2151,20 @@ export function useModelState({ ], ) + const onUpdateAssociationWhole = useCallback( + (edgeId, wholeNodeId) => { + updateEdgesAndPanel((current) => { + const edge = current.find((entry) => entry.id === edgeId) + if (!edge || edge.type !== COMPOSITION_EDGE_TYPE || + edge.source === edge.target || wholeNodeId !== edge.source) { + return current + } + return current.map((entry) => entry === edge ? reverseAssociationEndpoints(entry) : entry) + }) + }, + [updateEdgesAndPanel], + ) + const onToggleAssociationComposition = useCallback( (edgeId, nextValue) => { updateEdgesAndPanel((current) => { @@ -3001,6 +3015,7 @@ export function useModelState({ onUpdateRelationshipLineStyle, onResetRelationshipRouting, onToggleAssociationComposition, + onUpdateAssociationWhole, onEdgeDoubleClick, onHighlightAssociation, onHighlightNote, diff --git a/src/model/__tests__/compositionWhole.test.js b/src/model/__tests__/compositionWhole.test.js new file mode 100644 index 0000000..9507c11 --- /dev/null +++ b/src/model/__tests__/compositionWhole.test.js @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { normalizeEdges, reverseAssociationEndpoints } from '../edgeUtils.js' +import { parseModelFile } from '../modelOpening.js' +import { buildHashPayload } from '../../hooks/useFileActions.js' + +const edge = { + id: 'composition', type: 'composition', source: 'part', target: 'whole', + sourceHandle: 'right-source', targetHandle: 'top-target', selected: true, + data: { name: 'contains', comment: 'Keep this', type: 'composition', + multiplicityA: '0..*', multiplicityB: '1..1', roleA: 'items', roleB: 'owner', + lineStyle: 'manual', controlPoints: [{ x: 300, y: 40 }, { x: 450, y: 180 }], + custom: { retained: true } }, +} + +describe('composition endpoint reversal', () => { + it('moves endpoint data with the classes without mutating the original', () => { + const before = structuredClone(edge) + const reversed = reverseAssociationEndpoints(edge) + expect(reversed).toEqual({ + ...edge, source: 'whole', target: 'part', sourceHandle: 'top-source', targetHandle: 'right-target', + data: { ...edge.data, multiplicityA: '1..1', multiplicityB: '0..*', roleA: 'owner', roleB: 'items', + controlPoints: [{ x: 450, y: 180 }, { x: 300, y: 40 }] }, + }) + expect(edge).toEqual(before) + expect(reverseAssociationEndpoints(reversed)).toEqual(edge) + }) + + it.each(['left', 'right', 'top', 'bottom'])('retains the %s class-side handle', side => { + const reversed = reverseAssociationEndpoints({ ...edge, sourceHandle: `${side}-source`, targetHandle: `${side}-target` }) + expect(reversed.sourceHandle).toBe(`${side}-source`) + expect(reversed.targetHandle).toBe(`${side}-target`) + }) + + it('preserves absent optional fields on a round trip', () => { + const sparse = { id: 'sparse', type: 'composition', source: 'part', target: 'whole', data: { roleA: 'items' } } + const reversed = reverseAssociationEndpoints(sparse) + expect(reversed.data).toEqual({ roleB: 'items' }) + expect(reverseAssociationEndpoints(reversed)).toEqual(sparse) + expect(reverseAssociationEndpoints(reverseAssociationEndpoints({ ...sparse, data: undefined }))) + .toEqual({ ...sparse, data: undefined }) + }) + + it('loads existing compositions unchanged and round trips the chosen whole with version 2', () => { + const nodes = ['part', 'whole'].map(id => ({ id, type: 'class', position: { x: 0, y: 0 }, data: { label: id } })) + const original = parseModelFile(JSON.stringify({ version: 2, nodes, edges: [edge] })) + expect(original.edges[0]).toMatchObject(edge) + const reversed = reverseAssociationEndpoints(original.edges[0]) + const payload = buildHashPayload({ ...original, edges: [reversed] }) + expect(payload.version).toBe(2) + const reopened = parseModelFile(JSON.stringify(payload)) + expect(reopened.edges).toEqual(normalizeEdges([{ ...reversed, selected: false }])) + expect(reopened.edges[0].target).toBe('part') + expect(Object.keys(reopened.edges[0].data).sort()).toEqual(Object.keys(original.edges[0].data).sort()) + }) +}) diff --git a/src/model/edgeUtils.js b/src/model/edgeUtils.js index 38a8bc7..18dd4e5 100644 --- a/src/model/edgeUtils.js +++ b/src/model/edgeUtils.js @@ -20,6 +20,30 @@ const ALL_REFLEXIVE_SIDES = [ REFLEXIVE_SIDE_LOWER_LEFT, ] +// The whole of a composition is always its target, including in saved files. +// Reverse endpoint-specific data with the nodes so labels stay at their classes. +export function reverseAssociationEndpoints(edge) { + const next = { ...edge, source: edge.target, target: edge.source } + const exchange = (object, original, a, b, transform = value => value) => { + delete object[a] + delete object[b] + if (Object.hasOwn(original, b)) object[a] = transform(original[b], 'source') + if (Object.hasOwn(original, a)) object[b] = transform(original[a], 'target') + } + exchange(next, edge, 'sourceHandle', 'targetHandle', (handle, endpoint) => + typeof handle === 'string' ? handle.replace(/-(source|target)$/, `-${endpoint}`) : handle, + ) + if (edge.data) { + next.data = { ...edge.data } + exchange(next.data, edge.data, 'multiplicityA', 'multiplicityB') + exchange(next.data, edge.data, 'roleA', 'roleB') + if (Array.isArray(edge.data.controlPoints)) { + next.data.controlPoints = [...edge.data.controlPoints].reverse() + } + } + return next +} + export function normalizeControlPoints(value) { if (!Array.isArray(value)) { return []