@industream/flowmaker-flowbox-ui-components 1.0.6-poc.1 → 1.1.0
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/DCCatalogEntryPicker/AssetTree.svelte +180 -0
- package/dist/DCCatalogEntryPicker/AssetTree.svelte.d.ts +13 -0
- package/dist/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +141 -125
- package/dist/DCCatalogEntryPicker/LabelFacet.svelte +89 -0
- package/dist/DCCatalogEntryPicker/LabelFacet.svelte.d.ts +15 -0
- package/dist/DCCatalogEntryPicker/TagBrowser.svelte +71 -27
- package/dist/DCCatalogEntryPicker/TagBrowser.svelte.d.ts +14 -4
- package/dist/DCCatalogEntryPicker/picker-pagination.d.ts +24 -0
- package/dist/DCCatalogEntryPicker/picker-pagination.js +58 -0
- package/dist/DCCatalogEntryPicker/picker-pagination.test.d.ts +1 -0
- package/dist/DCCatalogEntryPicker/picker-pagination.test.js +61 -0
- package/dist/DCCatalogEntryPicker/picker-tree.d.ts +20 -0
- package/dist/DCCatalogEntryPicker/picker-tree.js +52 -0
- package/dist/DCCatalogEntryPicker/picker-tree.test.d.ts +1 -0
- package/dist/DCCatalogEntryPicker/picker-tree.test.js +54 -0
- package/package.json +7 -5
- package/src/DCCatalogEntryPicker/AssetTree.svelte +180 -0
- package/src/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +141 -125
- package/src/DCCatalogEntryPicker/LabelFacet.svelte +89 -0
- package/src/DCCatalogEntryPicker/TagBrowser.svelte +71 -27
- package/src/DCCatalogEntryPicker/picker-pagination.test.ts +81 -0
- package/src/DCCatalogEntryPicker/picker-pagination.ts +75 -0
- package/src/DCCatalogEntryPicker/picker-tree.test.ts +70 -0
- package/src/DCCatalogEntryPicker/picker-tree.ts +60 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script>
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
2
3
|
import { DEFAULT_COLUMNS, resolveColumn, resolveValue, resolveSearchText } from './picker-column-helpers';
|
|
3
4
|
|
|
4
5
|
let {
|
|
@@ -6,18 +7,23 @@
|
|
|
6
7
|
selectedIds = $bindable([]),
|
|
7
8
|
columns = DEFAULT_COLUMNS,
|
|
8
9
|
loading = false,
|
|
10
|
+
loadingMore = false,
|
|
9
11
|
error = '',
|
|
12
|
+
loadMoreError = '',
|
|
10
13
|
emptyMessage = 'No entries found for this source connection.',
|
|
14
|
+
totalCount = 0,
|
|
15
|
+
remaining = 0,
|
|
16
|
+
serverSearch = false,
|
|
17
|
+
searching = false,
|
|
11
18
|
onSelectionChange = null,
|
|
12
19
|
onSearch = null,
|
|
13
|
-
|
|
14
|
-
tooMany = false
|
|
20
|
+
onLoadMore = null
|
|
15
21
|
} = $props();
|
|
16
22
|
|
|
17
23
|
let searchQuery = $state('');
|
|
18
24
|
let debounceTimer = null;
|
|
19
25
|
|
|
20
|
-
/** Resolved column descriptors for rendering
|
|
26
|
+
/** Resolved column descriptors for rendering. */
|
|
21
27
|
const columnDescriptors = $derived(
|
|
22
28
|
columns
|
|
23
29
|
.map(k => { const col = resolveColumn(k); return col ? { key: k, ...col } : null; })
|
|
@@ -25,31 +31,40 @@
|
|
|
25
31
|
);
|
|
26
32
|
|
|
27
33
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
34
|
+
* In server-search mode entries are already filtered server-side.
|
|
35
|
+
* Otherwise (whole catalog loaded) filter the in-memory entries locally.
|
|
30
36
|
*/
|
|
31
37
|
const filtered = $derived(() => {
|
|
32
|
-
if (
|
|
38
|
+
if (serverSearch) return entries;
|
|
33
39
|
if (!searchQuery.trim()) return entries;
|
|
34
40
|
const q = searchQuery.toLowerCase();
|
|
35
41
|
return entries.filter(e => resolveSearchText(e, columns).includes(q));
|
|
36
42
|
});
|
|
37
43
|
|
|
38
44
|
const selectedCount = $derived(selectedIds.length);
|
|
39
|
-
const totalCount = $derived(entries.length);
|
|
40
45
|
const allVisibleSelected = $derived(filtered().length > 0 && filtered().every(e => selectedIds.includes(e.id)));
|
|
41
46
|
|
|
47
|
+
/** Whether the load-more affordance should show (more pages for the current query). */
|
|
48
|
+
const showLoadMore = $derived(remaining > 0 && !loading && !error);
|
|
49
|
+
|
|
42
50
|
function handleSearchInput(e) {
|
|
43
51
|
searchQuery = e.target.value;
|
|
44
|
-
if (!onSearch) return;
|
|
45
52
|
|
|
46
|
-
//
|
|
53
|
+
// Always cancel a pending server search: if the mode is (or just became)
|
|
54
|
+
// client-side, no request should fire; if server-side, we re-arm below.
|
|
47
55
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
56
|
+
if (!serverSearch) return; // client-side filtering is reactive via filtered()
|
|
57
|
+
|
|
58
|
+
// Debounce 1s for server search
|
|
48
59
|
debounceTimer = setTimeout(() => {
|
|
49
|
-
onSearch(searchQuery.trim());
|
|
60
|
+
onSearch?.(searchQuery.trim());
|
|
50
61
|
}, 1000);
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
onDestroy(() => {
|
|
65
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
66
|
+
});
|
|
67
|
+
|
|
53
68
|
function toggleEntry(id) {
|
|
54
69
|
selectedIds = selectedIds.indexOf(id) >= 0
|
|
55
70
|
? selectedIds.filter(s => s !== id)
|
|
@@ -73,6 +88,21 @@
|
|
|
73
88
|
function toggleAll() {
|
|
74
89
|
allVisibleSelected ? deselectAll() : selectAll();
|
|
75
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Svelte action: fires `onLoadMore` when the sentinel scrolls into the
|
|
94
|
+
* viewport of the scrollable table container. The "Load more" button is
|
|
95
|
+
* the accessible/explicit fallback for the same callback.
|
|
96
|
+
*/
|
|
97
|
+
function autoLoad(node) {
|
|
98
|
+
const observer = new IntersectionObserver((entriesObserved) => {
|
|
99
|
+
if (entriesObserved.some(en => en.isIntersecting) && !loadingMore) {
|
|
100
|
+
onLoadMore?.();
|
|
101
|
+
}
|
|
102
|
+
}, { root: node.closest('.table-container'), rootMargin: '120px' });
|
|
103
|
+
observer.observe(node);
|
|
104
|
+
return { destroy() { observer.disconnect(); } };
|
|
105
|
+
}
|
|
76
106
|
</script>
|
|
77
107
|
|
|
78
108
|
<div class="tag-browser">
|
|
@@ -84,7 +114,7 @@
|
|
|
84
114
|
<input
|
|
85
115
|
type="text"
|
|
86
116
|
class="search-input"
|
|
87
|
-
placeholder={
|
|
117
|
+
placeholder={serverSearch ? "Search to find entries..." : "Search..."}
|
|
88
118
|
value={searchQuery}
|
|
89
119
|
oninput={handleSearchInput}
|
|
90
120
|
/>
|
|
@@ -107,14 +137,8 @@
|
|
|
107
137
|
<span>{error}</span>
|
|
108
138
|
</div>
|
|
109
139
|
{:else if entries.length === 0 && !searching}
|
|
110
|
-
<div class="state-message empty-state"><span>{
|
|
140
|
+
<div class="state-message empty-state"><span>{emptyMessage}</span></div>
|
|
111
141
|
{:else}
|
|
112
|
-
{#if tooMany}
|
|
113
|
-
<div class="too-many-banner">
|
|
114
|
-
<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>
|
|
115
|
-
<span>Showing {entries.length} entries (not all). Use the search box to find others.</span>
|
|
116
|
-
</div>
|
|
117
|
-
{/if}
|
|
118
142
|
<div class="table-container">
|
|
119
143
|
<table class="entry-table">
|
|
120
144
|
<!-- Table headers: one checkbox column + one column per columnDescriptor -->
|
|
@@ -156,8 +180,22 @@
|
|
|
156
180
|
{/each}
|
|
157
181
|
</tbody>
|
|
158
182
|
</table>
|
|
183
|
+
{#if showLoadMore}
|
|
184
|
+
<div class="load-more" use:autoLoad>
|
|
185
|
+
{#if loadingMore}
|
|
186
|
+
<div class="spinner"></div> <span>Loading more…</span>
|
|
187
|
+
{:else}
|
|
188
|
+
<button class="action-button" onclick={() => onLoadMore?.()}>
|
|
189
|
+
Load more ({remaining} remaining)
|
|
190
|
+
</button>
|
|
191
|
+
{/if}
|
|
192
|
+
</div>
|
|
193
|
+
{#if loadMoreError}
|
|
194
|
+
<div class="load-more-error">{loadMoreError} — <button class="link-button" onclick={() => onLoadMore?.()}>retry</button></div>
|
|
195
|
+
{/if}
|
|
196
|
+
{/if}
|
|
159
197
|
</div>
|
|
160
|
-
{#if !
|
|
198
|
+
{#if !serverSearch && filtered().length !== entries.length}
|
|
161
199
|
<div class="filter-info">Showing {filtered().length} of {entries.length} entries</div>
|
|
162
200
|
{/if}
|
|
163
201
|
{/if}
|
|
@@ -201,14 +239,6 @@
|
|
|
201
239
|
}
|
|
202
240
|
.error-state { color: var(--cds-support-error, #fa4d56); }
|
|
203
241
|
.empty-state { color: var(--cds-text-helper, #8d8d8d); }
|
|
204
|
-
.too-many-banner {
|
|
205
|
-
display: flex; align-items: center; gap: 0.5rem;
|
|
206
|
-
padding: 0.5rem 0.75rem;
|
|
207
|
-
background: var(--cds-notification-background-warning, #3d3522);
|
|
208
|
-
border-left: 3px solid var(--cds-support-warning, #f1c21b);
|
|
209
|
-
color: var(--cds-text-primary, #f4f4f4); font-size: 0.75rem;
|
|
210
|
-
}
|
|
211
|
-
.too-many-banner svg { color: var(--cds-support-warning, #f1c21b); flex: 0 0 auto; }
|
|
212
242
|
.spinner {
|
|
213
243
|
width: 1rem; height: 1rem;
|
|
214
244
|
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
@@ -260,4 +290,18 @@
|
|
|
260
290
|
font-size: 0.75rem; color: var(--cds-text-helper, #8d8d8d);
|
|
261
291
|
text-align: center; padding: 0.25rem;
|
|
262
292
|
}
|
|
293
|
+
.load-more {
|
|
294
|
+
display: flex; align-items: center; justify-content: center;
|
|
295
|
+
gap: 0.5rem; padding: 0.75rem; font-size: 0.8125rem;
|
|
296
|
+
color: var(--cds-text-secondary, #c6c6c6);
|
|
297
|
+
}
|
|
298
|
+
.load-more-error {
|
|
299
|
+
font-size: 0.75rem; color: var(--cds-support-error, #fa4d56);
|
|
300
|
+
text-align: center; padding: 0.25rem;
|
|
301
|
+
}
|
|
302
|
+
.link-button {
|
|
303
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
304
|
+
color: var(--cds-link-primary, #78a9ff); text-decoration: underline;
|
|
305
|
+
font-size: inherit;
|
|
306
|
+
}
|
|
263
307
|
</style>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
3
|
+
import {
|
|
4
|
+
PAGE_SIZE,
|
|
5
|
+
MATCH_ALL,
|
|
6
|
+
deduplicateEntries,
|
|
7
|
+
mergePage,
|
|
8
|
+
normalizeQuery,
|
|
9
|
+
isAllLoaded,
|
|
10
|
+
hasMore,
|
|
11
|
+
remainingCount,
|
|
12
|
+
upsertSelectedCache,
|
|
13
|
+
resolveSelected
|
|
14
|
+
} from './picker-pagination';
|
|
15
|
+
|
|
16
|
+
const entry = (id: string, name = id): CatalogEntry => ({ id, name } as CatalogEntry);
|
|
17
|
+
|
|
18
|
+
describe('constants', () => {
|
|
19
|
+
it('exposes the page size and match-all token', () => {
|
|
20
|
+
expect(PAGE_SIZE).toBe(100);
|
|
21
|
+
expect(MATCH_ALL).toBe('%');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('deduplicateEntries', () => {
|
|
26
|
+
it('keeps first occurrence by id and preserves order', () => {
|
|
27
|
+
const result = deduplicateEntries([entry('a'), entry('b'), entry('a')]);
|
|
28
|
+
expect(result.map(e => e.id)).toEqual(['a', 'b']);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('mergePage', () => {
|
|
33
|
+
it('appends a new page and drops duplicates against existing', () => {
|
|
34
|
+
const result = mergePage([entry('a'), entry('b')], [entry('b'), entry('c')]);
|
|
35
|
+
expect(result.map(e => e.id)).toEqual(['a', 'b', 'c']);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('normalizeQuery', () => {
|
|
40
|
+
it('trims and maps empty input to the match-all token', () => {
|
|
41
|
+
expect(normalizeQuery(' ')).toBe(MATCH_ALL);
|
|
42
|
+
expect(normalizeQuery('')).toBe(MATCH_ALL);
|
|
43
|
+
});
|
|
44
|
+
it('trims a real query', () => {
|
|
45
|
+
expect(normalizeQuery(' temp ')).toBe('temp');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('isAllLoaded', () => {
|
|
50
|
+
it('is true only for match-all with every item loaded', () => {
|
|
51
|
+
expect(isAllLoaded(MATCH_ALL, 50, 50)).toBe(true);
|
|
52
|
+
expect(isAllLoaded(MATCH_ALL, 50, 120)).toBe(false);
|
|
53
|
+
expect(isAllLoaded('temp', 50, 50)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('hasMore / remainingCount', () => {
|
|
58
|
+
it('reports remaining pages relative to total', () => {
|
|
59
|
+
expect(hasMore(100, 350)).toBe(true);
|
|
60
|
+
expect(hasMore(350, 350)).toBe(false);
|
|
61
|
+
expect(remainingCount(100, 350)).toBe(250);
|
|
62
|
+
expect(remainingCount(350, 350)).toBe(0);
|
|
63
|
+
expect(remainingCount(400, 350)).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('selected cache', () => {
|
|
68
|
+
it('upserts fetched items by id', () => {
|
|
69
|
+
const cache = upsertSelectedCache(new Map(), [entry('a'), entry('b')]);
|
|
70
|
+
upsertSelectedCache(cache, [entry('b', 'renamed')]);
|
|
71
|
+
expect(cache.get('b')?.name).toBe('renamed');
|
|
72
|
+
expect(cache.size).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
it('resolves selected entries from window first, cache as fallback, in selection order', () => {
|
|
75
|
+
const cache = upsertSelectedCache(new Map(), [entry('x'), entry('y')]);
|
|
76
|
+
const window = [entry('y', 'in-window')];
|
|
77
|
+
const result = resolveSelected(window, cache, ['x', 'y']);
|
|
78
|
+
expect(result.map(e => e.id)).toEqual(['x', 'y']);
|
|
79
|
+
expect(result.find(e => e.id === 'y')?.name).toBe('in-window');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
2
|
+
|
|
3
|
+
/** Number of entries fetched per page. */
|
|
4
|
+
export const PAGE_SIZE = 100;
|
|
5
|
+
|
|
6
|
+
/** Query token that matches all entries (server-side wildcard). */
|
|
7
|
+
export const MATCH_ALL = '%';
|
|
8
|
+
|
|
9
|
+
/** Deduplicate entries by id, keeping the first occurrence and preserving order. */
|
|
10
|
+
export function deduplicateEntries(items: CatalogEntry[]): CatalogEntry[] {
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
const result: CatalogEntry[] = [];
|
|
13
|
+
for (const item of items) {
|
|
14
|
+
if (!seen.has(item.id)) {
|
|
15
|
+
seen.add(item.id);
|
|
16
|
+
result.push(item);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Append a freshly fetched page to the accumulated list, dropping duplicates. */
|
|
23
|
+
export function mergePage(existing: CatalogEntry[], page: CatalogEntry[]): CatalogEntry[] {
|
|
24
|
+
return deduplicateEntries([...existing, ...page]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Trim a raw search input; an empty query becomes the match-all token. */
|
|
28
|
+
export function normalizeQuery(raw: string): string {
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
return trimmed === '' ? MATCH_ALL : trimmed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** True when the full match-all set is in memory (small catalog → client-side search). */
|
|
34
|
+
export function isAllLoaded(query: string, loadedCount: number, totalCount: number): boolean {
|
|
35
|
+
return query === MATCH_ALL && loadedCount >= totalCount;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether more pages remain for the current query. */
|
|
39
|
+
export function hasMore(loadedCount: number, totalCount: number): boolean {
|
|
40
|
+
return loadedCount < totalCount;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Number of not-yet-loaded entries for the current query (never negative). */
|
|
44
|
+
export function remainingCount(loadedCount: number, totalCount: number): number {
|
|
45
|
+
return Math.max(0, totalCount - loadedCount);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Upsert every fetched entry into the selected-entry metadata cache (mutates and returns it). */
|
|
49
|
+
export function upsertSelectedCache(
|
|
50
|
+
cache: Map<string, CatalogEntry>,
|
|
51
|
+
items: CatalogEntry[]
|
|
52
|
+
): Map<string, CatalogEntry> {
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
cache.set(item.id, item);
|
|
55
|
+
}
|
|
56
|
+
return cache;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the selected entries for display: prefer the currently loaded window,
|
|
61
|
+
* fall back to the cache, in the order of `selectedIds`. Ids absent from both are skipped.
|
|
62
|
+
*/
|
|
63
|
+
export function resolveSelected(
|
|
64
|
+
entries: CatalogEntry[],
|
|
65
|
+
cache: Map<string, CatalogEntry>,
|
|
66
|
+
selectedIds: string[]
|
|
67
|
+
): CatalogEntry[] {
|
|
68
|
+
const windowById = new Map(entries.map(e => [e.id, e]));
|
|
69
|
+
const result: CatalogEntry[] = [];
|
|
70
|
+
for (const id of selectedIds) {
|
|
71
|
+
const found = windowById.get(id) ?? cache.get(id);
|
|
72
|
+
if (found) result.push(found);
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import type { AssetNode, AssetDictionary, CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
3
|
+
import {
|
|
4
|
+
flattenNodes,
|
|
5
|
+
findNodeById,
|
|
6
|
+
collectEntryIds,
|
|
7
|
+
filterEntriesByConnection,
|
|
8
|
+
firstDictionaryId
|
|
9
|
+
} from './picker-tree';
|
|
10
|
+
|
|
11
|
+
const node = (id: string, entryIds: string[] = [], children: AssetNode[] = []): AssetNode =>
|
|
12
|
+
({ id, dictionaryId: 'd1', name: id, order: 0, entryIds, children } as AssetNode);
|
|
13
|
+
|
|
14
|
+
const tree: AssetNode[] = [
|
|
15
|
+
node('root', ['e1'], [
|
|
16
|
+
node('zone-a', ['e2', 'e3'], [
|
|
17
|
+
node('leaf', ['e3', 'e4'])
|
|
18
|
+
]),
|
|
19
|
+
node('zone-b', ['e5'])
|
|
20
|
+
])
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe('flattenNodes', () => {
|
|
24
|
+
it('returns every node depth-first, self before children', () => {
|
|
25
|
+
expect(flattenNodes(tree).map(n => n.id)).toEqual(['root', 'zone-a', 'leaf', 'zone-b']);
|
|
26
|
+
});
|
|
27
|
+
it('handles an empty forest', () => {
|
|
28
|
+
expect(flattenNodes([])).toEqual([]);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('findNodeById', () => {
|
|
33
|
+
it('finds a nested node', () => {
|
|
34
|
+
expect(findNodeById(tree, 'leaf')?.id).toBe('leaf');
|
|
35
|
+
});
|
|
36
|
+
it('returns null when absent', () => {
|
|
37
|
+
expect(findNodeById(tree, 'nope')).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('collectEntryIds', () => {
|
|
42
|
+
it('returns only the node own entryIds by default', () => {
|
|
43
|
+
expect(collectEntryIds(findNodeById(tree, 'zone-a')!)).toEqual(['e2', 'e3']);
|
|
44
|
+
});
|
|
45
|
+
it('unions descendants and de-duplicates, node first, DFS order', () => {
|
|
46
|
+
expect(collectEntryIds(findNodeById(tree, 'zone-a')!, true)).toEqual(['e2', 'e3', 'e4']);
|
|
47
|
+
});
|
|
48
|
+
it('collects the whole tree from the root with descendants', () => {
|
|
49
|
+
expect(collectEntryIds(tree[0], true)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('filterEntriesByConnection', () => {
|
|
54
|
+
const entry = (id: string, connId: string): CatalogEntry =>
|
|
55
|
+
({ id, sourceConnection: { id: connId } } as CatalogEntry);
|
|
56
|
+
const rows = [entry('a', 'c1'), entry('b', 'c2'), entry('c', 'c1')];
|
|
57
|
+
it('keeps only rows on the given connection', () => {
|
|
58
|
+
expect(filterEntriesByConnection(rows, 'c1').map(e => e.id)).toEqual(['a', 'c']);
|
|
59
|
+
});
|
|
60
|
+
it('returns all rows when connection id is empty', () => {
|
|
61
|
+
expect(filterEntriesByConnection(rows, '')).toHaveLength(3);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('firstDictionaryId', () => {
|
|
66
|
+
it('returns the first id or undefined', () => {
|
|
67
|
+
expect(firstDictionaryId([{ id: 'x' } as AssetDictionary])).toBe('x');
|
|
68
|
+
expect(firstDictionaryId([])).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AssetNode, AssetDictionary } from '@industream/datacatalog-client/dto';
|
|
2
|
+
import type { CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
3
|
+
|
|
4
|
+
/** Depth-first flatten of a node forest — self emitted before its children. */
|
|
5
|
+
export function flattenNodes(nodes: AssetNode[]): AssetNode[] {
|
|
6
|
+
const result: AssetNode[] = [];
|
|
7
|
+
for (const n of nodes) {
|
|
8
|
+
result.push(n);
|
|
9
|
+
if (n.children && n.children.length > 0) {
|
|
10
|
+
result.push(...flattenNodes(n.children));
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Find a node by id anywhere in the forest; null when absent. */
|
|
17
|
+
export function findNodeById(nodes: AssetNode[], id: string): AssetNode | null {
|
|
18
|
+
for (const n of flattenNodes(nodes)) {
|
|
19
|
+
if (n.id === id) return n;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Collect a node's assigned parent entryIds. With `includeDescendants`, unions
|
|
26
|
+
* every descendant's entryIds too. De-duplicated, node-first, DFS order.
|
|
27
|
+
*/
|
|
28
|
+
export function collectEntryIds(node: AssetNode, includeDescendants: boolean = false): string[] {
|
|
29
|
+
const source = includeDescendants ? flattenNodes([node]) : [node];
|
|
30
|
+
const seen = new Set<string>();
|
|
31
|
+
const result: string[] = [];
|
|
32
|
+
for (const n of source) {
|
|
33
|
+
for (const id of n.entryIds ?? []) {
|
|
34
|
+
if (!seen.has(id)) {
|
|
35
|
+
seen.add(id);
|
|
36
|
+
result.push(id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Keep only binding rows on the given source connection. An empty
|
|
45
|
+
* `sourceConnectionId` disables filtering (returns the input unchanged).
|
|
46
|
+
* The typed `query` filter cannot scope by connection, so the container
|
|
47
|
+
* filters client-side (node entry counts are small).
|
|
48
|
+
*/
|
|
49
|
+
export function filterEntriesByConnection(
|
|
50
|
+
entries: CatalogEntry[],
|
|
51
|
+
sourceConnectionId: string
|
|
52
|
+
): CatalogEntry[] {
|
|
53
|
+
if (!sourceConnectionId) return entries;
|
|
54
|
+
return entries.filter(e => e.sourceConnection?.id === sourceConnectionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Id of the first dictionary (default selection), or undefined for an empty list. */
|
|
58
|
+
export function firstDictionaryId(dictionaries: AssetDictionary[]): string | undefined {
|
|
59
|
+
return dictionaries.length > 0 ? dictionaries[0].id : undefined;
|
|
60
|
+
}
|