@industream/flowmaker-flowbox-ui-components 1.1.1 → 1.2.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/DCCatalogEntryPicker/AssetTree.svelte +197 -0
- package/dist/DCCatalogEntryPicker/AssetTree.svelte.d.ts +13 -0
- package/dist/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +185 -49
- package/dist/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte.d.ts +1 -0
- package/dist/DCCatalogEntryPicker/LabelFacet.svelte +97 -0
- package/dist/DCCatalogEntryPicker/LabelFacet.svelte.d.ts +15 -0
- package/dist/DCCatalogEntryPicker/TagBrowser.svelte +2 -2
- 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 +3 -3
- package/src/DCCatalogEntryPicker/AssetTree.svelte +197 -0
- package/src/DCCatalogEntryPicker/DCCatalogEntryPicker.svelte +185 -49
- package/src/DCCatalogEntryPicker/LabelFacet.svelte +97 -0
- package/src/DCCatalogEntryPicker/TagBrowser.svelte +2 -2
- package/src/DCCatalogEntryPicker/picker-tree.test.ts +70 -0
- package/src/DCCatalogEntryPicker/picker-tree.ts +60 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* LabelFacet - loads catalog labels and renders them as toggle chips.
|
|
4
|
+
* Selecting chips emits the active labelIds (OR filter) to the container,
|
|
5
|
+
* which owns the fetch. This component never fetches catalog entries.
|
|
6
|
+
*/
|
|
7
|
+
let {
|
|
8
|
+
client = null,
|
|
9
|
+
selectedLabelIds = $bindable([]),
|
|
10
|
+
onChange = null
|
|
11
|
+
} = $props();
|
|
12
|
+
|
|
13
|
+
let labels = $state([]);
|
|
14
|
+
let loading = $state(false);
|
|
15
|
+
let error = $state('');
|
|
16
|
+
|
|
17
|
+
/** Cancels an in-flight labels fetch (e.g. client/url changes before it resolves). */
|
|
18
|
+
let abort = null;
|
|
19
|
+
|
|
20
|
+
/** Load labels once a client is available. */
|
|
21
|
+
$effect(() => {
|
|
22
|
+
if (client) loadLabels(client);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function loadLabels(dc) {
|
|
26
|
+
abort?.abort();
|
|
27
|
+
const a = new AbortController();
|
|
28
|
+
abort = a;
|
|
29
|
+
loading = true;
|
|
30
|
+
error = '';
|
|
31
|
+
try {
|
|
32
|
+
const result = await dc.labels.get(undefined, a.signal);
|
|
33
|
+
if (a.signal.aborted) return;
|
|
34
|
+
labels = result.items ?? [];
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (a.signal.aborted) return;
|
|
37
|
+
error = err?.message ?? 'Failed to load labels';
|
|
38
|
+
labels = [];
|
|
39
|
+
} finally {
|
|
40
|
+
if (!a.signal.aborted) loading = false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toggle(id) {
|
|
45
|
+
selectedLabelIds = selectedLabelIds.indexOf(id) >= 0
|
|
46
|
+
? selectedLabelIds.filter(x => x !== id)
|
|
47
|
+
: [...selectedLabelIds, id];
|
|
48
|
+
onChange?.(selectedLabelIds);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clearAll() {
|
|
52
|
+
if (selectedLabelIds.length === 0) return;
|
|
53
|
+
selectedLabelIds = [];
|
|
54
|
+
onChange?.(selectedLabelIds);
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
{#if loading}
|
|
59
|
+
<div class="label-facet muted">Loading labels...</div>
|
|
60
|
+
{:else if error}
|
|
61
|
+
<div class="label-facet notice">Labels unavailable - search works without label filtering.</div>
|
|
62
|
+
{:else if labels.length > 0}
|
|
63
|
+
<div class="label-facet">
|
|
64
|
+
<span class="facet-label">Labels:</span>
|
|
65
|
+
{#each labels as label (label.id)}
|
|
66
|
+
{@const active = selectedLabelIds.includes(label.id)}
|
|
67
|
+
<button type="button" class="chip" class:active onclick={() => toggle(label.id)}>{label.name}</button>
|
|
68
|
+
{/each}
|
|
69
|
+
{#if selectedLabelIds.length > 0}
|
|
70
|
+
<button type="button" class="chip clear" onclick={clearAll}>Clear</button>
|
|
71
|
+
{/if}
|
|
72
|
+
</div>
|
|
73
|
+
{/if}
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.label-facet {
|
|
77
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem;
|
|
78
|
+
padding: 0.5rem 0; font-size: 0.75rem;
|
|
79
|
+
}
|
|
80
|
+
.facet-label { color: var(--cds-text-secondary, #c6c6c6); margin-right: 0.25rem; }
|
|
81
|
+
.muted { color: var(--cds-text-helper, #8d8d8d); }
|
|
82
|
+
.notice { color: var(--cds-support-warning, #f1c21b); }
|
|
83
|
+
.chip {
|
|
84
|
+
padding: 0.125rem 0.5rem;
|
|
85
|
+
background: var(--cds-tag-background-gray, #393939);
|
|
86
|
+
color: var(--cds-tag-color-gray, #c6c6c6);
|
|
87
|
+
border: 1px solid transparent; border-radius: 1rem;
|
|
88
|
+
font-size: 0.6875rem; cursor: pointer; transition: background-color 0.15s, border-color 0.15s;
|
|
89
|
+
}
|
|
90
|
+
.chip:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
|
|
91
|
+
.chip.active {
|
|
92
|
+
background: var(--cds-layer-selected-01, #052FAD);
|
|
93
|
+
color: var(--cds-text-on-color, #ffffff);
|
|
94
|
+
border-color: var(--cds-focus, #052FAD);
|
|
95
|
+
}
|
|
96
|
+
.chip.clear { background: transparent; border-color: var(--cds-border-strong-01, #6f6f6f); }
|
|
97
|
+
</style>
|
|
@@ -183,7 +183,7 @@
|
|
|
183
183
|
{#if showLoadMore}
|
|
184
184
|
<div class="load-more" use:autoLoad>
|
|
185
185
|
{#if loadingMore}
|
|
186
|
-
<div class="spinner"></div> <span>Loading more
|
|
186
|
+
<div class="spinner"></div> <span>Loading more...</span>
|
|
187
187
|
{:else}
|
|
188
188
|
<button class="action-button" onclick={() => onLoadMore?.()}>
|
|
189
189
|
Load more ({remaining} remaining)
|
|
@@ -191,7 +191,7 @@
|
|
|
191
191
|
{/if}
|
|
192
192
|
</div>
|
|
193
193
|
{#if loadMoreError}
|
|
194
|
-
<div class="load-more-error">{loadMoreError}
|
|
194
|
+
<div class="load-more-error">{loadMoreError} - <button class="link-button" onclick={() => onLoadMore?.()}>retry</button></div>
|
|
195
195
|
{/if}
|
|
196
196
|
{/if}
|
|
197
197
|
</div>
|
|
@@ -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
|
+
}
|