Skip to content

Commit ddbc531

Browse files
feat(library): render crow's-foot entities as structured column tables
A crow's-foot / Information-Engineering ER entity is a table whose columns each carry a name, data type and key role — the layout Mermaid `erDiagram`, DBML and physical-ER tools all use. The previous implementation reused the class diagram's single free-text row, so type/PK/FK were just typing conventions baked into one opaque string. Model each column as { name, type?, keys?: (PK|FK|UK)[] } and render the entity as a real table: a key gutter (PK/FK/UK), a name column with the primary key underlined, and a right-aligned data-type column. Replace the reused class attribute editor with a structured column editor (name + type inputs and a PK/FK/UK selector, drag to reorder). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e79c32e commit ddbc531

12 files changed

Lines changed: 453 additions & 30 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { KeyboardEvent, useState } from "react"
2+
import { Box, ToggleButton, ToggleButtonGroup } from "@mui/material"
3+
import {
4+
DndContext,
5+
closestCenter,
6+
KeyboardSensor,
7+
PointerSensor,
8+
useSensor,
9+
useSensors,
10+
DragEndEvent,
11+
} from "@dnd-kit/core"
12+
import {
13+
arrayMove,
14+
SortableContext,
15+
sortableKeyboardCoordinates,
16+
useSortable,
17+
verticalListSortingStrategy,
18+
} from "@dnd-kit/sortable"
19+
import { CSS } from "@dnd-kit/utilities"
20+
import { TextField, Typography, PrimaryButton } from "@/components/ui"
21+
import { DeleteIcon, DragHandleIcon } from "@/components/Icon"
22+
import { useDiagramStore } from "@/store"
23+
import { useShallow } from "zustand/shallow"
24+
import { generateUUID } from "@/utils"
25+
import { ErCfColumn, ErCfColumnKey, ErCfEntityProps } from "@/types"
26+
import { LAYOUT } from "@/constants"
27+
28+
const KEY_OPTIONS: ErCfColumnKey[] = ["PK", "FK", "UK"]
29+
30+
interface RowProps {
31+
column: ErCfColumn
32+
onChange: (id: string, patch: Partial<ErCfColumn>) => void
33+
onDelete: (id: string) => void
34+
}
35+
36+
const SortableColumnRow: React.FC<RowProps> = ({
37+
column,
38+
onChange,
39+
onDelete,
40+
}) => {
41+
const {
42+
attributes,
43+
listeners,
44+
setNodeRef,
45+
transform,
46+
transition,
47+
isDragging,
48+
} = useSortable({ id: column.id })
49+
50+
return (
51+
<Box
52+
ref={setNodeRef}
53+
style={{
54+
transform: CSS.Transform.toString(transform),
55+
transition,
56+
opacity: isDragging ? 0.4 : 1,
57+
}}
58+
sx={{ display: "flex", gap: 0.5, alignItems: "center" }}
59+
>
60+
<Box
61+
{...attributes}
62+
{...listeners}
63+
sx={{ cursor: "grab", display: "flex", color: "text.secondary" }}
64+
>
65+
<DragHandleIcon width={16} height={16} />
66+
</Box>
67+
<TextField
68+
size="small"
69+
placeholder="name"
70+
value={column.name}
71+
onChange={(e) => onChange(column.id, { name: e.target.value })}
72+
sx={{ flex: 2 }}
73+
/>
74+
<TextField
75+
size="small"
76+
placeholder="type"
77+
value={column.type ?? ""}
78+
onChange={(e) => onChange(column.id, { type: e.target.value })}
79+
sx={{ flex: 1 }}
80+
/>
81+
<ToggleButtonGroup
82+
size="small"
83+
value={column.keys ?? []}
84+
onChange={(_, keys: ErCfColumnKey[]) => onChange(column.id, { keys })}
85+
>
86+
{KEY_OPTIONS.map((k) => (
87+
<ToggleButton key={k} value={k} sx={{ px: 0.75 }}>
88+
{k}
89+
</ToggleButton>
90+
))}
91+
</ToggleButtonGroup>
92+
<DeleteIcon
93+
width={16}
94+
height={16}
95+
style={{ cursor: "pointer", flexShrink: 0 }}
96+
onClick={() => onDelete(column.id)}
97+
/>
98+
</Box>
99+
)
100+
}
101+
102+
// Editor for a crow's-foot entity's columns: name + data type + key role(s),
103+
// with drag-to-reorder. Replaces the class diagram's single-name editor.
104+
export const ErCfColumnList: React.FC<{ nodeId: string }> = ({ nodeId }) => {
105+
const { nodes, setNodes } = useDiagramStore(
106+
useShallow((state) => ({ setNodes: state.setNodes, nodes: state.nodes }))
107+
)
108+
const [newName, setNewName] = useState("")
109+
110+
const data = nodes.find((n) => n.id === nodeId)?.data as
111+
| ErCfEntityProps
112+
| undefined
113+
const columns = data?.attributes ?? []
114+
115+
const patch = (next: ErCfColumn[], heightDelta = 0) =>
116+
setNodes((nodes) =>
117+
nodes.map((node) =>
118+
node.id === nodeId
119+
? {
120+
...node,
121+
data: { ...node.data, attributes: next },
122+
...(heightDelta
123+
? {
124+
height: (node.height ?? 0) + heightDelta,
125+
measured: {
126+
...node.measured,
127+
height: (node.height ?? 0) + heightDelta,
128+
},
129+
}
130+
: {}),
131+
}
132+
: node
133+
)
134+
)
135+
136+
const onChange = (id: string, fields: Partial<ErCfColumn>) =>
137+
patch(columns.map((c) => (c.id === id ? { ...c, ...fields } : c)))
138+
139+
const onDelete = (id: string) =>
140+
patch(
141+
columns.filter((c) => c.id !== id),
142+
-LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT
143+
)
144+
145+
const onAdd = () => {
146+
if (newName.trim() === "") return
147+
patch(
148+
[...columns, { id: generateUUID(), name: newName.trim(), keys: [] }],
149+
LAYOUT.DEFAULT_ATTRIBUTE_HEIGHT
150+
)
151+
setNewName("")
152+
}
153+
154+
const sensors = useSensors(
155+
useSensor(PointerSensor),
156+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
157+
)
158+
const onDragEnd = ({ active, over }: DragEndEvent) => {
159+
if (!over || active.id === over.id) return
160+
patch(
161+
arrayMove(
162+
columns,
163+
columns.findIndex((c) => c.id === active.id),
164+
columns.findIndex((c) => c.id === over.id)
165+
)
166+
)
167+
}
168+
169+
return (
170+
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
171+
<Typography variant="h6">Columns</Typography>
172+
<DndContext
173+
sensors={sensors}
174+
collisionDetection={closestCenter}
175+
onDragEnd={onDragEnd}
176+
>
177+
<SortableContext
178+
items={columns.map((c) => c.id)}
179+
strategy={verticalListSortingStrategy}
180+
>
181+
{columns.map((column) => (
182+
<SortableColumnRow
183+
key={column.id}
184+
column={column}
185+
onChange={onChange}
186+
onDelete={onDelete}
187+
/>
188+
))}
189+
</SortableContext>
190+
</DndContext>
191+
<Box sx={{ display: "flex", gap: 0.5, mt: 0.5 }}>
192+
<TextField
193+
size="small"
194+
placeholder="new column"
195+
value={newName}
196+
onChange={(e) => setNewName(e.target.value)}
197+
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) =>
198+
e.key === "Enter" && onAdd()
199+
}
200+
fullWidth
201+
/>
202+
<PrimaryButton isSelected={false} onClick={onAdd}>
203+
Add
204+
</PrimaryButton>
205+
</Box>
206+
</Box>
207+
)
208+
}

library/lib/components/popovers/erDiagram/ErCfEntityEditPopover.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { useDiagramStore } from "@/store"
33
import { ErCfEntityProps } from "@/types"
44
import { useShallow } from "zustand/shallow"
55
import { PopoverProps } from "../types"
6-
import { EditableAttributeList } from "../classDiagram/EditableAttributesList"
6+
import { ErCfColumnList } from "./ErCfColumnList"
77

8-
// Crow's-foot entity: a table with editable attribute (column) rows. Reuses the
9-
// class diagram's attribute list since both store `data.attributes`.
8+
// Crow's-foot entity: a table whose columns carry a name, data type and key
9+
// role(s) — edited through the structured column list.
1010
export const ErCfEntityEditPopover: React.FC<PopoverProps> = ({
1111
elementId,
1212
}) => {
@@ -33,7 +33,7 @@ export const ErCfEntityEditPopover: React.FC<PopoverProps> = ({
3333
nodeData={nodeData}
3434
handleDataFieldUpdate={handleDataFieldUpdate}
3535
/>
36-
<EditableAttributeList nodeId={elementId} />
36+
<ErCfColumnList nodeId={elementId} />
3737
</>
3838
)
3939
}

library/lib/components/svgs/nodes/erDiagram/ErCfEntitySVG.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ClassNodeElement, ErCfEntityProps } from "@/types"
1+
import { ErCfColumn, ErCfEntityProps } from "@/types"
22
import { LAYOUT } from "@/constants"
33
import { SeparationLine } from "@/components/svgs/nodes/SeparationLine"
44
import { HeaderSection } from "../HeaderSection"
5-
import { RowBlockSection } from "../RowBlockSection"
5+
import { ErCfRowSection } from "./ErCfRowSection"
66
import { useDiagramStore } from "@/store"
77
import { useShallow } from "zustand/shallow"
88
import AssessmentIcon from "../../AssessmentIcon"
@@ -33,7 +33,7 @@ export const ErCfEntitySVG = ({
3333
const padding = LAYOUT.DEFAULT_PADDING
3434

3535
const assessments = useDiagramStore(useShallow((state) => state.assessments))
36-
const processedAttributes = attributes.map((el: ClassNodeElement) => ({
36+
const processedAttributes = attributes.map((el: ErCfColumn) => ({
3737
...el,
3838
score: assessments[el.id]?.score,
3939
}))
@@ -79,14 +79,13 @@ export const ErCfEntitySVG = ({
7979
width={width}
8080
strokeColor={strokeColor}
8181
/>
82-
<RowBlockSection
83-
items={processedAttributes}
82+
<ErCfRowSection
83+
columns={processedAttributes}
8484
padding={padding}
8585
itemHeight={attributeHeight}
8686
width={width}
8787
offsetFromTop={headerHeight}
8888
showAssessmentResults={showAssessmentResults}
89-
itemElementType="attribute"
9089
/>
9190

9291
{showAssessmentResults && (
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { FC } from "react"
2+
import { ErCfColumn } from "@/types"
3+
import { CustomText } from "../CustomText"
4+
import AssessmentIcon from "../../AssessmentIcon"
5+
import { FeedbackDropzone } from "@/components/wrapper/FeedbackDropzone"
6+
import { AssessmentSelectableElement } from "@/components/AssessmentSelectableElement"
7+
import { getCustomColorsFromData, measureTextWidth } from "@/utils"
8+
import { DEFAULT_FONT_SIZE, LAYOUT } from "@/constants"
9+
10+
// Width of the leftmost key gutter (holds "PK" / "FK" / "UK"). Matches Mermaid's
11+
// dedicated key column.
12+
export const ER_CF_KEY_GUTTER_WIDTH = 34
13+
14+
interface Props {
15+
columns: (ErCfColumn & { score?: number })[]
16+
padding: number
17+
itemHeight: number
18+
width: number
19+
offsetFromTop: number
20+
showAssessmentResults?: boolean
21+
}
22+
23+
// Renders an entity's columns as structured rows: [key gutter] name [type],
24+
// the layout Mermaid / DBML / physical ER tools use. Primary-key names are
25+
// underlined (drawn as an explicit line so it survives PNG/PDF export, unlike
26+
// CSS text-decoration).
27+
export const ErCfRowSection: FC<Props> = ({
28+
columns,
29+
padding,
30+
itemHeight,
31+
width,
32+
offsetFromTop,
33+
showAssessmentResults = false,
34+
}) => {
35+
const nameX = padding + ER_CF_KEY_GUTTER_WIDTH
36+
const typeX = width - padding
37+
38+
return (
39+
<g transform={`translate(0, ${offsetFromTop})`}>
40+
{columns.map((column, index) => {
41+
const y = index * itemHeight
42+
const centerY = 15 + y
43+
const { fillColor, textColor } = getCustomColorsFromData(column)
44+
const keys = column.keys ?? []
45+
const isPrimary = keys.includes("PK")
46+
47+
return (
48+
<AssessmentSelectableElement
49+
key={column.id}
50+
elementId={column.id}
51+
width={width}
52+
itemHeight={itemHeight}
53+
yOffset={y}
54+
>
55+
<FeedbackDropzone elementId={column.id} elementType="attribute">
56+
<rect
57+
x={LAYOUT.LINE_WIDTH / 2}
58+
y={y + LAYOUT.LINE_WIDTH / 2}
59+
width={width - LAYOUT.LINE_WIDTH}
60+
height={itemHeight - LAYOUT.LINE_WIDTH}
61+
fill={fillColor}
62+
/>
63+
64+
{keys.length > 0 && (
65+
<CustomText
66+
x={padding}
67+
y={centerY}
68+
dominantBaseline="middle"
69+
textAnchor="start"
70+
fontWeight="600"
71+
fill={textColor}
72+
>
73+
{keys.join(", ")}
74+
</CustomText>
75+
)}
76+
77+
<CustomText
78+
x={nameX}
79+
y={centerY}
80+
dominantBaseline="middle"
81+
textAnchor="start"
82+
fill={textColor}
83+
>
84+
{column.name}
85+
</CustomText>
86+
{isPrimary && column.name && (
87+
<line
88+
x1={nameX}
89+
x2={nameX + measureTextWidth(column.name)}
90+
y1={centerY + DEFAULT_FONT_SIZE * 0.6}
91+
y2={centerY + DEFAULT_FONT_SIZE * 0.6}
92+
stroke={textColor}
93+
strokeWidth={1.5}
94+
/>
95+
)}
96+
97+
{column.type && (
98+
<CustomText
99+
x={typeX}
100+
y={centerY}
101+
dominantBaseline="middle"
102+
textAnchor="end"
103+
fill={textColor}
104+
opacity={0.65}
105+
>
106+
{column.type}
107+
</CustomText>
108+
)}
109+
</FeedbackDropzone>
110+
{showAssessmentResults && typeof column.score === "number" && (
111+
<AssessmentIcon score={column.score} x={width - 15} y={y - 12} />
112+
)}
113+
</AssessmentSelectableElement>
114+
)
115+
})}
116+
</g>
117+
)
118+
}

library/lib/components/svgs/nodes/erDiagram/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from "./EREntitySVG"
22
export * from "./ERRelationshipSVG"
33
export * from "./ERAttributeSVG"
44
export * from "./ErCfEntitySVG"
5+
export * from "./ErCfRowSection"

0 commit comments

Comments
 (0)