@opensyster/diagram-ui 0.0.3-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "Node.js Development Container",
3
+ "image": "mcr.microsoft.com/devcontainers/typescript-node:1-20-bookworm",
4
+ "features": {
5
+ "ghcr.io/devcontainers/features/github-cli:1": {
6
+ "version": "latest"
7
+ },
8
+ "ghcr.io/devcontainers-extra/features/bun:1": {
9
+ "version": "latest"
10
+ }
11
+ },
12
+ "customizations": {
13
+ "vscode": {
14
+ "extensions": [
15
+ "dbaeumer.vscode-eslint",
16
+ "esbenp.prettier-vscode",
17
+ "ms-vscode.vscode-typescript-next"
18
+ ],
19
+ "settings": {
20
+ "editor.formatOnSave": true,
21
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
22
+ "[typescript]": {
23
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
24
+ },
25
+ "[javascript]": {
26
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
27
+ }
28
+ }
29
+ }
30
+ },
31
+ "postCreateCommand": "node --version && npm --version && bun --version",
32
+ "forwardPorts": [3000, 5173],
33
+ "remoteUser": "node"
34
+ }
@@ -0,0 +1,14 @@
1
+ # CI workflow using reusable syster-pipelines workflow
2
+ # See: https://github.com/jade-codes/syster-pipelines
3
+
4
+ name: CI
5
+
6
+ on:
7
+ push:
8
+ branches: [main]
9
+ pull_request:
10
+ branches: [main]
11
+
12
+ jobs:
13
+ npm-ci:
14
+ uses: jade-codes/syster-pipelines/.github/workflows/npm-ci.yml@main
@@ -0,0 +1,20 @@
1
+ # Release workflow using reusable syster-pipelines workflow
2
+ # See: https://github.com/jade-codes/syster-pipelines
3
+
4
+ name: Release
5
+
6
+ on:
7
+ push:
8
+ tags:
9
+ - 'v[0-9]+.[0-9]+.[0-9]+'
10
+ - 'v[0-9]+.[0-9]+.[0-9]+-*' # Pre-releases like v0.1.0-alpha
11
+
12
+ permissions:
13
+ contents: write
14
+ id-token: write
15
+
16
+ jobs:
17
+ npm-release:
18
+ uses: jade-codes/syster-pipelines/.github/workflows/npm-release.yml@main
19
+ secrets:
20
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
package/Makefile ADDED
@@ -0,0 +1,20 @@
1
+ .PHONY: run-guidelines typecheck test package clean
2
+
3
+ # Run all quality checks
4
+ run-guidelines: typecheck test
5
+
6
+ # Run TypeScript type checking
7
+ typecheck:
8
+ bun run typecheck
9
+
10
+ # Run tests
11
+ test:
12
+ bun test
13
+
14
+ # Package for publishing (placeholder)
15
+ package:
16
+ @echo "Package target not yet implemented"
17
+
18
+ # Clean build artifacts
19
+ clean:
20
+ rm -rf node_modules dist
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @syster/diagram-ui
2
+
3
+ React UI components for SysML v2 diagrams.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @syster/diagram-ui
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```tsx
14
+ import { nodeTypes, edgeTypes } from '@syster/diagram-ui';
15
+ import { ReactFlow } from '@xyflow/react';
16
+
17
+ function DiagramViewer({ nodes, edges }) {
18
+ return (
19
+ <ReactFlow
20
+ nodes={nodes}
21
+ edges={edges}
22
+ nodeTypes={nodeTypes}
23
+ edgeTypes={edgeTypes}
24
+ />
25
+ );
26
+ }
27
+ ```
28
+
29
+ ## Features
30
+
31
+ - React Flow node components for SysML element types
32
+ - React Flow edge components for SysML relationships
33
+ - Styled for VS Code themes
34
+ - Supports all SysML v2 definition and usage types
35
+
36
+ ## Dependencies
37
+
38
+ - `@xyflow/react` - React Flow library
39
+ - `@opensyster/diagram-core` - Core data types
40
+
41
+ ## License
42
+
43
+ MIT
44
+
45
+ ## Development
46
+
47
+ ### DevContainer Setup (Recommended)
48
+
49
+ This project includes a DevContainer configuration for a consistent development environment.
50
+
51
+ **Using VS Code:**
52
+ 1. Install the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
53
+ 2. Open this repository in VS Code
54
+ 3. Click "Reopen in Container" when prompted (or use Command Palette: "Dev Containers: Reopen in Container")
55
+
56
+ **What's included:**
57
+ - Node.js 20 LTS
58
+ - Bun runtime
59
+ - ESLint, Prettier
60
+ - GitHub CLI
61
+ - All VS Code extensions pre-configured
62
+
63
+ ### Manual Setup
64
+
65
+ If not using DevContainer:
66
+
67
+ ```bash
68
+ # Install dependencies
69
+ npm install
70
+ # or
71
+ bun install
72
+
73
+ # Run tests
74
+ npm test
75
+ # or
76
+ bun test
77
+ ```
package/bunfig.toml ADDED
@@ -0,0 +1,8 @@
1
+ [test]
2
+ preload = ["./test-setup.ts"]
3
+ coverage = true
4
+ coverageDir = "coverage"
5
+ coverageReporter = ["text", "lcov"]
6
+ coveragePathIgnorePatterns = ["**/test-setup.ts", "**/__mocks__/**"]
7
+ coverageSkipTestFiles = true
8
+ coverageThreshold = { global = { branches = 95, functions = 95, lines = 95, statements = 95 } }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@opensyster/diagram-ui",
3
+ "version": "0.0.3-alpha",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/jade-codes/syster-diagram-ui"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test",
13
+ "typecheck": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "@opensyster/diagram-core": "^0.0.3-alpha",
17
+ "@xyflow/react": "^12.10.0",
18
+ "elkjs": "^0.9.3",
19
+ "react": "^18.2.0"
20
+ },
21
+ "devDependencies": {
22
+ "@testing-library/react": "^14.3.1",
23
+ "@types/bun": "^1.2.14",
24
+ "@types/react": "^18.2.0",
25
+ "happy-dom": "^20.1.0",
26
+ "react-dom": "^18.2.0",
27
+ "typescript": "^5.0.0"
28
+ },
29
+ "peerDependencies": {
30
+ "@xyflow/react": "^12.0.0",
31
+ "react": "^18.2.0"
32
+ }
33
+ }
@@ -0,0 +1,387 @@
1
+ import { describe, test, expect, mock, afterEach } from 'bun:test';
2
+ import { render, cleanup } from '@testing-library/react';
3
+ import { EDGE_TYPES } from '@opensyster/diagram-core';
4
+ import { MarkerType, Position } from '@xyflow/react';
5
+
6
+ // Mock @xyflow/react components
7
+ mock.module('@xyflow/react', () => ({
8
+ BaseEdge: ({ id, path, style }: { id: string; path: string; style?: object }) => (
9
+ <path data-testid={`edge-${id}`} d={path} style={style} />
10
+ ),
11
+ EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => (
12
+ <div data-testid="edge-label-renderer">{children}</div>
13
+ ),
14
+ getSmoothStepPath: () => ['M0,0 L100,100', 50, 50],
15
+ MarkerType: { Arrow: 'arrow', ArrowClosed: 'arrowclosed' },
16
+ Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
17
+ }));
18
+
19
+ import { EDGE_CONFIGS, getEdgeConfig, createSysMLEdge, edgeTypes } from '../edges';
20
+ import type { EdgeConfig } from '../edges';
21
+
22
+ // Cleanup after each test to prevent DOM pollution
23
+ afterEach(() => {
24
+ cleanup();
25
+ });
26
+
27
+ describe('EDGE_CONFIGS', () => {
28
+ test('has configuration for all EDGE_TYPES', () => {
29
+ const edgeTypeValues = Object.values(EDGE_TYPES);
30
+
31
+ for (const edgeType of edgeTypeValues) {
32
+ expect(EDGE_CONFIGS[edgeType]).toBeDefined();
33
+ }
34
+ });
35
+
36
+ test('specialization edge has correct config', () => {
37
+ const config = EDGE_CONFIGS[EDGE_TYPES.SPECIALIZATION];
38
+
39
+ expect(config.strokeColor).toBe('#475569');
40
+ expect(config.strokeWidth).toBe(2);
41
+ expect(config.markerEnd).toBe(MarkerType.ArrowClosed);
42
+ expect(config.label).toBe('specializes');
43
+ });
44
+
45
+ test('composition edge has correct config', () => {
46
+ const config = EDGE_CONFIGS[EDGE_TYPES.COMPOSITION];
47
+
48
+ expect(config.strokeColor).toBe('#2563eb');
49
+ expect(config.strokeWidth).toBe(2);
50
+ expect(config.markerEnd).toBe(MarkerType.ArrowClosed);
51
+ });
52
+
53
+ test('typing edge is dashed', () => {
54
+ const config = EDGE_CONFIGS[EDGE_TYPES.TYPING];
55
+
56
+ expect(config.strokeDasharray).toBe('5 5');
57
+ expect(config.markerEnd).toBe(MarkerType.Arrow);
58
+ });
59
+
60
+ test('satisfy edge has stereotype label', () => {
61
+ const config = EDGE_CONFIGS[EDGE_TYPES.SATISFY];
62
+
63
+ expect(config.label).toBe('«satisfy»');
64
+ expect(config.strokeColor).toBe('#d97706');
65
+ });
66
+
67
+ test('perform edge has behavioral color', () => {
68
+ const config = EDGE_CONFIGS[EDGE_TYPES.PERFORM];
69
+
70
+ expect(config.strokeColor).toBe('#059669');
71
+ expect(config.label).toBe('«perform»');
72
+ });
73
+
74
+ test('subsetting and cross-subsetting have different labels', () => {
75
+ const subsettingConfig = EDGE_CONFIGS[EDGE_TYPES.SUBSETTING];
76
+ const crossSubsettingConfig = EDGE_CONFIGS[EDGE_TYPES.CROSS_SUBSETTING];
77
+
78
+ expect(subsettingConfig.label).toBe('subsets');
79
+ expect(crossSubsettingConfig.label).toBe('cross-subsets');
80
+ expect(subsettingConfig.label).not.toBe(crossSubsettingConfig.label);
81
+ });
82
+ });
83
+
84
+ describe('getEdgeConfig', () => {
85
+ test('returns config for known edge type', () => {
86
+ const config = getEdgeConfig(EDGE_TYPES.SPECIALIZATION);
87
+
88
+ expect(config.strokeColor).toBe('#475569');
89
+ expect(config.label).toBe('specializes');
90
+ });
91
+
92
+ test('returns default config for unknown edge type', () => {
93
+ const config = getEdgeConfig('unknown_edge_type');
94
+
95
+ expect(config.strokeColor).toBe('#475569');
96
+ expect(config.strokeWidth).toBe(2);
97
+ expect(config.markerEnd).toBe(MarkerType.ArrowClosed);
98
+ });
99
+ });
100
+
101
+ describe('createSysMLEdge', () => {
102
+ test('creates edge component from config', () => {
103
+ const config: EdgeConfig = {
104
+ strokeColor: '#ff0000',
105
+ strokeWidth: 3,
106
+ markerEnd: MarkerType.Arrow,
107
+ label: 'test',
108
+ };
109
+
110
+ const EdgeComponent = createSysMLEdge(config);
111
+ expect(EdgeComponent).toBeDefined();
112
+ expect(typeof EdgeComponent).toBe('function');
113
+ });
114
+
115
+ test('created edge has correct displayName without edgeType', () => {
116
+ const config: EdgeConfig = {
117
+ strokeColor: '#ff0000',
118
+ strokeWidth: 3,
119
+ markerEnd: MarkerType.Arrow,
120
+ label: 'test',
121
+ };
122
+
123
+ const EdgeComponent = createSysMLEdge(config);
124
+ expect(EdgeComponent.displayName).toBe('SysMLEdge');
125
+ });
126
+
127
+ test('created edge has unique displayName with edgeType', () => {
128
+ const config: EdgeConfig = {
129
+ strokeColor: '#ff0000',
130
+ strokeWidth: 3,
131
+ markerEnd: MarkerType.Arrow,
132
+ label: 'test',
133
+ };
134
+
135
+ const EdgeComponent = createSysMLEdge(config, 'SPECIALIZATION');
136
+ expect(EdgeComponent.displayName).toBe('SpecializationEdge');
137
+ });
138
+
139
+ test('created edge has unique displayName for multi-word edge types', () => {
140
+ const config: EdgeConfig = {
141
+ strokeColor: '#ff0000',
142
+ strokeWidth: 3,
143
+ markerEnd: MarkerType.Arrow,
144
+ label: 'test',
145
+ };
146
+
147
+ const EdgeComponent = createSysMLEdge(config, 'CROSS_SUBSETTING');
148
+ expect(EdgeComponent.displayName).toBe('CrossSubsettingEdge');
149
+ });
150
+
151
+ test('created edge renders with correct styles', () => {
152
+ const config: EdgeConfig = {
153
+ strokeColor: '#ff0000',
154
+ strokeWidth: 3,
155
+ markerEnd: MarkerType.Arrow,
156
+ label: 'test-label',
157
+ };
158
+
159
+ const EdgeComponent = createSysMLEdge(config);
160
+
161
+ const { container, getByText } = render(
162
+ <EdgeComponent
163
+ id="test-edge"
164
+ source="node1"
165
+ target="node2"
166
+ sourceX={0}
167
+ sourceY={0}
168
+ targetX={100}
169
+ targetY={100}
170
+ sourcePosition={Position.Bottom}
171
+ targetPosition={Position.Top}
172
+ />
173
+ );
174
+
175
+ // Edge path should be rendered
176
+ const path = container.querySelector('[data-testid="edge-test-edge"]');
177
+ expect(path).not.toBeNull();
178
+
179
+ // Label should be rendered
180
+ expect(getByText('test-label')).not.toBeNull();
181
+ });
182
+
183
+ test('created edge shows data label over config label', () => {
184
+ const config: EdgeConfig = {
185
+ strokeColor: '#ff0000',
186
+ strokeWidth: 3,
187
+ markerEnd: MarkerType.Arrow,
188
+ label: 'config-label',
189
+ };
190
+
191
+ const EdgeComponent = createSysMLEdge(config);
192
+
193
+ const { getByText, queryByText } = render(
194
+ <EdgeComponent
195
+ id="test-edge"
196
+ source="node1"
197
+ target="node2"
198
+ sourceX={0}
199
+ sourceY={0}
200
+ targetX={100}
201
+ targetY={100}
202
+ sourcePosition={Position.Bottom}
203
+ targetPosition={Position.Top}
204
+ data={{ label: 'data-label' }}
205
+ />
206
+ );
207
+
208
+ // Data label should be used instead of config label
209
+ expect(getByText('data-label')).not.toBeNull();
210
+ expect(queryByText('config-label')).toBeNull();
211
+ });
212
+
213
+ test('created edge shows multiplicity for composition', () => {
214
+ const config: EdgeConfig = {
215
+ strokeColor: '#2563eb',
216
+ strokeWidth: 2,
217
+ markerEnd: MarkerType.ArrowClosed,
218
+ };
219
+
220
+ const EdgeComponent = createSysMLEdge(config);
221
+
222
+ const { getByText } = render(
223
+ <EdgeComponent
224
+ id="test-edge"
225
+ source="node1"
226
+ target="node2"
227
+ sourceX={0}
228
+ sourceY={0}
229
+ targetX={100}
230
+ targetY={100}
231
+ sourcePosition={Position.Bottom}
232
+ targetPosition={Position.Top}
233
+ data={{ multiplicity: '4' }}
234
+ />
235
+ );
236
+
237
+ expect(getByText('[4]')).not.toBeNull();
238
+ });
239
+ });
240
+
241
+ describe('edgeTypes', () => {
242
+ test('has entry for each EDGE_TYPE', () => {
243
+ const edgeTypeValues = Object.values(EDGE_TYPES);
244
+
245
+ for (const edgeType of edgeTypeValues) {
246
+ expect(edgeTypes[edgeType]).toBeDefined();
247
+ expect(typeof edgeTypes[edgeType]).toBe('function');
248
+ }
249
+ });
250
+
251
+ test('specialization edge renders correctly', () => {
252
+ const SpecializationEdge = edgeTypes[EDGE_TYPES.SPECIALIZATION];
253
+
254
+ const { container, getByText } = render(
255
+ <SpecializationEdge
256
+ id="spec-edge"
257
+ source="child"
258
+ target="parent"
259
+ sourceX={0}
260
+ sourceY={0}
261
+ targetX={100}
262
+ targetY={100}
263
+ sourcePosition={Position.Bottom}
264
+ targetPosition={Position.Top}
265
+ />
266
+ );
267
+
268
+ const path = container.querySelector('[data-testid="edge-spec-edge"]');
269
+ expect(path).not.toBeNull();
270
+ expect(getByText('specializes')).not.toBeNull();
271
+ });
272
+
273
+ test('composition edge renders correctly', () => {
274
+ const CompositionEdge = edgeTypes[EDGE_TYPES.COMPOSITION];
275
+
276
+ const { container } = render(
277
+ <CompositionEdge
278
+ id="comp-edge"
279
+ source="container"
280
+ target="contained"
281
+ sourceX={0}
282
+ sourceY={0}
283
+ targetX={100}
284
+ targetY={100}
285
+ sourcePosition={Position.Bottom}
286
+ targetPosition={Position.Top}
287
+ data={{ multiplicity: '*' }}
288
+ />
289
+ );
290
+
291
+ const path = container.querySelector('[data-testid="edge-comp-edge"]');
292
+ expect(path).not.toBeNull();
293
+ });
294
+
295
+ test('typing edge renders with dashed style', () => {
296
+ const TypingEdge = edgeTypes[EDGE_TYPES.TYPING];
297
+
298
+ const { container, getByText } = render(
299
+ <TypingEdge
300
+ id="typing-edge"
301
+ source="usage"
302
+ target="def"
303
+ sourceX={0}
304
+ sourceY={0}
305
+ targetX={100}
306
+ targetY={100}
307
+ sourcePosition={Position.Bottom}
308
+ targetPosition={Position.Top}
309
+ />
310
+ );
311
+
312
+ const path = container.querySelector('[data-testid="edge-typing-edge"]');
313
+ expect(path).not.toBeNull();
314
+ expect(getByText(':')).not.toBeNull();
315
+ });
316
+
317
+ test('satisfy edge renders with stereotype label', () => {
318
+ const SatisfyEdge = edgeTypes[EDGE_TYPES.SATISFY];
319
+
320
+ const { getByText } = render(
321
+ <SatisfyEdge
322
+ id="satisfy-edge"
323
+ source="implementation"
324
+ target="requirement"
325
+ sourceX={0}
326
+ sourceY={0}
327
+ targetX={100}
328
+ targetY={100}
329
+ sourcePosition={Position.Bottom}
330
+ targetPosition={Position.Top}
331
+ />
332
+ );
333
+
334
+ expect(getByText('«satisfy»')).not.toBeNull();
335
+ });
336
+
337
+ test('selected edge has highlight color', () => {
338
+ const SpecEdge = edgeTypes[EDGE_TYPES.SPECIALIZATION];
339
+
340
+ const { container } = render(
341
+ <SpecEdge
342
+ id="selected-edge"
343
+ source="child"
344
+ target="parent"
345
+ sourceX={0}
346
+ sourceY={0}
347
+ targetX={100}
348
+ targetY={100}
349
+ sourcePosition={Position.Bottom}
350
+ targetPosition={Position.Top}
351
+ selected={true}
352
+ />
353
+ );
354
+
355
+ const path = container.querySelector('[data-testid="edge-selected-edge"]');
356
+ expect(path).not.toBeNull();
357
+ });
358
+
359
+ test('edge without label or multiplicity renders path only', () => {
360
+ const config: EdgeConfig = {
361
+ strokeColor: '#000000',
362
+ strokeWidth: 1,
363
+ markerEnd: MarkerType.Arrow,
364
+ };
365
+
366
+ const EdgeComponent = createSysMLEdge(config);
367
+
368
+ const { container, queryByTestId } = render(
369
+ <EdgeComponent
370
+ id="no-label-edge"
371
+ source="a"
372
+ target="b"
373
+ sourceX={0}
374
+ sourceY={0}
375
+ targetX={100}
376
+ targetY={100}
377
+ sourcePosition={Position.Bottom}
378
+ targetPosition={Position.Top}
379
+ />
380
+ );
381
+
382
+ const path = container.querySelector('[data-testid="edge-no-label-edge"]');
383
+ expect(path).not.toBeNull();
384
+ // No label renderer should be present
385
+ expect(queryByTestId('edge-label-renderer')).toBeNull();
386
+ });
387
+ });