@cwygoda/service-catalog-ui 1.0.1
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.
Potentially problematic release.
This version of @cwygoda/service-catalog-ui might be problematic. Click here for more details.
- package/dist/__mocks__/app-environment.d.ts +4 -0
- package/dist/__mocks__/app-environment.js +4 -0
- package/dist/__mocks__/app-state.d.ts +12 -0
- package/dist/__mocks__/app-state.js +10 -0
- package/dist/adapters/index.d.ts +1 -0
- package/dist/adapters/index.js +1 -0
- package/dist/adapters/static-json.adapter.d.ts +2 -0
- package/dist/adapters/static-json.adapter.js +34 -0
- package/dist/components/BpmnDiagram.svelte +360 -0
- package/dist/components/BpmnDiagram.svelte.d.ts +7 -0
- package/dist/components/Breadcrumbs.svelte +32 -0
- package/dist/components/Breadcrumbs.svelte.d.ts +11 -0
- package/dist/components/DomainCard.svelte +29 -0
- package/dist/components/DomainCard.svelte.d.ts +9 -0
- package/dist/components/DomainCard.test.d.ts +1 -0
- package/dist/components/DomainCard.test.js +45 -0
- package/dist/components/Header.svelte +110 -0
- package/dist/components/Header.svelte.d.ts +3 -0
- package/dist/components/NavModeToggle.svelte +28 -0
- package/dist/components/NavModeToggle.svelte.d.ts +18 -0
- package/dist/components/NavTree.svelte +182 -0
- package/dist/components/NavTree.svelte.d.ts +9 -0
- package/dist/components/ServiceCard.svelte +26 -0
- package/dist/components/ServiceCard.svelte.d.ts +7 -0
- package/dist/components/ServiceCard.test.d.ts +1 -0
- package/dist/components/ServiceCard.test.js +36 -0
- package/dist/components/ServiceGraph.svelte +348 -0
- package/dist/components/ServiceGraph.svelte.d.ts +10 -0
- package/dist/components/ThemeToggle.svelte +29 -0
- package/dist/components/ThemeToggle.svelte.d.ts +18 -0
- package/dist/components/ThemeToggle.test.d.ts +1 -0
- package/dist/components/ThemeToggle.test.js +52 -0
- package/dist/components/UseCaseCard.svelte +57 -0
- package/dist/components/UseCaseCard.svelte.d.ts +7 -0
- package/dist/components/UseCaseCard.test.d.ts +1 -0
- package/dist/components/UseCaseCard.test.js +87 -0
- package/dist/components/index.d.ts +10 -0
- package/dist/components/index.js +10 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/ports/catalog.port.d.ts +5 -0
- package/dist/ports/catalog.port.js +1 -0
- package/dist/ports/index.d.ts +1 -0
- package/dist/ports/index.js +1 -0
- package/dist/stores/index.d.ts +3 -0
- package/dist/stores/index.js +2 -0
- package/dist/stores/nav-mode.svelte.d.ts +7 -0
- package/dist/stores/nav-mode.svelte.js +32 -0
- package/dist/stores/theme.svelte.d.ts +8 -0
- package/dist/stores/theme.svelte.js +61 -0
- package/dist/utils/fetch-catalog.d.ts +6 -0
- package/dist/utils/fetch-catalog.js +26 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/package.json +54 -0
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import { browser } from '$app/environment';
|
|
4
|
+
import { goto } from '$app/navigation';
|
|
5
|
+
import type { GraphNode, GraphEdge } from '@cwygoda/service-catalog-core/domain';
|
|
6
|
+
import type * as D3 from 'd3';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
nodes: GraphNode[];
|
|
10
|
+
edges: GraphEdge[];
|
|
11
|
+
height?: number;
|
|
12
|
+
highlightedNodes?: string[] | undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { nodes, edges, height = 500, highlightedNodes }: Props = $props();
|
|
16
|
+
|
|
17
|
+
// Domain color mapping
|
|
18
|
+
const domainColors: Record<string, string> = {
|
|
19
|
+
commerce: '#3b82f6', // blue
|
|
20
|
+
platform: '#10b981', // green
|
|
21
|
+
default: '#6b7280', // gray
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function getDomainColor(domain?: string): string {
|
|
25
|
+
const key = domain ?? 'default';
|
|
26
|
+
return domainColors[key] ?? '#6b7280';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let wrapper: HTMLDivElement;
|
|
30
|
+
let container: HTMLDivElement;
|
|
31
|
+
let svg: SVGSVGElement | null = null;
|
|
32
|
+
let simulation: D3.Simulation<D3.SimulationNodeDatum, undefined> | null = null;
|
|
33
|
+
|
|
34
|
+
// Simulation node type with position
|
|
35
|
+
interface SimNode extends GraphNode {
|
|
36
|
+
x: number;
|
|
37
|
+
y: number;
|
|
38
|
+
fx?: number | null;
|
|
39
|
+
fy?: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Simulation link type with node references
|
|
43
|
+
interface SimLink {
|
|
44
|
+
source: SimNode;
|
|
45
|
+
target: SimNode;
|
|
46
|
+
type: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// D3 selection refs for highlight updates
|
|
50
|
+
let nodeSelection: D3.Selection<SVGGElement, SimNode, SVGGElement, unknown> | null = null;
|
|
51
|
+
let linkSelection: D3.Selection<SVGLineElement, SimLink, SVGGElement, unknown> | null = null;
|
|
52
|
+
|
|
53
|
+
// Tooltip state
|
|
54
|
+
let tooltip = $state({ visible: false, x: 0, y: 0, content: '' });
|
|
55
|
+
|
|
56
|
+
// Fullscreen state
|
|
57
|
+
let isFullscreen = $state(false);
|
|
58
|
+
|
|
59
|
+
function toggleFullscreen(): void {
|
|
60
|
+
if (!browser) return;
|
|
61
|
+
if (!document.fullscreenElement) {
|
|
62
|
+
void wrapper.requestFullscreen();
|
|
63
|
+
} else {
|
|
64
|
+
void document.exitFullscreen();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function handleFullscreenChange(): void {
|
|
69
|
+
isFullscreen = !!document.fullscreenElement;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
onMount(async () => {
|
|
73
|
+
if (!browser) return;
|
|
74
|
+
|
|
75
|
+
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
76
|
+
|
|
77
|
+
const d3 = await import('d3');
|
|
78
|
+
|
|
79
|
+
const width = container.clientWidth;
|
|
80
|
+
|
|
81
|
+
// Create simulation nodes with positions
|
|
82
|
+
const simNodes: SimNode[] = nodes.map((n) => ({
|
|
83
|
+
...n,
|
|
84
|
+
x: width / 2 + (Math.random() - 0.5) * 100,
|
|
85
|
+
y: height / 2 + (Math.random() - 0.5) * 100,
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
// Create links referencing node objects
|
|
89
|
+
const nodeById = new Map(simNodes.map((n) => [n.id, n]));
|
|
90
|
+
const simLinks: SimLink[] = [];
|
|
91
|
+
for (const e of edges) {
|
|
92
|
+
const source = nodeById.get(e.source);
|
|
93
|
+
const target = nodeById.get(e.target);
|
|
94
|
+
if (source && target) {
|
|
95
|
+
simLinks.push({ source, target, type: e.type });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Create SVG
|
|
100
|
+
const svgEl = d3
|
|
101
|
+
.select(container)
|
|
102
|
+
.append('svg')
|
|
103
|
+
.attr('width', '100%')
|
|
104
|
+
.attr('height', height)
|
|
105
|
+
.attr('viewBox', `0 0 ${String(width)} ${String(height)}`);
|
|
106
|
+
|
|
107
|
+
svg = svgEl.node();
|
|
108
|
+
|
|
109
|
+
// Arrow marker definitions
|
|
110
|
+
const defs = svgEl.append('defs');
|
|
111
|
+
defs
|
|
112
|
+
.append('marker')
|
|
113
|
+
.attr('id', 'arrowhead')
|
|
114
|
+
.attr('viewBox', '0 -5 10 10')
|
|
115
|
+
.attr('refX', 25)
|
|
116
|
+
.attr('refY', 0)
|
|
117
|
+
.attr('markerWidth', 6)
|
|
118
|
+
.attr('markerHeight', 6)
|
|
119
|
+
.attr('orient', 'auto')
|
|
120
|
+
.append('path')
|
|
121
|
+
.attr('d', 'M0,-5L10,0L0,5')
|
|
122
|
+
.attr('class', 'fill-gray-400 dark:fill-gray-500');
|
|
123
|
+
|
|
124
|
+
// Container group for zoom/pan
|
|
125
|
+
const g = svgEl.append('g');
|
|
126
|
+
|
|
127
|
+
// Add zoom behavior
|
|
128
|
+
const zoom = d3
|
|
129
|
+
.zoom<SVGSVGElement, unknown>()
|
|
130
|
+
.scaleExtent([0.25, 4])
|
|
131
|
+
.on('zoom', (event: D3.D3ZoomEvent<SVGSVGElement, unknown>) => {
|
|
132
|
+
g.attr('transform', event.transform.toString());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
svgEl.call(zoom);
|
|
136
|
+
|
|
137
|
+
// Draw edges
|
|
138
|
+
linkSelection = g
|
|
139
|
+
.append('g')
|
|
140
|
+
.selectAll<SVGLineElement, SimLink>('line')
|
|
141
|
+
.data(simLinks)
|
|
142
|
+
.join('line')
|
|
143
|
+
.attr('class', (d) =>
|
|
144
|
+
d.type === 'event'
|
|
145
|
+
? 'stroke-gray-400 dark:stroke-gray-500'
|
|
146
|
+
: 'stroke-gray-500 dark:stroke-gray-400'
|
|
147
|
+
)
|
|
148
|
+
.attr('stroke-width', 2)
|
|
149
|
+
.attr('stroke-dasharray', (d) => (d.type === 'event' ? '5,5' : 'none'))
|
|
150
|
+
.attr('marker-end', 'url(#arrowhead)');
|
|
151
|
+
|
|
152
|
+
// Draw nodes
|
|
153
|
+
const nodeGroups = g
|
|
154
|
+
.append('g')
|
|
155
|
+
.selectAll<SVGGElement, SimNode>('g')
|
|
156
|
+
.data(simNodes)
|
|
157
|
+
.join('g')
|
|
158
|
+
.attr('class', 'cursor-pointer');
|
|
159
|
+
|
|
160
|
+
// Add drag behavior
|
|
161
|
+
nodeGroups.call(
|
|
162
|
+
d3
|
|
163
|
+
.drag<SVGGElement, SimNode>()
|
|
164
|
+
.on('start', (event, d) => {
|
|
165
|
+
if (!event.active) simulation?.alphaTarget(0.3).restart();
|
|
166
|
+
d.fx = d.x;
|
|
167
|
+
d.fy = d.y;
|
|
168
|
+
})
|
|
169
|
+
.on('drag', (event, d) => {
|
|
170
|
+
d.fx = event.x as number;
|
|
171
|
+
d.fy = event.y as number;
|
|
172
|
+
})
|
|
173
|
+
.on('end', (event, d) => {
|
|
174
|
+
if (!event.active) simulation?.alphaTarget(0);
|
|
175
|
+
d.fx = null;
|
|
176
|
+
d.fy = null;
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
nodeSelection = nodeGroups;
|
|
181
|
+
|
|
182
|
+
// Node circles
|
|
183
|
+
nodeSelection
|
|
184
|
+
.append('circle')
|
|
185
|
+
.attr('r', 20)
|
|
186
|
+
.attr('fill', (d) => getDomainColor(d.domain))
|
|
187
|
+
.attr('class', 'stroke-2 stroke-white dark:stroke-gray-800 transition-opacity')
|
|
188
|
+
.on('click', (_, d) => {
|
|
189
|
+
void goto(`/services/${d.id}`);
|
|
190
|
+
})
|
|
191
|
+
.on('mouseenter', function (event: MouseEvent, d: SimNode) {
|
|
192
|
+
d3.select(this).attr('r', 24);
|
|
193
|
+
tooltip = {
|
|
194
|
+
visible: true,
|
|
195
|
+
x: event.pageX,
|
|
196
|
+
y: event.pageY - 10,
|
|
197
|
+
content: `${d.name}${d.domain ? ` (${d.domain})` : ''}`,
|
|
198
|
+
};
|
|
199
|
+
})
|
|
200
|
+
.on('mousemove', (event: MouseEvent) => {
|
|
201
|
+
tooltip.x = event.pageX;
|
|
202
|
+
tooltip.y = event.pageY - 10;
|
|
203
|
+
})
|
|
204
|
+
.on('mouseleave', function () {
|
|
205
|
+
d3.select(this).attr('r', 20);
|
|
206
|
+
tooltip.visible = false;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Node labels
|
|
210
|
+
nodeSelection
|
|
211
|
+
.append('text')
|
|
212
|
+
.text((d) => d.name.split(' ')[0] ?? d.name)
|
|
213
|
+
.attr('text-anchor', 'middle')
|
|
214
|
+
.attr('dy', 35)
|
|
215
|
+
.attr(
|
|
216
|
+
'class',
|
|
217
|
+
'text-xs fill-gray-700 dark:fill-gray-300 pointer-events-none transition-opacity'
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// Force simulation - D3's forceSimulation has complex generics that don't match our SimNode
|
|
221
|
+
simulation = d3
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
223
|
+
.forceSimulation(simNodes as any)
|
|
224
|
+
.force(
|
|
225
|
+
'link',
|
|
226
|
+
d3
|
|
227
|
+
.forceLink(simLinks)
|
|
228
|
+
.id((d) => (d as SimNode).id)
|
|
229
|
+
.distance(120)
|
|
230
|
+
)
|
|
231
|
+
.force('charge', d3.forceManyBody().strength(-400))
|
|
232
|
+
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
233
|
+
.force('collision', d3.forceCollide().radius(40))
|
|
234
|
+
.on('tick', () => {
|
|
235
|
+
linkSelection
|
|
236
|
+
?.attr('x1', (d) => d.source.x)
|
|
237
|
+
.attr('y1', (d) => d.source.y)
|
|
238
|
+
.attr('x2', (d) => d.target.x)
|
|
239
|
+
.attr('y2', (d) => d.target.y);
|
|
240
|
+
|
|
241
|
+
nodeSelection?.attr('transform', (d) => `translate(${String(d.x)},${String(d.y)})`);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Effect to update highlighting when highlightedNodes changes
|
|
246
|
+
$effect(() => {
|
|
247
|
+
if (!nodeSelection || !linkSelection) return;
|
|
248
|
+
|
|
249
|
+
const highlighted = highlightedNodes;
|
|
250
|
+
const hasHighlight = highlighted && highlighted.length > 0;
|
|
251
|
+
|
|
252
|
+
if (hasHighlight) {
|
|
253
|
+
const highlightSet = new Set(highlighted);
|
|
254
|
+
|
|
255
|
+
// Dim non-highlighted nodes
|
|
256
|
+
nodeSelection.attr('opacity', (d) => (highlightSet.has(d.id) ? 1 : 0.2));
|
|
257
|
+
|
|
258
|
+
// Dim edges not between highlighted nodes
|
|
259
|
+
linkSelection.attr('opacity', (d) => {
|
|
260
|
+
return highlightSet.has(d.source.id) && highlightSet.has(d.target.id) ? 1 : 0.1;
|
|
261
|
+
});
|
|
262
|
+
} else {
|
|
263
|
+
// Reset all to full opacity
|
|
264
|
+
nodeSelection.attr('opacity', 1);
|
|
265
|
+
linkSelection.attr('opacity', 1);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
onDestroy(() => {
|
|
270
|
+
if (browser) {
|
|
271
|
+
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
272
|
+
}
|
|
273
|
+
simulation?.stop();
|
|
274
|
+
svg?.remove();
|
|
275
|
+
});
|
|
276
|
+
</script>
|
|
277
|
+
|
|
278
|
+
<div bind:this={wrapper} class="relative {isFullscreen ? 'bg-white dark:bg-gray-900' : ''}">
|
|
279
|
+
<div
|
|
280
|
+
bind:this={container}
|
|
281
|
+
class="w-full rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
|
282
|
+
style="height: {isFullscreen ? '100vh' : `${String(height)}px`}"
|
|
283
|
+
></div>
|
|
284
|
+
|
|
285
|
+
<!-- Tooltip -->
|
|
286
|
+
{#if tooltip.visible}
|
|
287
|
+
<div
|
|
288
|
+
class="pointer-events-none fixed z-50 rounded bg-gray-900 px-2 py-1 text-sm text-white shadow-lg dark:bg-gray-700"
|
|
289
|
+
style="left: {tooltip.x}px; top: {tooltip.y}px; transform: translate(-50%, -100%)"
|
|
290
|
+
>
|
|
291
|
+
{tooltip.content}
|
|
292
|
+
</div>
|
|
293
|
+
{/if}
|
|
294
|
+
|
|
295
|
+
<!-- Controls -->
|
|
296
|
+
<div
|
|
297
|
+
class="absolute right-3 top-3 flex flex-col gap-1 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-gray-600 dark:bg-gray-800/90"
|
|
298
|
+
>
|
|
299
|
+
<button
|
|
300
|
+
onclick={toggleFullscreen}
|
|
301
|
+
class="rounded p-1.5 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white"
|
|
302
|
+
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
|
303
|
+
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
304
|
+
>
|
|
305
|
+
{#if isFullscreen}
|
|
306
|
+
<!-- Exit fullscreen icon -->
|
|
307
|
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
308
|
+
<path
|
|
309
|
+
stroke-linecap="round"
|
|
310
|
+
stroke-linejoin="round"
|
|
311
|
+
d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25"
|
|
312
|
+
/>
|
|
313
|
+
</svg>
|
|
314
|
+
{:else}
|
|
315
|
+
<!-- Enter fullscreen icon -->
|
|
316
|
+
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
317
|
+
<path
|
|
318
|
+
stroke-linecap="round"
|
|
319
|
+
stroke-linejoin="round"
|
|
320
|
+
d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15"
|
|
321
|
+
/>
|
|
322
|
+
</svg>
|
|
323
|
+
{/if}
|
|
324
|
+
</button>
|
|
325
|
+
</div>
|
|
326
|
+
|
|
327
|
+
<!-- Legend -->
|
|
328
|
+
<div
|
|
329
|
+
class="absolute bottom-3 left-3 flex flex-wrap gap-3 rounded-lg border border-gray-200 bg-white/90 px-3 py-2 text-xs backdrop-blur-sm dark:border-gray-600 dark:bg-gray-800/90"
|
|
330
|
+
>
|
|
331
|
+
<div class="flex items-center gap-1.5">
|
|
332
|
+
<span class="h-3 w-3 rounded-full bg-blue-500"></span>
|
|
333
|
+
<span class="text-gray-600 dark:text-gray-400">Commerce</span>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="flex items-center gap-1.5">
|
|
336
|
+
<span class="h-3 w-3 rounded-full bg-green-500"></span>
|
|
337
|
+
<span class="text-gray-600 dark:text-gray-400">Platform</span>
|
|
338
|
+
</div>
|
|
339
|
+
<div class="flex items-center gap-1.5">
|
|
340
|
+
<span class="mr-1 h-0.5 w-4 bg-gray-500"></span>
|
|
341
|
+
<span class="text-gray-600 dark:text-gray-400">HTTP</span>
|
|
342
|
+
</div>
|
|
343
|
+
<div class="flex items-center gap-1.5">
|
|
344
|
+
<span class="mr-1 h-0.5 w-4 border-t-2 border-dashed border-gray-400"></span>
|
|
345
|
+
<span class="text-gray-600 dark:text-gray-400">Event</span>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GraphNode, GraphEdge } from '@cwygoda/service-catalog-core/domain';
|
|
2
|
+
interface Props {
|
|
3
|
+
nodes: GraphNode[];
|
|
4
|
+
edges: GraphEdge[];
|
|
5
|
+
height?: number;
|
|
6
|
+
highlightedNodes?: string[] | undefined;
|
|
7
|
+
}
|
|
8
|
+
declare const ServiceGraph: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type ServiceGraph = ReturnType<typeof ServiceGraph>;
|
|
10
|
+
export default ServiceGraph;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { theme } from '../stores/theme.svelte';
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<button
|
|
6
|
+
onclick={() => {
|
|
7
|
+
theme.toggle();
|
|
8
|
+
}}
|
|
9
|
+
class="rounded-md p-2 text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
|
10
|
+
aria-label="Toggle theme"
|
|
11
|
+
>
|
|
12
|
+
{#if theme.resolved === 'dark'}
|
|
13
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
14
|
+
<path
|
|
15
|
+
stroke-linecap="round"
|
|
16
|
+
stroke-linejoin="round"
|
|
17
|
+
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
18
|
+
/>
|
|
19
|
+
</svg>
|
|
20
|
+
{:else}
|
|
21
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
22
|
+
<path
|
|
23
|
+
stroke-linecap="round"
|
|
24
|
+
stroke-linejoin="round"
|
|
25
|
+
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
26
|
+
/>
|
|
27
|
+
</svg>
|
|
28
|
+
{/if}
|
|
29
|
+
</button>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const ThemeToggle: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type ThemeToggle = InstanceType<typeof ThemeToggle>;
|
|
18
|
+
export default ThemeToggle;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/// <reference types="@testing-library/jest-dom" />
|
|
2
|
+
import { render, screen, cleanup } from '@testing-library/svelte';
|
|
3
|
+
import { userEvent } from '@testing-library/user-event';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import ThemeToggle from './ThemeToggle.svelte';
|
|
6
|
+
describe('ThemeToggle', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// Clear localStorage and reset document
|
|
9
|
+
localStorage.clear();
|
|
10
|
+
document.documentElement.classList.remove('dark');
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
cleanup();
|
|
14
|
+
});
|
|
15
|
+
it('renders toggle button', () => {
|
|
16
|
+
render(ThemeToggle);
|
|
17
|
+
expect(screen.getByRole('button', { name: 'Toggle theme' })).toBeInTheDocument();
|
|
18
|
+
});
|
|
19
|
+
it('toggles theme on click', async () => {
|
|
20
|
+
const user = userEvent.setup();
|
|
21
|
+
render(ThemeToggle);
|
|
22
|
+
const button = screen.getByRole('button', { name: 'Toggle theme' });
|
|
23
|
+
const initialDark = document.documentElement.classList.contains('dark');
|
|
24
|
+
await user.click(button);
|
|
25
|
+
// Theme should be toggled from initial state
|
|
26
|
+
const afterClick = document.documentElement.classList.contains('dark');
|
|
27
|
+
expect(afterClick).not.toBe(initialDark);
|
|
28
|
+
});
|
|
29
|
+
it('toggles theme back on second click', async () => {
|
|
30
|
+
const user = userEvent.setup();
|
|
31
|
+
render(ThemeToggle);
|
|
32
|
+
const button = screen.getByRole('button', { name: 'Toggle theme' });
|
|
33
|
+
// Get state after first click
|
|
34
|
+
await user.click(button);
|
|
35
|
+
const afterFirstClick = document.documentElement.classList.contains('dark');
|
|
36
|
+
// Click again
|
|
37
|
+
await user.click(button);
|
|
38
|
+
const afterSecondClick = document.documentElement.classList.contains('dark');
|
|
39
|
+
// Should be opposite of after first click
|
|
40
|
+
expect(afterSecondClick).not.toBe(afterFirstClick);
|
|
41
|
+
});
|
|
42
|
+
it('persists theme to localStorage', async () => {
|
|
43
|
+
const user = userEvent.setup();
|
|
44
|
+
render(ThemeToggle);
|
|
45
|
+
const button = screen.getByRole('button', { name: 'Toggle theme' });
|
|
46
|
+
await user.click(button);
|
|
47
|
+
// Should have persisted something to localStorage
|
|
48
|
+
const stored = localStorage.getItem('theme');
|
|
49
|
+
expect(stored).toBeTruthy();
|
|
50
|
+
expect(['light', 'dark']).toContain(stored);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { UseCase } from '@cwygoda/service-catalog-core/domain';
|
|
3
|
+
|
|
4
|
+
let { useCase }: { useCase: UseCase } = $props();
|
|
5
|
+
|
|
6
|
+
function truncate(text: string, maxLength: number): string {
|
|
7
|
+
if (text.length <= maxLength) return text;
|
|
8
|
+
return text.slice(0, maxLength).trimEnd() + '...';
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<a
|
|
13
|
+
href="/use-cases/{useCase.id}"
|
|
14
|
+
class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
|
|
15
|
+
>
|
|
16
|
+
<div class="mb-2 flex items-center justify-between">
|
|
17
|
+
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
18
|
+
{useCase.name}
|
|
19
|
+
</h3>
|
|
20
|
+
{#if useCase.bpmn}
|
|
21
|
+
<span
|
|
22
|
+
class="rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
|
23
|
+
>
|
|
24
|
+
BPMN
|
|
25
|
+
</span>
|
|
26
|
+
{/if}
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
30
|
+
{truncate(useCase.description, 120)}
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-500">
|
|
34
|
+
<span class="flex items-center gap-1">
|
|
35
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
36
|
+
<path
|
|
37
|
+
stroke-linecap="round"
|
|
38
|
+
stroke-linejoin="round"
|
|
39
|
+
stroke-width="2"
|
|
40
|
+
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
|
41
|
+
/>
|
|
42
|
+
</svg>
|
|
43
|
+
{useCase.participants.length} participant{useCase.participants.length !== 1 ? 's' : ''}
|
|
44
|
+
</span>
|
|
45
|
+
<span class="flex items-center gap-1">
|
|
46
|
+
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
47
|
+
<path
|
|
48
|
+
stroke-linecap="round"
|
|
49
|
+
stroke-linejoin="round"
|
|
50
|
+
stroke-width="2"
|
|
51
|
+
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
|
52
|
+
/>
|
|
53
|
+
</svg>
|
|
54
|
+
{useCase.steps.length} step{useCase.steps.length !== 1 ? 's' : ''}
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
</a>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UseCase } from '@cwygoda/service-catalog-core/domain';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
useCase: UseCase;
|
|
4
|
+
};
|
|
5
|
+
declare const UseCaseCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type UseCaseCard = ReturnType<typeof UseCaseCard>;
|
|
7
|
+
export default UseCaseCard;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/// <reference types="@testing-library/jest-dom" />
|
|
2
|
+
import { render, screen } from '@testing-library/svelte';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import UseCaseCard from './UseCaseCard.svelte';
|
|
5
|
+
describe('UseCaseCard', () => {
|
|
6
|
+
const baseUseCase = {
|
|
7
|
+
id: 'test-use-case',
|
|
8
|
+
name: 'Test Use Case',
|
|
9
|
+
description: 'A test use case description',
|
|
10
|
+
participants: [],
|
|
11
|
+
steps: [],
|
|
12
|
+
};
|
|
13
|
+
it('renders use case name', () => {
|
|
14
|
+
render(UseCaseCard, { props: { useCase: baseUseCase } });
|
|
15
|
+
expect(screen.getByText('Test Use Case')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
it('renders use case description', () => {
|
|
18
|
+
render(UseCaseCard, { props: { useCase: baseUseCase } });
|
|
19
|
+
expect(screen.getByText('A test use case description')).toBeInTheDocument();
|
|
20
|
+
});
|
|
21
|
+
it('links to use case detail page', () => {
|
|
22
|
+
render(UseCaseCard, { props: { useCase: baseUseCase } });
|
|
23
|
+
const link = screen.getByRole('link');
|
|
24
|
+
expect(link).toHaveAttribute('href', '/use-cases/test-use-case');
|
|
25
|
+
});
|
|
26
|
+
it('truncates long descriptions with ellipsis', () => {
|
|
27
|
+
const longDesc = 'A'.repeat(150);
|
|
28
|
+
const useCaseWithLongDesc = {
|
|
29
|
+
...baseUseCase,
|
|
30
|
+
description: longDesc,
|
|
31
|
+
};
|
|
32
|
+
render(UseCaseCard, { props: { useCase: useCaseWithLongDesc } });
|
|
33
|
+
// Should truncate to 120 chars + "..."
|
|
34
|
+
expect(screen.getByText(/^A{100,120}\.\.\.$/)).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
it('shows BPMN badge when bpmn is defined', () => {
|
|
37
|
+
const useCaseWithBpmn = {
|
|
38
|
+
...baseUseCase,
|
|
39
|
+
bpmn: './diagram.bpmn',
|
|
40
|
+
};
|
|
41
|
+
render(UseCaseCard, { props: { useCase: useCaseWithBpmn } });
|
|
42
|
+
expect(screen.getByText('BPMN')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
it('does not show BPMN badge when no bpmn', () => {
|
|
45
|
+
render(UseCaseCard, { props: { useCase: baseUseCase } });
|
|
46
|
+
expect(screen.queryByText('BPMN')).not.toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
it('shows participant count', () => {
|
|
49
|
+
const useCaseWithParticipants = {
|
|
50
|
+
...baseUseCase,
|
|
51
|
+
participants: [
|
|
52
|
+
{ service: 'svc-1', role: 'Role 1' },
|
|
53
|
+
{ service: 'svc-2', role: 'Role 2' },
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
render(UseCaseCard, { props: { useCase: useCaseWithParticipants } });
|
|
57
|
+
expect(screen.getByText('2 participants')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
it('shows singular participant when count is 1', () => {
|
|
60
|
+
const useCaseWithOneParticipant = {
|
|
61
|
+
...baseUseCase,
|
|
62
|
+
participants: [{ service: 'svc-1', role: 'Role 1' }],
|
|
63
|
+
};
|
|
64
|
+
render(UseCaseCard, { props: { useCase: useCaseWithOneParticipant } });
|
|
65
|
+
expect(screen.getByText('1 participant')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
it('shows step count', () => {
|
|
68
|
+
const useCaseWithSteps = {
|
|
69
|
+
...baseUseCase,
|
|
70
|
+
steps: [
|
|
71
|
+
{ sequence: 1, action: 'Step 1' },
|
|
72
|
+
{ sequence: 2, action: 'Step 2' },
|
|
73
|
+
{ sequence: 3, action: 'Step 3' },
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
render(UseCaseCard, { props: { useCase: useCaseWithSteps } });
|
|
77
|
+
expect(screen.getByText('3 steps')).toBeInTheDocument();
|
|
78
|
+
});
|
|
79
|
+
it('shows singular step when count is 1', () => {
|
|
80
|
+
const useCaseWithOneStep = {
|
|
81
|
+
...baseUseCase,
|
|
82
|
+
steps: [{ sequence: 1, action: 'Step 1' }],
|
|
83
|
+
};
|
|
84
|
+
render(UseCaseCard, { props: { useCase: useCaseWithOneStep } });
|
|
85
|
+
expect(screen.getByText('1 step')).toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as BpmnDiagram } from './BpmnDiagram.svelte';
|
|
2
|
+
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
|
|
3
|
+
export { default as DomainCard } from './DomainCard.svelte';
|
|
4
|
+
export { default as Header } from './Header.svelte';
|
|
5
|
+
export { default as NavModeToggle } from './NavModeToggle.svelte';
|
|
6
|
+
export { default as NavTree } from './NavTree.svelte';
|
|
7
|
+
export { default as ServiceCard } from './ServiceCard.svelte';
|
|
8
|
+
export { default as ServiceGraph } from './ServiceGraph.svelte';
|
|
9
|
+
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
|
10
|
+
export { default as UseCaseCard } from './UseCaseCard.svelte';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as BpmnDiagram } from './BpmnDiagram.svelte';
|
|
2
|
+
export { default as Breadcrumbs } from './Breadcrumbs.svelte';
|
|
3
|
+
export { default as DomainCard } from './DomainCard.svelte';
|
|
4
|
+
export { default as Header } from './Header.svelte';
|
|
5
|
+
export { default as NavModeToggle } from './NavModeToggle.svelte';
|
|
6
|
+
export { default as NavTree } from './NavTree.svelte';
|
|
7
|
+
export { default as ServiceCard } from './ServiceCard.svelte';
|
|
8
|
+
export { default as ServiceGraph } from './ServiceGraph.svelte';
|
|
9
|
+
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
|
10
|
+
export { default as UseCaseCard } from './UseCaseCard.svelte';
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { CatalogPort } from './catalog.port.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|