fix
This commit is contained in:
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal file
214
frontend/src/lib/components/ui/DatabaseSearchCombobox.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<!-- #region DatabaseSearchCombobox [C:3] [TYPE Component] [SEMANTICS ui, database, search, combobox, single-select] -->
|
||||
<!-- @BRIEF Searchable single-select combobox for Superset databases. Shows name and engine. -->
|
||||
<!-- @UX_STATE default -> Input with dropdown hidden, selected db name shown if selected -->
|
||||
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered database options -->
|
||||
<!-- @UX_STATE empty -> No databases match the search filter -->
|
||||
<!-- @UX_STATE loading -> Spinner in input while fetching databases -->
|
||||
<!-- @UX_STATE error -> Toast shown on API failure -->
|
||||
<!-- @UX_FEEDBACK Toast on API fetch error -->
|
||||
<!-- @UX_REACTIVITY envId -> resets databases 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 Superset databases.
|
||||
* @prop {string} envId - Environment ID for API calls
|
||||
* @prop {string} value - Bindable selected database 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 databases...',
|
||||
disabled = false,
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let databases = $state([]);
|
||||
let isLoading = $state(false);
|
||||
let containerEl = $state(null);
|
||||
let selectedDb = $state(null);
|
||||
|
||||
// Find the currently selected database
|
||||
$effect(() => {
|
||||
if (value && databases.length > 0) {
|
||||
const found = databases.find(d => String(d.id) === String(value));
|
||||
if (found) {
|
||||
selectedDb = found;
|
||||
searchQuery = found.database_name;
|
||||
}
|
||||
} else if (!value) {
|
||||
selectedDb = null;
|
||||
if (!isOpen) searchQuery = '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleInput(e) {
|
||||
searchQuery = e.target.value;
|
||||
if (!isOpen) isOpen = true;
|
||||
// Filtering is handled by $derived(filteredDatabases)
|
||||
}
|
||||
|
||||
async function fetchDatabases() {
|
||||
if (!envId) return;
|
||||
isLoading = true;
|
||||
try {
|
||||
const result = await api.getEnvironmentDatabases(envId);
|
||||
databases = result || [];
|
||||
if (value) {
|
||||
selectedDb = databases.find(d => String(d.id) === String(value)) || selectedDb;
|
||||
if (selectedDb) searchQuery = selectedDb.database_name;
|
||||
}
|
||||
} catch (e) {
|
||||
databases = [];
|
||||
addToast(e.message || 'Failed to load databases', 'error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
isOpen = true;
|
||||
if (databases.length === 0 && envId) {
|
||||
fetchDatabases();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectDatabase(db) {
|
||||
value = String(db.id);
|
||||
selectedDb = db;
|
||||
searchQuery = db.database_name;
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
value = '';
|
||||
selectedDb = null;
|
||||
searchQuery = '';
|
||||
isOpen = true;
|
||||
if (envId) fetchDatabases();
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (containerEl && !containerEl.contains(e.target)) {
|
||||
isOpen = false;
|
||||
if (selectedDb && !isOpen) {
|
||||
searchQuery = selectedDb.database_name;
|
||||
} else if (!selectedDb && !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) {
|
||||
databases = [];
|
||||
selectedDb = null;
|
||||
if (!value) searchQuery = '';
|
||||
if (isOpen) fetchDatabases();
|
||||
}
|
||||
});
|
||||
|
||||
let filteredDatabases = $derived(
|
||||
searchQuery
|
||||
? databases.filter(
|
||||
(db) =>
|
||||
db.database_name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(db.engine && db.engine.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
: databases,
|
||||
);
|
||||
</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 && databases.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 filteredDatabases.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
||||
{searchQuery ? 'No databases match your search' : 'No databases available'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredDatabases as db (db.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectDatabase(db)}
|
||||
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(db.id) === String(value) ? 'bg-blue-50' : ''}"
|
||||
role="option"
|
||||
aria-selected={String(db.id) === String(value)}
|
||||
>
|
||||
<span class="font-medium text-gray-900">{db.database_name}</span>
|
||||
{#if db.engine}
|
||||
<span class="ml-auto text-xs text-gray-400">{db.engine}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion DatabaseSearchCombobox -->
|
||||
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal file
204
frontend/src/lib/components/ui/DatasetSearchCombobox.svelte
Normal file
@@ -0,0 +1,204 @@
|
||||
<!-- #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 -->
|
||||
Reference in New Issue
Block a user