@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.

Files changed (55) hide show
  1. package/dist/__mocks__/app-environment.d.ts +4 -0
  2. package/dist/__mocks__/app-environment.js +4 -0
  3. package/dist/__mocks__/app-state.d.ts +12 -0
  4. package/dist/__mocks__/app-state.js +10 -0
  5. package/dist/adapters/index.d.ts +1 -0
  6. package/dist/adapters/index.js +1 -0
  7. package/dist/adapters/static-json.adapter.d.ts +2 -0
  8. package/dist/adapters/static-json.adapter.js +34 -0
  9. package/dist/components/BpmnDiagram.svelte +360 -0
  10. package/dist/components/BpmnDiagram.svelte.d.ts +7 -0
  11. package/dist/components/Breadcrumbs.svelte +32 -0
  12. package/dist/components/Breadcrumbs.svelte.d.ts +11 -0
  13. package/dist/components/DomainCard.svelte +29 -0
  14. package/dist/components/DomainCard.svelte.d.ts +9 -0
  15. package/dist/components/DomainCard.test.d.ts +1 -0
  16. package/dist/components/DomainCard.test.js +45 -0
  17. package/dist/components/Header.svelte +110 -0
  18. package/dist/components/Header.svelte.d.ts +3 -0
  19. package/dist/components/NavModeToggle.svelte +28 -0
  20. package/dist/components/NavModeToggle.svelte.d.ts +18 -0
  21. package/dist/components/NavTree.svelte +182 -0
  22. package/dist/components/NavTree.svelte.d.ts +9 -0
  23. package/dist/components/ServiceCard.svelte +26 -0
  24. package/dist/components/ServiceCard.svelte.d.ts +7 -0
  25. package/dist/components/ServiceCard.test.d.ts +1 -0
  26. package/dist/components/ServiceCard.test.js +36 -0
  27. package/dist/components/ServiceGraph.svelte +348 -0
  28. package/dist/components/ServiceGraph.svelte.d.ts +10 -0
  29. package/dist/components/ThemeToggle.svelte +29 -0
  30. package/dist/components/ThemeToggle.svelte.d.ts +18 -0
  31. package/dist/components/ThemeToggle.test.d.ts +1 -0
  32. package/dist/components/ThemeToggle.test.js +52 -0
  33. package/dist/components/UseCaseCard.svelte +57 -0
  34. package/dist/components/UseCaseCard.svelte.d.ts +7 -0
  35. package/dist/components/UseCaseCard.test.d.ts +1 -0
  36. package/dist/components/UseCaseCard.test.js +87 -0
  37. package/dist/components/index.d.ts +10 -0
  38. package/dist/components/index.js +10 -0
  39. package/dist/index.d.ts +5 -0
  40. package/dist/index.js +5 -0
  41. package/dist/ports/catalog.port.d.ts +5 -0
  42. package/dist/ports/catalog.port.js +1 -0
  43. package/dist/ports/index.d.ts +1 -0
  44. package/dist/ports/index.js +1 -0
  45. package/dist/stores/index.d.ts +3 -0
  46. package/dist/stores/index.js +2 -0
  47. package/dist/stores/nav-mode.svelte.d.ts +7 -0
  48. package/dist/stores/nav-mode.svelte.js +32 -0
  49. package/dist/stores/theme.svelte.d.ts +8 -0
  50. package/dist/stores/theme.svelte.js +61 -0
  51. package/dist/utils/fetch-catalog.d.ts +6 -0
  52. package/dist/utils/fetch-catalog.js +26 -0
  53. package/dist/utils/index.d.ts +1 -0
  54. package/dist/utils/index.js +1 -0
  55. 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';
@@ -0,0 +1,5 @@
1
+ export * from './components/index.js';
2
+ export * from './stores/index.js';
3
+ export * from './adapters/index.js';
4
+ export * from './ports/index.js';
5
+ export * from './utils/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './components/index.js';
2
+ export * from './stores/index.js';
3
+ export * from './adapters/index.js';
4
+ export * from './ports/index.js';
5
+ export * from './utils/index.js';
@@ -0,0 +1,5 @@
1
+ import type { Catalog, Service } from '@cwygoda/service-catalog-core/domain';
2
+ export interface CatalogPort {
3
+ getCatalog(): Promise<Catalog>;
4
+ getService(id: string): Promise<Service | undefined>;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export type { CatalogPort } from './catalog.port.js';
@@ -0,0 +1 @@
1
+ export {};