Files
ss-tools/frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
2026-05-26 18:15:37 +03:00

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 -->