feat(datasets): add tooltips, text hints and icon-only action buttons

StatsBar — title-подсказки на все кнопки-фильтры.
DatasetList — action-кнопки заменены на SVG-иконки с tooltip
(title="map_columns_tip" / "generate_docs_tip"), title на
select/deselect и пагинацию.
+page.svelte — title на refresh, bulk-панель, обе модалки
(маппинг колонок и генерация docs): source_type_tip,
database_tip, sql_query_tip, file_input_tip, llm_provider_tip
и текстовые хинты. Добавлены id на form-элементы для a11y.
i18n: 20 новых tooltip-ключей в ru/en.
This commit is contained in:
2026-05-24 09:44:18 +03:00
parent 7219c47357
commit edc500c3e4
6 changed files with 120 additions and 29 deletions

View File

@@ -56,5 +56,29 @@
"save_description": "Save description",
"add_description": "Add description",
"columns": "Columns",
"detail_empty": "No data"
"detail_empty": "No data",
"filter_all_tip": "Show all datasets in the environment",
"filter_unmapped_tip": "Datasets without mapped columns",
"filter_mapped_tip": "Datasets with mapped columns",
"filter_linked_tip": "Datasets linked to Superset dashboards",
"refresh_tip": "Refresh the dataset list",
"select_all_tip": "Select all datasets on this page",
"deselect_all_tip": "Deselect all datasets",
"map_columns_tip": "Run column mapping for this dataset",
"generate_docs_tip": "Generate column descriptions via LLM",
"pagination_first_tip": "First page",
"pagination_prev_tip": "Previous page",
"pagination_next_tip": "Next page",
"pagination_last_tip": "Last page",
"bulk_map_tip": "Bulk column mapping for selected datasets",
"bulk_docs_tip": "Bulk documentation generation for selected datasets",
"clear_selection_tip": "Deselect all datasets",
"source_type_tip": "Select data source: SQL Lab (Superset DB) or XLSX file",
"database_tip": "Superset database to look up column mappings",
"sql_query_tip": "Custom SQL query for mapping. Leave empty for automatic information_schema lookup",
"file_input_tip": "XLSX file with column mapping data",
"start_mapping_tip": "Start mapping task for selected datasets",
"cancel_tip": "Cancel and close",
"llm_provider_tip": "Select LLM provider for documentation generation",
"start_docs_tip": "Start documentation generation for selected datasets"
}

View File

@@ -56,5 +56,29 @@
"save_description": "Сохранить описание",
"add_description": "Добавить описание",
"columns": "Колонки",
"detail_empty": "Нет данных"
"detail_empty": "Нет данных",
"filter_all_tip": "Показать все датасеты в окружении",
"filter_unmapped_tip": "Датасеты без размеченных колонок",
"filter_mapped_tip": "Датасеты с размеченными колонками",
"filter_linked_tip": "Датасеты, связанные с дашбордами Superset",
"refresh_tip": "Обновить список датасетов",
"select_all_tip": "Выбрать все датасеты на странице",
"deselect_all_tip": "Снять выбор со всех датасетов",
"map_columns_tip": "Запустить маппинг колонок для этого датасета",
"generate_docs_tip": "Сгенерировать описание колонок через LLM",
"pagination_first_tip": "Первая страница",
"pagination_prev_tip": "Предыдущая страница",
"pagination_next_tip": "Следующая страница",
"pagination_last_tip": "Последняя страница",
"bulk_map_tip": "Массовый маппинг колонок для выбранных датасетов",
"bulk_docs_tip": "Массовая генерация документации для выбранных датасетов",
"clear_selection_tip": "Снять выбор со всех датасетов",
"source_type_tip": "Выберите источник данных: SQL Lab (база Superset) или XLSX-файл",
"database_tip": "База данных Superset для поиска соответствий колонок",
"sql_query_tip": "Произвольный SQL-запрос для маппинга. Оставьте пустым для автоматического поиска по information_schema",
"file_input_tip": "XLSX-файл с данными маппинга колонок",
"start_mapping_tip": "Запустить задачу маппинга для выбранных датасетов",
"cancel_tip": "Отменить и закрыть",
"llm_provider_tip": "Выберите LLM-провайдера для генерации документации",
"start_docs_tip": "Запустить генерацию документации для выбранных датасетов"
}

View File

@@ -259,7 +259,7 @@
<span class="px-2 py-1 text-xs rounded border border-gray-300 bg-gray-50 text-gray-600">
{$t.datasets?.all}: {stats?.total ?? 0}
</span>
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={() => loadDatasets()}>
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.refresh_tip} onclick={() => loadDatasets()}>
{$t.common?.refresh ?? '⟳'}
</button>
</div>
@@ -331,13 +331,13 @@
<div class="max-w-screen-2xl mx-auto flex items-center justify-between">
<span class="font-medium text-sm">{selectedIds.size} {$t.datasets?.selected}</span>
<div class="flex gap-2">
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={() => { showMapColumnsModal = true; mapSourceType = "sqllab"; mapDatabaseId = null; mapSqlQuery = ""; mapFileData = null; if (selectedEnv) api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); }}>
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.bulk_map_tip} onclick={() => { showMapColumnsModal = true; mapSourceType = "sqllab"; mapDatabaseId = null; mapSqlQuery = ""; mapFileData = null; if (selectedEnv) api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); }}>
🗺 {$t.datasets?.action_map_columns}
</button>
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={() => { showGenerateDocsModal = true; }}>
<button class="px-3 py-1.5 text-sm bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.bulk_docs_tip} onclick={() => { showGenerateDocsModal = true; }}>
📄 {$t.datasets?.generate_docs}
</button>
<button class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-100" onclick={() => selectedIds = new SvelteSet()}>
<button class="px-3 py-1.5 text-sm border border-gray-300 rounded hover:bg-gray-100" title={$t.datasets?.clear_selection_tip} onclick={() => selectedIds = new SvelteSet()}>
{$t.common?.cancel ?? 'Clear'}
</button>
</div>
@@ -351,34 +351,54 @@
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full" style="max-height: 90vh; overflow-y: auto;" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold mb-4">{$t.datasets?.bulk_map_columns}</h3>
<p class="text-sm text-gray-600 mb-3">{$t.datasets?.selected_count?.replace('{count}', String(selectedIds.size))}</p>
<select class="w-full border rounded px-2 py-1.5 mb-3" bind:value={mapSourceType} onchange={() => { if (mapSourceType === 'sqllab' && mapDatabases.length === 0 && selectedEnv) { api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); } }}>
<select
class="w-full border rounded px-2 py-1.5 mb-3"
title={$t.datasets?.source_type_tip}
bind:value={mapSourceType}
onchange={() => { if (mapSourceType === 'sqllab' && mapDatabases.length === 0 && selectedEnv) { api.getEnvironmentDatabases(selectedEnv).then(dbs => mapDatabases = dbs ?? []).catch(() => {}); } }}
>
<option value="sqllab">{$t.datasets?.source_sqllab ?? 'Superset SQL Lab'}</option>
<option value="xlsx">{$t.datasets?.source_xlsx}</option>
</select>
{#if mapSourceType === "sqllab"}
<label class="text-sm font-medium mb-1 block">{$t.datasets?.source_database ?? 'База данных'}</label>
<select class="w-full border rounded px-2 py-1.5 mb-3" bind:value={mapDatabaseId}>
<label class="text-sm font-medium mb-1 block" for="map-database-select">{$t.datasets?.source_database ?? 'База данных'}</label>
<select
id="map-database-select"
class="w-full border rounded px-2 py-1.5 mb-3"
title={$t.datasets?.database_tip}
bind:value={mapDatabaseId}
>
<option value="">-- {$t.datasets?.select_database ?? 'выберите базу данных'} --</option>
{#each mapDatabases as db}
{#each mapDatabases as db (db.id)}
<option value={db.id}>{db.database_name || db.name}</option>
{/each}
</select>
<label class="text-sm font-medium mb-1 block">
<label class="text-sm font-medium mb-1 block" for="map-sql-query">
{$t.datasets?.sql_query ?? 'SQL-запрос'}
<span class="text-xs text-gray-400 font-normal ml-1">({$t.datasets?.sql_query_hint ?? 'оставьте пустым для information_schema'})</span>
</label>
<textarea
id="map-sql-query"
class="w-full border rounded px-2 py-1.5 mb-4 font-mono text-xs"
rows="4"
title={$t.datasets?.sql_query_tip}
placeholder="SELECT column_name, description AS verbose_name FROM my_mappings WHERE table_name = 'my_table';"
bind:value={mapSqlQuery}
></textarea>
{:else}
<input type="file" accept=".xlsx" class="w-full mb-4" onchange={(e) => mapFileData = e.target.files} />
<label class="text-sm font-medium mb-1 block" for="map-file-input">{$t.datasets?.xlsx_file ?? 'XLSX-файл'}</label>
<input
id="map-file-input"
type="file"
accept=".xlsx"
class="w-full mb-4"
title={$t.datasets?.file_input_tip}
onchange={(e) => mapFileData = e.target.files}
/>
{/if}
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" onclick={() => showMapColumnsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" onclick={handleBulkMapColumns}>{$t.datasets?.start_mapping}</button>
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" title={$t.datasets?.cancel_tip} onclick={() => showMapColumnsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.start_mapping_tip} onclick={handleBulkMapColumns}>{$t.datasets?.start_mapping}</button>
</div>
</div>
</div>
@@ -390,15 +410,15 @@
<div class="bg-white rounded-lg shadow-xl p-6 max-w-md w-full" onclick={(e) => e.stopPropagation()}>
<h3 class="text-lg font-semibold mb-4">{$t.datasets?.bulk_docs_generation}</h3>
<p class="text-sm text-gray-600 mb-3">{$t.datasets?.selected_count?.replace('{count}', String(selectedIds.size))}</p>
<select class="w-full border rounded px-2 py-1.5 mb-4" bind:value={llmProvider}>
<select class="w-full border rounded px-2 py-1.5 mb-4" title={$t.datasets?.llm_provider_tip} bind:value={llmProvider}>
<option value="">{$t.datasets?.select_llm_provider_option}</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="local">Local LLM</option>
</select>
<div class="flex justify-end gap-2">
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" onclick={() => showGenerateDocsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" onclick={handleBulkGenerateDocs}>{$t.datasets?.generate_docs}</button>
<button class="px-3 py-1.5 border rounded hover:bg-gray-100" title={$t.datasets?.cancel_tip} onclick={() => showGenerateDocsModal = false}>{$t.common?.cancel}</button>
<button class="px-3 py-1.5 bg-primary text-white rounded hover:bg-primary-hover" title={$t.datasets?.start_docs_tip} onclick={handleBulkGenerateDocs}>{$t.datasets?.generate_docs}</button>
</div>
</div>
</div>

View File

@@ -65,10 +65,12 @@
<div class="flex gap-2">
<button
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50"
title={$t.datasets?.select_all_tip}
onclick={() => { selectedIds = new Set(datasets.map(d => d.id)); oncheckbox?.({}, 'all'); }}
>{$t.datasets?.select_all}</button>
<button
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50"
title={$t.datasets?.deselect_all_tip}
onclick={() => { selectedIds = new Set(); oncheckbox?.({}, 'clear-all'); }}
>{$t.datasets?.deselect_all}</button>
</div>
@@ -134,16 +136,35 @@
</div>
{/if}
</div>
<!-- Quick actions -->
<!-- Quick actions (icon-only) -->
<div class="flex gap-1 shrink-0 items-start">
<button
class="px-2 py-0.5 text-xs border border-gray-300 text-gray-500 rounded hover:bg-gray-50 hover:text-gray-700 transition-colors"
class="p-1 border border-gray-300 text-gray-400 rounded hover:bg-gray-50 hover:text-gray-600 transition-colors"
title={$t.datasets?.map_columns_tip}
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'map_columns'); }}
>{$t.datasets?.action_map_columns}</button>
>
<!-- columns/grid icon -->
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
</button>
<button
class="px-2 py-0.5 text-xs border border-gray-300 text-gray-500 rounded hover:bg-gray-50 hover:text-gray-700 transition-colors"
class="p-1 border border-gray-300 text-gray-400 rounded hover:bg-gray-50 hover:text-gray-600 transition-colors"
title={$t.datasets?.generate_docs_tip}
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'generate_docs'); }}
>{$t.datasets?.generate_docs}</button>
>
<!-- file/text icon -->
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
</button>
</div>
</div>
</div>
@@ -157,11 +178,11 @@
{($t.datasets?.showing ?? 'Showing {start}-{end} of {total}').replace('{start}', String((page - 1) * pageSize + 1)).replace('{end}', String(Math.min(page * pageSize, total))).replace('{total}', String(total))}
</span>
<div class="flex gap-1">
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" onclick={() => onpagechange?.(1)} disabled={page <= 1}</button>
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" onclick={() => onpagechange?.(page - 1)} disabled={page <= 1}></button>
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" title={$t.datasets?.pagination_first_tip} onclick={() => onpagechange?.(1)} disabled={page <= 1}</button>
<button class="px-2 py-1 text-xs border rounded {page <= 1 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" title={$t.datasets?.pagination_prev_tip} onclick={() => onpagechange?.(page - 1)} disabled={page <= 1}></button>
<span class="px-2 py-1 text-xs">{page} / {totalPages}</span>
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" onclick={() => onpagechange?.(page + 1)} disabled={page >= totalPages}></button>
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" onclick={() => onpagechange?.(totalPages)} disabled={page >= totalPages}>»</button>
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" title={$t.datasets?.pagination_next_tip} onclick={() => onpagechange?.(page + 1)} disabled={page >= totalPages}></button>
<button class="px-2 py-1 text-xs border rounded {page >= totalPages ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'}" title={$t.datasets?.pagination_last_tip} onclick={() => onpagechange?.(totalPages)} disabled={page >= totalPages}>»</button>
</div>
</div>
{/if}

View File

@@ -38,6 +38,7 @@
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-all {activeFilter === tile.key ? 'bg-primary text-white shadow-sm' : 'bg-white border border-gray-300 text-gray-600 hover:border-gray-400 hover:text-gray-800'}"
onclick={() => handleTileClick(tile.key)}
aria-pressed={activeFilter === tile.key}
title={$t.datasets?.[tile.key === 'all' ? 'filter_all_tip' : tile.key === 'unmapped' ? 'filter_unmapped_tip' : tile.key === 'mapped' ? 'filter_mapped_tip' : 'filter_linked_tip']}
>
<span>{$t.datasets?.[tile.label] ?? tile.label}</span>
<span class="font-semibold tabular-nums">{tile.getCount(stats)}</span>

View File

@@ -50,10 +50,11 @@ describe('DatasetList Component', () => {
expect(src).toContain('selectedIds');
});
it('has quick-action buttons (map, docs)', () => {
it('has quick-action icon buttons (map, docs)', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('action_map_columns');
expect(src).toContain('generate_docs');
expect(src).toContain('map_columns_tip');
expect(src).toContain('generate_docs_tip');
expect(src).toContain('<svg'); // icon-based, not text
});
it('shows skeleton when loading', () => {