Files
ss-tools/frontend/src/lib/components/translate/BulkCorrectionSidebar.svelte
busya 67ba04d4ff feat: implement LLM table translation service with searchable datasource dropdown, i18n, sidebar, and RBAC
- Add backend plugin modules: preview, executor, orchestrator, events, sql_generator, superset_executor, dictionary, scheduler, metrics
- Add API routes for translate jobs, runs, dictionaries, preview, schedule, metrics, corrections
- Add ORM models (12 tables) and Pydantic schemas (15 DTOs)
- Register translate router in app.py with RBAC permission guards
- Add frontend pages: job list, job config, dictionary list/editor, history
- Add 7 reusable Svelte 5 components: Preview, RunProgress, RunResult, TermCorrection, BulkCorrection, ScheduleConfig, MetricsDashboard
- Add searchable datasource dropdown with Superset API integration
- Add i18n (en/ru) with ~300 keys across common, config, jobs, dictionaries, preview namespaces
- Add Translation sidebar category with Jobs/Dictionaries/History sub-items
- Hide health monitor error toast via suppressToast API option
- Add 69 backend tests and 44 frontend test files
- Fix: SupersetClient env resolution (string -> Environment object)
- Fix: Dataset detail API returns proper database dict
- Fix: Database dialect extraction fallback when metadata incomplete
2026-05-09 19:34:25 +03:00

293 lines
12 KiB
Svelte

<!-- [DEF:BulkCorrectionSidebar:Component] -->
<script>
/**
* @COMPLEXITY: 4
* @PURPOSE: Sidebar for collecting multiple incorrect terms across different rows,
* per-term correction inputs, submit atomically to dictionary.
* @LAYER: UI
* @RELATION: DEPENDS_ON -> [TranslateApi]
* @RELATION: DEPENDS_ON -> [dictionaryApi]
*
* @UX_STATE: closed -> Sidebar hidden
* @UX_STATE: collecting -> Items being collected, user can edit corrections
* @UX_STATE: reviewing -> User reviews collected corrections before submit
* @UX_STATE: submitting -> API call in progress
* @UX_STATE: submitted -> Success feedback
*
* @UX_FEEDBACK: Toast on success/error; count badge on toggle button
* @UX_RECOVERY: Remove individual items; retry submission
*/
import { t } from '$lib/i18n';
import { addToast } from '$lib/toasts.js';
import { submitBulkCorrections, dictionaryApi } from '$lib/api/translate.js';
let {
runId = '',
/** @type {Array<{sourceTerm: string, incorrectTarget: string, rowKey?: string}>} */
initialItems = [],
onClose = () => {},
onSubmitted = () => {}
} = $props();
/** @type {'closed'|'collecting'|'reviewing'|'submitting'|'submitted'} */
let uxState = $state('closed');
let items = $state([]);
let dictionaries = $state([]);
let selectedDictId = $state('');
let submitResult = $state(null);
let isOpen = $state(false);
// Open sidebar when items are provided
$effect(() => {
if (initialItems.length > 0) {
// Merge new items into existing collection, deduplicate by sourceTerm+incorrectTarget
for (const newItem of initialItems) {
const exists = items.some(
i => i.sourceTerm === newItem.sourceTerm && i.incorrectTarget === newItem.incorrectTarget
);
if (!exists) {
items = [...items, { ...newItem, correctedTarget: '' }];
}
}
if (!isOpen) {
openSidebar();
}
}
});
async function openSidebar() {
if (items.length === 0) return;
isOpen = true;
uxState = 'collecting';
await loadDictionaries();
}
function closeSidebar() {
isOpen = false;
uxState = 'closed';
onClose();
}
async function loadDictionaries() {
try {
const result = await dictionaryApi.fetchDictionaries({ page_size: 100 });
dictionaries = Array.isArray(result) ? result : (result?.items || []);
if (dictionaries.length === 1) {
selectedDictId = dictionaries[0].id;
}
} catch (err) {
addToast($t.translate?.common?.error + ': ' + ($t.translate?.corrections?.loading || 'Failed to load dictionaries'), 'error');
}
}
function updateCorrection(index, value) {
items = items.map((item, i) => i === index ? { ...item, correctedTarget: value } : item);
}
function removeItem(index) {
items = items.filter((_, i) => i !== index);
if (items.length === 0) {
closeSidebar();
}
}
async function handleSubmitAll() {
if (!selectedDictId || items.length === 0) return;
// Validate all items have corrected targets
const invalidItems = items.filter(i => !i.correctedTarget.trim());
if (invalidItems.length > 0) {
addToast($t.translate?.corrections?.submit_failed || 'All items must have a corrected target', 'error');
return;
}
uxState = 'submitting';
try {
const corrections = items.map(item => ({
source_term: item.sourceTerm,
incorrect_target_term: item.incorrectTarget,
corrected_target_term: item.correctedTarget.trim(),
origin_run_id: runId || undefined,
origin_row_key: item.rowKey || undefined,
}));
const result = await submitBulkCorrections({
corrections,
dictionary_id: selectedDictId,
});
submitResult = result;
uxState = 'submitted';
const successCount = result.created_count || result.updated_count || corrections.length;
const totalCount = corrections.length;
if (successCount === totalCount) {
addToast($t.translate?.corrections?.submit_success || 'All corrections submitted', 'success');
} else {
addToast(
($t.translate?.corrections?.submit_partial || '{success} of {total} submitted')
.replace('{success}', successCount)
.replace('{total}', totalCount),
'info'
);
}
onSubmitted(result);
} catch (err) {
addToast(err?.message || $t.translate?.corrections?.submit_failed || 'Failed to submit corrections', 'error');
uxState = 'collecting';
}
}
function resetAndClose() {
items = [];
submitResult = null;
closeSidebar();
}
// Derived
let validItemCount = $derived(items.filter(i => i.correctedTarget.trim()).length);
</script>
<!-- Toggle button -->
<button
onclick={openSidebar}
class="fixed right-0 top-1/3 z-40 flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded-l-lg shadow-lg hover:bg-blue-700 transition-all translate-x-0"
class:translate-x-[360px]={isOpen}
title={$t.translate?.corrections?.title}
>
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span class="text-xs font-medium">{$t.translate?.corrections?.title}</span>
{#if items.length > 0}
<span class="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
{items.length}
</span>
{/if}
</button>
<!-- Sidebar overlay -->
{#if isOpen}
<div class="fixed inset-0 z-30 bg-black/20" onclick={closeSidebar}></div>
<div class="fixed right-0 top-0 z-40 h-full w-[360px] bg-white shadow-2xl border-l border-gray-200 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<div>
<h3 class="text-sm font-semibold text-gray-900">{$t.translate?.corrections?.title}</h3>
<p class="text-xs text-gray-500">{$t.translate?.corrections?.subtitle}</p>
</div>
<button onclick={closeSidebar} class="p-1 text-gray-400 hover:text-gray-600 rounded hover:bg-gray-100">
<svg class="w-5 h-5" 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>
<!-- Body -->
<div class="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{#if uxState === 'collecting'}
{#if items.length === 0}
<div class="text-center py-8">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-sm text-gray-500">{$t.translate?.corrections?.no_items}</p>
<p class="text-xs text-gray-400 mt-1">{$t.translate?.corrections?.add_hint}</p>
</div>
{:else}
{#each items as item, idx}
<div class="border border-gray-200 rounded-lg p-3 space-y-2">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-500">{$t.translate?.corrections?.source_term}</p>
<p class="text-sm font-mono text-gray-800 truncate">{item.sourceTerm}</p>
</div>
<button
onclick={() => removeItem(idx)}
class="ml-2 p-0.5 text-gray-400 hover:text-red-500 rounded"
title={$t.translate?.corrections?.remove}
>
<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>
<div>
<p class="text-xs font-medium text-red-500">{$t.translate?.corrections?.incorrect_target}</p>
<p class="text-sm font-mono text-red-600 truncate">{item.incorrectTarget}</p>
</div>
<div>
<label class="text-xs font-medium text-green-600">{$t.translate?.corrections?.corrected_target}</label>
<input
type="text"
value={item.correctedTarget}
oninput={(e) => updateCorrection(idx, e.target.value)}
placeholder={$t.translate?.corrections?.corrected_placeholder}
class="w-full mt-1 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-green-500 focus:border-green-500"
/>
</div>
</div>
{/each}
{/if}
{:else if uxState === 'submitting'}
<div class="flex flex-col items-center justify-center py-12">
<div class="animate-spin w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full mb-3"></div>
<p class="text-sm text-gray-500">{$t.translate?.corrections?.submitting}</p>
</div>
{:else if uxState === 'submitted'}
<div class="text-center py-8">
<svg class="w-12 h-12 text-green-500 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<p class="text-sm font-medium text-gray-900">{$t.translate?.corrections?.submit_success}</p>
{#if submitResult}
<p class="text-xs text-gray-500 mt-1">
{$t.translate?.corrections?.submit_partial
?.replace('{success}', submitResult.created_count || submitResult.updated_count || items.length)
?.replace('{total}', items.length)}
</p>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-t border-gray-200 px-4 py-3 space-y-3">
{#if uxState === 'collecting' && items.length > 0}
<!-- Dictionary selector -->
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">{$t.translate?.corrections?.dictionary}</label>
<select
bind:value={selectedDictId}
class="w-full px-2 py-1.5 border border-gray-300 rounded text-sm"
>
<option value="">{$t.translate?.corrections?.select_dictionary}</option>
{#each dictionaries as dict}
<option value={dict.id}>{dict.name}</option>
{/each}
</select>
</div>
<button
onclick={handleSubmitAll}
disabled={!selectedDictId || validItemCount === 0}
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{$t.translate?.corrections?.submit_all?.replace('{count}', validItemCount)}
</button>
{:else if uxState === 'submitted'}
<button
onclick={resetAndClose}
class="w-full px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{$t.translate?.common?.close}
</button>
{/if}
</div>
</div>
{/if}
<!-- [/DEF:BulkCorrectionSidebar:Component] -->