@cwygoda/service-catalog-ui 0.17.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.
Files changed (68) 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 +496 -0
  10. package/dist/components/BpmnDiagram.svelte.d.ts +18 -0
  11. package/dist/components/Breadcrumbs.svelte +32 -0
  12. package/dist/components/Breadcrumbs.svelte.d.ts +11 -0
  13. package/dist/components/DataStoreCard.svelte +26 -0
  14. package/dist/components/DataStoreCard.svelte.d.ts +7 -0
  15. package/dist/components/DataStoreShield.svelte +67 -0
  16. package/dist/components/DataStoreShield.svelte.d.ts +9 -0
  17. package/dist/components/DomainCard.svelte +34 -0
  18. package/dist/components/DomainCard.svelte.d.ts +9 -0
  19. package/dist/components/DomainCard.test.d.ts +1 -0
  20. package/dist/components/DomainCard.test.js +45 -0
  21. package/dist/components/Header.svelte +144 -0
  22. package/dist/components/Header.svelte.d.ts +3 -0
  23. package/dist/components/NavModeToggle.svelte +43 -0
  24. package/dist/components/NavModeToggle.svelte.d.ts +18 -0
  25. package/dist/components/NavTree.svelte +245 -0
  26. package/dist/components/NavTree.svelte.d.ts +10 -0
  27. package/dist/components/SearchModal.svelte +288 -0
  28. package/dist/components/SearchModal.svelte.d.ts +3 -0
  29. package/dist/components/ServiceCard.svelte +36 -0
  30. package/dist/components/ServiceCard.svelte.d.ts +7 -0
  31. package/dist/components/ServiceCard.test.d.ts +1 -0
  32. package/dist/components/ServiceCard.test.js +43 -0
  33. package/dist/components/ServiceGraph.svelte +437 -0
  34. package/dist/components/ServiceGraph.svelte.d.ts +10 -0
  35. package/dist/components/ServiceTypeShield.svelte +32 -0
  36. package/dist/components/ServiceTypeShield.svelte.d.ts +8 -0
  37. package/dist/components/Shield.svelte +118 -0
  38. package/dist/components/Shield.svelte.d.ts +7 -0
  39. package/dist/components/ThemeToggle.svelte +44 -0
  40. package/dist/components/ThemeToggle.svelte.d.ts +18 -0
  41. package/dist/components/ThemeToggle.test.d.ts +1 -0
  42. package/dist/components/ThemeToggle.test.js +52 -0
  43. package/dist/components/UseCaseCard.svelte +61 -0
  44. package/dist/components/UseCaseCard.svelte.d.ts +7 -0
  45. package/dist/components/UseCaseCard.test.d.ts +1 -0
  46. package/dist/components/UseCaseCard.test.js +87 -0
  47. package/dist/components/index.d.ts +15 -0
  48. package/dist/components/index.js +15 -0
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.js +5 -0
  51. package/dist/ports/catalog.port.d.ts +5 -0
  52. package/dist/ports/catalog.port.js +1 -0
  53. package/dist/ports/index.d.ts +1 -0
  54. package/dist/ports/index.js +1 -0
  55. package/dist/source.css +1 -0
  56. package/dist/stores/index.d.ts +4 -0
  57. package/dist/stores/index.js +3 -0
  58. package/dist/stores/nav-mode.svelte.d.ts +7 -0
  59. package/dist/stores/nav-mode.svelte.js +32 -0
  60. package/dist/stores/search.svelte.d.ts +9 -0
  61. package/dist/stores/search.svelte.js +14 -0
  62. package/dist/stores/theme.svelte.d.ts +8 -0
  63. package/dist/stores/theme.svelte.js +61 -0
  64. package/dist/utils/fetch-catalog.d.ts +6 -0
  65. package/dist/utils/fetch-catalog.js +26 -0
  66. package/dist/utils/index.d.ts +1 -0
  67. package/dist/utils/index.js +1 -0
  68. package/package.json +63 -0
@@ -0,0 +1,288 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, tick } from 'svelte';
3
+ import { browser } from '$app/environment';
4
+ import { searchStore } from '../stores/search.svelte.js';
5
+
6
+ let modalEl: HTMLDivElement | undefined = $state();
7
+
8
+ interface SearchResult {
9
+ id: string;
10
+ url: string;
11
+ meta: { title?: string; type?: string; domain?: string };
12
+ excerpt: string;
13
+ }
14
+
15
+ let query = $state('');
16
+ let results = $state<SearchResult[]>([]);
17
+ let loading = $state(false);
18
+ let selectedIndex = $state(0);
19
+ let devMode = $state(false);
20
+
21
+ let inputEl: HTMLInputElement | undefined = $state();
22
+ let pagefind: {
23
+ search: (
24
+ q: string
25
+ ) => Promise<{ results: { id: string; data: () => Promise<SearchResult> }[] }>;
26
+ } | null = null;
27
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
28
+
29
+ async function initPagefind() {
30
+ if (pagefind) return;
31
+ try {
32
+ // Use a variable to prevent bundler from statically analyzing the import
33
+ // path. Pagefind injects its JS into the build output at build time.
34
+ const pagefindPath = '/pagefind/pagefind.js';
35
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
36
+ const pf = await import(/* @vite-ignore */ pagefindPath);
37
+ await pf.init();
38
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
39
+ pagefind = pf;
40
+ } catch {
41
+ pagefind = null;
42
+ devMode = true;
43
+ }
44
+ }
45
+
46
+ function handleKeydown(e: KeyboardEvent) {
47
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
48
+ e.preventDefault();
49
+ searchStore.toggle();
50
+ }
51
+ if (e.key === 'Escape' && searchStore.open) {
52
+ e.preventDefault();
53
+ close();
54
+ }
55
+ }
56
+
57
+ function close() {
58
+ searchStore.hide();
59
+ query = '';
60
+ results = [];
61
+ selectedIndex = 0;
62
+ }
63
+
64
+ async function search(q: string) {
65
+ if (!pagefind || !q.trim()) {
66
+ results = [];
67
+ return;
68
+ }
69
+ loading = true;
70
+ try {
71
+ const response = await pagefind.search(q);
72
+ const items = await Promise.all(response.results.slice(0, 8).map((r) => r.data()));
73
+ results = items;
74
+ selectedIndex = 0;
75
+ } finally {
76
+ loading = false;
77
+ }
78
+ }
79
+
80
+ function handleInput() {
81
+ clearTimeout(debounceTimer);
82
+ debounceTimer = setTimeout(() => {
83
+ void search(query);
84
+ }, 150);
85
+ }
86
+
87
+ function handleModalKeydown(e: KeyboardEvent) {
88
+ if (e.key === 'ArrowDown') {
89
+ e.preventDefault();
90
+ selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
91
+ } else if (e.key === 'ArrowUp') {
92
+ e.preventDefault();
93
+ selectedIndex = Math.max(selectedIndex - 1, 0);
94
+ } else if (e.key === 'Enter') {
95
+ const selected = results[selectedIndex];
96
+ if (selected) {
97
+ e.preventDefault();
98
+ navigate(selected.url);
99
+ }
100
+ } else if (e.key === 'Tab') {
101
+ trapFocus(e);
102
+ }
103
+ }
104
+
105
+ function trapFocus(e: KeyboardEvent) {
106
+ if (!modalEl) return;
107
+ const focusable = modalEl.querySelectorAll<HTMLElement>(
108
+ 'input, button, [tabindex]:not([tabindex="-1"]), a[href]'
109
+ );
110
+ const first = focusable[0];
111
+ const last = focusable[focusable.length - 1];
112
+ if (!first || !last) return;
113
+ if (e.shiftKey && document.activeElement === first) {
114
+ e.preventDefault();
115
+ last.focus();
116
+ } else if (!e.shiftKey && document.activeElement === last) {
117
+ e.preventDefault();
118
+ first.focus();
119
+ }
120
+ }
121
+
122
+ function navigate(url: string) {
123
+ close();
124
+ window.location.href = url;
125
+ }
126
+
127
+ function getTypeBadgeClass(type: string | undefined): string {
128
+ switch (type) {
129
+ case 'Service':
130
+ return 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300';
131
+ case 'Use Case':
132
+ return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300';
133
+ case 'Domain':
134
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300';
135
+ default:
136
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
137
+ }
138
+ }
139
+
140
+ $effect(() => {
141
+ if (searchStore.open && browser) {
142
+ void initPagefind();
143
+ void tick().then(() => inputEl?.focus());
144
+ }
145
+ });
146
+
147
+ onMount(() => {
148
+ if (browser) {
149
+ window.addEventListener('keydown', handleKeydown);
150
+ }
151
+ });
152
+
153
+ onDestroy(() => {
154
+ if (browser) {
155
+ window.removeEventListener('keydown', handleKeydown);
156
+ }
157
+ clearTimeout(debounceTimer);
158
+ });
159
+ </script>
160
+
161
+ {#if searchStore.open}
162
+ <!-- Backdrop -->
163
+ <div
164
+ class="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm dark:bg-black/70"
165
+ role="presentation"
166
+ onclick={close}
167
+ onkeydown={(e) => {
168
+ if (e.key === 'Escape') close();
169
+ }}
170
+ ></div>
171
+
172
+ <!-- Modal -->
173
+ <div
174
+ bind:this={modalEl}
175
+ class="fixed inset-x-0 top-[15%] z-50 mx-auto w-full max-w-lg"
176
+ role="dialog"
177
+ tabindex="-1"
178
+ aria-modal="true"
179
+ aria-label="Search catalog"
180
+ onkeydown={handleModalKeydown}
181
+ >
182
+ <div
183
+ class="mx-4 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-800"
184
+ >
185
+ <!-- Search input -->
186
+ <div class="flex items-center border-b border-gray-200 px-4 dark:border-gray-700">
187
+ <svg
188
+ class="h-5 w-5 shrink-0 text-gray-400"
189
+ fill="none"
190
+ viewBox="0 0 24 24"
191
+ stroke="currentColor"
192
+ stroke-width="2"
193
+ aria-hidden="true"
194
+ >
195
+ <path
196
+ stroke-linecap="round"
197
+ stroke-linejoin="round"
198
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
199
+ />
200
+ </svg>
201
+ <input
202
+ bind:this={inputEl}
203
+ bind:value={query}
204
+ oninput={handleInput}
205
+ type="text"
206
+ placeholder="Search services, use cases, domains..."
207
+ class="w-full bg-transparent px-3 py-4 text-sm text-gray-900 outline-none placeholder:text-gray-400 dark:text-white dark:placeholder:text-gray-500"
208
+ />
209
+ <kbd
210
+ class="hidden rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-400 sm:inline dark:border-gray-600"
211
+ >
212
+ Esc
213
+ </kbd>
214
+ </div>
215
+
216
+ <!-- Results -->
217
+ <div class="max-h-80 overflow-y-auto">
218
+ {#if devMode}
219
+ <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
220
+ Search available in production build
221
+ </div>
222
+ {:else if loading}
223
+ <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
224
+ Searching...
225
+ </div>
226
+ {:else if query && results.length === 0}
227
+ <div class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
228
+ No results for "{query}"
229
+ </div>
230
+ {:else}
231
+ <ul role="listbox">
232
+ {#each results as result, i (result.url)}
233
+ <li
234
+ role="option"
235
+ aria-selected={i === selectedIndex}
236
+ class="cursor-pointer border-b border-gray-100 px-4 py-3 last:border-b-0 dark:border-gray-700 {i ===
237
+ selectedIndex
238
+ ? 'bg-primary-50 dark:bg-primary-900/30'
239
+ : 'hover:bg-gray-50 dark:hover:bg-gray-700/50'}"
240
+ onclick={() => {
241
+ navigate(result.url);
242
+ }}
243
+ onkeydown={(e) => {
244
+ if (e.key === 'Enter') navigate(result.url);
245
+ }}
246
+ onmouseenter={() => {
247
+ selectedIndex = i;
248
+ }}
249
+ >
250
+ <div class="flex items-center gap-2">
251
+ {#if result.meta.type}
252
+ <span
253
+ class="rounded px-1.5 py-0.5 text-xs font-medium {getTypeBadgeClass(
254
+ result.meta.type
255
+ )}"
256
+ >
257
+ {result.meta.type}
258
+ </span>
259
+ {/if}
260
+ <span class="text-sm font-medium text-gray-900 dark:text-white">
261
+ {result.meta.title ?? ''}
262
+ </span>
263
+ </div>
264
+ {#if result.excerpt}
265
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
266
+ <!-- eslint-disable-next-line svelte/no-at-html-tags — pagefind sanitizes excerpts -->
267
+ {@html result.excerpt}
268
+ </p>
269
+ {/if}
270
+ </li>
271
+ {/each}
272
+ </ul>
273
+ {/if}
274
+ </div>
275
+
276
+ <!-- Footer -->
277
+ <div
278
+ class="flex items-center justify-between border-t border-gray-200 px-4 py-2 text-xs text-gray-400 dark:border-gray-700 dark:text-gray-500"
279
+ >
280
+ <div class="flex gap-2">
281
+ <span><kbd class="font-mono">&uarr;&darr;</kbd> navigate</span>
282
+ <span><kbd class="font-mono">&crarr;</kbd> open</span>
283
+ </div>
284
+ <span>Powered by Pagefind</span>
285
+ </div>
286
+ </div>
287
+ </div>
288
+ {/if}
@@ -0,0 +1,3 @@
1
+ declare const SearchModal: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type SearchModal = ReturnType<typeof SearchModal>;
3
+ export default SearchModal;
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ import type { Service } from '@cwygoda/service-catalog-core/domain';
3
+ import ServiceTypeShield from './ServiceTypeShield.svelte';
4
+
5
+ let { service }: { service: Service } = $props();
6
+ </script>
7
+
8
+ <a
9
+ href="/services/{service.id}"
10
+ aria-label="View {service.name} service"
11
+ class="block rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md active:shadow-sm dark:border-gray-700 dark:bg-gray-800"
12
+ >
13
+ <div class="mb-3 flex items-start gap-4">
14
+ <ServiceTypeShield type={service.type} size={44} />
15
+ <div class="min-w-0 flex-1">
16
+ <div class="flex items-center justify-between gap-2">
17
+ <h3 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
18
+ {service.name}
19
+ </h3>
20
+ {#if service.lifecycle !== 'active'}
21
+ <span
22
+ class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {service.lifecycle ===
23
+ 'deprecated' || service.lifecycle === 'sunset'
24
+ ? 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
25
+ : 'bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200'}"
26
+ >
27
+ {service.lifecycle}
28
+ </span>
29
+ {/if}
30
+ </div>
31
+ <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
32
+ {service.description}
33
+ </p>
34
+ </div>
35
+ </div>
36
+ </a>
@@ -0,0 +1,7 @@
1
+ import type { Service } from '@cwygoda/service-catalog-core/domain';
2
+ type $$ComponentProps = {
3
+ service: Service;
4
+ };
5
+ declare const ServiceCard: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ServiceCard = ReturnType<typeof ServiceCard>;
7
+ export default ServiceCard;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { render, screen } from '@testing-library/svelte';
3
+ import { describe, expect, it } from 'vitest';
4
+ import ServiceCard from './ServiceCard.svelte';
5
+ describe('ServiceCard', () => {
6
+ const baseService = {
7
+ id: 'test-service',
8
+ name: 'Test Service',
9
+ description: 'A test service description',
10
+ type: 'web-service',
11
+ lifecycle: 'active',
12
+ };
13
+ it('renders service name', () => {
14
+ render(ServiceCard, { props: { service: baseService } });
15
+ expect(screen.getByText('Test Service')).toBeInTheDocument();
16
+ });
17
+ it('renders service description', () => {
18
+ render(ServiceCard, { props: { service: baseService } });
19
+ expect(screen.getByText('A test service description')).toBeInTheDocument();
20
+ });
21
+ it('links to service detail page', () => {
22
+ render(ServiceCard, { props: { service: baseService } });
23
+ const link = screen.getByRole('link');
24
+ expect(link).toHaveAttribute('href', '/services/test-service');
25
+ });
26
+ it('shows service type shield with tooltip', () => {
27
+ render(ServiceCard, { props: { service: baseService } });
28
+ expect(screen.getByTitle('Web Service')).toBeInTheDocument();
29
+ expect(screen.getByRole('tooltip')).toHaveTextContent('Web Service');
30
+ });
31
+ it('shows lifecycle badge when not active', () => {
32
+ const deprecatedService = {
33
+ ...baseService,
34
+ lifecycle: 'deprecated',
35
+ };
36
+ render(ServiceCard, { props: { service: deprecatedService } });
37
+ expect(screen.getByText('deprecated')).toBeInTheDocument();
38
+ });
39
+ it('does not show lifecycle badge when active', () => {
40
+ render(ServiceCard, { props: { service: baseService } });
41
+ expect(screen.queryByText('active')).not.toBeInTheDocument();
42
+ });
43
+ });