@industream/flowmaker-flowbox-ui-components 1.1.1 → 1.2.2
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,20 @@
|
|
|
1
|
+
import type { AssetNode, AssetDictionary } from '@industream/datacatalog-client/dto';
|
|
2
|
+
import type { CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
3
|
+
/** Depth-first flatten of a node forest — self emitted before its children. */
|
|
4
|
+
export declare function flattenNodes(nodes: AssetNode[]): AssetNode[];
|
|
5
|
+
/** Find a node by id anywhere in the forest; null when absent. */
|
|
6
|
+
export declare function findNodeById(nodes: AssetNode[], id: string): AssetNode | null;
|
|
7
|
+
/**
|
|
8
|
+
* Collect a node's assigned parent entryIds. With `includeDescendants`, unions
|
|
9
|
+
* every descendant's entryIds too. De-duplicated, node-first, DFS order.
|
|
10
|
+
*/
|
|
11
|
+
export declare function collectEntryIds(node: AssetNode, includeDescendants?: boolean): string[];
|
|
12
|
+
/**
|
|
13
|
+
* Keep only binding rows on the given source connection. An empty
|
|
14
|
+
* `sourceConnectionId` disables filtering (returns the input unchanged).
|
|
15
|
+
* The typed `query` filter cannot scope by connection, so the container
|
|
16
|
+
* filters client-side (node entry counts are small).
|
|
17
|
+
*/
|
|
18
|
+
export declare function filterEntriesByConnection(entries: CatalogEntry[], sourceConnectionId: string): CatalogEntry[];
|
|
19
|
+
/** Id of the first dictionary (default selection), or undefined for an empty list. */
|
|
20
|
+
export declare function firstDictionaryId(dictionaries: AssetDictionary[]): string | undefined;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Depth-first flatten of a node forest — self emitted before its children. */
|
|
2
|
+
export function flattenNodes(nodes) {
|
|
3
|
+
const result = [];
|
|
4
|
+
for (const n of nodes) {
|
|
5
|
+
result.push(n);
|
|
6
|
+
if (n.children && n.children.length > 0) {
|
|
7
|
+
result.push(...flattenNodes(n.children));
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
/** Find a node by id anywhere in the forest; null when absent. */
|
|
13
|
+
export function findNodeById(nodes, id) {
|
|
14
|
+
for (const n of flattenNodes(nodes)) {
|
|
15
|
+
if (n.id === id)
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Collect a node's assigned parent entryIds. With `includeDescendants`, unions
|
|
22
|
+
* every descendant's entryIds too. De-duplicated, node-first, DFS order.
|
|
23
|
+
*/
|
|
24
|
+
export function collectEntryIds(node, includeDescendants = false) {
|
|
25
|
+
const source = includeDescendants ? flattenNodes([node]) : [node];
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const result = [];
|
|
28
|
+
for (const n of source) {
|
|
29
|
+
for (const id of n.entryIds ?? []) {
|
|
30
|
+
if (!seen.has(id)) {
|
|
31
|
+
seen.add(id);
|
|
32
|
+
result.push(id);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Keep only binding rows on the given source connection. An empty
|
|
40
|
+
* `sourceConnectionId` disables filtering (returns the input unchanged).
|
|
41
|
+
* The typed `query` filter cannot scope by connection, so the container
|
|
42
|
+
* filters client-side (node entry counts are small).
|
|
43
|
+
*/
|
|
44
|
+
export function filterEntriesByConnection(entries, sourceConnectionId) {
|
|
45
|
+
if (!sourceConnectionId)
|
|
46
|
+
return entries;
|
|
47
|
+
return entries.filter(e => e.sourceConnection?.id === sourceConnectionId);
|
|
48
|
+
}
|
|
49
|
+
/** Id of the first dictionary (default selection), or undefined for an empty list. */
|
|
50
|
+
export function firstDictionaryId(dictionaries) {
|
|
51
|
+
return dictionaries.length > 0 ? dictionaries[0].id : undefined;
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { flattenNodes, findNodeById, collectEntryIds, filterEntriesByConnection, firstDictionaryId } from './picker-tree';
|
|
3
|
+
const node = (id, entryIds = [], children = []) => ({ id, dictionaryId: 'd1', name: id, order: 0, entryIds, children });
|
|
4
|
+
const tree = [
|
|
5
|
+
node('root', ['e1'], [
|
|
6
|
+
node('zone-a', ['e2', 'e3'], [
|
|
7
|
+
node('leaf', ['e3', 'e4'])
|
|
8
|
+
]),
|
|
9
|
+
node('zone-b', ['e5'])
|
|
10
|
+
])
|
|
11
|
+
];
|
|
12
|
+
describe('flattenNodes', () => {
|
|
13
|
+
it('returns every node depth-first, self before children', () => {
|
|
14
|
+
expect(flattenNodes(tree).map(n => n.id)).toEqual(['root', 'zone-a', 'leaf', 'zone-b']);
|
|
15
|
+
});
|
|
16
|
+
it('handles an empty forest', () => {
|
|
17
|
+
expect(flattenNodes([])).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('findNodeById', () => {
|
|
21
|
+
it('finds a nested node', () => {
|
|
22
|
+
expect(findNodeById(tree, 'leaf')?.id).toBe('leaf');
|
|
23
|
+
});
|
|
24
|
+
it('returns null when absent', () => {
|
|
25
|
+
expect(findNodeById(tree, 'nope')).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('collectEntryIds', () => {
|
|
29
|
+
it('returns only the node own entryIds by default', () => {
|
|
30
|
+
expect(collectEntryIds(findNodeById(tree, 'zone-a'))).toEqual(['e2', 'e3']);
|
|
31
|
+
});
|
|
32
|
+
it('unions descendants and de-duplicates, node first, DFS order', () => {
|
|
33
|
+
expect(collectEntryIds(findNodeById(tree, 'zone-a'), true)).toEqual(['e2', 'e3', 'e4']);
|
|
34
|
+
});
|
|
35
|
+
it('collects the whole tree from the root with descendants', () => {
|
|
36
|
+
expect(collectEntryIds(tree[0], true)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('filterEntriesByConnection', () => {
|
|
40
|
+
const entry = (id, connId) => ({ id, sourceConnection: { id: connId } });
|
|
41
|
+
const rows = [entry('a', 'c1'), entry('b', 'c2'), entry('c', 'c1')];
|
|
42
|
+
it('keeps only rows on the given connection', () => {
|
|
43
|
+
expect(filterEntriesByConnection(rows, 'c1').map(e => e.id)).toEqual(['a', 'c']);
|
|
44
|
+
});
|
|
45
|
+
it('returns all rows when connection id is empty', () => {
|
|
46
|
+
expect(filterEntriesByConnection(rows, '')).toHaveLength(3);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('firstDictionaryId', () => {
|
|
50
|
+
it('returns the first id or undefined', () => {
|
|
51
|
+
expect(firstDictionaryId([{ id: 'x' }])).toBe('x');
|
|
52
|
+
expect(firstDictionaryId([])).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@industream/flowmaker-flowbox-ui-components",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Reusable Svelte components for FlowMaker FlowBox UI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"svelte": "./dist/index.js",
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {
|
|
47
47
|
"svelte": "^5.0.0",
|
|
48
|
-
"@industream/datacatalog-client": "^1.
|
|
48
|
+
"@industream/datacatalog-client": "^1.10.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@industream/datacatalog-client": "1.
|
|
51
|
+
"@industream/datacatalog-client": "^1.10.0",
|
|
52
52
|
"svelte": "^5.0.0",
|
|
53
53
|
"vite": "^6.0.0",
|
|
54
54
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
/**
|
|
3
|
+
* AssetTree - loads an asset dictionary as a tree and lets the user pick a node.
|
|
4
|
+
* Emits the selected node's own entryIds to the container, which fetches and
|
|
5
|
+
* filters the entries. This component holds no selection or entry state.
|
|
6
|
+
*/
|
|
7
|
+
import { firstDictionaryId, collectEntryIds } from './picker-tree';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
client = null,
|
|
11
|
+
onSelectNode = null
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
let dictionaries = $state([]);
|
|
15
|
+
let activeDictionaryId = $state('');
|
|
16
|
+
let rootNodes = $state([]);
|
|
17
|
+
let expanded = $state(new Set());
|
|
18
|
+
let selectedNodeId = $state('');
|
|
19
|
+
let loading = $state(false);
|
|
20
|
+
let error = $state('');
|
|
21
|
+
|
|
22
|
+
/** Cancels in-flight dictionary/tree fetches so rapid switching can't render a stale tree. */
|
|
23
|
+
let abort = null;
|
|
24
|
+
function newAbort() {
|
|
25
|
+
abort?.abort();
|
|
26
|
+
abort = new AbortController();
|
|
27
|
+
return abort.signal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Load the dictionary list when a client becomes available. */
|
|
31
|
+
$effect(() => {
|
|
32
|
+
if (client) loadDictionaries(client);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function loadDictionaries(dc) {
|
|
36
|
+
const signal = newAbort();
|
|
37
|
+
loading = true;
|
|
38
|
+
error = '';
|
|
39
|
+
try {
|
|
40
|
+
const result = await dc.assetDictionaries.get({ asTree: true }, signal);
|
|
41
|
+
if (signal.aborted) return;
|
|
42
|
+
dictionaries = result.items ?? [];
|
|
43
|
+
const first = firstDictionaryId(dictionaries);
|
|
44
|
+
if (first) {
|
|
45
|
+
activeDictionaryId = first;
|
|
46
|
+
await loadTreeInner(dc, first, signal);
|
|
47
|
+
} else {
|
|
48
|
+
rootNodes = [];
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (signal.aborted) return;
|
|
52
|
+
error = err?.message ?? 'Failed to load asset dictionaries';
|
|
53
|
+
dictionaries = [];
|
|
54
|
+
rootNodes = [];
|
|
55
|
+
} finally {
|
|
56
|
+
if (!signal.aborted) loading = false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadTree(dc, id) {
|
|
61
|
+
await loadTreeInner(dc, id, newAbort());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function loadTreeInner(dc, id, signal) {
|
|
65
|
+
loading = true;
|
|
66
|
+
error = '';
|
|
67
|
+
try {
|
|
68
|
+
const dict = await dc.assetDictionaries.getById(id, { asTree: true }, signal);
|
|
69
|
+
if (signal.aborted) return;
|
|
70
|
+
rootNodes = dict.nodes ?? [];
|
|
71
|
+
expanded = new Set(rootNodes.map(n => n.id));
|
|
72
|
+
} catch (err) {
|
|
73
|
+
if (signal.aborted) return;
|
|
74
|
+
error = err?.message ?? 'Failed to load the asset tree';
|
|
75
|
+
rootNodes = [];
|
|
76
|
+
} finally {
|
|
77
|
+
if (!signal.aborted) loading = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function onDictionaryChange(e) {
|
|
82
|
+
activeDictionaryId = e.target.value;
|
|
83
|
+
selectedNodeId = '';
|
|
84
|
+
if (client && activeDictionaryId) loadTree(client, activeDictionaryId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toggleExpand(id) {
|
|
88
|
+
const next = new Set(expanded);
|
|
89
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
90
|
+
expanded = next;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function selectNode(node) {
|
|
94
|
+
selectedNodeId = node.id;
|
|
95
|
+
onSelectNode?.(collectEntryIds(node), node.id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function retry() {
|
|
99
|
+
if (client) loadDictionaries(client);
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<div class="asset-tree">
|
|
104
|
+
{#if dictionaries.length > 1}
|
|
105
|
+
<select class="dict-select" value={activeDictionaryId} onchange={onDictionaryChange}>
|
|
106
|
+
{#each dictionaries as dict (dict.id)}
|
|
107
|
+
<option value={dict.id}>{dict.name}</option>
|
|
108
|
+
{/each}
|
|
109
|
+
</select>
|
|
110
|
+
{/if}
|
|
111
|
+
|
|
112
|
+
{#if loading}
|
|
113
|
+
<div class="tree-state"><div class="spinner"></div> <span>Loading tree...</span></div>
|
|
114
|
+
{:else if error}
|
|
115
|
+
<div class="tree-state error">{error} - <button class="link-button" onclick={retry}>retry</button></div>
|
|
116
|
+
{:else if rootNodes.length === 0}
|
|
117
|
+
<div class="tree-state muted">No asset dictionary available.</div>
|
|
118
|
+
{:else}
|
|
119
|
+
<ul class="tree-root">
|
|
120
|
+
{#each rootNodes as node (node.id)}
|
|
121
|
+
{@render treeNode(node, 0)}
|
|
122
|
+
{/each}
|
|
123
|
+
</ul>
|
|
124
|
+
{/if}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{#snippet treeNode(node, depth)}
|
|
128
|
+
{@const hasChildren = node.children && node.children.length > 0}
|
|
129
|
+
{@const isOpen = expanded.has(node.id)}
|
|
130
|
+
<li class="tree-item">
|
|
131
|
+
<div
|
|
132
|
+
class="node-row"
|
|
133
|
+
class:selected={selectedNodeId === node.id}
|
|
134
|
+
style="padding-left: {depth * 1}rem"
|
|
135
|
+
>
|
|
136
|
+
{#if hasChildren}
|
|
137
|
+
<button type="button" class="twisty" class:open={isOpen} onclick={() => toggleExpand(node.id)} aria-label="Toggle">
|
|
138
|
+
<svg viewBox="0 0 16 16" width="12" height="12"><path d="M11 8L6 13V3z" fill="currentColor"></path></svg>
|
|
139
|
+
</button>
|
|
140
|
+
{:else}
|
|
141
|
+
<span class="twisty-spacer"></span>
|
|
142
|
+
{/if}
|
|
143
|
+
<button type="button" class="node-label" onclick={() => selectNode(node)}>
|
|
144
|
+
{node.name}
|
|
145
|
+
{#if node.entryIds && node.entryIds.length > 0}<span class="node-count">{node.entryIds.length}</span>{/if}
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
{#if hasChildren && isOpen}
|
|
149
|
+
<ul>
|
|
150
|
+
{#each node.children as child (child.id)}
|
|
151
|
+
{@render treeNode(child, depth + 1)}
|
|
152
|
+
{/each}
|
|
153
|
+
</ul>
|
|
154
|
+
{/if}
|
|
155
|
+
</li>
|
|
156
|
+
{/snippet}
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
.asset-tree { font-size: 0.8125rem; }
|
|
160
|
+
.dict-select {
|
|
161
|
+
width: 100%; margin-bottom: 0.5rem; padding: 0.375rem 0.5rem;
|
|
162
|
+
background: var(--cds-field-01, #353535); color: var(--cds-text-primary, #f4f4f4);
|
|
163
|
+
border: none; border-bottom: 1px solid var(--cds-border-strong-01, #6f6f6f); font-size: 0.8125rem;
|
|
164
|
+
}
|
|
165
|
+
.tree-state { display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: var(--cds-text-secondary, #c6c6c6); }
|
|
166
|
+
.tree-state.error { color: var(--cds-support-error, #fa4d56); }
|
|
167
|
+
.tree-state.muted { color: var(--cds-text-helper, #8d8d8d); }
|
|
168
|
+
.tree-root, .tree-item ul { list-style: none; margin: 0; padding: 0; }
|
|
169
|
+
.tree-root { max-height: 400px; overflow-y: auto; border: 1px solid var(--cds-border-subtle-01, #393939); }
|
|
170
|
+
.node-row { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; }
|
|
171
|
+
.node-row:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
|
|
172
|
+
.node-row.selected { background: var(--cds-layer-selected-01, #3d3d3d); }
|
|
173
|
+
.twisty, .twisty-spacer { width: 1.25rem; height: 1.5rem; flex-shrink: 0; }
|
|
174
|
+
.twisty { background: none; border: none; color: var(--cds-text-secondary, #c6c6c6); cursor: pointer; padding: 0; }
|
|
175
|
+
.twisty svg { transition: transform 0.15s ease; }
|
|
176
|
+
.twisty.open svg { transform: rotate(90deg); }
|
|
177
|
+
.node-label {
|
|
178
|
+
flex: 1; display: flex; align-items: center; gap: 0.375rem;
|
|
179
|
+
background: none; border: none; color: var(--cds-text-primary, #f4f4f4);
|
|
180
|
+
text-align: left; padding: 0.25rem 0.25rem; cursor: pointer; font-size: 0.8125rem;
|
|
181
|
+
}
|
|
182
|
+
.node-count {
|
|
183
|
+
font-size: 0.625rem; padding: 0 0.375rem; border-radius: 1rem;
|
|
184
|
+
background: var(--cds-tag-background-gray, #393939); color: var(--cds-tag-color-gray, #c6c6c6);
|
|
185
|
+
}
|
|
186
|
+
.spinner {
|
|
187
|
+
width: 1rem; height: 1rem;
|
|
188
|
+
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
189
|
+
border-top-color: var(--cds-interactive, #052FAD);
|
|
190
|
+
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
191
|
+
}
|
|
192
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
193
|
+
.link-button {
|
|
194
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
195
|
+
color: var(--cds-link-primary, #78a9ff); text-decoration: underline; font-size: inherit;
|
|
196
|
+
}
|
|
197
|
+
</style>
|
|
@@ -6,16 +6,15 @@
|
|
|
6
6
|
* SelectedTags (collapsible summary with remove). Owns the DataCatalog fetch
|
|
7
7
|
* lifecycle, pagination state, and the selected-entry metadata cache.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
* -
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
* - Selected entries are cached independently of the loaded window so their
|
|
15
|
-
* metadata always renders.
|
|
9
|
+
* Two-tab layout (Lot B):
|
|
10
|
+
* - Search tab: LabelFacet (label OR filter) + TagBrowser (paginated, server search).
|
|
11
|
+
* - Tree tab: AssetTree (dictionary node picker) + TagBrowser (client-side, no load-more).
|
|
12
|
+
* - selectedIds and selectedCache are shared across both tabs.
|
|
13
|
+
* - sourceTypes scopes every fetch for type-correct entries.
|
|
16
14
|
*
|
|
17
15
|
* @prop dcApiUrl — DataCatalog API base URL
|
|
18
16
|
* @prop sourceConnectionId — Filters catalog entries by source connection
|
|
17
|
+
* @prop sourceTypes — Optional type scope (e.g. ['OPC-UA']); empty = all types
|
|
19
18
|
* @prop selectedIds — (bindable) Array of selected CatalogEntry IDs
|
|
20
19
|
* @prop entries — (bindable) Loaded catalog entries (exposed for parent access)
|
|
21
20
|
* @prop columns — Column keys for the browse table
|
|
@@ -39,12 +38,16 @@
|
|
|
39
38
|
upsertSelectedCache,
|
|
40
39
|
resolveSelected
|
|
41
40
|
} from './picker-pagination';
|
|
41
|
+
import { filterEntriesByConnection } from './picker-tree';
|
|
42
42
|
import TagBrowser from './TagBrowser.svelte';
|
|
43
43
|
import SelectedTags from './SelectedTags.svelte';
|
|
44
|
+
import LabelFacet from './LabelFacet.svelte';
|
|
45
|
+
import AssetTree from './AssetTree.svelte';
|
|
44
46
|
|
|
45
47
|
interface Props {
|
|
46
48
|
dcApiUrl?: string;
|
|
47
49
|
sourceConnectionId?: string;
|
|
50
|
+
sourceTypes?: string[];
|
|
48
51
|
selectedIds?: string[];
|
|
49
52
|
entries?: CatalogEntry[];
|
|
50
53
|
columns?: string[];
|
|
@@ -57,6 +60,7 @@
|
|
|
57
60
|
let {
|
|
58
61
|
dcApiUrl = '',
|
|
59
62
|
sourceConnectionId = '',
|
|
63
|
+
sourceTypes = [],
|
|
60
64
|
selectedIds = $bindable([]),
|
|
61
65
|
entries = $bindable([]),
|
|
62
66
|
columns = DEFAULT_COLUMNS,
|
|
@@ -75,6 +79,14 @@
|
|
|
75
79
|
let currentQuery = $state(MATCH_ALL);
|
|
76
80
|
let offset = $state(0);
|
|
77
81
|
|
|
82
|
+
let activeTab = $state<'search' | 'tree'>('search');
|
|
83
|
+
let activeLabelIds = $state<string[]>([]);
|
|
84
|
+
let treeNodeSelected = $state(false);
|
|
85
|
+
/** Tree-tab empty message: distinguishes "pick a node" from "node has no entries here". */
|
|
86
|
+
const treeEmptyMessage = $derived(treeNodeSelected
|
|
87
|
+
? 'No entries for this node on this source connection.'
|
|
88
|
+
: 'Select a node in the tree to see its entries.');
|
|
89
|
+
|
|
78
90
|
/**
|
|
79
91
|
* Metadata for selected tags, kept across reloads/searches so SelectedTags always
|
|
80
92
|
* renders. Invariant: every mutation of this Map is paired with an `entries`
|
|
@@ -102,7 +114,13 @@
|
|
|
102
114
|
let clientUrl = '';
|
|
103
115
|
|
|
104
116
|
/** Whether search runs server-side (large catalog) or client-side (everything loaded). */
|
|
105
|
-
const
|
|
117
|
+
const searchModeServer = $derived(!isAllLoaded(MATCH_ALL, entries.length, totalCount) || currentQuery !== MATCH_ALL);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Force client-side filtering in the Tree tab (node entries are fully in memory).
|
|
121
|
+
* In the Search tab, use the derived server/client decision.
|
|
122
|
+
*/
|
|
123
|
+
const serverSearch = $derived(activeTab === 'tree' ? false : searchModeServer);
|
|
106
124
|
|
|
107
125
|
/** Resolved entries for the selected-tags panel: window first, cache fallback. */
|
|
108
126
|
const selectedEntries = $derived(resolveSelected(entries, selectedCache, selectedIds));
|
|
@@ -114,14 +132,45 @@
|
|
|
114
132
|
return client;
|
|
115
133
|
}
|
|
116
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Search options shared by page-1, server-search and load-more, so sourceTypes
|
|
137
|
+
* (mandatory scoping) and labelIds (OR facet) are threaded in exactly one place.
|
|
138
|
+
* `labelIds` is honoured by SDK >= 1.10.0; older runtimes ignore it (unfiltered).
|
|
139
|
+
*/
|
|
140
|
+
function buildSearchOptions(offsetValue: number) {
|
|
141
|
+
return {
|
|
142
|
+
sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
|
|
143
|
+
sourceTypes: sourceTypes,
|
|
144
|
+
labelIds: activeLabelIds,
|
|
145
|
+
limit: PAGE_SIZE,
|
|
146
|
+
offset: offsetValue
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Single source of truth for url/connection/tab changes. Runs when any is read here.
|
|
117
151
|
$effect(() => {
|
|
118
|
-
|
|
119
|
-
|
|
152
|
+
const url = dcApiUrl;
|
|
153
|
+
const conn = sourceConnectionId;
|
|
154
|
+
const tab = activeTab;
|
|
155
|
+
if (!url) {
|
|
156
|
+
fetchAbort?.abort();
|
|
157
|
+
entries = [];
|
|
158
|
+
totalCount = 0;
|
|
159
|
+
error = '';
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (tab === 'search') {
|
|
163
|
+
loadInitial(url, conn);
|
|
120
164
|
} else {
|
|
165
|
+
// Tree tab: a url/connection change invalidates the resolved node entries
|
|
166
|
+
// (they were scoped to the previous connection). Clear and await a re-pick.
|
|
121
167
|
fetchAbort?.abort();
|
|
122
168
|
entries = [];
|
|
123
169
|
totalCount = 0;
|
|
170
|
+
offset = 0;
|
|
171
|
+
currentQuery = MATCH_ALL;
|
|
124
172
|
error = '';
|
|
173
|
+
treeNodeSelected = false;
|
|
125
174
|
}
|
|
126
175
|
});
|
|
127
176
|
|
|
@@ -141,11 +190,7 @@
|
|
|
141
190
|
|
|
142
191
|
try {
|
|
143
192
|
const dc = getClient(apiUrl);
|
|
144
|
-
const result = await dc.catalogEntries.search(MATCH_ALL,
|
|
145
|
-
sourceConnectionIds: connId ? [connId] : [],
|
|
146
|
-
limit: PAGE_SIZE,
|
|
147
|
-
offset: 0
|
|
148
|
-
}, abort.signal);
|
|
193
|
+
const result = await dc.catalogEntries.search(MATCH_ALL, buildSearchOptions(0), abort.signal);
|
|
149
194
|
if (abort.signal.aborted) return;
|
|
150
195
|
const items = deduplicateEntries(result.items ?? []);
|
|
151
196
|
entries = items;
|
|
@@ -178,11 +223,7 @@
|
|
|
178
223
|
searching = true;
|
|
179
224
|
|
|
180
225
|
const dc = getClient(dcApiUrl);
|
|
181
|
-
dc.catalogEntries.search(query,
|
|
182
|
-
sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
|
|
183
|
-
limit: PAGE_SIZE,
|
|
184
|
-
offset: 0
|
|
185
|
-
}, abort.signal)
|
|
226
|
+
dc.catalogEntries.search(query, buildSearchOptions(0), abort.signal)
|
|
186
227
|
.then((result) => {
|
|
187
228
|
if (abort.signal.aborted) return;
|
|
188
229
|
const items = deduplicateEntries(result.items ?? []);
|
|
@@ -214,11 +255,7 @@
|
|
|
214
255
|
const startOffset = offset;
|
|
215
256
|
|
|
216
257
|
const dc = getClient(dcApiUrl);
|
|
217
|
-
dc.catalogEntries.search(currentQuery,
|
|
218
|
-
sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
|
|
219
|
-
limit: PAGE_SIZE,
|
|
220
|
-
offset: startOffset
|
|
221
|
-
}, abort.signal)
|
|
258
|
+
dc.catalogEntries.search(currentQuery, buildSearchOptions(startOffset), abort.signal)
|
|
222
259
|
.then((result) => {
|
|
223
260
|
if (abort.signal.aborted) return;
|
|
224
261
|
const items = result.items ?? [];
|
|
@@ -235,26 +272,120 @@
|
|
|
235
272
|
});
|
|
236
273
|
}
|
|
237
274
|
|
|
275
|
+
// Just flip the tab; the $effect above reacts (search → loadInitial, tree → clear),
|
|
276
|
+
// so page-1 is fetched exactly once (no double fetch).
|
|
277
|
+
function selectTab(tab: 'search' | 'tree') {
|
|
278
|
+
if (activeTab === tab) return;
|
|
279
|
+
loadMoreError = '';
|
|
280
|
+
activeTab = tab;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Label chips changed — reset pagination and re-query server-side (Search tab). */
|
|
284
|
+
function handleLabelChange(ids: string[]) {
|
|
285
|
+
activeLabelIds = ids;
|
|
286
|
+
handleServerSearch(currentQuery === MATCH_ALL ? '' : currentQuery);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A tree node was picked — resolve its entries for the current connection/type. */
|
|
290
|
+
function handleSelectNode(entryIds: string[]) {
|
|
291
|
+
if (!dcApiUrl) return;
|
|
292
|
+
treeNodeSelected = true;
|
|
293
|
+
fetchAbort?.abort();
|
|
294
|
+
const abort = new AbortController();
|
|
295
|
+
fetchAbort = abort;
|
|
296
|
+
|
|
297
|
+
loading = true;
|
|
298
|
+
error = '';
|
|
299
|
+
loadMoreError = '';
|
|
300
|
+
currentQuery = MATCH_ALL;
|
|
301
|
+
entries = [];
|
|
302
|
+
totalCount = 0;
|
|
303
|
+
offset = 0;
|
|
304
|
+
|
|
305
|
+
if (entryIds.length === 0) {
|
|
306
|
+
entries = [];
|
|
307
|
+
totalCount = 0;
|
|
308
|
+
loading = false;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const dc = getClient(dcApiUrl);
|
|
313
|
+
dc.catalogEntries.query({ entryIds, sourceTypes }, abort.signal)
|
|
314
|
+
.then((result) => {
|
|
315
|
+
if (abort.signal.aborted) return;
|
|
316
|
+
const scoped = filterEntriesByConnection(result.items ?? [], sourceConnectionId);
|
|
317
|
+
const items = deduplicateEntries(scoped);
|
|
318
|
+
entries = items;
|
|
319
|
+
totalCount = items.length;
|
|
320
|
+
offset = items.length;
|
|
321
|
+
upsertSelectedCache(selectedCache, items);
|
|
322
|
+
loading = false;
|
|
323
|
+
})
|
|
324
|
+
.catch((err: any) => {
|
|
325
|
+
if (abort.signal.aborted) return;
|
|
326
|
+
error = err?.message ?? 'Failed to load node entries';
|
|
327
|
+
entries = [];
|
|
328
|
+
loading = false;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
238
332
|
const remaining = $derived(remainingCount(entries.length, totalCount));
|
|
239
333
|
</script>
|
|
240
334
|
|
|
241
|
-
<
|
|
242
|
-
{
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
{
|
|
248
|
-
|
|
249
|
-
{
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
335
|
+
<div class="picker-tabs" role="tablist">
|
|
336
|
+
<button type="button" class="tab" class:active={activeTab === 'search'} role="tab" aria-selected={activeTab === 'search'} onclick={() => selectTab('search')}>Search</button>
|
|
337
|
+
<button type="button" class="tab" class:active={activeTab === 'tree'} role="tab" aria-selected={activeTab === 'tree'} onclick={() => selectTab('tree')}>Asset</button>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
{#if activeTab === 'search'}
|
|
341
|
+
{#if dcApiUrl}
|
|
342
|
+
<LabelFacet client={getClient(dcApiUrl)} bind:selectedLabelIds={activeLabelIds} onChange={handleLabelChange} />
|
|
343
|
+
{/if}
|
|
344
|
+
<TagBrowser
|
|
345
|
+
{entries}
|
|
346
|
+
bind:selectedIds
|
|
347
|
+
{columns}
|
|
348
|
+
{loading}
|
|
349
|
+
{loadingMore}
|
|
350
|
+
{error}
|
|
351
|
+
{loadMoreError}
|
|
352
|
+
{emptyMessage}
|
|
353
|
+
{totalCount}
|
|
354
|
+
{remaining}
|
|
355
|
+
{serverSearch}
|
|
356
|
+
{searching}
|
|
357
|
+
{onSelectionChange}
|
|
358
|
+
onSearch={handleServerSearch}
|
|
359
|
+
onLoadMore={handleLoadMore}
|
|
360
|
+
/>
|
|
361
|
+
{:else}
|
|
362
|
+
<div class="tree-layout">
|
|
363
|
+
<div class="tree-pane">
|
|
364
|
+
{#if dcApiUrl}
|
|
365
|
+
<AssetTree client={getClient(dcApiUrl)} onSelectNode={handleSelectNode} />
|
|
366
|
+
{/if}
|
|
367
|
+
</div>
|
|
368
|
+
<div class="tree-entries">
|
|
369
|
+
<TagBrowser
|
|
370
|
+
{entries}
|
|
371
|
+
bind:selectedIds
|
|
372
|
+
{columns}
|
|
373
|
+
{loading}
|
|
374
|
+
loadingMore={false}
|
|
375
|
+
{error}
|
|
376
|
+
loadMoreError={''}
|
|
377
|
+
emptyMessage={treeEmptyMessage}
|
|
378
|
+
{totalCount}
|
|
379
|
+
remaining={0}
|
|
380
|
+
serverSearch={false}
|
|
381
|
+
searching={false}
|
|
382
|
+
{onSelectionChange}
|
|
383
|
+
onSearch={null}
|
|
384
|
+
onLoadMore={null}
|
|
385
|
+
/>
|
|
386
|
+
</div>
|
|
387
|
+
</div>
|
|
388
|
+
{/if}
|
|
258
389
|
|
|
259
390
|
{#if selectedIds.length > 0}
|
|
260
391
|
<SelectedTags
|
|
@@ -268,12 +399,17 @@
|
|
|
268
399
|
{/if}
|
|
269
400
|
|
|
270
401
|
<style>
|
|
271
|
-
.
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
.helper-text.warning {
|
|
277
|
-
color: var(--cds-support-warning, #f1c21b);
|
|
402
|
+
.picker-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--cds-border-subtle-01, #393939); margin-bottom: 0.5rem; }
|
|
403
|
+
.tab {
|
|
404
|
+
padding: 0.5rem 1rem; background: transparent; border: none;
|
|
405
|
+
border-bottom: 2px solid transparent; color: var(--cds-text-secondary, #c6c6c6);
|
|
406
|
+
font-size: 0.8125rem; cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
|
278
407
|
}
|
|
408
|
+
.tab:hover { color: var(--cds-text-primary, #f4f4f4); }
|
|
409
|
+
.tab.active { color: var(--cds-text-primary, #f4f4f4); border-bottom-color: var(--cds-border-interactive, #052FAD); }
|
|
410
|
+
.tree-layout { display: grid; grid-template-columns: minmax(180px, 40%) 1fr; gap: 0.75rem; align-items: start; }
|
|
411
|
+
.tree-pane { min-width: 0; }
|
|
412
|
+
.tree-entries { min-width: 0; }
|
|
413
|
+
.helper-text { color: var(--cds-text-secondary, #525252); font-size: 0.75rem; margin-top: 0.25rem; }
|
|
414
|
+
.helper-text.warning { color: var(--cds-support-warning, #f1c21b); }
|
|
279
415
|
</style>
|