205 lines
7.2 KiB
Svelte
205 lines
7.2 KiB
Svelte
<!-- #region DatasetSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, dataset, search, combobox, single-select] -->
|
|
<!-- @BRIEF Searchable single-select combobox for datasets. Shows name, schema, and database. -->
|
|
<!-- @UX_STATE default -> Input with dropdown hidden, selected dataset name shown if selected -->
|
|
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered dataset options -->
|
|
<!-- @UX_STATE empty -> No datasets match the search filter -->
|
|
<!-- @UX_STATE loading -> Spinner in input while fetching datasets from API -->
|
|
<!-- @UX_STATE error -> Toast shown on API failure, dropdown shows error message -->
|
|
<!-- @UX_FEEDBACK Toast on API fetch error -->
|
|
<!-- @UX_REACTIVITY envId -> resets datasets on change, triggers refetch; Props -> $props(); LocalState -> $state(...) -->
|
|
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:api] -->
|
|
<!-- @RELATION CALLED_BY -> [MapperTool] -->
|
|
<script>
|
|
import { api } from '$lib/api';
|
|
import { addToast } from '$lib/toasts.js';
|
|
|
|
/**
|
|
* Searchable single-select combobox for datasets.
|
|
* @prop {string} envId - Environment ID for API calls
|
|
* @prop {string} value - Bindable selected dataset ID
|
|
* @prop {string} label - Label text above input
|
|
* @prop {string} placeholder - Placeholder for search input
|
|
* @prop {boolean} disabled - Disable the input
|
|
*/
|
|
let {
|
|
envId = '',
|
|
value = $bindable(''),
|
|
label = '',
|
|
placeholder = 'Search datasets...',
|
|
disabled = false,
|
|
} = $props();
|
|
|
|
let searchQuery = $state('');
|
|
let isOpen = $state(false);
|
|
let datasets = $state([]);
|
|
let isLoading = $state(false);
|
|
let containerEl = $state(null);
|
|
let selectedDataset = $state(null);
|
|
|
|
// Find the currently selected dataset from the loaded list
|
|
$effect(() => {
|
|
if (value && datasets.length > 0) {
|
|
const found = datasets.find(d => String(d.id) === String(value));
|
|
if (found) {
|
|
selectedDataset = found;
|
|
searchQuery = found.table_name;
|
|
}
|
|
} else if (!value) {
|
|
selectedDataset = null;
|
|
if (!isOpen) searchQuery = '';
|
|
}
|
|
});
|
|
|
|
async function handleInput(e) {
|
|
searchQuery = e.target.value;
|
|
if (!isOpen) isOpen = true;
|
|
await fetchDatasets(searchQuery);
|
|
}
|
|
|
|
async function fetchDatasets(search) {
|
|
if (!envId) return;
|
|
isLoading = true;
|
|
try {
|
|
const result = await api.getDatasets(envId, { search: search || undefined, page_size: 50 });
|
|
datasets = result?.datasets || result || [];
|
|
// Re-apply selection highlight
|
|
if (value) {
|
|
selectedDataset = datasets.find(d => String(d.id) === String(value)) || selectedDataset;
|
|
}
|
|
} catch (e) {
|
|
datasets = [];
|
|
addToast(e.message || 'Failed to load datasets', 'error');
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
}
|
|
|
|
function handleFocus() {
|
|
if (!disabled) {
|
|
isOpen = true;
|
|
if (datasets.length === 0 && envId) {
|
|
fetchDatasets(searchQuery);
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectDataset(dataset) {
|
|
value = String(dataset.id);
|
|
selectedDataset = dataset;
|
|
searchQuery = dataset.table_name;
|
|
isOpen = false;
|
|
}
|
|
|
|
function clearSelection() {
|
|
value = '';
|
|
selectedDataset = null;
|
|
searchQuery = '';
|
|
isOpen = true;
|
|
if (envId) fetchDatasets('');
|
|
}
|
|
|
|
function handleClickOutside(e) {
|
|
if (containerEl && !containerEl.contains(e.target)) {
|
|
isOpen = false;
|
|
// Restore search query to selected dataset name if any
|
|
if (selectedDataset && !isOpen) {
|
|
searchQuery = selectedDataset.table_name;
|
|
} else if (!selectedDataset && !isOpen) {
|
|
searchQuery = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('click', handleClickOutside);
|
|
return () => document.removeEventListener('click', handleClickOutside);
|
|
}
|
|
});
|
|
|
|
// Reset when envId changes — clear cache so handleFocus triggers refetch
|
|
$effect(() => {
|
|
if (envId) {
|
|
datasets = [];
|
|
selectedDataset = null;
|
|
if (!value) searchQuery = '';
|
|
if (isOpen) fetchDatasets(searchQuery);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<div bind:this={containerEl} class="relative">
|
|
{#if label}
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
|
{/if}
|
|
|
|
<!-- Search Input -->
|
|
<div class="relative">
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
oninput={handleInput}
|
|
onfocus={handleFocus}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
class="w-full px-3 py-2 pr-8 border border-gray-300 rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring {disabled ? 'bg-gray-50 cursor-not-allowed' : ''}"
|
|
role="combobox"
|
|
aria-expanded={isOpen}
|
|
aria-haspopup="listbox"
|
|
/>
|
|
{#if isLoading}
|
|
<div class="absolute right-2.5 top-2.5 animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
|
{:else if value && !isOpen}
|
|
<button
|
|
onclick={clearSelection}
|
|
class="absolute right-2.5 top-2.5 text-gray-400 hover:text-gray-600"
|
|
aria-label="Clear selection"
|
|
type="button"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
{:else if !isOpen}
|
|
<div class="absolute right-2.5 top-2.5 text-gray-400">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Dropdown -->
|
|
{#if isOpen}
|
|
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg" role="listbox">
|
|
<div class="max-h-60 overflow-y-auto">
|
|
{#if isLoading && datasets.length === 0}
|
|
<div class="px-3 py-6 text-center text-sm text-gray-400">
|
|
<div class="inline-block animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
|
|
<p>Loading...</p>
|
|
</div>
|
|
{:else if datasets.length === 0}
|
|
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
|
{searchQuery ? 'No datasets match your search' : 'No datasets available'}
|
|
</div>
|
|
{:else}
|
|
{#each datasets as ds (ds.id)}
|
|
<button
|
|
type="button"
|
|
onclick={() => selectDataset(ds)}
|
|
class="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-blue-50 border-b border-gray-100 last:border-b-0 transition-colors {String(ds.id) === String(value) ? 'bg-blue-50' : ''}"
|
|
role="option"
|
|
aria-selected={String(ds.id) === String(value)}
|
|
>
|
|
<span class="font-medium text-gray-900">{ds.table_name}</span>
|
|
<span class="text-gray-400 shrink-0">{ds.schema || ds.schema_name || 'public'}</span>
|
|
<span class="ml-auto text-xs text-gray-400 shrink-0">{ds.database} · {ds.mapped_fields ? `${ds.mapped_fields.mapped}/${ds.mapped_fields.total}` : 'unknown'}</span>
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<!-- #endregion DatasetSearchCombobox -->
|