fix(db): add ondelete cascade to all FK constraints, fix multimodal flag persistence
- Add ondelete=CASCADE/SET NULL to all environment and translate FK constraints - Add is_multimodal to GET /api/settings/consolidated response - Fix toggleActive to not overwrite is_multimodal with false - New SearchableMultiSelect component for dashboard search with multi-select - Fix validation task page: task data unwrapping, date formatting, dashboard multi-select - Fix infinite effect loop on dashboards page via queueMicrotask guard - 3 new Alembic migrations (merge f0e9d8c7b6a5, translate b0c1d2e3f4a5)
This commit is contained in:
246
frontend/src/lib/components/ui/SearchableMultiSelect.svelte
Normal file
246
frontend/src/lib/components/ui/SearchableMultiSelect.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<!-- #region SearchableMultiSelect [C:3] [TYPE Component] [SEMANTICS ui, multi-select, search, combobox, filter] -->
|
||||
<!-- @BRIEF Searchable multi-select combobox with dropdown, checkboxes, Select All/Clear, and selected chips. Like dataset search in translate but with multi-select. -->
|
||||
<!-- @UX_STATE default -> Input with dropdown hidden -->
|
||||
<!-- @UX_STATE dropdown_open -> Dropdown visible with filtered options -->
|
||||
<!-- @UX_STATE empty -> No options match the search filter -->
|
||||
<!-- @UX_STATE loading -> Spinner in input while fetching options -->
|
||||
<!-- @RELATION CALLED_BY -> [DashboardHub] -->
|
||||
<!-- @RELATION CALLED_BY -> [ValidationTaskConfig] -->
|
||||
<script>
|
||||
/**
|
||||
* Searchable multi-select combobox.
|
||||
* @prop {Array<{id: string, name: string, subtitle?: string, hint?: string}>} options - Available items
|
||||
* @prop {string[]} selected - Bindable array of selected item IDs
|
||||
* @prop {string} label - Label text above input
|
||||
* @prop {string} placeholder - Placeholder for search input
|
||||
* @prop {string} emptyMessage - Text shown when no options match
|
||||
* @prop {boolean} loading - Show spinner in input
|
||||
* @prop {string} selectAllLabel - Text for Select All button
|
||||
* @prop {string} clearLabel - Text for Clear button
|
||||
* @prop {number} debounceMs - Debounce delay for search callback (0 = no debounce)
|
||||
* @prop {(query: string) => void} onSearch - Callback when user types (for server-side)
|
||||
* @prop {boolean} disabled - Disable the input
|
||||
*/
|
||||
let {
|
||||
options = [],
|
||||
selected = $bindable([]),
|
||||
label = '',
|
||||
placeholder = 'Search...',
|
||||
emptyMessage = 'No matches found',
|
||||
loading = false,
|
||||
selectAllLabel = 'Select All',
|
||||
clearLabel = 'Clear',
|
||||
debounceMs = 0,
|
||||
onSearch = undefined,
|
||||
disabled = false,
|
||||
} = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let isOpen = $state(false);
|
||||
let inputEl = $state(null);
|
||||
let containerEl = $state(null);
|
||||
let debounceTimer = $state(null);
|
||||
|
||||
let filteredOptions = $derived(
|
||||
searchQuery
|
||||
? options.filter(
|
||||
(o) =>
|
||||
o.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(o.subtitle &&
|
||||
o.subtitle.toLowerCase().includes(searchQuery.toLowerCase())),
|
||||
)
|
||||
: options,
|
||||
);
|
||||
|
||||
let allFilteredSelected = $derived(
|
||||
filteredOptions.length > 0 &&
|
||||
filteredOptions.every((o) => selected.includes(o.id)),
|
||||
);
|
||||
|
||||
let someFilteredSelected = $derived(
|
||||
filteredOptions.some((o) => selected.includes(o.id)),
|
||||
);
|
||||
|
||||
function handleInput(e) {
|
||||
searchQuery = e.target.value;
|
||||
if (debounceMs > 0 && onSearch) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => onSearch(searchQuery), debounceMs);
|
||||
}
|
||||
if (!isOpen) isOpen = true;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) isOpen = true;
|
||||
}
|
||||
|
||||
function handleClickOutside(e) {
|
||||
if (containerEl && !containerEl.contains(e.target)) {
|
||||
isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id) {
|
||||
if (selected.includes(id)) {
|
||||
selected = selected.filter((s) => s !== id);
|
||||
} else {
|
||||
selected = [...selected, id];
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllFiltered() {
|
||||
const filteredIds = filteredOptions.map((o) => o.id);
|
||||
if (allFilteredSelected) {
|
||||
// Deselect all filtered
|
||||
selected = selected.filter((s) => !filteredIds.includes(s));
|
||||
} else {
|
||||
// Select all filtered
|
||||
const newSelected = new Set([...selected, ...filteredIds]);
|
||||
selected = Array.from(newSelected);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selected = [];
|
||||
}
|
||||
|
||||
function removeItem(id) {
|
||||
selected = selected.filter((s) => s !== id);
|
||||
}
|
||||
|
||||
function getItemById(id) {
|
||||
return options.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
// Click outside handler
|
||||
$effect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
clearTimeout(debounceTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
</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
|
||||
bind:this={inputEl}
|
||||
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' : ''}"
|
||||
/>
|
||||
{#if loading}
|
||||
<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 selected.length > 0 && !isOpen}
|
||||
<div class="absolute right-2.5 top-2.5 text-xs font-medium text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded-full">
|
||||
{selected.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected Chips -->
|
||||
{#if selected.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mt-1.5">
|
||||
{#each selected as id}
|
||||
{@const item = getItemById(id)}
|
||||
{#if item}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 text-blue-700 rounded-full text-xs font-medium border border-blue-200">
|
||||
{item.name}
|
||||
<button
|
||||
onclick={() => removeItem(id)}
|
||||
class="hover:text-blue-900 hover:bg-blue-100 rounded-full p-0.5"
|
||||
aria-label="Remove {item.name}"
|
||||
>
|
||||
<svg class="w-3 h-3" 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>
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
<button
|
||||
onclick={clearAll}
|
||||
class="text-xs text-gray-500 hover:text-gray-700 px-1 py-0.5"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div class="absolute z-50 mt-1 w-full bg-white border border-gray-200 rounded-lg shadow-lg">
|
||||
<!-- Select All / Clear toolbar -->
|
||||
{#if filteredOptions.length > 0}
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100 bg-gray-50 rounded-t-lg">
|
||||
<button
|
||||
onclick={selectAllFiltered}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
{allFilteredSelected ? 'Deselect all' : selectAllLabel}
|
||||
<span class="text-gray-400 font-normal">
|
||||
({selected.length}/{options.length})
|
||||
</span>
|
||||
</button>
|
||||
{#if selected.length > 0}
|
||||
<button
|
||||
onclick={clearAll}
|
||||
class="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
{clearLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Options list -->
|
||||
<div class="max-h-60 overflow-y-auto" role="listbox">
|
||||
{#if loading && options.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 filteredOptions.length === 0}
|
||||
<div class="px-3 py-6 text-center text-sm text-gray-400 italic">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredOptions as opt}
|
||||
<label
|
||||
class="flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-blue-50 cursor-pointer transition-colors border-b border-gray-50 last:border-b-0 {selected.includes(opt.id) ? 'bg-blue-50/50' : ''}"
|
||||
role="option"
|
||||
aria-selected={selected.includes(opt.id)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(opt.id)}
|
||||
onchange={() => toggle(opt.id)}
|
||||
class="rounded border-gray-300 text-blue-600 focus-visible:ring-blue-500"
|
||||
/>
|
||||
<span class="font-medium text-gray-900">{opt.name}</span>
|
||||
{#if opt.subtitle}
|
||||
<span class="text-gray-400 text-xs">{opt.subtitle}</span>
|
||||
{/if}
|
||||
{#if opt.hint}
|
||||
<span class="ml-auto text-xs text-gray-400">{opt.hint}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- #endregion SearchableMultiSelect -->
|
||||
Reference in New Issue
Block a user