Files
ss-tools/frontend/src/lib/components/translate/ConfigTabForm.svelte
busya a819e1ec4d fix: resolve 60 unresolved @RELATION targets and add @RATIONALE to models
- Batch-fixed [ApiModule.xxx] → [xxx] in 60 @RELATION targets across 26 API files
- Fixed [ToastsModule.addToast] → [addToast:Function] in notifyApiError contract
- Added #region contracts for getMaintenanceEventsWsUrl and getTranslateRunWsUrl
- Added @RATIONALE belief protocol to ValidationTasksListModel, DeploymentModel, MigrationModel
- Semantic audit: unresolved_relation dropped 104 → 43 (-59%)
2026-06-03 16:11:03 +03:00

539 lines
22 KiB
Svelte

<!-- #region ConfigTabForm [C:4] [TYPE Component] [SEMANTICS translate, config, datasource, llm, columns] -->
<!-- @BRIEF Configuration tab content for Translation Job — datasource selection with auto-detected
DB dialect, column mapping, LLM provider, target languages (multi-select from settings-allowed languages),
dictionaries, disable reasoning toggle. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasources] -->
<!-- @RELATION DEPENDS_ON -> [EXT:frontend:fetchDatasourceColumns] -->
<!-- @RELATION DEPENDS_ON -> [getAllowedLanguages] -->
<!-- @RELATION DEPENDS_ON -> [MultiSelect] -->
<!-- @RELATION CALLS -> [filterLanguages]
<!-- @RATIONALE Target language list is now fetched from /settings/allowed-languages instead of static ALL_LANGUAGES.
This ensures only admin-configured languages (Settings → Languages) are shown. Falls back to full list on API error
or when allowed-languages is empty. -->
<!-- @UX_STATE Datasource searching -> Dropdown with filtered results, spinner on load -->
<!-- @UX_STATE Datasource selected -> Columns loaded, DB dialect detected, mapping ready -->
<!-- @UX_STATE Columns loading -> Skeleton pulse in column section -->
<!-- @UX_STATE Validation error -> Red border on invalid fields, inline error text -->
<!-- @UX_FEEDBACK Detected dialect badge (green pill) when datasource selected -->
<!-- @UX_FEEDBACK Spinner in datasource input while searching -->
<!-- @UX_FEEDBACK Virtual column warning (yellow bg + asterisk) -->
<!-- @UX_RECOVERY Datasource not found -> try different search term -->
<!-- @UX_RECOVERY Environment changed -> datasource search cleared, new DB list loaded -->
<!-- @UX_REACTIVITY Props -> $bindable() for all form fields, $props() for environment list -->
<!-- @UX_REACTIVITY LocalState -> datasourceList, datasourceLoading, showDatasourceDropdown -->
<script lang="ts">
import { getT } from '$lib/i18n/index.svelte.js';
import { ALL_LANGUAGES, filterLanguages } from '$lib/i18n/languages.js';
import { api } from '$lib/api.js';
import { fetchDatasources, fetchDatasourceColumns } from '$lib/api/translate.js';
import MultiSelect from '$lib/components/ui/MultiSelect.svelte';
import HelpTooltip from '$lib/ui/HelpTooltip.svelte';
// ---- Bindable form state (parent-owned) ----
let {
name = $bindable(''),
description = $bindable(''),
environmentId = $bindable(''),
environments = [],
datasourceId = $bindable(''),
datasourceSearch = $bindable(''),
databaseDialect = $bindable(''),
availableColumns = [],
virtualColumns = $bindable([]),
sourceKeyCols = $bindable([]),
targetKeyCols = $bindable([]),
translationColumn = $bindable(''),
contextColumns = $bindable([]),
targetLanguages = $bindable(['en']),
targetLanguageColumn = $bindable(''),
targetSourceColumn = $bindable(''),
targetSourceLanguageColumn = $bindable(''),
providerId = $bindable(''),
llmProviders = [],
dictionaryIds = $bindable([]),
availableDictionaries = [],
disableReasoning = $bindable(false),
validationErrors = {},
onEnvChange = () => {},
} = $props();
// ---- Local state ----
let datasourceList = $state([]);
let datasourceLoading = $state(false);
let showDatasourceDropdown = $state(false);
let isColumnsLoading = $state(false);
let columnList = $derived(availableColumns);
let allowedLanguageCodes: string[] = $state([]);
// @RATIONALE Svelte 5 store detection fails for `$t` in deeply nested templates.
// Use getT() + $derived to keep reactivity to locale changes.
const _t = $derived(getT());
// Language options filtered by backend-allowed codes (fallback to full list)
let languageOptions = $derived(
allowedLanguageCodes.length > 0
? filterLanguages(allowedLanguageCodes)
: ALL_LANGUAGES
);
// Load allowed languages from settings once on mount
$effect(() => {
api.getAllowedLanguages().then((codes) => {
if (Array.isArray(codes) && codes.length > 0) {
allowedLanguageCodes = codes;
}
}).catch(() => {
// Non-critical: fall back to full list
allowedLanguageCodes = [];
});
});
// ---- Derived ----
function isVirtual(colName) {
return (virtualColumns || []).includes(colName);
}
// Auto-load columns when datasourceId changes (from parent loadJob or user selection)
$effect(() => {
if (datasourceId && environmentId) {
loadColumnsForDatasource();
}
});
// ---- Datasource search ----
let searchTimer = null;
async function handleDatasourceSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => searchDatasources(datasourceSearch), 300);
}
async function searchDatasources(searchTerm = '') {
if (!environmentId) return;
datasourceLoading = true;
try {
datasourceList = await fetchDatasources(environmentId, searchTerm);
showDatasourceDropdown = true;
} catch {
datasourceList = [];
} finally {
datasourceLoading = false;
}
}
/** Select a dataset from the dropdown */
function selectDatasource(ds) {
datasourceId = String(ds.id);
datasourceSearch = `${ds.table_name} (${ds.database_name} · ${ds.database_dialect})`;
showDatasourceDropdown = false;
availableColumns = [];
virtualColumns = [];
sourceKeyCols = [];
targetKeyCols = [];
translationColumn = '';
targetLanguageColumn = '';
targetSourceColumn = '';
targetSourceLanguageColumn = '';
contextColumns = [];
databaseDialect = ds.database_dialect || '';
}
async function loadColumnsForDatasource() {
if (!datasourceId || !environmentId) return;
isColumnsLoading = true;
try {
const response = await fetchDatasourceColumns(datasourceId, environmentId);
availableColumns = response.columns || response || [];
virtualColumns = response.virtual_columns || [];
} catch {
availableColumns = [];
virtualColumns = [];
} finally {
isColumnsLoading = false;
}
}
// ---- Key column helpers ----
function toggleSourceKeyCol(colName) {
if (sourceKeyCols.includes(colName)) {
const idx = sourceKeyCols.indexOf(colName);
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
} else {
sourceKeyCols = [...sourceKeyCols, colName];
targetKeyCols = [...targetKeyCols, colName];
}
}
function updateTargetKeyCol(idx, value) {
targetKeyCols[idx] = value;
targetKeyCols = targetKeyCols; // trigger reactivity
}
function toggleContextColumn(col) {
if (contextColumns.includes(col)) {
contextColumns = contextColumns.filter(c => c !== col);
} else {
contextColumns = [...contextColumns, col];
}
}
</script>
<div class="space-y-6">
<!-- Basic Info -->
<section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.basic_info}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.name} <span class="text-destructive">*</span>
<HelpTooltip text={_t.translate?.config?.help_name || ''} />
</label>
<input
type="text"
bind:value={name}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.name ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={_t.translate?.config?.name_placeholder}
/>
{#if validationErrors.name}
<p class="text-xs text-destructive mt-1">{validationErrors.name}</p>
{/if}
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.description}
<HelpTooltip text={_t.translate?.config?.help_description || ''} />
</label>
<textarea
bind:value={description}
rows="2"
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={_t.translate?.config?.description_placeholder}
/>
</div>
</div>
</section>
<!-- Datasource & Environment -->
<section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.datasource}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.environment}
<HelpTooltip text={_t.translate?.config?.help_environment || ''} />
</label>
<select
bind:value={environmentId}
onchange={(e) => onEnvChange(e.target.value)}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{_t.translate?.config?.select_environment}</option>
{#each environments as env}
<option value={env.id}>{env.name} ({env.id})</option>
{/each}
</select>
</div>
<div class="relative">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.datasource_id}
<HelpTooltip text={_t.translate?.config?.help_datasource || ''} />
</label>
<input
type="text"
bind:value={datasourceSearch}
oninput={handleDatasourceSearch}
onfocus={() => searchDatasources('')}
onblur={() => setTimeout(() => (showDatasourceDropdown = false), 150)}
class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
placeholder={_t.translate?.config?.datasource_search_placeholder || 'Search datasets...'}
/>
{#if datasourceLoading}
<div class="absolute right-3 top-9 animate-spin h-4 w-4 border-2 border-primary-ring border-t-transparent rounded-full"></div>
{/if}
{#if showDatasourceDropdown}
<div class="absolute z-10 mt-1 w-full bg-surface-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
{#each datasourceList as ds}
<button
type="button"
onmousedown={() => selectDatasource(ds)}
class="w-full text-left px-3 py-2 text-sm hover:bg-primary-light border-b border-border last:border-b-0"
>
<span class="font-medium">{ds.table_name}</span>
<span class="text-text-subtle ml-2">{ds.schema}</span>
<span class="float-right text-xs text-text-subtle">{ds.database_name} · {ds.database_dialect}</span>
</button>
{/each}
</div>
{/if}
</div>
{#if databaseDialect}
<div class="md:col-span-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-light text-success">
{_t.translate?.config?.detected_dialect.replace('{dialect}', databaseDialect)}
</span>
</div>
{/if}
</div>
{#if isColumnsLoading}
<div class="mt-4 h-8 bg-surface-muted rounded animate-pulse" />
{/if}
{#if columnList.length > 0}
<div class="mt-4">
<label class="block text-sm font-medium text-text mb-2">
{_t.translate?.config?.available_columns.replace('{count}', columnList.length)}
</label>
<div class="max-h-40 overflow-y-auto border border-border rounded-lg divide-y divide-border">
{#each columnList as col}
<div class="px-3 py-1.5 text-sm flex items-center gap-2 {isVirtual(col.name) ? 'bg-warning-light' : ''}">
<span class="font-mono text-xs">{col.name}</span>
{#if col.type}
<span class="text-xs text-text-subtle">({col.type})</span>
{/if}
{#if isVirtual(col.name)}
<span class="text-xs text-warning italic">{_t.translate?.config?.virtual}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</section>
<!-- Column Mapping -->
<section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.column_mapping}</h2>
<!-- Translation Column -->
<div class="mb-4">
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.translation_column} <span class="text-destructive">*</span>
<HelpTooltip text={_t.translate?.config?.help_translation_column || ''} />
</label>
<select
bind:value={translationColumn}
class="w-full px-3 py-2 border rounded-lg text-sm {validationErrors.translationColumn ? 'border-destructive-ring bg-destructive-light' : 'border-border-strong'} focus-visible:ring-2 focus-visible:ring-primary-ring focus-visible:border-primary-ring"
>
<option value="">{_t.translate?.config?.select_column}</option>
{#each columnList as col}
<option value={col.name}>{col.name}{isVirtual(col.name) ? ` (${_t.translate?.config?.virtual})` : ''}</option>
{/each}
</select>
{#if validationErrors.translationColumn}
<p class="text-xs text-destructive mt-1">{validationErrors.translationColumn}</p>
{/if}
</div>
<!-- Key Mapping (source -> target pairs) -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-text flex items-center gap-1">
{_t.translate?.config?.key_columns}
<HelpTooltip text={_t.translate?.config?.help_key_columns || ''} />
</label>
{#if sourceKeyCols.length > 0}
<button
onclick={() => { sourceKeyCols = []; targetKeyCols = []; }}
class="text-xs text-destructive hover:text-destructive"
>
{_t.translate?.config?.clear_all}
</button>
{/if}
</div>
{#if columnList.length === 0}
<div class="flex flex-wrap gap-2 mb-3">
<button
onclick={() => {
sourceKeyCols = [...sourceKeyCols, ''];
targetKeyCols = [...targetKeyCols, ''];
}}
class="px-3 py-1 text-xs border border-dashed border-border-strong rounded text-text-muted hover:border-primary-ring hover:text-primary"
>
{_t.translate?.config?.add_key_column}
</button>
</div>
{:else}
<div class="flex flex-wrap gap-2 mb-3">
{#each columnList as col}
<button
onclick={() => toggleSourceKeyCol(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{sourceKeyCols.includes(col.name)
? 'bg-primary-light border-primary-ring text-primary'
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
title={isVirtual(col.name) ? _t.translate?.config?.virtual_column_warning.replace('{col}', col.name) : ''}
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-warning">*</span>
{/if}
</button>
{/each}
</div>
{/if}
{#if sourceKeyCols.length > 0}
<div class="space-y-2">
{#each sourceKeyCols as srcCol, idx}
<div class="flex items-center gap-2">
<input
type="text"
value={srcCol}
disabled
class="flex-1 px-3 py-1.5 border border-border bg-surface-muted rounded text-sm font-mono"
/>
<span class="text-text-subtle text-sm">&rarr;</span>
<input
type="text"
value={targetKeyCols[idx] || ''}
placeholder={_t.translate?.config?.target_column_placeholder}
oninput={(e) => updateTargetKeyCol(idx, e.target.value)}
class="flex-1 px-3 py-1.5 border border-border-strong rounded text-sm font-mono focus-visible:ring-2 focus-visible:ring-primary-ring"
/>
<button
onclick={() => {
sourceKeyCols = sourceKeyCols.filter((_, i) => i !== idx);
targetKeyCols = targetKeyCols.filter((_, i) => i !== idx);
}}
class="p-1 text-text-subtle hover:text-destructive"
>
<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>
</div>
{/each}
</div>
{/if}
{#if validationErrors.targetKeyCols}
<p class="text-xs text-destructive mt-1">{validationErrors.targetKeyCols}</p>
{/if}
</div>
<!-- Context Columns -->
<div>
<label class="block text-sm font-medium text-text mb-2 flex items-center gap-1">
{_t.translate?.config?.context_columns}
<HelpTooltip text={_t.translate?.config?.help_context_columns || ''} />
</label>
{#if columnList.length === 0}
<p class="text-xs text-text-subtle">{_t.translate?.config?.select_datasource_hint}</p>
{:else}
<div class="flex flex-wrap gap-2">
{#each columnList as col}
<button
onclick={() => toggleContextColumn(col.name)}
class="px-2.5 py-1 text-xs rounded-full border transition-colors
{contextColumns.includes(col.name)
? 'bg-info-light border-purple-300 text-info'
: 'bg-surface-card border-border text-text-muted hover:border-border-strong'}"
>
{col.name}
{#if isVirtual(col.name)}
<span class="text-warning">*</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</section>
<!-- LLM Settings -->
<section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4">{_t.translate?.config?.llm_settings}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-text mb-1 flex items-center gap-1">
{_t.translate?.config?.provider}
<HelpTooltip text={_t.translate?.config?.help_provider || ''} />
</label>
<select bind:value={providerId} class="w-full px-3 py-2 border border-border-strong rounded-lg text-sm">
<option value="">{_t.translate?.config?.select_provider}</option>
{#each llmProviders as provider}
<option value={provider.id}>{provider.name || provider.provider_type}</option>
{/each}
</select>
</div>
<div>
<div class="flex items-center gap-1 mb-1">
<span class="text-sm font-medium text-text">{_t.translate?.config?.target_language}</span>
<span class="text-destructive">*</span>
<HelpTooltip text={_t.translate?.config?.help_target_language || ''} />
</div>
<MultiSelect
label=""
options={languageOptions}
bind:selected={targetLanguages}
searchable={true}
placeholder={_t.translate?.config?.target_language_search_placeholder || 'Search languages...'}
required={false}
/>
{#if targetLanguages.length === 0}
<p class="text-xs text-warning mt-1">{_t.translate?.config?.target_language_required || 'Select at least one target language'}</p>
{/if}
</div>
</div>
<!-- Disable reasoning toggle -->
<div class="mt-3">
<label class="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
bind:checked={disableReasoning}
class="mt-0.5 rounded border-border-strong text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-text flex items-center gap-1">
{_t.translate?.config?.disable_reasoning || 'Disable reasoning (save tokens)'}
<HelpTooltip text={_t.translate?.config?.help_disable_reasoning || ''} />
</span>
<p class="text-xs text-text-subtle mt-0.5">
{_t.translate?.config?.disable_reasoning_hint || 'Saves output tokens by suppressing Chain of Thought reasoning'}
</p>
</div>
</label>
</div>
</section>
<!-- Dictionary Attachment -->
<section class="bg-surface-card border border-border rounded-lg p-6">
<h2 class="text-lg font-semibold text-text mb-4 flex items-center gap-2">
{_t.translate?.config?.terminology_dictionaries}
<HelpTooltip text={_t.translate?.config?.help_dictionaries || ''} />
</h2>
{#if availableDictionaries.length === 0}
<p class="text-sm text-text-subtle">
{_t.translate?.config?.no_dictionaries}
</p>
{:else}
<div class="space-y-2">
{#each availableDictionaries as dict}
<label class="flex items-center gap-3 p-3 border border-border rounded-lg hover:bg-surface-muted cursor-pointer">
<input
type="checkbox"
checked={dictionaryIds.includes(dict.id)}
onchange={() => {
if (dictionaryIds.includes(dict.id)) {
dictionaryIds = dictionaryIds.filter(d => d !== dict.id);
} else {
dictionaryIds = [...dictionaryIds, dict.id];
}
}}
class="rounded border-border-strong text-primary focus-visible:ring-primary-ring"
/>
<div>
<span class="text-sm font-medium text-text">{dict.name}</span>
{#if dict.description}
<p class="text-xs text-text-muted">{dict.description}</p>
{/if}
</div>
</label>
{/each}
</div>
{/if}
</section>
</div>
<!-- #endregion ConfigTabForm -->