@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.
- 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 +496 -0
- package/dist/components/BpmnDiagram.svelte.d.ts +18 -0
- package/dist/components/Breadcrumbs.svelte +32 -0
- package/dist/components/Breadcrumbs.svelte.d.ts +11 -0
- package/dist/components/DataStoreCard.svelte +26 -0
- package/dist/components/DataStoreCard.svelte.d.ts +7 -0
- package/dist/components/DataStoreShield.svelte +67 -0
- package/dist/components/DataStoreShield.svelte.d.ts +9 -0
- package/dist/components/DomainCard.svelte +34 -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 +144 -0
- package/dist/components/Header.svelte.d.ts +3 -0
- package/dist/components/NavModeToggle.svelte +43 -0
- package/dist/components/NavModeToggle.svelte.d.ts +18 -0
- package/dist/components/NavTree.svelte +245 -0
- package/dist/components/NavTree.svelte.d.ts +10 -0
- package/dist/components/SearchModal.svelte +288 -0
- package/dist/components/SearchModal.svelte.d.ts +3 -0
- package/dist/components/ServiceCard.svelte +36 -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 +43 -0
- package/dist/components/ServiceGraph.svelte +437 -0
- package/dist/components/ServiceGraph.svelte.d.ts +10 -0
- package/dist/components/ServiceTypeShield.svelte +32 -0
- package/dist/components/ServiceTypeShield.svelte.d.ts +8 -0
- package/dist/components/Shield.svelte +118 -0
- package/dist/components/Shield.svelte.d.ts +7 -0
- package/dist/components/ThemeToggle.svelte +44 -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 +61 -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 +15 -0
- package/dist/components/index.js +15 -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/source.css +1 -0
- package/dist/stores/index.d.ts +4 -0
- package/dist/stores/index.js +3 -0
- package/dist/stores/nav-mode.svelte.d.ts +7 -0
- package/dist/stores/nav-mode.svelte.js +32 -0
- package/dist/stores/search.svelte.d.ts +9 -0
- package/dist/stores/search.svelte.js +14 -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 +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">↑↓</kbd> navigate</span>
|
|
282
|
+
<span><kbd class="font-mono">↵</kbd> open</span>
|
|
283
|
+
</div>
|
|
284
|
+
<span>Powered by Pagefind</span>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
{/if}
|
|
@@ -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
|
+
});
|