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:
2026-05-22 09:21:24 +03:00
parent 48cc02ea0b
commit 0653a75ee7
13 changed files with 549 additions and 73 deletions

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