@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
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
/** Load the dictionary list when a client becomes available. */
|
|
23
|
+
$effect(() => {
|
|
24
|
+
if (client) loadDictionaries(client);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
async function loadDictionaries(dc) {
|
|
28
|
+
loading = true;
|
|
29
|
+
error = '';
|
|
30
|
+
try {
|
|
31
|
+
const result = await dc.assetDictionaries.get({ asTree: true });
|
|
32
|
+
dictionaries = result.items ?? [];
|
|
33
|
+
const first = firstDictionaryId(dictionaries);
|
|
34
|
+
if (first) {
|
|
35
|
+
activeDictionaryId = first;
|
|
36
|
+
await loadTree(dc, first);
|
|
37
|
+
} else {
|
|
38
|
+
rootNodes = [];
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
error = err?.message ?? 'Failed to load asset dictionaries';
|
|
42
|
+
dictionaries = [];
|
|
43
|
+
rootNodes = [];
|
|
44
|
+
} finally {
|
|
45
|
+
loading = false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadTree(dc, id) {
|
|
50
|
+
loading = true;
|
|
51
|
+
error = '';
|
|
52
|
+
try {
|
|
53
|
+
const dict = await dc.assetDictionaries.getById(id, { asTree: true });
|
|
54
|
+
rootNodes = dict.nodes ?? [];
|
|
55
|
+
expanded = new Set(rootNodes.map(n => n.id));
|
|
56
|
+
} catch (err) {
|
|
57
|
+
error = err?.message ?? 'Failed to load the asset tree';
|
|
58
|
+
rootNodes = [];
|
|
59
|
+
} finally {
|
|
60
|
+
loading = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function onDictionaryChange(e) {
|
|
65
|
+
activeDictionaryId = e.target.value;
|
|
66
|
+
selectedNodeId = '';
|
|
67
|
+
if (client && activeDictionaryId) loadTree(client, activeDictionaryId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function toggleExpand(id) {
|
|
71
|
+
const next = new Set(expanded);
|
|
72
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
73
|
+
expanded = next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function selectNode(node) {
|
|
77
|
+
selectedNodeId = node.id;
|
|
78
|
+
onSelectNode?.(collectEntryIds(node), node.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function retry() {
|
|
82
|
+
if (client) loadDictionaries(client);
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<div class="asset-tree">
|
|
87
|
+
{#if dictionaries.length > 1}
|
|
88
|
+
<select class="dict-select" value={activeDictionaryId} onchange={onDictionaryChange}>
|
|
89
|
+
{#each dictionaries as dict (dict.id)}
|
|
90
|
+
<option value={dict.id}>{dict.name}</option>
|
|
91
|
+
{/each}
|
|
92
|
+
</select>
|
|
93
|
+
{/if}
|
|
94
|
+
|
|
95
|
+
{#if loading}
|
|
96
|
+
<div class="tree-state"><div class="spinner"></div> <span>Loading tree…</span></div>
|
|
97
|
+
{:else if error}
|
|
98
|
+
<div class="tree-state error">{error} — <button class="link-button" onclick={retry}>retry</button></div>
|
|
99
|
+
{:else if rootNodes.length === 0}
|
|
100
|
+
<div class="tree-state muted">No asset dictionary available.</div>
|
|
101
|
+
{:else}
|
|
102
|
+
<ul class="tree-root">
|
|
103
|
+
{#each rootNodes as node (node.id)}
|
|
104
|
+
{@render treeNode(node, 0)}
|
|
105
|
+
{/each}
|
|
106
|
+
</ul>
|
|
107
|
+
{/if}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{#snippet treeNode(node, depth)}
|
|
111
|
+
{@const hasChildren = node.children && node.children.length > 0}
|
|
112
|
+
{@const isOpen = expanded.has(node.id)}
|
|
113
|
+
<li class="tree-item">
|
|
114
|
+
<div
|
|
115
|
+
class="node-row"
|
|
116
|
+
class:selected={selectedNodeId === node.id}
|
|
117
|
+
style="padding-left: {depth * 1}rem"
|
|
118
|
+
>
|
|
119
|
+
{#if hasChildren}
|
|
120
|
+
<button type="button" class="twisty" class:open={isOpen} onclick={() => toggleExpand(node.id)} aria-label="Toggle">
|
|
121
|
+
<svg viewBox="0 0 16 16" width="12" height="12"><path d="M11 8L6 13V3z" fill="currentColor"></path></svg>
|
|
122
|
+
</button>
|
|
123
|
+
{:else}
|
|
124
|
+
<span class="twisty-spacer"></span>
|
|
125
|
+
{/if}
|
|
126
|
+
<button type="button" class="node-label" onclick={() => selectNode(node)}>
|
|
127
|
+
{node.name}
|
|
128
|
+
{#if node.entryIds && node.entryIds.length > 0}<span class="node-count">{node.entryIds.length}</span>{/if}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
{#if hasChildren && isOpen}
|
|
132
|
+
<ul>
|
|
133
|
+
{#each node.children as child (child.id)}
|
|
134
|
+
{@render treeNode(child, depth + 1)}
|
|
135
|
+
{/each}
|
|
136
|
+
</ul>
|
|
137
|
+
{/if}
|
|
138
|
+
</li>
|
|
139
|
+
{/snippet}
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
.asset-tree { font-size: 0.8125rem; }
|
|
143
|
+
.dict-select {
|
|
144
|
+
width: 100%; margin-bottom: 0.5rem; padding: 0.375rem 0.5rem;
|
|
145
|
+
background: var(--cds-field-01, #353535); color: var(--cds-text-primary, #f4f4f4);
|
|
146
|
+
border: none; border-bottom: 1px solid var(--cds-border-strong-01, #6f6f6f); font-size: 0.8125rem;
|
|
147
|
+
}
|
|
148
|
+
.tree-state { display: flex; align-items: center; gap: 0.5rem; padding: 1rem; color: var(--cds-text-secondary, #c6c6c6); }
|
|
149
|
+
.tree-state.error { color: var(--cds-support-error, #fa4d56); }
|
|
150
|
+
.tree-state.muted { color: var(--cds-text-helper, #8d8d8d); }
|
|
151
|
+
.tree-root, .tree-item ul { list-style: none; margin: 0; padding: 0; }
|
|
152
|
+
.tree-root { max-height: 400px; overflow-y: auto; border: 1px solid var(--cds-border-subtle-01, #393939); }
|
|
153
|
+
.node-row { display: flex; align-items: center; gap: 0.25rem; cursor: pointer; }
|
|
154
|
+
.node-row:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
|
|
155
|
+
.node-row.selected { background: var(--cds-layer-selected-01, #3d3d3d); }
|
|
156
|
+
.twisty, .twisty-spacer { width: 1.25rem; height: 1.5rem; flex-shrink: 0; }
|
|
157
|
+
.twisty { background: none; border: none; color: var(--cds-text-secondary, #c6c6c6); cursor: pointer; padding: 0; }
|
|
158
|
+
.twisty svg { transition: transform 0.15s ease; }
|
|
159
|
+
.twisty.open svg { transform: rotate(90deg); }
|
|
160
|
+
.node-label {
|
|
161
|
+
flex: 1; display: flex; align-items: center; gap: 0.375rem;
|
|
162
|
+
background: none; border: none; color: var(--cds-text-primary, #f4f4f4);
|
|
163
|
+
text-align: left; padding: 0.25rem 0.25rem; cursor: pointer; font-size: 0.8125rem;
|
|
164
|
+
}
|
|
165
|
+
.node-count {
|
|
166
|
+
font-size: 0.625rem; padding: 0 0.375rem; border-radius: 1rem;
|
|
167
|
+
background: var(--cds-tag-background-gray, #393939); color: var(--cds-tag-color-gray, #c6c6c6);
|
|
168
|
+
}
|
|
169
|
+
.spinner {
|
|
170
|
+
width: 1rem; height: 1rem;
|
|
171
|
+
border: 2px solid var(--cds-border-subtle-01, #e0e0e0);
|
|
172
|
+
border-top-color: var(--cds-interactive, #052FAD);
|
|
173
|
+
border-radius: 50%; animation: spin 0.8s linear infinite;
|
|
174
|
+
}
|
|
175
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
176
|
+
.link-button {
|
|
177
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
178
|
+
color: var(--cds-link-primary, #78a9ff); text-decoration: underline; font-size: inherit;
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -2,37 +2,46 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* DCCatalogEntryPicker — DataCatalog entry picker with configurable columns.
|
|
4
4
|
*
|
|
5
|
-
* Composes TagBrowser (searchable table with multi-select) and
|
|
6
|
-
* (collapsible summary with remove). Owns the DataCatalog fetch
|
|
5
|
+
* Composes TagBrowser (searchable, paginated table with multi-select) and
|
|
6
|
+
* SelectedTags (collapsible summary with remove). Owns the DataCatalog fetch
|
|
7
|
+
* lifecycle, pagination state, and the selected-entry metadata cache.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* with 1s debounce, AbortSignal cancels in-flight requests).
|
|
9
|
+
* Strategy (see docs/superpowers/specs/2026-06-30-tag-picker-lazy-pagination-design.md):
|
|
10
|
+
* - Always load page 1 on open — never a blank table.
|
|
11
|
+
* - Lazy "load more" pagination via offset; totalCount drives the counter and end.
|
|
12
|
+
* - Search: client-side instant when the whole catalog is loaded (small catalogs),
|
|
13
|
+
* debounced server search otherwise.
|
|
14
|
+
* - Selected entries are cached independently of the loaded window so their
|
|
15
|
+
* metadata always renders.
|
|
16
16
|
*
|
|
17
17
|
* @prop dcApiUrl — DataCatalog API base URL
|
|
18
18
|
* @prop sourceConnectionId — Filters catalog entries by source connection
|
|
19
19
|
* @prop selectedIds — (bindable) Array of selected CatalogEntry IDs
|
|
20
20
|
* @prop entries — (bindable) Loaded catalog entries (exposed for parent access)
|
|
21
|
-
* @prop columns — Column keys for the browse table
|
|
22
|
-
* @prop selectedColumnsDisplay — Column keys for the selected tags panel
|
|
21
|
+
* @prop columns — Column keys for the browse table
|
|
22
|
+
* @prop selectedColumnsDisplay — Column keys for the selected tags panel
|
|
23
23
|
* @prop emptyMessage — Message when no entries match the source connection
|
|
24
24
|
* @prop onSelectionChange — Called when selection changes (receives full selectedIds array)
|
|
25
25
|
* @prop onRemove — Called when a single entry is removed from the selection
|
|
26
26
|
*/
|
|
27
|
-
import { untrack } from 'svelte';
|
|
28
27
|
import { DataCatalogClient } from '@industream/datacatalog-client';
|
|
29
28
|
import type { CatalogEntry } from '@industream/datacatalog-client/dto';
|
|
30
29
|
import { DEFAULT_COLUMNS, DEFAULT_SELECTED_COLUMNS_DISPLAY } from './picker-column-helpers';
|
|
30
|
+
import {
|
|
31
|
+
PAGE_SIZE,
|
|
32
|
+
MATCH_ALL,
|
|
33
|
+
mergePage,
|
|
34
|
+
deduplicateEntries,
|
|
35
|
+
normalizeQuery,
|
|
36
|
+
isAllLoaded,
|
|
37
|
+
hasMore,
|
|
38
|
+
remainingCount,
|
|
39
|
+
upsertSelectedCache,
|
|
40
|
+
resolveSelected
|
|
41
|
+
} from './picker-pagination';
|
|
31
42
|
import TagBrowser from './TagBrowser.svelte';
|
|
32
43
|
import SelectedTags from './SelectedTags.svelte';
|
|
33
44
|
|
|
34
|
-
const MAX_ENTRIES = 100;
|
|
35
|
-
|
|
36
45
|
interface Props {
|
|
37
46
|
dcApiUrl?: string;
|
|
38
47
|
sourceConnectionId?: string;
|
|
@@ -58,33 +67,46 @@
|
|
|
58
67
|
}: Props = $props();
|
|
59
68
|
|
|
60
69
|
let loading = $state(false);
|
|
70
|
+
let loadingMore = $state(false);
|
|
61
71
|
let searching = $state(false);
|
|
62
72
|
let error = $state('');
|
|
63
|
-
let
|
|
73
|
+
let loadMoreError = $state('');
|
|
74
|
+
let totalCount = $state(0);
|
|
75
|
+
let currentQuery = $state(MATCH_ALL);
|
|
76
|
+
let offset = $state(0);
|
|
64
77
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Metadata for selected tags, kept across reloads/searches so SelectedTags always
|
|
80
|
+
* renders. Invariant: every mutation of this Map is paired with an `entries`
|
|
81
|
+
* reassignment, so the `selectedEntries` derived (which reads both) re-runs and picks
|
|
82
|
+
* up the change. Do NOT upsert the cache without also reassigning `entries`.
|
|
83
|
+
*/
|
|
84
|
+
let selectedCache = new Map<string, CatalogEntry>();
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
|
-
*
|
|
87
|
+
* Best-effort total for the current query. The server's search response MAY omit
|
|
88
|
+
* `totalCount`; when it does, we must not silently cap pagination at one page.
|
|
89
|
+
* Fall back to: a full page → "at least one more page" (loaded + PAGE_SIZE) so the
|
|
90
|
+
* load-more affordance stays available; a short page → exactly what is loaded (the end).
|
|
74
91
|
*/
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
for (const item of items) {
|
|
79
|
-
const id = item.id;
|
|
80
|
-
if (!seen.has(id)) {
|
|
81
|
-
seen.add(id);
|
|
82
|
-
result.push(item);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
return result;
|
|
92
|
+
function estimateTotal(reported: number | undefined, loadedCount: number, lastPageLen: number): number {
|
|
93
|
+
if (reported != null) return reported;
|
|
94
|
+
return lastPageLen < PAGE_SIZE ? loadedCount : loadedCount + PAGE_SIZE;
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
/** Abort controller for cancelling in-flight requests. */
|
|
98
|
+
let fetchAbort: AbortController | null = null;
|
|
99
|
+
|
|
100
|
+
/** Cached client instance — recreated when dcApiUrl changes. */
|
|
101
|
+
let client: DataCatalogClient | null = null;
|
|
102
|
+
let clientUrl = '';
|
|
103
|
+
|
|
104
|
+
/** Whether search runs server-side (large catalog) or client-side (everything loaded). */
|
|
105
|
+
const serverSearch = $derived(!isAllLoaded(MATCH_ALL, entries.length, totalCount) || currentQuery !== MATCH_ALL);
|
|
106
|
+
|
|
107
|
+
/** Resolved entries for the selected-tags panel: window first, cache fallback. */
|
|
108
|
+
const selectedEntries = $derived(resolveSelected(entries, selectedCache, selectedIds));
|
|
109
|
+
|
|
88
110
|
function getClient(apiUrl: string): DataCatalogClient {
|
|
89
111
|
if (client && clientUrl === apiUrl) return client;
|
|
90
112
|
client = new DataCatalogClient({ baseUrl: apiUrl });
|
|
@@ -96,135 +118,124 @@
|
|
|
96
118
|
if (dcApiUrl) {
|
|
97
119
|
loadInitial(dcApiUrl, sourceConnectionId);
|
|
98
120
|
} else {
|
|
121
|
+
fetchAbort?.abort();
|
|
99
122
|
entries = [];
|
|
123
|
+
totalCount = 0;
|
|
100
124
|
error = '';
|
|
101
|
-
tooMany = false;
|
|
102
125
|
}
|
|
103
126
|
});
|
|
104
127
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
async function loadInitial(apiUrl: string, connId?: string) {
|
|
111
|
-
// Cancel any in-flight search
|
|
112
|
-
searchAbort?.abort();
|
|
113
|
-
searchAbort = null;
|
|
128
|
+
/** (Re)load page 1 for the match-all query. Never leaves the table blank on success. */
|
|
129
|
+
async function loadInitial(apiUrl: string, connId: string = '') {
|
|
130
|
+
fetchAbort?.abort();
|
|
131
|
+
const abort = new AbortController();
|
|
132
|
+
fetchAbort = abort;
|
|
114
133
|
|
|
115
134
|
loading = true;
|
|
116
135
|
error = '';
|
|
117
|
-
|
|
136
|
+
loadMoreError = '';
|
|
137
|
+
currentQuery = MATCH_ALL;
|
|
138
|
+
offset = 0;
|
|
118
139
|
entries = [];
|
|
140
|
+
totalCount = 0;
|
|
119
141
|
|
|
120
142
|
try {
|
|
121
143
|
const dc = getClient(apiUrl);
|
|
122
|
-
const result = await dc.catalogEntries.search(
|
|
144
|
+
const result = await dc.catalogEntries.search(MATCH_ALL, {
|
|
123
145
|
sourceConnectionIds: connId ? [connId] : [],
|
|
124
|
-
limit:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
tooMany = true;
|
|
134
|
-
entries = uniqueItems.slice(0, MAX_ENTRIES);
|
|
135
|
-
} else {
|
|
136
|
-
tooMany = false;
|
|
137
|
-
entries = uniqueItems;
|
|
138
|
-
}
|
|
146
|
+
limit: PAGE_SIZE,
|
|
147
|
+
offset: 0
|
|
148
|
+
}, abort.signal);
|
|
149
|
+
if (abort.signal.aborted) return;
|
|
150
|
+
const items = deduplicateEntries(result.items ?? []);
|
|
151
|
+
entries = items;
|
|
152
|
+
totalCount = estimateTotal(result.totalCount, items.length, items.length);
|
|
153
|
+
offset = items.length;
|
|
154
|
+
upsertSelectedCache(selectedCache, items);
|
|
139
155
|
} catch (err: any) {
|
|
140
|
-
|
|
156
|
+
if (abort.signal.aborted) return;
|
|
157
|
+
error = err?.message ?? 'Failed to load catalog entries';
|
|
141
158
|
entries = [];
|
|
142
159
|
} finally {
|
|
143
|
-
loading = false;
|
|
160
|
+
if (!abort.signal.aborted) loading = false;
|
|
144
161
|
}
|
|
145
162
|
}
|
|
146
163
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
149
|
-
* Cancels previous in-flight request via AbortSignal.
|
|
150
|
-
*/
|
|
151
|
-
function handleServerSearch(query: string) {
|
|
164
|
+
/** Server-side search (large catalogs). Resets pagination for the new query. */
|
|
165
|
+
function handleServerSearch(rawQuery: string) {
|
|
152
166
|
if (!dcApiUrl) return;
|
|
153
|
-
|
|
154
|
-
// Cancel previous in-flight search
|
|
155
|
-
searchAbort?.abort();
|
|
156
|
-
|
|
157
|
-
if (!query) {
|
|
158
|
-
// Empty query in too-many mode → restore the first-N preview.
|
|
159
|
-
searching = false;
|
|
160
|
-
loadInitial(dcApiUrl, sourceConnectionId);
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
167
|
+
fetchAbort?.abort();
|
|
164
168
|
const abort = new AbortController();
|
|
165
|
-
|
|
169
|
+
fetchAbort = abort;
|
|
170
|
+
|
|
171
|
+
const query = normalizeQuery(rawQuery);
|
|
172
|
+
currentQuery = query;
|
|
173
|
+
offset = 0;
|
|
174
|
+
entries = [];
|
|
175
|
+
totalCount = 0;
|
|
176
|
+
error = '';
|
|
177
|
+
loadMoreError = '';
|
|
166
178
|
searching = true;
|
|
167
179
|
|
|
168
180
|
const dc = getClient(dcApiUrl);
|
|
169
181
|
dc.catalogEntries.search(query, {
|
|
170
182
|
sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
|
|
171
|
-
limit:
|
|
183
|
+
limit: PAGE_SIZE,
|
|
184
|
+
offset: 0
|
|
172
185
|
}, abort.signal)
|
|
173
186
|
.then((result) => {
|
|
174
187
|
if (abort.signal.aborted) return;
|
|
175
|
-
const items = result.items ?? [];
|
|
176
|
-
entries =
|
|
188
|
+
const items = deduplicateEntries(result.items ?? []);
|
|
189
|
+
entries = items;
|
|
190
|
+
totalCount = estimateTotal(result.totalCount, items.length, items.length);
|
|
191
|
+
offset = items.length;
|
|
192
|
+
upsertSelectedCache(selectedCache, items);
|
|
177
193
|
searching = false;
|
|
178
194
|
})
|
|
179
|
-
.catch((err) => {
|
|
195
|
+
.catch((err: any) => {
|
|
180
196
|
if (abort.signal.aborted) return;
|
|
181
|
-
error = err
|
|
197
|
+
error = err?.message ?? 'Search failed';
|
|
182
198
|
entries = [];
|
|
183
199
|
searching = false;
|
|
184
200
|
});
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
* selected tags that happen to be in `entries` → on large sources it shows
|
|
192
|
-
* "No tags selected" / loses the selection visually despite a correct count.
|
|
193
|
-
*/
|
|
194
|
-
let selectedEntries = $state<CatalogEntry[]>([]);
|
|
203
|
+
/** Append the next page for the current query. No-op when everything is loaded. */
|
|
204
|
+
function handleLoadMore() {
|
|
205
|
+
if (!dcApiUrl || loadingMore || loading || searching) return;
|
|
206
|
+
if (!hasMore(entries.length, totalCount)) return;
|
|
195
207
|
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (!url || ids.length === 0) {
|
|
201
|
-
selectedEntries = [];
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Reuse already-resolved entries that are still selected; fetch only the missing.
|
|
206
|
-
const idSet = new Set(ids);
|
|
207
|
-
const current = untrack(() => selectedEntries);
|
|
208
|
-
const keep = current.filter((e) => idSet.has(e.id));
|
|
209
|
-
const have = new Set(keep.map((e) => e.id));
|
|
210
|
-
const missing = ids.filter((id) => !have.has(id));
|
|
208
|
+
fetchAbort?.abort();
|
|
209
|
+
const abort = new AbortController();
|
|
210
|
+
fetchAbort = abort;
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
212
|
+
loadingMore = true;
|
|
213
|
+
loadMoreError = '';
|
|
214
|
+
const startOffset = offset;
|
|
216
215
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
const dc = getClient(dcApiUrl);
|
|
217
|
+
dc.catalogEntries.search(currentQuery, {
|
|
218
|
+
sourceConnectionIds: sourceConnectionId ? [sourceConnectionId] : [],
|
|
219
|
+
limit: PAGE_SIZE,
|
|
220
|
+
offset: startOffset
|
|
221
|
+
}, abort.signal)
|
|
222
|
+
.then((result) => {
|
|
223
|
+
if (abort.signal.aborted) return;
|
|
224
|
+
const items = result.items ?? [];
|
|
225
|
+
entries = mergePage(entries, items);
|
|
226
|
+
totalCount = estimateTotal(result.totalCount, entries.length, items.length);
|
|
227
|
+
offset = entries.length;
|
|
228
|
+
upsertSelectedCache(selectedCache, items);
|
|
229
|
+
loadingMore = false;
|
|
223
230
|
})
|
|
224
|
-
.catch(() => {
|
|
225
|
-
|
|
231
|
+
.catch((err: any) => {
|
|
232
|
+
if (abort.signal.aborted) return;
|
|
233
|
+
loadMoreError = err?.message ?? 'Failed to load more entries';
|
|
234
|
+
loadingMore = false;
|
|
226
235
|
});
|
|
227
|
-
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const remaining = $derived(remainingCount(entries.length, totalCount));
|
|
228
239
|
</script>
|
|
229
240
|
|
|
230
241
|
<TagBrowser
|
|
@@ -232,17 +243,22 @@
|
|
|
232
243
|
bind:selectedIds
|
|
233
244
|
{columns}
|
|
234
245
|
{loading}
|
|
246
|
+
{loadingMore}
|
|
235
247
|
{error}
|
|
248
|
+
{loadMoreError}
|
|
236
249
|
{emptyMessage}
|
|
237
|
-
{
|
|
238
|
-
|
|
250
|
+
{totalCount}
|
|
251
|
+
{remaining}
|
|
252
|
+
serverSearch={serverSearch}
|
|
239
253
|
{searching}
|
|
240
|
-
{
|
|
254
|
+
{onSelectionChange}
|
|
255
|
+
onSearch={handleServerSearch}
|
|
256
|
+
onLoadMore={handleLoadMore}
|
|
241
257
|
/>
|
|
242
258
|
|
|
243
259
|
{#if selectedIds.length > 0}
|
|
244
260
|
<SelectedTags
|
|
245
|
-
entries={selectedEntries
|
|
261
|
+
entries={selectedEntries}
|
|
246
262
|
bind:selectedIds
|
|
247
263
|
columns={selectedColumnsDisplay}
|
|
248
264
|
{onRemove}
|
|
@@ -0,0 +1,89 @@
|
|
|
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
|
+
/** Load labels once a client is available. */
|
|
18
|
+
$effect(() => {
|
|
19
|
+
if (client) loadLabels(client);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
async function loadLabels(dc) {
|
|
23
|
+
loading = true;
|
|
24
|
+
error = '';
|
|
25
|
+
try {
|
|
26
|
+
const result = await dc.labels.get();
|
|
27
|
+
labels = result.items ?? [];
|
|
28
|
+
} catch (err) {
|
|
29
|
+
error = err?.message ?? 'Failed to load labels';
|
|
30
|
+
labels = [];
|
|
31
|
+
} finally {
|
|
32
|
+
loading = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toggle(id) {
|
|
37
|
+
selectedLabelIds = selectedLabelIds.indexOf(id) >= 0
|
|
38
|
+
? selectedLabelIds.filter(x => x !== id)
|
|
39
|
+
: [...selectedLabelIds, id];
|
|
40
|
+
onChange?.(selectedLabelIds);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function clearAll() {
|
|
44
|
+
if (selectedLabelIds.length === 0) return;
|
|
45
|
+
selectedLabelIds = [];
|
|
46
|
+
onChange?.(selectedLabelIds);
|
|
47
|
+
}
|
|
48
|
+
</script>
|
|
49
|
+
|
|
50
|
+
{#if loading}
|
|
51
|
+
<div class="label-facet muted">Loading labels…</div>
|
|
52
|
+
{:else if error}
|
|
53
|
+
<div class="label-facet notice">Labels unavailable — search works without label filtering.</div>
|
|
54
|
+
{:else if labels.length > 0}
|
|
55
|
+
<div class="label-facet">
|
|
56
|
+
<span class="facet-label">Labels:</span>
|
|
57
|
+
{#each labels as label (label.id)}
|
|
58
|
+
{@const active = selectedLabelIds.includes(label.id)}
|
|
59
|
+
<button type="button" class="chip" class:active onclick={() => toggle(label.id)}>{label.name}</button>
|
|
60
|
+
{/each}
|
|
61
|
+
{#if selectedLabelIds.length > 0}
|
|
62
|
+
<button type="button" class="chip clear" onclick={clearAll}>Clear</button>
|
|
63
|
+
{/if}
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
66
|
+
|
|
67
|
+
<style>
|
|
68
|
+
.label-facet {
|
|
69
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem;
|
|
70
|
+
padding: 0.5rem 0; font-size: 0.75rem;
|
|
71
|
+
}
|
|
72
|
+
.facet-label { color: var(--cds-text-secondary, #c6c6c6); margin-right: 0.25rem; }
|
|
73
|
+
.muted { color: var(--cds-text-helper, #8d8d8d); }
|
|
74
|
+
.notice { color: var(--cds-support-warning, #f1c21b); }
|
|
75
|
+
.chip {
|
|
76
|
+
padding: 0.125rem 0.5rem;
|
|
77
|
+
background: var(--cds-tag-background-gray, #393939);
|
|
78
|
+
color: var(--cds-tag-color-gray, #c6c6c6);
|
|
79
|
+
border: 1px solid transparent; border-radius: 1rem;
|
|
80
|
+
font-size: 0.6875rem; cursor: pointer; transition: background-color 0.15s, border-color 0.15s;
|
|
81
|
+
}
|
|
82
|
+
.chip:hover { background: var(--cds-layer-hover-01, #4c4c4c); }
|
|
83
|
+
.chip.active {
|
|
84
|
+
background: var(--cds-layer-selected-01, #052FAD);
|
|
85
|
+
color: var(--cds-text-on-color, #ffffff);
|
|
86
|
+
border-color: var(--cds-focus, #052FAD);
|
|
87
|
+
}
|
|
88
|
+
.chip.clear { background: transparent; border-color: var(--cds-border-strong-01, #6f6f6f); }
|
|
89
|
+
</style>
|