@industream/flowmaker-flowbox-ui-components 0.0.13 → 0.0.15
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/DCCatalogEntry.svelte +133 -22
- package/dist/DCCatalogEntry.svelte.d.ts +10 -3
- package/dist/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +98 -11
- package/dist/DCCatalogEntryPicker/SelectedTags.svelte +4 -3
- package/dist/DCCatalogEntryPicker/TagBrowser.svelte +56 -10
- package/dist/DCCatalogEntryPicker/TagBrowser.svelte.d.ts +6 -0
- package/dist/DCCatalogEntryPicker/picker-column-helpers.d.ts +2 -0
- package/package.json +3 -2
- package/src/DCCatalogEntry.svelte +133 -22
- package/src/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +98 -11
- package/src/DCCatalogEntryPicker/SelectedTags.svelte +4 -3
- package/src/DCCatalogEntryPicker/TagBrowser.svelte +56 -10
- package/src/DCCatalogEntryPicker/picker-column-helpers.ts +2 -0
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import { DataCatalogClient } from '@industream/datacatalog-client';
|
|
3
3
|
import type { CatalogEntry, DataType, SourceType } from '@industream/datacatalog-client/dto';
|
|
4
4
|
|
|
5
|
+
interface FilterOptions {
|
|
6
|
+
searchtext?: string | null; // Case-insensitive LIKE %text% filter on entry name
|
|
7
|
+
datasetfilter?: string | null; // Filter entries by sourceParams.dataset
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
interface Props {
|
|
6
11
|
id?: string;
|
|
7
12
|
dcapiurl?: string;
|
|
@@ -9,8 +14,9 @@
|
|
|
9
14
|
datatypefilter?: DataType | DataType[] | null;
|
|
10
15
|
namefilter?: string | string[] | null;
|
|
11
16
|
initialselection?: string | null;
|
|
12
|
-
onentryselect?: (entry: CatalogEntry) => void;
|
|
13
|
-
onitemsloaded?: (
|
|
17
|
+
onentryselect?: (entry: CatalogEntry | null) => void;
|
|
18
|
+
onitemsloaded?: (filtered: CatalogEntry[], all: CatalogEntry[]) => void;
|
|
19
|
+
onsearchmiss?: (text: string) => void; // Called when searchtext matches nothing
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
let {
|
|
@@ -21,7 +27,8 @@
|
|
|
21
27
|
namefilter = null,
|
|
22
28
|
initialselection = null,
|
|
23
29
|
onentryselect = null,
|
|
24
|
-
onitemsloaded = null
|
|
30
|
+
onitemsloaded = null,
|
|
31
|
+
onsearchmiss = null
|
|
25
32
|
}: Props = $props();
|
|
26
33
|
|
|
27
34
|
let catalogEntries = $state<CatalogEntry[]>([]);
|
|
@@ -31,6 +38,13 @@
|
|
|
31
38
|
let error = $state<string | null>(null);
|
|
32
39
|
let dropdownRef = $state<HTMLElement | null>(null);
|
|
33
40
|
|
|
41
|
+
// Active dynamic filters (set via setFilters method)
|
|
42
|
+
let activeSearchtext: string | null = null;
|
|
43
|
+
let activeDatasetfilter: string | null = null;
|
|
44
|
+
let autoSelectedId: string | null = null;
|
|
45
|
+
let searchMissText = $state<string | null>(null);
|
|
46
|
+
let multipleMatchText = $state<string | null>(null);
|
|
47
|
+
|
|
34
48
|
// Load catalog entries when component mounts or dcapiurl/sourcetypefilter changes
|
|
35
49
|
$effect(() => {
|
|
36
50
|
// Read sourcetypefilter to establish dependency tracking
|
|
@@ -40,11 +54,6 @@
|
|
|
40
54
|
}
|
|
41
55
|
});
|
|
42
56
|
|
|
43
|
-
// Apply client-side filters when entries or filters change
|
|
44
|
-
$effect(() => {
|
|
45
|
-
filteredEntries = applyFilters(catalogEntries);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
57
|
// Inject styles into shadow DOM to fix trigger-label width
|
|
49
58
|
$effect(() => {
|
|
50
59
|
if (dropdownRef) {
|
|
@@ -60,11 +69,10 @@
|
|
|
60
69
|
}
|
|
61
70
|
});
|
|
62
71
|
|
|
63
|
-
function
|
|
72
|
+
function applyBaseFilters(entries: CatalogEntry[]): CatalogEntry[] {
|
|
64
73
|
let result = entries;
|
|
65
74
|
|
|
66
75
|
// Filter by source type name (from sourceConnection.sourceType.name)
|
|
67
|
-
// Note: Server-side filtering is now done via API, this is just for additional client-side filtering if needed
|
|
68
76
|
if (sourcetypefilter && sourcetypefilter.length > 0) {
|
|
69
77
|
const sourceTypeNames = Array.isArray(sourcetypefilter) ? sourcetypefilter : [sourcetypefilter];
|
|
70
78
|
result = result.filter(entry =>
|
|
@@ -84,6 +92,54 @@
|
|
|
84
92
|
return result;
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
function applyDynamicFilters(entries: CatalogEntry[], { skipSearch = false } = {}): CatalogEntry[] {
|
|
96
|
+
let result = entries;
|
|
97
|
+
|
|
98
|
+
if (activeDatasetfilter) {
|
|
99
|
+
result = result.filter(entry =>
|
|
100
|
+
entry.sourceParams?.dataset === activeDatasetfilter
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!skipSearch && activeSearchtext?.trim()) {
|
|
105
|
+
const needle = activeSearchtext.trim().toLowerCase();
|
|
106
|
+
result = result.filter(entry =>
|
|
107
|
+
entry.name?.toLowerCase().includes(needle)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runFilters() {
|
|
115
|
+
const base = applyBaseFilters(catalogEntries);
|
|
116
|
+
let result = applyDynamicFilters(base);
|
|
117
|
+
|
|
118
|
+
// Search-miss fallback: drop search filter and notify consumer
|
|
119
|
+
if (result.length === 0 && activeSearchtext?.trim()) {
|
|
120
|
+
searchMissText = activeSearchtext.trim();
|
|
121
|
+
multipleMatchText = null;
|
|
122
|
+
result = applyDynamicFilters(base, { skipSearch: true });
|
|
123
|
+
onsearchmiss?.(activeSearchtext.trim());
|
|
124
|
+
} else {
|
|
125
|
+
searchMissText = null;
|
|
126
|
+
multipleMatchText = (result.length > 1 && activeSearchtext?.trim()) ? activeSearchtext.trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
filteredEntries = result;
|
|
130
|
+
|
|
131
|
+
// Auto-select / auto-clear only when dynamic filters are active
|
|
132
|
+
if (activeSearchtext?.trim() || activeDatasetfilter) {
|
|
133
|
+
if (result.length === 1 && result[0].id !== autoSelectedId) {
|
|
134
|
+
autoSelectedId = result[0].id;
|
|
135
|
+
select(result[0]);
|
|
136
|
+
} else if (result.length !== 1) {
|
|
137
|
+
autoSelectedId = null;
|
|
138
|
+
select(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
87
143
|
async function loadCatalogEntries() {
|
|
88
144
|
loading = true;
|
|
89
145
|
error = null;
|
|
@@ -104,8 +160,13 @@
|
|
|
104
160
|
|
|
105
161
|
const result = await client.catalogEntries.get(filters);
|
|
106
162
|
catalogEntries = result.items || [];
|
|
107
|
-
// Apply filters synchronously so initialselection works
|
|
108
|
-
filteredEntries =
|
|
163
|
+
// Apply base filters synchronously so initialselection works
|
|
164
|
+
filteredEntries = applyBaseFilters(catalogEntries);
|
|
165
|
+
|
|
166
|
+
// Re-apply dynamic filters if any are active
|
|
167
|
+
if (activeSearchtext || activeDatasetfilter) {
|
|
168
|
+
runFilters();
|
|
169
|
+
}
|
|
109
170
|
|
|
110
171
|
// Auto-select initial selection if provided
|
|
111
172
|
if (initialselection) {
|
|
@@ -116,7 +177,7 @@
|
|
|
116
177
|
}
|
|
117
178
|
}
|
|
118
179
|
|
|
119
|
-
onitemsloaded?.(filteredEntries);
|
|
180
|
+
onitemsloaded?.(filteredEntries, catalogEntries);
|
|
120
181
|
} catch (e) {
|
|
121
182
|
console.error('Failed to load catalog entries:', e);
|
|
122
183
|
error = e.message || 'Failed to load catalog entries';
|
|
@@ -138,20 +199,29 @@
|
|
|
138
199
|
}
|
|
139
200
|
}
|
|
140
201
|
|
|
141
|
-
//
|
|
142
|
-
export function
|
|
202
|
+
// Set dynamic filters and re-run filter logic. Call with {} to clear all dynamic filters.
|
|
203
|
+
export function setFilters(opts: FilterOptions) {
|
|
204
|
+
activeSearchtext = opts.searchtext ?? null;
|
|
205
|
+
activeDatasetfilter = opts.datasetfilter ?? null;
|
|
206
|
+
runFilters();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Programmatically select a catalog entry (pass null to clear)
|
|
210
|
+
export function select(idOrEntry: string | CatalogEntry | null) {
|
|
211
|
+
if (!idOrEntry) {
|
|
212
|
+
selectedValue = '';
|
|
213
|
+
if (dropdownRef) dropdownRef.value = '';
|
|
214
|
+
onentryselect?.(null);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
143
217
|
const id = typeof idOrEntry === 'string' ? idOrEntry : idOrEntry?.id;
|
|
144
218
|
if (!id) return;
|
|
145
219
|
|
|
146
220
|
const entry = filteredEntries.find(ce => ce.id === id);
|
|
147
221
|
if (entry) {
|
|
148
222
|
selectedValue = id;
|
|
149
|
-
if (dropdownRef)
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (onentryselect) {
|
|
153
|
-
onentryselect(entry);
|
|
154
|
-
}
|
|
223
|
+
if (dropdownRef) dropdownRef.value = id;
|
|
224
|
+
onentryselect?.(entry);
|
|
155
225
|
}
|
|
156
226
|
}
|
|
157
227
|
|
|
@@ -169,6 +239,13 @@
|
|
|
169
239
|
export function getAllEntries(): CatalogEntry[] {
|
|
170
240
|
return catalogEntries;
|
|
171
241
|
}
|
|
242
|
+
|
|
243
|
+
// Reload entries from the API, optionally auto-selecting an entry by ID after reload
|
|
244
|
+
export function reload(selectAfterReload?: string) {
|
|
245
|
+
loadCatalogEntries().then(() => {
|
|
246
|
+
if (selectAfterReload) select(selectAfterReload);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
172
249
|
</script>
|
|
173
250
|
|
|
174
251
|
{#if loading}
|
|
@@ -208,8 +285,42 @@
|
|
|
208
285
|
</cds-dropdown-item>
|
|
209
286
|
{/each}
|
|
210
287
|
</cds-dropdown>
|
|
288
|
+
{#if searchMissText}
|
|
289
|
+
<div class="search-miss search-miss-muted">
|
|
290
|
+
<svg viewBox="0 0 32 32" fill="currentColor" width="14" height="14">
|
|
291
|
+
<path d="M16 2a14 14 0 1014 14A14 14 0 0016 2zm-1.13 5h2.25v12h-2.25zm1.13 17a1.5 1.5 0 111.5-1.5A1.5 1.5 0 0116 24z"/>
|
|
292
|
+
</svg>
|
|
293
|
+
<span>No match for "{searchMissText}" — showing all entries</span>
|
|
294
|
+
</div>
|
|
295
|
+
{/if}
|
|
296
|
+
{#if multipleMatchText}
|
|
297
|
+
<div class="search-miss search-miss-muted">
|
|
298
|
+
<svg viewBox="0 0 32 32" fill="currentColor" width="14" height="14">
|
|
299
|
+
<path d="M16 2a14 14 0 1014 14A14 14 0 0016 2zm-1.13 5h2.25v12h-2.25zm1.13 17a1.5 1.5 0 111.5-1.5A1.5 1.5 0 0116 24z"/>
|
|
300
|
+
</svg>
|
|
301
|
+
<span>Multiple entries found for "{multipleMatchText}" — please make a selection</span>
|
|
302
|
+
</div>
|
|
303
|
+
{/if}
|
|
211
304
|
{/if}
|
|
212
305
|
|
|
213
306
|
<style>
|
|
214
|
-
|
|
307
|
+
.search-miss {
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 0.5rem;
|
|
311
|
+
padding: 0.5rem 0.75rem;
|
|
312
|
+
margin-top: 0.25rem;
|
|
313
|
+
font-size: 0.875rem;
|
|
314
|
+
background: var(--cds-support-warning, #f1c21b);
|
|
315
|
+
color: #161616;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.search-miss-muted {
|
|
319
|
+
background: var(--cds-layer-02, #e0e0e0);
|
|
320
|
+
color: var(--cds-text-secondary, #525252);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.search-miss svg {
|
|
324
|
+
flex-shrink: 0;
|
|
325
|
+
}
|
|
215
326
|
</style>
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { CatalogEntry, DataType } from '@industream/datacatalog-client/dto';
|
|
2
|
+
interface FilterOptions {
|
|
3
|
+
searchtext?: string | null;
|
|
4
|
+
datasetfilter?: string | null;
|
|
5
|
+
}
|
|
2
6
|
interface Props {
|
|
3
7
|
id?: string;
|
|
4
8
|
dcapiurl?: string;
|
|
@@ -6,14 +10,17 @@ interface Props {
|
|
|
6
10
|
datatypefilter?: DataType | DataType[] | null;
|
|
7
11
|
namefilter?: string | string[] | null;
|
|
8
12
|
initialselection?: string | null;
|
|
9
|
-
onentryselect?: (entry: CatalogEntry) => void;
|
|
10
|
-
onitemsloaded?: (
|
|
13
|
+
onentryselect?: (entry: CatalogEntry | null) => void;
|
|
14
|
+
onitemsloaded?: (filtered: CatalogEntry[], all: CatalogEntry[]) => void;
|
|
15
|
+
onsearchmiss?: (text: string) => void;
|
|
11
16
|
}
|
|
12
17
|
declare const DCCatalogEntry: import("svelte").Component<Props, {
|
|
13
|
-
|
|
18
|
+
setFilters: (opts: FilterOptions) => void;
|
|
19
|
+
select: (idOrEntry: string | CatalogEntry | null) => void;
|
|
14
20
|
getSelection: () => CatalogEntry | null;
|
|
15
21
|
getEntries: () => CatalogEntry[];
|
|
16
22
|
getAllEntries: () => CatalogEntry[];
|
|
23
|
+
reload: (selectAfterReload?: string) => void;
|
|
17
24
|
}, "">;
|
|
18
25
|
type DCCatalogEntry = ReturnType<typeof DCCatalogEntry>;
|
|
19
26
|
export default DCCatalogEntry;
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Composes TagBrowser (searchable table with multi-select) and SelectedTags
|
|
6
6
|
* (collapsible summary with remove). Owns the DataCatalog fetch lifecycle.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Uses `client.catalogEntries.search()` (v1.4.0+) for both initial load and
|
|
9
|
+
* server-side search. Filters by `sourceConnections` to scope entries.
|
|
10
|
+
*
|
|
11
|
+
* Load strategy:
|
|
12
|
+
* 1. Initial load fetches up to MAX_ENTRIES + 1 items.
|
|
13
|
+
* 2. If ≤ MAX_ENTRIES → client mode (client-side search in TagBrowser).
|
|
14
|
+
* 3. If > MAX_ENTRIES → server mode (TagBrowser search triggers server search
|
|
15
|
+
* with 1s debounce, AbortSignal cancels in-flight requests).
|
|
14
16
|
*
|
|
15
17
|
* @prop dcApiUrl — DataCatalog API base URL
|
|
16
18
|
* @prop sourceConnectionId — Filters catalog entries by source connection
|
|
@@ -28,6 +30,8 @@
|
|
|
28
30
|
import TagBrowser from './TagBrowser.svelte';
|
|
29
31
|
import SelectedTags from './SelectedTags.svelte';
|
|
30
32
|
|
|
33
|
+
const MAX_ENTRIES = 100;
|
|
34
|
+
|
|
31
35
|
interface Props {
|
|
32
36
|
dcApiUrl?: string;
|
|
33
37
|
sourceConnectionId?: string;
|
|
@@ -53,24 +57,65 @@
|
|
|
53
57
|
}: Props = $props();
|
|
54
58
|
|
|
55
59
|
let loading = $state(false);
|
|
60
|
+
let searching = $state(false);
|
|
56
61
|
let error = $state('');
|
|
62
|
+
let tooMany = $state(false);
|
|
63
|
+
|
|
64
|
+
/** Abort controller for cancelling in-flight search requests */
|
|
65
|
+
let searchAbort: AbortController | null = null;
|
|
66
|
+
|
|
67
|
+
/** Cached client instance — recreated when dcApiUrl changes */
|
|
68
|
+
let client: DataCatalogClient | null = null;
|
|
69
|
+
let clientUrl = '';
|
|
70
|
+
|
|
71
|
+
function getClient(apiUrl: string): DataCatalogClient {
|
|
72
|
+
if (client && clientUrl === apiUrl) return client;
|
|
73
|
+
client = new DataCatalogClient({ baseUrl: apiUrl });
|
|
74
|
+
clientUrl = apiUrl;
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
57
77
|
|
|
58
78
|
$effect(() => {
|
|
59
79
|
if (dcApiUrl && sourceConnectionId) {
|
|
60
|
-
|
|
80
|
+
loadInitial(dcApiUrl, sourceConnectionId);
|
|
61
81
|
} else {
|
|
62
82
|
entries = [];
|
|
63
83
|
error = '';
|
|
84
|
+
tooMany = false;
|
|
64
85
|
}
|
|
65
86
|
});
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Initial load — fetch MAX_ENTRIES + 1 to detect overflow.
|
|
90
|
+
* If ≤ MAX_ENTRIES items, stay in client mode.
|
|
91
|
+
* If more, switch to server search mode.
|
|
92
|
+
*/
|
|
93
|
+
async function loadInitial(apiUrl: string, connId: string) {
|
|
94
|
+
// Cancel any in-flight search
|
|
95
|
+
searchAbort?.abort();
|
|
96
|
+
searchAbort = null;
|
|
97
|
+
|
|
68
98
|
loading = true;
|
|
69
99
|
error = '';
|
|
100
|
+
tooMany = false;
|
|
101
|
+
entries = [];
|
|
102
|
+
|
|
70
103
|
try {
|
|
71
|
-
const
|
|
72
|
-
const result = await
|
|
73
|
-
|
|
104
|
+
const dc = getClient(apiUrl);
|
|
105
|
+
const result = await dc.catalogEntries.search('%', {
|
|
106
|
+
sourceConnectionIds: [connId],
|
|
107
|
+
limit: MAX_ENTRIES + 1
|
|
108
|
+
});
|
|
109
|
+
const items = result.items ?? [];
|
|
110
|
+
|
|
111
|
+
if (items.length > MAX_ENTRIES) {
|
|
112
|
+
// Too many — enter server search mode, don't show entries
|
|
113
|
+
tooMany = true;
|
|
114
|
+
entries = [];
|
|
115
|
+
} else {
|
|
116
|
+
tooMany = false;
|
|
117
|
+
entries = items;
|
|
118
|
+
}
|
|
74
119
|
} catch (err: any) {
|
|
75
120
|
error = err.message ?? 'Failed to load catalog entries';
|
|
76
121
|
entries = [];
|
|
@@ -78,6 +123,45 @@
|
|
|
78
123
|
loading = false;
|
|
79
124
|
}
|
|
80
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Server-side search — called by TagBrowser when in server mode (tooMany).
|
|
129
|
+
* Cancels previous in-flight request via AbortSignal.
|
|
130
|
+
*/
|
|
131
|
+
function handleServerSearch(query: string) {
|
|
132
|
+
if (!dcApiUrl || !sourceConnectionId) return;
|
|
133
|
+
|
|
134
|
+
// Cancel previous in-flight search
|
|
135
|
+
searchAbort?.abort();
|
|
136
|
+
|
|
137
|
+
if (!query) {
|
|
138
|
+
// Empty query in too-many mode → clear results, show hint again
|
|
139
|
+
entries = [];
|
|
140
|
+
searching = false;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const abort = new AbortController();
|
|
145
|
+
searchAbort = abort;
|
|
146
|
+
searching = true;
|
|
147
|
+
|
|
148
|
+
const dc = getClient(dcApiUrl);
|
|
149
|
+
dc.catalogEntries.search(query, {
|
|
150
|
+
sourceConnectionIds: [sourceConnectionId],
|
|
151
|
+
limit: MAX_ENTRIES
|
|
152
|
+
}, abort.signal)
|
|
153
|
+
.then((result) => {
|
|
154
|
+
if (abort.signal.aborted) return;
|
|
155
|
+
entries = result.items ?? [];
|
|
156
|
+
searching = false;
|
|
157
|
+
})
|
|
158
|
+
.catch((err) => {
|
|
159
|
+
if (abort.signal.aborted) return;
|
|
160
|
+
error = err.message ?? 'Search failed';
|
|
161
|
+
entries = [];
|
|
162
|
+
searching = false;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
81
165
|
</script>
|
|
82
166
|
|
|
83
167
|
<TagBrowser
|
|
@@ -88,6 +172,9 @@
|
|
|
88
172
|
{error}
|
|
89
173
|
{emptyMessage}
|
|
90
174
|
{onSelectionChange}
|
|
175
|
+
onSearch={tooMany ? handleServerSearch : null}
|
|
176
|
+
{searching}
|
|
177
|
+
{tooMany}
|
|
91
178
|
/>
|
|
92
179
|
|
|
93
180
|
{#if selectedIds.length > 0}
|
|
@@ -56,17 +56,18 @@
|
|
|
56
56
|
<div class="tag-info">
|
|
57
57
|
{#each columnDescriptors as columnDescriptor, index (columnDescriptor.key)}
|
|
58
58
|
{@const val = resolveValue(entry, columnDescriptor.key)}
|
|
59
|
+
{@const displayVal = columnDescriptor.format ? columnDescriptor.format(val, columnDescriptor.key) : val}
|
|
59
60
|
{#if index === 0}
|
|
60
61
|
<!-- First column: bold primary label -->
|
|
61
|
-
<span class="tag-primary">{
|
|
62
|
+
<span class="tag-primary">{displayVal ?? '-'}</span>
|
|
62
63
|
{:else if columnDescriptor.type === 'labels' && Array.isArray(val)}
|
|
63
64
|
{#each val as label (label.id)}
|
|
64
65
|
<span class="meta-item">{label.name}</span>
|
|
65
66
|
{/each}
|
|
66
67
|
{:else if columnDescriptor.type === 'code'}
|
|
67
|
-
{#if
|
|
68
|
+
{#if displayVal}<code class="tag-code">{displayVal}</code>{/if}
|
|
68
69
|
{:else}
|
|
69
|
-
{#if
|
|
70
|
+
{#if displayVal}<span class="meta-item">{displayVal}</span>{/if}
|
|
70
71
|
{/if}
|
|
71
72
|
{/each}
|
|
72
73
|
</div>
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
loading = false,
|
|
9
9
|
error = '',
|
|
10
10
|
emptyMessage = 'No entries found for this source connection.',
|
|
11
|
-
onSelectionChange = null
|
|
11
|
+
onSelectionChange = null,
|
|
12
|
+
onSearch = null,
|
|
13
|
+
searching = false,
|
|
14
|
+
tooMany = false
|
|
12
15
|
} = $props();
|
|
13
16
|
|
|
14
17
|
let searchQuery = $state('');
|
|
18
|
+
let debounceTimer = null;
|
|
15
19
|
|
|
16
20
|
/** Resolved column descriptors for rendering — maps each column key to its label, resolver and render type */
|
|
17
21
|
const columnDescriptors = $derived(
|
|
@@ -20,7 +24,12 @@
|
|
|
20
24
|
.filter(Boolean)
|
|
21
25
|
);
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* When onSearch is set (server mode), entries are already filtered server-side.
|
|
29
|
+
* When onSearch is NOT set (client mode), filter locally.
|
|
30
|
+
*/
|
|
23
31
|
const filtered = $derived(() => {
|
|
32
|
+
if (onSearch) return entries;
|
|
24
33
|
if (!searchQuery.trim()) return entries;
|
|
25
34
|
const q = searchQuery.toLowerCase();
|
|
26
35
|
return entries.filter(e => resolveSearchText(e, columns).includes(q));
|
|
@@ -30,6 +39,20 @@
|
|
|
30
39
|
const totalCount = $derived(entries.length);
|
|
31
40
|
const allVisibleSelected = $derived(filtered().length > 0 && filtered().every(e => selectedIds.includes(e.id)));
|
|
32
41
|
|
|
42
|
+
/** Whether to show the "too many entries" hint instead of the table */
|
|
43
|
+
const showTooManyHint = $derived(tooMany && !searchQuery.trim());
|
|
44
|
+
|
|
45
|
+
function handleSearchInput(e) {
|
|
46
|
+
searchQuery = e.target.value;
|
|
47
|
+
if (!onSearch) return;
|
|
48
|
+
|
|
49
|
+
// Debounce 1s for server search
|
|
50
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
51
|
+
debounceTimer = setTimeout(() => {
|
|
52
|
+
onSearch(searchQuery.trim());
|
|
53
|
+
}, 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function toggleEntry(id) {
|
|
34
57
|
selectedIds = selectedIds.indexOf(id) >= 0
|
|
35
58
|
? selectedIds.filter(s => s !== id)
|
|
@@ -61,12 +84,21 @@
|
|
|
61
84
|
<svg class="search-icon" viewBox="0 0 16 16" width="16" height="16">
|
|
62
85
|
<path d="M15.5 13.586L11.742 9.828A5.514 5.514 0 0 0 13 6.5 5.506 5.506 0 0 0 7.5 1 5.506 5.506 0 0 0 2 6.5 5.506 5.506 0 0 0 7.5 12a5.514 5.514 0 0 0 3.328-1.258l3.758 3.758zM3 6.5A4.505 4.505 0 0 1 7.5 2 4.505 4.505 0 0 1 12 6.5 4.505 4.505 0 0 1 7.5 11 4.505 4.505 0 0 1 3 6.5z" fill="currentColor"></path>
|
|
63
86
|
</svg>
|
|
64
|
-
<input
|
|
87
|
+
<input
|
|
88
|
+
type="text"
|
|
89
|
+
class="search-input"
|
|
90
|
+
placeholder={tooMany ? "Search to find entries..." : "Search..."}
|
|
91
|
+
value={searchQuery}
|
|
92
|
+
oninput={handleSearchInput}
|
|
93
|
+
/>
|
|
94
|
+
{#if searching}
|
|
95
|
+
<div class="search-spinner"></div>
|
|
96
|
+
{/if}
|
|
65
97
|
</div>
|
|
66
98
|
<div class="toolbar-actions">
|
|
67
99
|
<span class="count-indicator">{selectedCount} / {totalCount} selected</span>
|
|
68
|
-
<button class="action-button" onclick={selectAll} disabled={loading || filtered().length === 0}>Select All</button>
|
|
69
|
-
<button class="action-button" onclick={deselectAll} disabled={loading || selectedCount === 0}>Deselect All</button>
|
|
100
|
+
<button class="action-button" onclick={selectAll} disabled={loading || searching || filtered().length === 0}>Select All</button>
|
|
101
|
+
<button class="action-button" onclick={deselectAll} disabled={loading || searching || selectedCount === 0}>Deselect All</button>
|
|
70
102
|
</div>
|
|
71
103
|
</div>
|
|
72
104
|
|
|
@@ -77,7 +109,12 @@
|
|
|
77
109
|
<svg viewBox="0 0 16 16" width="16" height="16"><path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.75 3.5h1.5v5h-1.5zm.75 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" fill="currentColor"></path></svg>
|
|
78
110
|
<span>{error}</span>
|
|
79
111
|
</div>
|
|
80
|
-
{:else if
|
|
112
|
+
{:else if showTooManyHint}
|
|
113
|
+
<div class="state-message too-many-state">
|
|
114
|
+
<svg viewBox="0 0 16 16" width="16" height="16"><path d="M15.5 13.586L11.742 9.828A5.514 5.514 0 0 0 13 6.5 5.506 5.506 0 0 0 7.5 1 5.506 5.506 0 0 0 2 6.5 5.506 5.506 0 0 0 7.5 12a5.514 5.514 0 0 0 3.328-1.258l3.758 3.758zM3 6.5A4.505 4.505 0 0 1 7.5 2 4.505 4.505 0 0 1 12 6.5 4.505 4.505 0 0 1 7.5 11 4.505 4.505 0 0 1 3 6.5z" fill="currentColor"></path></svg>
|
|
115
|
+
<span>Too many entries. Type in the search box to refine.</span>
|
|
116
|
+
</div>
|
|
117
|
+
{:else if entries.length === 0 && !searching}
|
|
81
118
|
<div class="state-message empty-state"><span>{emptyMessage}</span></div>
|
|
82
119
|
{:else}
|
|
83
120
|
<div class="table-container">
|
|
@@ -102,17 +139,18 @@
|
|
|
102
139
|
<!-- Render each column cell according to its type (text, code, pill, labels) -->
|
|
103
140
|
{#each columnDescriptors as columnDescriptor (columnDescriptor.key)}
|
|
104
141
|
{@const val = resolveValue(entry, columnDescriptor.key)}
|
|
105
|
-
|
|
142
|
+
{@const displayVal = columnDescriptor.format ? columnDescriptor.format(val, columnDescriptor.key) : val}
|
|
143
|
+
<td title={columnDescriptor.type === 'text' ? (displayVal ?? '') : ''}>
|
|
106
144
|
{#if columnDescriptor.type === 'labels' && Array.isArray(val)}
|
|
107
145
|
{#each val as label (label.id)}
|
|
108
146
|
<span class="label-pill">{label.name}</span>
|
|
109
147
|
{/each}
|
|
110
148
|
{:else if columnDescriptor.type === 'code'}
|
|
111
|
-
<code>{
|
|
149
|
+
<code>{displayVal ?? '-'}</code>
|
|
112
150
|
{:else if columnDescriptor.type === 'pill'}
|
|
113
|
-
{#if
|
|
151
|
+
{#if displayVal}<span class="pill">{displayVal}</span>{:else}-{/if}
|
|
114
152
|
{:else}
|
|
115
|
-
{
|
|
153
|
+
{displayVal ?? '-'}
|
|
116
154
|
{/if}
|
|
117
155
|
</td>
|
|
118
156
|
{/each}
|
|
@@ -121,7 +159,7 @@
|
|
|
121
159
|
</tbody>
|
|
122
160
|
</table>
|
|
123
161
|
</div>
|
|
124
|
-
{#if filtered().length !== entries.length}
|
|
162
|
+
{#if !onSearch && filtered().length !== entries.length}
|
|
125
163
|
<div class="filter-info">Showing {filtered().length} of {entries.length} entries</div>
|
|
126
164
|
{/if}
|
|
127
165
|
{/if}
|
|
@@ -141,6 +179,13 @@
|
|
|
141
179
|
}
|
|
142
180
|
.search-input::placeholder { color: var(--cds-text-placeholder, #6f6f6f); }
|
|
143
181
|
.search-input:focus { border-bottom-color: var(--cds-focus, #0f62fe); }
|
|
182
|
+
.search-spinner {
|
|
183
|
+
position: absolute; right: 0.75rem;
|
|
184
|
+
width: 0.875rem; height: 0.875rem;
|
|
185
|
+
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
186
|
+
border-top-color: var(--cds-interactive, #0f62fe);
|
|
187
|
+
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
188
|
+
}
|
|
144
189
|
.toolbar-actions { display: flex; align-items: center; gap: 0.5rem; }
|
|
145
190
|
.count-indicator { font-size: 0.75rem; color: var(--cds-text-secondary, #c6c6c6); margin-right: auto; }
|
|
146
191
|
.action-button {
|
|
@@ -158,6 +203,7 @@
|
|
|
158
203
|
}
|
|
159
204
|
.error-state { color: var(--cds-support-error, #fa4d56); }
|
|
160
205
|
.empty-state { color: var(--cds-text-helper, #8d8d8d); }
|
|
206
|
+
.too-many-state { color: var(--cds-support-warning, #f1c21b); }
|
|
161
207
|
.spinner {
|
|
162
208
|
width: 1rem; height: 1rem;
|
|
163
209
|
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
@@ -11,6 +11,9 @@ declare const TagBrowser: import("svelte").Component<{
|
|
|
11
11
|
error?: string;
|
|
12
12
|
emptyMessage?: string;
|
|
13
13
|
onSelectionChange?: any;
|
|
14
|
+
onSearch?: any;
|
|
15
|
+
searching?: boolean;
|
|
16
|
+
tooMany?: boolean;
|
|
14
17
|
}, {}, "selectedIds">;
|
|
15
18
|
type $$ComponentProps = {
|
|
16
19
|
entries?: any[];
|
|
@@ -20,5 +23,8 @@ type $$ComponentProps = {
|
|
|
20
23
|
error?: string;
|
|
21
24
|
emptyMessage?: string;
|
|
22
25
|
onSelectionChange?: any;
|
|
26
|
+
onSearch?: any;
|
|
27
|
+
searching?: boolean;
|
|
28
|
+
tooMany?: boolean;
|
|
23
29
|
};
|
|
24
30
|
import { DEFAULT_COLUMNS } from './picker-column-helpers';
|
|
@@ -43,6 +43,8 @@ export interface ColumnDef {
|
|
|
43
43
|
label: string;
|
|
44
44
|
resolve: (entry: CatalogEntry) => unknown;
|
|
45
45
|
type: ColumnType;
|
|
46
|
+
/** Optional formatter function to transform the resolved value for display */
|
|
47
|
+
format?: (value: unknown, key: string) => string;
|
|
46
48
|
}
|
|
47
49
|
/**
|
|
48
50
|
* Static column definitions for top-level CatalogEntry fields.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@industream/flowmaker-flowbox-ui-components",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
4
4
|
"description": "Reusable Svelte components for FlowMaker FlowBox UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -43,9 +43,10 @@
|
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"svelte": "^5.0.0",
|
|
46
|
-
"@industream/datacatalog-client": "^1.
|
|
46
|
+
"@industream/datacatalog-client": "^1.4.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
+
"@industream/datacatalog-client": "1.4.1",
|
|
49
50
|
"svelte": "^5.0.0",
|
|
50
51
|
"vite": "^6.0.0",
|
|
51
52
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import { DataCatalogClient } from '@industream/datacatalog-client';
|
|
3
3
|
import type { CatalogEntry, DataType, SourceType } from '@industream/datacatalog-client/dto';
|
|
4
4
|
|
|
5
|
+
interface FilterOptions {
|
|
6
|
+
searchtext?: string | null; // Case-insensitive LIKE %text% filter on entry name
|
|
7
|
+
datasetfilter?: string | null; // Filter entries by sourceParams.dataset
|
|
8
|
+
}
|
|
9
|
+
|
|
5
10
|
interface Props {
|
|
6
11
|
id?: string;
|
|
7
12
|
dcapiurl?: string;
|
|
@@ -9,8 +14,9 @@
|
|
|
9
14
|
datatypefilter?: DataType | DataType[] | null;
|
|
10
15
|
namefilter?: string | string[] | null;
|
|
11
16
|
initialselection?: string | null;
|
|
12
|
-
onentryselect?: (entry: CatalogEntry) => void;
|
|
13
|
-
onitemsloaded?: (
|
|
17
|
+
onentryselect?: (entry: CatalogEntry | null) => void;
|
|
18
|
+
onitemsloaded?: (filtered: CatalogEntry[], all: CatalogEntry[]) => void;
|
|
19
|
+
onsearchmiss?: (text: string) => void; // Called when searchtext matches nothing
|
|
14
20
|
}
|
|
15
21
|
|
|
16
22
|
let {
|
|
@@ -21,7 +27,8 @@
|
|
|
21
27
|
namefilter = null,
|
|
22
28
|
initialselection = null,
|
|
23
29
|
onentryselect = null,
|
|
24
|
-
onitemsloaded = null
|
|
30
|
+
onitemsloaded = null,
|
|
31
|
+
onsearchmiss = null
|
|
25
32
|
}: Props = $props();
|
|
26
33
|
|
|
27
34
|
let catalogEntries = $state<CatalogEntry[]>([]);
|
|
@@ -31,6 +38,13 @@
|
|
|
31
38
|
let error = $state<string | null>(null);
|
|
32
39
|
let dropdownRef = $state<HTMLElement | null>(null);
|
|
33
40
|
|
|
41
|
+
// Active dynamic filters (set via setFilters method)
|
|
42
|
+
let activeSearchtext: string | null = null;
|
|
43
|
+
let activeDatasetfilter: string | null = null;
|
|
44
|
+
let autoSelectedId: string | null = null;
|
|
45
|
+
let searchMissText = $state<string | null>(null);
|
|
46
|
+
let multipleMatchText = $state<string | null>(null);
|
|
47
|
+
|
|
34
48
|
// Load catalog entries when component mounts or dcapiurl/sourcetypefilter changes
|
|
35
49
|
$effect(() => {
|
|
36
50
|
// Read sourcetypefilter to establish dependency tracking
|
|
@@ -40,11 +54,6 @@
|
|
|
40
54
|
}
|
|
41
55
|
});
|
|
42
56
|
|
|
43
|
-
// Apply client-side filters when entries or filters change
|
|
44
|
-
$effect(() => {
|
|
45
|
-
filteredEntries = applyFilters(catalogEntries);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
57
|
// Inject styles into shadow DOM to fix trigger-label width
|
|
49
58
|
$effect(() => {
|
|
50
59
|
if (dropdownRef) {
|
|
@@ -60,11 +69,10 @@
|
|
|
60
69
|
}
|
|
61
70
|
});
|
|
62
71
|
|
|
63
|
-
function
|
|
72
|
+
function applyBaseFilters(entries: CatalogEntry[]): CatalogEntry[] {
|
|
64
73
|
let result = entries;
|
|
65
74
|
|
|
66
75
|
// Filter by source type name (from sourceConnection.sourceType.name)
|
|
67
|
-
// Note: Server-side filtering is now done via API, this is just for additional client-side filtering if needed
|
|
68
76
|
if (sourcetypefilter && sourcetypefilter.length > 0) {
|
|
69
77
|
const sourceTypeNames = Array.isArray(sourcetypefilter) ? sourcetypefilter : [sourcetypefilter];
|
|
70
78
|
result = result.filter(entry =>
|
|
@@ -84,6 +92,54 @@
|
|
|
84
92
|
return result;
|
|
85
93
|
}
|
|
86
94
|
|
|
95
|
+
function applyDynamicFilters(entries: CatalogEntry[], { skipSearch = false } = {}): CatalogEntry[] {
|
|
96
|
+
let result = entries;
|
|
97
|
+
|
|
98
|
+
if (activeDatasetfilter) {
|
|
99
|
+
result = result.filter(entry =>
|
|
100
|
+
entry.sourceParams?.dataset === activeDatasetfilter
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!skipSearch && activeSearchtext?.trim()) {
|
|
105
|
+
const needle = activeSearchtext.trim().toLowerCase();
|
|
106
|
+
result = result.filter(entry =>
|
|
107
|
+
entry.name?.toLowerCase().includes(needle)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function runFilters() {
|
|
115
|
+
const base = applyBaseFilters(catalogEntries);
|
|
116
|
+
let result = applyDynamicFilters(base);
|
|
117
|
+
|
|
118
|
+
// Search-miss fallback: drop search filter and notify consumer
|
|
119
|
+
if (result.length === 0 && activeSearchtext?.trim()) {
|
|
120
|
+
searchMissText = activeSearchtext.trim();
|
|
121
|
+
multipleMatchText = null;
|
|
122
|
+
result = applyDynamicFilters(base, { skipSearch: true });
|
|
123
|
+
onsearchmiss?.(activeSearchtext.trim());
|
|
124
|
+
} else {
|
|
125
|
+
searchMissText = null;
|
|
126
|
+
multipleMatchText = (result.length > 1 && activeSearchtext?.trim()) ? activeSearchtext.trim() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
filteredEntries = result;
|
|
130
|
+
|
|
131
|
+
// Auto-select / auto-clear only when dynamic filters are active
|
|
132
|
+
if (activeSearchtext?.trim() || activeDatasetfilter) {
|
|
133
|
+
if (result.length === 1 && result[0].id !== autoSelectedId) {
|
|
134
|
+
autoSelectedId = result[0].id;
|
|
135
|
+
select(result[0]);
|
|
136
|
+
} else if (result.length !== 1) {
|
|
137
|
+
autoSelectedId = null;
|
|
138
|
+
select(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
87
143
|
async function loadCatalogEntries() {
|
|
88
144
|
loading = true;
|
|
89
145
|
error = null;
|
|
@@ -104,8 +160,13 @@
|
|
|
104
160
|
|
|
105
161
|
const result = await client.catalogEntries.get(filters);
|
|
106
162
|
catalogEntries = result.items || [];
|
|
107
|
-
// Apply filters synchronously so initialselection works
|
|
108
|
-
filteredEntries =
|
|
163
|
+
// Apply base filters synchronously so initialselection works
|
|
164
|
+
filteredEntries = applyBaseFilters(catalogEntries);
|
|
165
|
+
|
|
166
|
+
// Re-apply dynamic filters if any are active
|
|
167
|
+
if (activeSearchtext || activeDatasetfilter) {
|
|
168
|
+
runFilters();
|
|
169
|
+
}
|
|
109
170
|
|
|
110
171
|
// Auto-select initial selection if provided
|
|
111
172
|
if (initialselection) {
|
|
@@ -116,7 +177,7 @@
|
|
|
116
177
|
}
|
|
117
178
|
}
|
|
118
179
|
|
|
119
|
-
onitemsloaded?.(filteredEntries);
|
|
180
|
+
onitemsloaded?.(filteredEntries, catalogEntries);
|
|
120
181
|
} catch (e) {
|
|
121
182
|
console.error('Failed to load catalog entries:', e);
|
|
122
183
|
error = e.message || 'Failed to load catalog entries';
|
|
@@ -138,20 +199,29 @@
|
|
|
138
199
|
}
|
|
139
200
|
}
|
|
140
201
|
|
|
141
|
-
//
|
|
142
|
-
export function
|
|
202
|
+
// Set dynamic filters and re-run filter logic. Call with {} to clear all dynamic filters.
|
|
203
|
+
export function setFilters(opts: FilterOptions) {
|
|
204
|
+
activeSearchtext = opts.searchtext ?? null;
|
|
205
|
+
activeDatasetfilter = opts.datasetfilter ?? null;
|
|
206
|
+
runFilters();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Programmatically select a catalog entry (pass null to clear)
|
|
210
|
+
export function select(idOrEntry: string | CatalogEntry | null) {
|
|
211
|
+
if (!idOrEntry) {
|
|
212
|
+
selectedValue = '';
|
|
213
|
+
if (dropdownRef) dropdownRef.value = '';
|
|
214
|
+
onentryselect?.(null);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
143
217
|
const id = typeof idOrEntry === 'string' ? idOrEntry : idOrEntry?.id;
|
|
144
218
|
if (!id) return;
|
|
145
219
|
|
|
146
220
|
const entry = filteredEntries.find(ce => ce.id === id);
|
|
147
221
|
if (entry) {
|
|
148
222
|
selectedValue = id;
|
|
149
|
-
if (dropdownRef)
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (onentryselect) {
|
|
153
|
-
onentryselect(entry);
|
|
154
|
-
}
|
|
223
|
+
if (dropdownRef) dropdownRef.value = id;
|
|
224
|
+
onentryselect?.(entry);
|
|
155
225
|
}
|
|
156
226
|
}
|
|
157
227
|
|
|
@@ -169,6 +239,13 @@
|
|
|
169
239
|
export function getAllEntries(): CatalogEntry[] {
|
|
170
240
|
return catalogEntries;
|
|
171
241
|
}
|
|
242
|
+
|
|
243
|
+
// Reload entries from the API, optionally auto-selecting an entry by ID after reload
|
|
244
|
+
export function reload(selectAfterReload?: string) {
|
|
245
|
+
loadCatalogEntries().then(() => {
|
|
246
|
+
if (selectAfterReload) select(selectAfterReload);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
172
249
|
</script>
|
|
173
250
|
|
|
174
251
|
{#if loading}
|
|
@@ -208,8 +285,42 @@
|
|
|
208
285
|
</cds-dropdown-item>
|
|
209
286
|
{/each}
|
|
210
287
|
</cds-dropdown>
|
|
288
|
+
{#if searchMissText}
|
|
289
|
+
<div class="search-miss search-miss-muted">
|
|
290
|
+
<svg viewBox="0 0 32 32" fill="currentColor" width="14" height="14">
|
|
291
|
+
<path d="M16 2a14 14 0 1014 14A14 14 0 0016 2zm-1.13 5h2.25v12h-2.25zm1.13 17a1.5 1.5 0 111.5-1.5A1.5 1.5 0 0116 24z"/>
|
|
292
|
+
</svg>
|
|
293
|
+
<span>No match for "{searchMissText}" — showing all entries</span>
|
|
294
|
+
</div>
|
|
295
|
+
{/if}
|
|
296
|
+
{#if multipleMatchText}
|
|
297
|
+
<div class="search-miss search-miss-muted">
|
|
298
|
+
<svg viewBox="0 0 32 32" fill="currentColor" width="14" height="14">
|
|
299
|
+
<path d="M16 2a14 14 0 1014 14A14 14 0 0016 2zm-1.13 5h2.25v12h-2.25zm1.13 17a1.5 1.5 0 111.5-1.5A1.5 1.5 0 0116 24z"/>
|
|
300
|
+
</svg>
|
|
301
|
+
<span>Multiple entries found for "{multipleMatchText}" — please make a selection</span>
|
|
302
|
+
</div>
|
|
303
|
+
{/if}
|
|
211
304
|
{/if}
|
|
212
305
|
|
|
213
306
|
<style>
|
|
214
|
-
|
|
307
|
+
.search-miss {
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
gap: 0.5rem;
|
|
311
|
+
padding: 0.5rem 0.75rem;
|
|
312
|
+
margin-top: 0.25rem;
|
|
313
|
+
font-size: 0.875rem;
|
|
314
|
+
background: var(--cds-support-warning, #f1c21b);
|
|
315
|
+
color: #161616;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.search-miss-muted {
|
|
319
|
+
background: var(--cds-layer-02, #e0e0e0);
|
|
320
|
+
color: var(--cds-text-secondary, #525252);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.search-miss svg {
|
|
324
|
+
flex-shrink: 0;
|
|
325
|
+
}
|
|
215
326
|
</style>
|
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* Composes TagBrowser (searchable table with multi-select) and SelectedTags
|
|
6
6
|
* (collapsible summary with remove). Owns the DataCatalog fetch lifecycle.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* Uses `client.catalogEntries.search()` (v1.4.0+) for both initial load and
|
|
9
|
+
* server-side search. Filters by `sourceConnections` to scope entries.
|
|
10
|
+
*
|
|
11
|
+
* Load strategy:
|
|
12
|
+
* 1. Initial load fetches up to MAX_ENTRIES + 1 items.
|
|
13
|
+
* 2. If ≤ MAX_ENTRIES → client mode (client-side search in TagBrowser).
|
|
14
|
+
* 3. If > MAX_ENTRIES → server mode (TagBrowser search triggers server search
|
|
15
|
+
* with 1s debounce, AbortSignal cancels in-flight requests).
|
|
14
16
|
*
|
|
15
17
|
* @prop dcApiUrl — DataCatalog API base URL
|
|
16
18
|
* @prop sourceConnectionId — Filters catalog entries by source connection
|
|
@@ -28,6 +30,8 @@
|
|
|
28
30
|
import TagBrowser from './TagBrowser.svelte';
|
|
29
31
|
import SelectedTags from './SelectedTags.svelte';
|
|
30
32
|
|
|
33
|
+
const MAX_ENTRIES = 100;
|
|
34
|
+
|
|
31
35
|
interface Props {
|
|
32
36
|
dcApiUrl?: string;
|
|
33
37
|
sourceConnectionId?: string;
|
|
@@ -53,24 +57,65 @@
|
|
|
53
57
|
}: Props = $props();
|
|
54
58
|
|
|
55
59
|
let loading = $state(false);
|
|
60
|
+
let searching = $state(false);
|
|
56
61
|
let error = $state('');
|
|
62
|
+
let tooMany = $state(false);
|
|
63
|
+
|
|
64
|
+
/** Abort controller for cancelling in-flight search requests */
|
|
65
|
+
let searchAbort: AbortController | null = null;
|
|
66
|
+
|
|
67
|
+
/** Cached client instance — recreated when dcApiUrl changes */
|
|
68
|
+
let client: DataCatalogClient | null = null;
|
|
69
|
+
let clientUrl = '';
|
|
70
|
+
|
|
71
|
+
function getClient(apiUrl: string): DataCatalogClient {
|
|
72
|
+
if (client && clientUrl === apiUrl) return client;
|
|
73
|
+
client = new DataCatalogClient({ baseUrl: apiUrl });
|
|
74
|
+
clientUrl = apiUrl;
|
|
75
|
+
return client;
|
|
76
|
+
}
|
|
57
77
|
|
|
58
78
|
$effect(() => {
|
|
59
79
|
if (dcApiUrl && sourceConnectionId) {
|
|
60
|
-
|
|
80
|
+
loadInitial(dcApiUrl, sourceConnectionId);
|
|
61
81
|
} else {
|
|
62
82
|
entries = [];
|
|
63
83
|
error = '';
|
|
84
|
+
tooMany = false;
|
|
64
85
|
}
|
|
65
86
|
});
|
|
66
87
|
|
|
67
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Initial load — fetch MAX_ENTRIES + 1 to detect overflow.
|
|
90
|
+
* If ≤ MAX_ENTRIES items, stay in client mode.
|
|
91
|
+
* If more, switch to server search mode.
|
|
92
|
+
*/
|
|
93
|
+
async function loadInitial(apiUrl: string, connId: string) {
|
|
94
|
+
// Cancel any in-flight search
|
|
95
|
+
searchAbort?.abort();
|
|
96
|
+
searchAbort = null;
|
|
97
|
+
|
|
68
98
|
loading = true;
|
|
69
99
|
error = '';
|
|
100
|
+
tooMany = false;
|
|
101
|
+
entries = [];
|
|
102
|
+
|
|
70
103
|
try {
|
|
71
|
-
const
|
|
72
|
-
const result = await
|
|
73
|
-
|
|
104
|
+
const dc = getClient(apiUrl);
|
|
105
|
+
const result = await dc.catalogEntries.search('%', {
|
|
106
|
+
sourceConnectionIds: [connId],
|
|
107
|
+
limit: MAX_ENTRIES + 1
|
|
108
|
+
});
|
|
109
|
+
const items = result.items ?? [];
|
|
110
|
+
|
|
111
|
+
if (items.length > MAX_ENTRIES) {
|
|
112
|
+
// Too many — enter server search mode, don't show entries
|
|
113
|
+
tooMany = true;
|
|
114
|
+
entries = [];
|
|
115
|
+
} else {
|
|
116
|
+
tooMany = false;
|
|
117
|
+
entries = items;
|
|
118
|
+
}
|
|
74
119
|
} catch (err: any) {
|
|
75
120
|
error = err.message ?? 'Failed to load catalog entries';
|
|
76
121
|
entries = [];
|
|
@@ -78,6 +123,45 @@
|
|
|
78
123
|
loading = false;
|
|
79
124
|
}
|
|
80
125
|
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Server-side search — called by TagBrowser when in server mode (tooMany).
|
|
129
|
+
* Cancels previous in-flight request via AbortSignal.
|
|
130
|
+
*/
|
|
131
|
+
function handleServerSearch(query: string) {
|
|
132
|
+
if (!dcApiUrl || !sourceConnectionId) return;
|
|
133
|
+
|
|
134
|
+
// Cancel previous in-flight search
|
|
135
|
+
searchAbort?.abort();
|
|
136
|
+
|
|
137
|
+
if (!query) {
|
|
138
|
+
// Empty query in too-many mode → clear results, show hint again
|
|
139
|
+
entries = [];
|
|
140
|
+
searching = false;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const abort = new AbortController();
|
|
145
|
+
searchAbort = abort;
|
|
146
|
+
searching = true;
|
|
147
|
+
|
|
148
|
+
const dc = getClient(dcApiUrl);
|
|
149
|
+
dc.catalogEntries.search(query, {
|
|
150
|
+
sourceConnectionIds: [sourceConnectionId],
|
|
151
|
+
limit: MAX_ENTRIES
|
|
152
|
+
}, abort.signal)
|
|
153
|
+
.then((result) => {
|
|
154
|
+
if (abort.signal.aborted) return;
|
|
155
|
+
entries = result.items ?? [];
|
|
156
|
+
searching = false;
|
|
157
|
+
})
|
|
158
|
+
.catch((err) => {
|
|
159
|
+
if (abort.signal.aborted) return;
|
|
160
|
+
error = err.message ?? 'Search failed';
|
|
161
|
+
entries = [];
|
|
162
|
+
searching = false;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
81
165
|
</script>
|
|
82
166
|
|
|
83
167
|
<TagBrowser
|
|
@@ -88,6 +172,9 @@
|
|
|
88
172
|
{error}
|
|
89
173
|
{emptyMessage}
|
|
90
174
|
{onSelectionChange}
|
|
175
|
+
onSearch={tooMany ? handleServerSearch : null}
|
|
176
|
+
{searching}
|
|
177
|
+
{tooMany}
|
|
91
178
|
/>
|
|
92
179
|
|
|
93
180
|
{#if selectedIds.length > 0}
|
|
@@ -56,17 +56,18 @@
|
|
|
56
56
|
<div class="tag-info">
|
|
57
57
|
{#each columnDescriptors as columnDescriptor, index (columnDescriptor.key)}
|
|
58
58
|
{@const val = resolveValue(entry, columnDescriptor.key)}
|
|
59
|
+
{@const displayVal = columnDescriptor.format ? columnDescriptor.format(val, columnDescriptor.key) : val}
|
|
59
60
|
{#if index === 0}
|
|
60
61
|
<!-- First column: bold primary label -->
|
|
61
|
-
<span class="tag-primary">{
|
|
62
|
+
<span class="tag-primary">{displayVal ?? '-'}</span>
|
|
62
63
|
{:else if columnDescriptor.type === 'labels' && Array.isArray(val)}
|
|
63
64
|
{#each val as label (label.id)}
|
|
64
65
|
<span class="meta-item">{label.name}</span>
|
|
65
66
|
{/each}
|
|
66
67
|
{:else if columnDescriptor.type === 'code'}
|
|
67
|
-
{#if
|
|
68
|
+
{#if displayVal}<code class="tag-code">{displayVal}</code>{/if}
|
|
68
69
|
{:else}
|
|
69
|
-
{#if
|
|
70
|
+
{#if displayVal}<span class="meta-item">{displayVal}</span>{/if}
|
|
70
71
|
{/if}
|
|
71
72
|
{/each}
|
|
72
73
|
</div>
|
|
@@ -8,10 +8,14 @@
|
|
|
8
8
|
loading = false,
|
|
9
9
|
error = '',
|
|
10
10
|
emptyMessage = 'No entries found for this source connection.',
|
|
11
|
-
onSelectionChange = null
|
|
11
|
+
onSelectionChange = null,
|
|
12
|
+
onSearch = null,
|
|
13
|
+
searching = false,
|
|
14
|
+
tooMany = false
|
|
12
15
|
} = $props();
|
|
13
16
|
|
|
14
17
|
let searchQuery = $state('');
|
|
18
|
+
let debounceTimer = null;
|
|
15
19
|
|
|
16
20
|
/** Resolved column descriptors for rendering — maps each column key to its label, resolver and render type */
|
|
17
21
|
const columnDescriptors = $derived(
|
|
@@ -20,7 +24,12 @@
|
|
|
20
24
|
.filter(Boolean)
|
|
21
25
|
);
|
|
22
26
|
|
|
27
|
+
/**
|
|
28
|
+
* When onSearch is set (server mode), entries are already filtered server-side.
|
|
29
|
+
* When onSearch is NOT set (client mode), filter locally.
|
|
30
|
+
*/
|
|
23
31
|
const filtered = $derived(() => {
|
|
32
|
+
if (onSearch) return entries;
|
|
24
33
|
if (!searchQuery.trim()) return entries;
|
|
25
34
|
const q = searchQuery.toLowerCase();
|
|
26
35
|
return entries.filter(e => resolveSearchText(e, columns).includes(q));
|
|
@@ -30,6 +39,20 @@
|
|
|
30
39
|
const totalCount = $derived(entries.length);
|
|
31
40
|
const allVisibleSelected = $derived(filtered().length > 0 && filtered().every(e => selectedIds.includes(e.id)));
|
|
32
41
|
|
|
42
|
+
/** Whether to show the "too many entries" hint instead of the table */
|
|
43
|
+
const showTooManyHint = $derived(tooMany && !searchQuery.trim());
|
|
44
|
+
|
|
45
|
+
function handleSearchInput(e) {
|
|
46
|
+
searchQuery = e.target.value;
|
|
47
|
+
if (!onSearch) return;
|
|
48
|
+
|
|
49
|
+
// Debounce 1s for server search
|
|
50
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
51
|
+
debounceTimer = setTimeout(() => {
|
|
52
|
+
onSearch(searchQuery.trim());
|
|
53
|
+
}, 1000);
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function toggleEntry(id) {
|
|
34
57
|
selectedIds = selectedIds.indexOf(id) >= 0
|
|
35
58
|
? selectedIds.filter(s => s !== id)
|
|
@@ -61,12 +84,21 @@
|
|
|
61
84
|
<svg class="search-icon" viewBox="0 0 16 16" width="16" height="16">
|
|
62
85
|
<path d="M15.5 13.586L11.742 9.828A5.514 5.514 0 0 0 13 6.5 5.506 5.506 0 0 0 7.5 1 5.506 5.506 0 0 0 2 6.5 5.506 5.506 0 0 0 7.5 12a5.514 5.514 0 0 0 3.328-1.258l3.758 3.758zM3 6.5A4.505 4.505 0 0 1 7.5 2 4.505 4.505 0 0 1 12 6.5 4.505 4.505 0 0 1 7.5 11 4.505 4.505 0 0 1 3 6.5z" fill="currentColor"></path>
|
|
63
86
|
</svg>
|
|
64
|
-
<input
|
|
87
|
+
<input
|
|
88
|
+
type="text"
|
|
89
|
+
class="search-input"
|
|
90
|
+
placeholder={tooMany ? "Search to find entries..." : "Search..."}
|
|
91
|
+
value={searchQuery}
|
|
92
|
+
oninput={handleSearchInput}
|
|
93
|
+
/>
|
|
94
|
+
{#if searching}
|
|
95
|
+
<div class="search-spinner"></div>
|
|
96
|
+
{/if}
|
|
65
97
|
</div>
|
|
66
98
|
<div class="toolbar-actions">
|
|
67
99
|
<span class="count-indicator">{selectedCount} / {totalCount} selected</span>
|
|
68
|
-
<button class="action-button" onclick={selectAll} disabled={loading || filtered().length === 0}>Select All</button>
|
|
69
|
-
<button class="action-button" onclick={deselectAll} disabled={loading || selectedCount === 0}>Deselect All</button>
|
|
100
|
+
<button class="action-button" onclick={selectAll} disabled={loading || searching || filtered().length === 0}>Select All</button>
|
|
101
|
+
<button class="action-button" onclick={deselectAll} disabled={loading || searching || selectedCount === 0}>Deselect All</button>
|
|
70
102
|
</div>
|
|
71
103
|
</div>
|
|
72
104
|
|
|
@@ -77,7 +109,12 @@
|
|
|
77
109
|
<svg viewBox="0 0 16 16" width="16" height="16"><path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm-.75 3.5h1.5v5h-1.5zm.75 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z" fill="currentColor"></path></svg>
|
|
78
110
|
<span>{error}</span>
|
|
79
111
|
</div>
|
|
80
|
-
{:else if
|
|
112
|
+
{:else if showTooManyHint}
|
|
113
|
+
<div class="state-message too-many-state">
|
|
114
|
+
<svg viewBox="0 0 16 16" width="16" height="16"><path d="M15.5 13.586L11.742 9.828A5.514 5.514 0 0 0 13 6.5 5.506 5.506 0 0 0 7.5 1 5.506 5.506 0 0 0 2 6.5 5.506 5.506 0 0 0 7.5 12a5.514 5.514 0 0 0 3.328-1.258l3.758 3.758zM3 6.5A4.505 4.505 0 0 1 7.5 2 4.505 4.505 0 0 1 12 6.5 4.505 4.505 0 0 1 7.5 11 4.505 4.505 0 0 1 3 6.5z" fill="currentColor"></path></svg>
|
|
115
|
+
<span>Too many entries. Type in the search box to refine.</span>
|
|
116
|
+
</div>
|
|
117
|
+
{:else if entries.length === 0 && !searching}
|
|
81
118
|
<div class="state-message empty-state"><span>{emptyMessage}</span></div>
|
|
82
119
|
{:else}
|
|
83
120
|
<div class="table-container">
|
|
@@ -102,17 +139,18 @@
|
|
|
102
139
|
<!-- Render each column cell according to its type (text, code, pill, labels) -->
|
|
103
140
|
{#each columnDescriptors as columnDescriptor (columnDescriptor.key)}
|
|
104
141
|
{@const val = resolveValue(entry, columnDescriptor.key)}
|
|
105
|
-
|
|
142
|
+
{@const displayVal = columnDescriptor.format ? columnDescriptor.format(val, columnDescriptor.key) : val}
|
|
143
|
+
<td title={columnDescriptor.type === 'text' ? (displayVal ?? '') : ''}>
|
|
106
144
|
{#if columnDescriptor.type === 'labels' && Array.isArray(val)}
|
|
107
145
|
{#each val as label (label.id)}
|
|
108
146
|
<span class="label-pill">{label.name}</span>
|
|
109
147
|
{/each}
|
|
110
148
|
{:else if columnDescriptor.type === 'code'}
|
|
111
|
-
<code>{
|
|
149
|
+
<code>{displayVal ?? '-'}</code>
|
|
112
150
|
{:else if columnDescriptor.type === 'pill'}
|
|
113
|
-
{#if
|
|
151
|
+
{#if displayVal}<span class="pill">{displayVal}</span>{:else}-{/if}
|
|
114
152
|
{:else}
|
|
115
|
-
{
|
|
153
|
+
{displayVal ?? '-'}
|
|
116
154
|
{/if}
|
|
117
155
|
</td>
|
|
118
156
|
{/each}
|
|
@@ -121,7 +159,7 @@
|
|
|
121
159
|
</tbody>
|
|
122
160
|
</table>
|
|
123
161
|
</div>
|
|
124
|
-
{#if filtered().length !== entries.length}
|
|
162
|
+
{#if !onSearch && filtered().length !== entries.length}
|
|
125
163
|
<div class="filter-info">Showing {filtered().length} of {entries.length} entries</div>
|
|
126
164
|
{/if}
|
|
127
165
|
{/if}
|
|
@@ -141,6 +179,13 @@
|
|
|
141
179
|
}
|
|
142
180
|
.search-input::placeholder { color: var(--cds-text-placeholder, #6f6f6f); }
|
|
143
181
|
.search-input:focus { border-bottom-color: var(--cds-focus, #0f62fe); }
|
|
182
|
+
.search-spinner {
|
|
183
|
+
position: absolute; right: 0.75rem;
|
|
184
|
+
width: 0.875rem; height: 0.875rem;
|
|
185
|
+
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
186
|
+
border-top-color: var(--cds-interactive, #0f62fe);
|
|
187
|
+
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
188
|
+
}
|
|
144
189
|
.toolbar-actions { display: flex; align-items: center; gap: 0.5rem; }
|
|
145
190
|
.count-indicator { font-size: 0.75rem; color: var(--cds-text-secondary, #c6c6c6); margin-right: auto; }
|
|
146
191
|
.action-button {
|
|
@@ -158,6 +203,7 @@
|
|
|
158
203
|
}
|
|
159
204
|
.error-state { color: var(--cds-support-error, #fa4d56); }
|
|
160
205
|
.empty-state { color: var(--cds-text-helper, #8d8d8d); }
|
|
206
|
+
.too-many-state { color: var(--cds-support-warning, #f1c21b); }
|
|
161
207
|
.spinner {
|
|
162
208
|
width: 1rem; height: 1rem;
|
|
163
209
|
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
@@ -46,6 +46,8 @@ export interface ColumnDef {
|
|
|
46
46
|
label: string;
|
|
47
47
|
resolve: (entry: CatalogEntry) => unknown;
|
|
48
48
|
type: ColumnType;
|
|
49
|
+
/** Optional formatter function to transform the resolved value for display */
|
|
50
|
+
format?: (value: unknown, key: string) => string;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|