Skip to content

Commit 84732d2

Browse files
committed
add connection editor
1 parent f5eb53e commit 84732d2

14 files changed

Lines changed: 402 additions & 104 deletions

web/locales/de-DE.arb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"nodeType_BED": "Bett",
4242
"nodeType_TEAM": "Team",
4343
"nodeType_USER": "Benutzer",
44+
"nodeType_ROLE": "Rolle",
45+
"delete": "Löschen",
4446
"deleteNodeTitle": "Knoten löschen?",
4547
"deleteNodeDescription": "Der Knoten und alle seine Verbindungen werden entfernt. Dies kann nicht rückgängig gemacht werden.",
4648
"email": "E-Mail",
@@ -60,5 +62,10 @@
6062
"value": "Wert",
6163
"centerView": "Ansicht zentrieren",
6264
"zoomIn": "Vergrößern",
63-
"zoomOut": "Verkleinern"
65+
"zoomOut": "Verkleinern",
66+
"connectionSettingsTitle": "Verbindungs-Einstellungen",
67+
"connectionType": "Verbindungstyp",
68+
"connectionType_default": "Standard",
69+
"connectionType_reference": "Referenz",
70+
"connectionType_dependency": "Abhängigkeit"
6471
}

web/locales/en-US.arb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
"nodeType_BED": "Bed",
4242
"nodeType_TEAM": "Team",
4343
"nodeType_USER": "User",
44+
"nodeType_ROLE": "Role",
45+
"delete": "Delete",
4446
"deleteNodeTitle": "Delete node?",
4547
"deleteNodeDescription": "This will remove the node and all its connections. This cannot be undone.",
4648
"email": "Email",
@@ -60,5 +62,10 @@
6062
"value": "Value",
6163
"centerView": "Center view",
6264
"zoomIn": "Zoom in",
63-
"zoomOut": "Zoom out"
65+
"zoomOut": "Zoom out",
66+
"connectionSettingsTitle": "Connection settings",
67+
"connectionType": "Connection type",
68+
"connectionType_default": "Default",
69+
"connectionType_reference": "Reference",
70+
"connectionType_dependency": "Dependency"
6471
}

web/src/App.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { useState, useEffect, useRef, useCallback } from 'react'
22
import { useNodesState, useEdgesState } from '@xyflow/react'
33
import type { Node } from '@xyflow/react'
4-
import type { Edge } from '@xyflow/react'
54
import { HelpwaveLogo, LanguageDialog, ThemeDialog } from '@helpwave/hightide'
65
import { useScaffoldTranslation } from './i18n/ScaffoldTranslationContext'
7-
import { GraphEditor } from './components/GraphEditor'
6+
import { GraphEditor, type ScaffoldEdge } from './components/GraphEditor'
87
import { Sidebar } from './components/Sidebar'
98
import type { ScaffoldNodeData } from './lib/scaffoldGraph'
109
import { flowToTree, downloadAsJson, getRootOrganizationNode } from './lib/scaffoldGraph'
@@ -27,7 +26,7 @@ function ensureRootOrgInNodes(nodes: Node<ScaffoldNodeData>[]): Node<ScaffoldNod
2726
return [getRootOrganizationNode(), ...nodes]
2827
}
2928

30-
function ensureRootOrgInEdges(edges: Edge[], nodes: Node<ScaffoldNodeData>[]): Edge[] {
29+
function ensureRootOrgInEdges(edges: ScaffoldEdge[], nodes: Node<ScaffoldNodeData>[]): ScaffoldEdge[] {
3130
const hasRoot = nodes.some((n) => n.id === ROOT_ORG_ID)
3231
if (hasRoot) return edges
3332
const firstOrg = nodes.find((n) => n.data?.type === 'ORGANIZATION')
@@ -43,7 +42,7 @@ function ensureRootOrgInEdges(edges: Edge[], nodes: Node<ScaffoldNodeData>[]): E
4342
function App() {
4443
const t = useScaffoldTranslation()
4544
const [nodes, setNodes] = useNodesState<Node<ScaffoldNodeData>>([getRootOrganizationNode()])
46-
const [edges, setEdges] = useEdgesState<Edge>([])
45+
const [edges, setEdges] = useEdgesState<ScaffoldEdge>([])
4746
const [treeError, setTreeError] = useState<string | null>(null)
4847
const [importError, setImportError] = useState<string | null>(null)
4948
const [themeDialogOpen, setThemeDialogOpen] = useState(false)
@@ -81,7 +80,7 @@ function App() {
8180
}
8281

8382
const handleImport = useCallback(
84-
(newNodes: Node<ScaffoldNodeData>[], newEdges: Edge[]) => {
83+
(newNodes: Node<ScaffoldNodeData>[], newEdges: ScaffoldEdge[]) => {
8584
const normalizedNodes = ensureRootOrgInNodes(newNodes)
8685
const normalizedEdges = ensureRootOrgInEdges(newEdges, newNodes)
8786
setNodes(normalizedNodes)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import { useEffect, useState } from 'react'
2+
import { Button, Chip, Dialog, Input } from '@helpwave/hightide'
3+
import { useScaffoldTranslation } from '../i18n/ScaffoldTranslationContext'
4+
import type { AttachedDataEntry, ScaffoldEdgeData, ScaffoldNodeType, UserRole } from '../types/scaffold'
5+
import type { ScaffoldTranslationEntries } from '../i18n/translations'
6+
7+
interface ConnectionSettingsDialogProps {
8+
isOpen: boolean,
9+
edgeId: string | null,
10+
edgeData: ScaffoldEdgeData | null,
11+
sourceLabel: string,
12+
targetLabel: string,
13+
sourceType?: ScaffoldNodeType,
14+
targetType?: ScaffoldNodeType,
15+
onClose: () => void,
16+
onSave: (edgeId: string, data: ScaffoldEdgeData) => void,
17+
onDelete: (edgeId: string) => void,
18+
}
19+
20+
const USER_ROLES: UserRole[] = ['viewer', 'moderator', 'admin']
21+
22+
function roleLabelKey(role: UserRole): keyof ScaffoldTranslationEntries {
23+
return role === 'viewer' ? 'roleViewer' : role === 'moderator' ? 'roleModerator' : 'roleAdmin'
24+
}
25+
26+
export function ConnectionSettingsDialog({
27+
isOpen,
28+
edgeId,
29+
edgeData,
30+
sourceLabel,
31+
targetLabel,
32+
sourceType,
33+
targetType,
34+
onClose,
35+
onSave,
36+
onDelete,
37+
}: ConnectionSettingsDialogProps) {
38+
const t = useScaffoldTranslation()
39+
const [role, setRole] = useState<UserRole | undefined>(undefined)
40+
const [attributes, setAttributes] = useState<AttachedDataEntry[]>([])
41+
const [newAttachKey, setNewAttachKey] = useState('')
42+
const [newAttachValue, setNewAttachValue] = useState('')
43+
44+
const isRoleAssignment = sourceType === 'USER' || targetType === 'USER'
45+
46+
useEffect(() => {
47+
if (isOpen && edgeData) {
48+
setRole(edgeData.role)
49+
setAttributes(edgeData.attributes ?? [])
50+
setNewAttachKey('')
51+
setNewAttachValue('')
52+
}
53+
}, [isOpen, edgeData])
54+
55+
const handleSave = () => {
56+
if (edgeId) {
57+
onSave(edgeId, {
58+
...(isRoleAssignment ? { role } : {}),
59+
...(attributes.length > 0 ? { attributes } : {}),
60+
})
61+
onClose()
62+
}
63+
}
64+
65+
const handleDelete = () => {
66+
if (edgeId) {
67+
onDelete(edgeId)
68+
onClose()
69+
}
70+
}
71+
72+
const handleClose = () => {
73+
onClose()
74+
}
75+
76+
if (!edgeId) return null
77+
78+
return (
79+
<Dialog
80+
titleElement={t('connectionSettingsTitle')}
81+
description={`${sourceLabel}${targetLabel}`}
82+
onClose={handleClose}
83+
isOpen={isOpen}
84+
>
85+
<div className="flex flex-col gap-4">
86+
{isRoleAssignment && (
87+
<div className="flex flex-col gap-1">
88+
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">{t('userRole')}</span>
89+
<div className="flex flex-wrap gap-1.5">
90+
{USER_ROLES.map((r) => (
91+
<button
92+
key={r}
93+
type="button"
94+
onClick={() => setRole(role === r ? undefined : r)}
95+
className="cursor-pointer rounded border-0 bg-transparent p-0"
96+
title={t(roleLabelKey(r))}
97+
>
98+
<Chip size="sm" color={role === r ? 'primary' : 'neutral'}>
99+
{t(roleLabelKey(r))}
100+
</Chip>
101+
</button>
102+
))}
103+
</div>
104+
</div>
105+
)}
106+
<div className="flex flex-col gap-2">
107+
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
108+
{t('attachedData')}
109+
</span>
110+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
111+
<Input
112+
aria-label={t('key')}
113+
value={newAttachKey}
114+
onValueChange={setNewAttachKey}
115+
placeholder={t('key')}
116+
/>
117+
<Input
118+
aria-label={t('value')}
119+
value={newAttachValue}
120+
onValueChange={setNewAttachValue}
121+
placeholder={t('value')}
122+
/>
123+
</div>
124+
<Button
125+
size="sm"
126+
onClick={() => {
127+
const k = newAttachKey.trim()
128+
if (k && !attributes.some((e) => e.key === k)) {
129+
setAttributes((prev) => [...prev, { key: k, value: newAttachValue.trim() }])
130+
setNewAttachKey('')
131+
setNewAttachValue('')
132+
}
133+
}}
134+
disabled={!newAttachKey.trim()}
135+
title={t('add')}
136+
>
137+
{t('add')}
138+
</Button>
139+
{attributes.length > 0 && (
140+
<ul className="flex max-h-[160px] flex-col gap-1.5 overflow-auto rounded border border-gray-200 p-2 dark:border-gray-600">
141+
{attributes.map((entry, index) => (
142+
<li
143+
key={`${entry.key}-${index}`}
144+
className="flex items-center justify-between gap-2 rounded bg-gray-50 px-2 py-1.5 text-sm dark:bg-gray-800"
145+
>
146+
<span className="truncate font-mono">{entry.key}</span>
147+
<span className="max-w-[120px] truncate text-gray-600 dark:text-gray-400">{entry.value}</span>
148+
<Button
149+
size="sm"
150+
color="negative"
151+
coloringStyle="text"
152+
onClick={() => setAttributes((prev) => prev.filter((_, i) => i !== index))}
153+
title={t('remove')}
154+
>
155+
{t('remove')}
156+
</Button>
157+
</li>
158+
))}
159+
</ul>
160+
)}
161+
</div>
162+
<div className="flex justify-between gap-2">
163+
<Button
164+
size="sm"
165+
color="negative"
166+
coloringStyle="text"
167+
onClick={handleDelete}
168+
title={t('delete')}
169+
>
170+
{t('delete')}
171+
</Button>
172+
<div className="flex gap-2">
173+
<Button coloringStyle="text" onClick={handleClose} title={t('cancel')}>
174+
{t('cancel')}
175+
</Button>
176+
<Button onClick={handleSave} title={t('save')}>
177+
{t('save')}
178+
</Button>
179+
</div>
180+
</div>
181+
</div>
182+
</Dialog>
183+
)
184+
}

0 commit comments

Comments
 (0)