feat(030): Dataset Lifecycle Workspace — Stats Bar, split-view, inline-edit, bulk actions

Backend:
- Add MetricItem, StatsCounts, ColumnDescriptionUpdate, MetricDescriptionUpdate DTOs
- Add metric_count to DatasetItem and DatasetDetailResponse
- Add server-side filtering via ?filter=unmapped|mapped|linked|all
- Add PUT endpoints for column/metric description inline-edit
- Add HTML stripping validation (max 2000 chars, plain text)
- Add WebSocket dataset.updated event on task completion
- RBAC: plugin:migration:WRITE for mutations, READ for reads
- 14 pytest tests (stats, filter, metrics, inline-edit, validation, 502/503)

Frontend:
- Rewrite +page.svelte as split-view orchestrator (387 lines)
- StatsBar.svelte — 4 metric tiles with aria-pressed, server-side filter dispatch
- DatasetList.svelte — card grid with progress bars, checkboxes, search
- DatasetPreview.svelte — presentational detail panel (container fetches)
- ColumnsTable.svelte — inline-edit with type chips, bold/italic fallback
- MetricsTable.svelte — inline-edit with expression hints
- 52 vitest tests across 7 test files
- i18n: 11 EN + 11 RU keys (with_mapping/с_маппингом)

Spec:
- 15 issues resolved (semantic label, filtering, endpoints, conflict, metric_count, RBAC, ID types, ports, i18n, validation, a11y, propagation, resize, perf, ownership)

Status: 14/14 backend tests pass, 52/52 frontend tests pass, build succeeds
Risk: Low — T051 browser validation pending (non-blocking)
This commit is contained in:
2026-05-21 13:58:47 +03:00
parent 673c0bb217
commit b529e8082a
32 changed files with 2191 additions and 942 deletions

View File

@@ -216,6 +216,7 @@ export const api = {
getDatasets: (envId, opts = {}) => {
const params = new URLSearchParams({ env_id: envId });
if (opts.search) params.append('search', opts.search);
if (opts.filter) params.append('filter', opts.filter);
if (opts.page) params.append('page', opts.page);
if (opts.page_size) params.append('page_size', opts.page_size);
return fetchApi(`/datasets?${params.toString()}`);

View File

@@ -43,5 +43,16 @@
"task_running": "Running...",
"task_done": "Done",
"task_failed": "Failed",
"task_waiting": "Waiting"
"task_waiting": "Waiting",
"all": "All",
"without_mapping": "Without mapping",
"with_mapping": "With mapping",
"linked_to_dashboards": "Linked to dashboards",
"metrics": "Metrics",
"metric_count": "metrics",
"no_selection": "Select a dataset from the list",
"save_description": "Save description",
"add_description": "Add description",
"columns": "Columns",
"detail_empty": "No data"
}

View File

@@ -43,5 +43,16 @@
"task_running": "Выполняется...",
"task_done": "Готово",
"task_failed": "Ошибка",
"task_waiting": "Ожидание"
"task_waiting": "Ожидание",
"all": "Все",
"without_mapping": "Без маппинга",
"with_mapping": "С маппингом",
"linked_to_dashboards": "Связаны с дашбордами",
"metrics": "Метрики",
"metric_count": "метрик",
"no_selection": "Выберите датасет из списка",
"save_description": "Сохранить описание",
"add_description": "Добавить описание",
"columns": "Колонки",
"detail_empty": "Нет данных"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
<!-- #region ColumnsTable [C:4] [TYPE Component] [SEMANTICS ui,dataset,columns,inline-edit] -->
<!-- @BRIEF Table of dataset columns with type chips, description, and inline-edit capability. -->
<!-- @LAYER UI -->
<!-- @RELATION CALLS -> [api_module] -->
<!-- @UX_STATE Display -> All rows in read mode. -->
<!-- @UX_STATE Editing -> One row in edit mode (textarea), others dimmed. -->
<!-- @UX_REACTIVITY Props -> columns, datasetId -->
<script>
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
let { columns = [], datasetId, envId } = $props();
let editingRowId = $state(null);
let editValue = $state("");
let editError = $state(null);
let isSaving = $state(false);
function getTypeColor(type) {
if (!type) return "bg-gray-100 text-gray-700";
const t = type.toLowerCase();
if (t.includes("date") || t.includes("time")) return "bg-green-100 text-green-700";
if (t.includes("string") || t.includes("text") || t.includes("varchar") || t.includes("char")) return "bg-purple-100 text-purple-700";
if (t.includes("int") || t.includes("bigint") || t.includes("smallint")) return "bg-blue-100 text-blue-700";
if (t.includes("float") || t.includes("double") || t.includes("decimal") || t.includes("numeric")) return "bg-orange-100 text-orange-700";
if (t.includes("bool")) return "bg-yellow-100 text-yellow-700";
return "bg-gray-100 text-gray-700";
}
function startEdit(col) {
editingRowId = col.id;
editValue = col.description ?? "";
editError = null;
}
function cancelEdit() {
editingRowId = null;
editValue = "";
editError = null;
}
async function saveEdit(col) {
isSaving = true;
editError = null;
try {
const result = await api.requestApi(
`/datasets/${datasetId}/columns/${col.id}/description?env_id=${envId}`,
"PUT",
{ description: editValue }
);
col.description = result.description;
editingRowId = null;
} catch (e) {
editError = e.message || "Failed to save";
} finally {
isSaving = false;
}
}
</script>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase">
<th class="py-2 px-3 w-1/3">{$t.datasets?.table_name ?? 'Column'}</th>
<th class="py-2 px-3 w-20">{$t.dashboard?.type ?? 'Type'}</th>
<th class="py-2 px-3">{$t.dashboard?.description ?? 'Description'}</th>
<th class="py-2 px-3 w-16"></th>
</tr>
</thead>
<tbody>
{#if columns.length === 0}
<tr><td colspan="4" class="py-4 text-center text-gray-400">{$t.datasets?.detail_empty ?? 'No data'}</td></tr>
{:else}
{#each columns as col (col.id)}
<tr class="border-b border-gray-100 hover:bg-gray-50 {editingRowId && editingRowId !== col.id ? 'opacity-50' : ''}">
<td class="py-2 px-3">
{#if col.verbose_name}
<span class="font-medium">{col.verbose_name}</span>
{:else}
<span class="italic text-gray-600">{col.name}</span>
{/if}
</td>
<td class="py-2 px-3">
<span class="inline-block px-1.5 py-0.5 rounded text-xs font-medium {getTypeColor(col.type)}">
{col.type ?? '—'}
</span>
</td>
<td class="py-2 px-3">
{#if editingRowId === col.id}
<div class="flex flex-col gap-1">
<textarea
class="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 {editError ? 'border-red-400' : 'border-gray-300'}"
rows="2"
maxlength="2000"
bind:value={editValue}
aria-label="Description"
></textarea>
{#if editError}
<span class="text-xs text-red-600" role="alert" aria-live="assertive">{editError}</span>
{/if}
<div class="flex gap-1">
<button
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50"
onclick={() => saveEdit(col)}
disabled={isSaving}
>{isSaving ? '...' : ($t.datasets?.save_description ?? 'Save')}</button>
<button
class="px-2 py-0.5 text-xs border border-gray-300 rounded hover:bg-gray-100"
onclick={cancelEdit}
>{$t.common?.cancel ?? 'Cancel'}</button>
</div>
</div>
{:else if col.description}
<span class="text-gray-700">{col.description}</span>
{:else}
<span class="text-gray-400 italic">{'✏️ ' + ($t.datasets?.add_description ?? 'Add description')}</span>
{/if}
</td>
<td class="py-2 px-3">
{#if editingRowId !== col.id}
<button
class="text-gray-400 hover:text-primary text-sm"
onclick={() => startEdit(col)}
aria-label={$t.datasets?.add_description ?? 'Edit'}
>✏️</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- #endregion ColumnsTable -->

View File

@@ -0,0 +1,168 @@
<!-- #region DatasetList [C:4] [TYPE Component] [SEMANTICS ui,dataset,card,list,pagination] -->
<!-- @BRIEF Renders paginated dataset cards with mapping progress bars, checkboxes, and quick actions. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [environmentContextStore] -->
<!-- @UX_STATE Loading -> Skeleton cards. -->
<!-- @UX_STATE Loaded -> Cards with progress bars. -->
<!-- @UX_STATE Empty -> "No datasets found" after search/filter. -->
<!-- @UX_REACTIVITY Props -> datasets, selectedIds, isLoading, error, total, page, totalPages, pageSize, onselect, onaction, onpagechange, onsearch, oncheckbox -->
<script>
import { t } from "$lib/i18n";
let {
datasets = [],
selectedIds = new Set(),
isLoading = false,
error = null,
total = 0,
page = 1,
totalPages = 1,
pageSize = 10,
onselect,
onaction,
onpagechange,
onsearch,
oncheckbox,
searchQuery = "",
} = $props();
let localSearch = $state(searchQuery);
function getProgressClass(mapped, total) {
if (!total || !mapped) return "bg-gray-200";
const pct = (mapped / total) * 100;
if (pct === 100) return "bg-green-500";
if (pct >= 50) return "bg-yellow-400";
return "bg-blue-400";
}
function handleCardClick(dataset, e) {
if (e.target.closest("input[type=checkbox], button")) return;
onselect?.(dataset.id);
}
function handleCheckboxChange(dataset, e) {
const checked = e.target.checked;
oncheckbox?.(dataset, checked);
}
function handleSearchInput(e) {
localSearch = e.target.value;
onsearch?.(localSearch);
}
</script>
<!-- Search -->
<div class="flex items-center justify-between mb-3 gap-3">
<input
type="text"
class="px-3 py-1.5 border border-gray-300 rounded bg-white text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 w-full max-w-xs"
placeholder={$t.datasets?.search_placeholder ?? "Search datasets..."}
value={localSearch}
oninput={handleSearchInput}
/>
<div class="flex gap-2">
<button
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50"
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"
onclick={() => { selectedIds = new Set(); oncheckbox?.({}, 'clear-all'); }}
>{$t.datasets?.deselect_all}</button>
</div>
</div>
<!-- Loading -->
{#if isLoading}
<div class="space-y-3" aria-busy="true">
{#each Array(3) as _}
<div class="bg-white border border-gray-200 rounded-lg p-4 animate-pulse">
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-3 bg-gray-200 rounded w-1/4 mb-3"></div>
<div class="h-2 bg-gray-200 rounded w-full"></div>
</div>
{/each}
</div>
{:else if error}
<div class="bg-red-50 border border-red-300 text-red-700 px-3 py-2 rounded text-sm">
{error}
</div>
{:else if datasets.length === 0}
<div class="py-8 text-center text-gray-400 text-sm">{$t.datasets?.empty}</div>
{:else}
<!-- Card Grid -->
<div class="space-y-2">
{#each datasets as dataset}
<div
class="bg-white border border-gray-200 rounded-lg p-3 cursor-pointer hover:border-gray-300 hover:shadow-sm transition-all"
onclick={(e) => handleCardClick(dataset, e)}
role="button"
tabindex="0"
>
<div class="flex items-start gap-3">
<!-- Checkbox -->
<input
type="checkbox"
class="mt-0.5 accent-primary"
checked={selectedIds.has(dataset.id)}
onchange={(e) => handleCheckboxChange(dataset, e)}
/>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 truncate">{dataset.table_name}</div>
<div class="text-xs text-gray-500 mt-0.5 flex gap-3 flex-wrap">
<span>{dataset.schema ?? '-'}</span>
<span>{dataset.column_count ?? 0} {$t.datasets?.columns?.toLowerCase() ?? 'columns'}</span>
{#if dataset.metric_count > 0}
<span>{dataset.metric_count} {$t.datasets?.metric_count ?? 'metrics'}</span>
{/if}
</div>
<!-- Progress bar -->
{#if dataset.mappedFields?.total}
<div class="mt-2">
<div class="w-full h-2 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {getProgressClass(dataset.mappedFields.mapped, dataset.mappedFields.total)}"
style="width: {(dataset.mappedFields.mapped / dataset.mappedFields.total) * 100}%"
></div>
</div>
<div class="text-xs text-gray-400 mt-0.5">
{dataset.mappedFields.mapped}/{dataset.mappedFields.total} {$t.datasets?.mapped_of_total}
</div>
</div>
{/if}
</div>
<!-- Quick actions -->
<div class="flex gap-1 shrink-0">
<button
class="px-2 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover"
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'map_columns'); }}
>{$t.datasets?.action_map_columns}</button>
<button
class="px-2 py-1 text-xs bg-primary text-white rounded hover:bg-primary-hover"
onclick={(e) => { e.stopPropagation(); onaction?.(dataset, 'generate_docs'); }}
>{$t.datasets?.generate_docs}</button>
</div>
</div>
</div>
{/each}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-200">
<span class="text-sm text-gray-500">
{($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>
<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>
</div>
</div>
{/if}
{/if}
<!-- #endregion DatasetList -->

View File

@@ -0,0 +1,84 @@
<!-- #region DatasetPreview [C:3] [TYPE Component] [SEMANTICS ui,dataset,detail,columns,metrics] -->
<!-- @BRIEF Right panel showing dataset detail: header info, columns table, metrics table, linked dashboards. Presentational. -->
<!-- @LAYER UI -->
<!-- @RELATION DEPENDS_ON -> [ColumnsTable] -->
<!-- @RELATION DEPENDS_ON -> [MetricsTable] -->
<!-- @UX_STATE NoSelection -> "Select a dataset" placeholder. -->
<!-- @UX_STATE Loading -> Skeleton. -->
<!-- @UX_STATE Loaded -> Full detail. -->
<!-- @UX_STATE Error -> Error banner with retry. -->
<script>
import { t } from "$lib/i18n";
import ColumnsTable from "./ColumnsTable.svelte";
import MetricsTable from "./MetricsTable.svelte";
let { dataset = null, isLoading = false, error = null, onretry, envId } = $props();
</script>
{#if isLoading}
<div class="p-6 space-y-4 animate-pulse" aria-busy="true">
<div class="h-5 bg-gray-200 rounded w-1/2"></div>
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
<div class="space-y-2 mt-4">
{#each Array(3) as _, i (i)}
<div class="h-3 bg-gray-100 rounded w-full"></div>
{/each}
</div>
</div>
{:else if error}
<div class="p-6 flex flex-col items-center justify-center gap-3" role="alert">
<div class="bg-red-50 border border-red-300 text-red-700 px-4 py-3 rounded text-sm">{error}</div>
{#if onretry}
<button class="px-3 py-1 text-sm bg-primary text-white rounded hover:bg-primary-hover" onclick={onretry}>
{$t.common?.retry ?? 'Retry'}
</button>
{/if}
</div>
{:else if !dataset}
<div class="p-6 flex items-center justify-center min-h-[300px] text-gray-400 text-sm">
<div class="text-center">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16"/></svg>
<p>{$t.datasets?.no_selection ?? 'Select a dataset from the list'}</p>
</div>
</div>
{:else}
<div class="overflow-y-auto h-full">
<!-- Header -->
<div class="px-6 pt-5 pb-3 border-b border-gray-200">
<h2 class="text-lg font-bold text-gray-900">{dataset.table_name ?? dataset.id}</h2>
<div class="flex gap-4 text-sm text-gray-500 mt-1 flex-wrap">
{#if dataset.schema}<span>📁 {dataset.schema}</span>{/if}
<span>{dataset.column_count ?? 0} {$t.datasets?.columns?.toLowerCase() ?? 'columns'}</span>
<span>{dataset.metric_count ?? 0} {$t.datasets?.metric_count ?? 'metrics'}</span>
{#if dataset.linked_dashboard_count > 0}
<span>📊 {dataset.linked_dashboard_count} dashboards</span>
{/if}
</div>
{#if dataset.linked_dashboards?.length}
<div class="flex gap-1.5 mt-2 flex-wrap">
{#each dataset.linked_dashboards as dash}
<a href={`/dashboards/${dash.slug || dash.id}`} class="inline-block px-2 py-0.5 text-xs bg-indigo-50 text-indigo-700 rounded-full hover:bg-indigo-100">
{dash.title}
</a>
{/each}
</div>
{/if}
{#if dataset.created_on}
<div class="text-xs text-gray-400 mt-1">{$t.dashboard?.created ?? 'Created'}: {dataset.created_on}</div>
{/if}
</div>
<!-- Columns -->
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="text-sm font-semibold text-gray-700 mb-2">{$t.datasets?.columns ?? 'Columns'} ({dataset.column_count ?? 0})</h3>
<ColumnsTable columns={dataset.columns ?? []} datasetId={dataset.id} {envId} />
</div>
<!-- Metrics -->
<div class="px-6 py-4">
<h3 class="text-sm font-semibold text-gray-700 mb-2">{$t.datasets?.metrics ?? 'Metrics'} ({dataset.metric_count ?? 0})</h3>
<MetricsTable metrics={dataset.metrics ?? []} datasetId={dataset.id} {envId} />
</div>
</div>
{/if}
<!-- #endregion DatasetPreview -->

View File

@@ -0,0 +1,121 @@
<!-- #region MetricsTable [C:4] [TYPE Component] [SEMANTICS ui,dataset,metrics,inline-edit] -->
<!-- @BRIEF Table of dataset metrics with expression, description, and inline-edit capability. -->
<!-- @LAYER UI -->
<!-- @RELATION CALLS -> [api_module] -->
<!-- @UX_STATE Display -> All rows in read mode with expression shown under name. -->
<!-- @UX_STATE Editing -> One row in edit mode. -->
<!-- @UX_REACTIVITY Props -> metrics, datasetId -->
<script>
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
let { metrics = [], datasetId, envId } = $props();
let editingRowId = $state(null);
let editValue = $state("");
let editError = $state(null);
let isSaving = $state(false);
function startEdit(metric) {
editingRowId = metric.id;
editValue = metric.description ?? "";
editError = null;
}
function cancelEdit() {
editingRowId = null;
editValue = "";
editError = null;
}
async function saveEdit(metric) {
isSaving = true;
editError = null;
try {
const result = await api.requestApi(
`/datasets/${datasetId}/metrics/${metric.id}/description?env_id=${envId}`,
"PUT",
{ description: editValue }
);
metric.description = result.description;
editingRowId = null;
} catch (e) {
editError = e.message || "Failed to save";
} finally {
isSaving = false;
}
}
</script>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase">
<th class="py-2 px-3 w-1/3">{$t.datasets?.metrics ?? 'Metric'}</th>
<th class="py-2 px-3">{$t.dashboard?.description ?? 'Description'}</th>
<th class="py-2 px-3 w-16"></th>
</tr>
</thead>
<tbody>
{#if metrics.length === 0}
<tr><td colspan="3" class="py-4 text-center text-gray-400">{$t.datasets?.detail_empty ?? 'No data'}</td></tr>
{:else}
{#each metrics as metric (metric.id)}
<tr class="border-b border-gray-100 hover:bg-gray-50 {editingRowId && editingRowId !== metric.id ? 'opacity-50' : ''}">
<td class="py-2 px-3">
{#if metric.verbose_name}
<span class="font-medium">{metric.verbose_name}</span>
{:else}
<span class="italic text-gray-600">{metric.metric_name}</span>
{/if}
{#if !metric.description && metric.expression}
<div class="text-xs text-gray-400 font-mono mt-0.5">{metric.expression}</div>
{/if}
</td>
<td class="py-2 px-3">
{#if editingRowId === metric.id}
<div class="flex flex-col gap-1">
<textarea
class="w-full border rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-primary/30 {editError ? 'border-red-400' : 'border-gray-300'}"
rows="2"
maxlength="2000"
bind:value={editValue}
aria-label="Description"
></textarea>
{#if editError}
<span class="text-xs text-red-600" role="alert" aria-live="assertive">{editError}</span>
{/if}
<div class="flex gap-1">
<button
class="px-2 py-0.5 text-xs bg-primary text-white rounded hover:bg-primary-hover disabled:opacity-50"
onclick={() => saveEdit(metric)}
disabled={isSaving}
>{isSaving ? '...' : ($t.datasets?.save_description ?? 'Save')}</button>
<button
class="px-2 py-0.5 text-xs border border-gray-300 rounded hover:bg-gray-100"
onclick={cancelEdit}
>{$t.common?.cancel ?? 'Cancel'}</button>
</div>
</div>
{:else if metric.description}
<span class="text-gray-700">{metric.description}</span>
{:else}
<span class="text-gray-400 italic">{'✏️ ' + ($t.datasets?.add_description ?? 'Add description')}</span>
{/if}
</td>
<td class="py-2 px-3">
{#if editingRowId !== metric.id}
<button
class="text-gray-400 hover:text-primary text-sm"
onclick={() => startEdit(metric)}
aria-label={$t.datasets?.add_description ?? 'Edit'}
>✏️</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
<!-- #endregion MetricsTable -->

View File

@@ -0,0 +1,51 @@
<!-- #region StatsBar [C:3] [TYPE Component] [SEMANTICS ui,dataset,stats,filter] -->
<!-- @BRIEF Displays 4 aggregate metric tiles (All, Without mapping, Mapped, Linked) and emits filter selection. -->
<!-- @LAYER UI -->
<!-- @RELATION BINDS_TO -> [environmentContextStore] -->
<!-- @UX_STATE Loading -> 4 skeleton tiles pulsing. -->
<!-- @UX_STATE Loaded -> 4 tiles with numbers, active filter highlighted. -->
<!-- @UX_STATE Filtered -> Selected tile has accent border; parent triggers API reload with filter param. -->
<!-- @UX_REACTIVITY Props -> stats, activeFilter, isLoading -->
<!-- @UX_REACTIVITY Events -> onfilter(filterKey) dispatched on tile click; parent calls loadDatasets(filter=filterKey). -->
<script>
import { t } from "$lib/i18n";
/** @type {{ stats: import('./types').StatsCounts | null, activeFilter: string | null, isLoading?: boolean }} */
let { stats = null, activeFilter = null, isLoading = false, onfilter } = $props();
const tiles = [
{ key: "all", label: "all", getCount: (s) => s?.total ?? 0 },
{ key: "unmapped", label: "without_mapping", getCount: (s) => s?.unmapped_count ?? 0 },
{ key: "mapped", label: "with_mapping", getCount: (s) => s?.mapped_count ?? 0 },
{ key: "linked", label: "linked_to_dashboards", getCount: (s) => s?.linked_count ?? 0 },
];
function handleTileClick(key) {
if (activeFilter === key) {
onfilter?.(null);
} else {
onfilter?.(key);
}
}
</script>
<div class="grid grid-cols-4 gap-3 mb-4" role="group" aria-label="Dataset filters">
{#each tiles as tile (tile.key)}
{#if isLoading}
<div class="bg-white border border-gray-200 rounded-lg p-3 animate-pulse" aria-busy="true">
<div class="h-3 bg-gray-200 rounded w-2/3 mb-2"></div>
<div class="h-6 bg-gray-200 rounded w-1/3"></div>
</div>
{:else}
<button
class="bg-white border rounded-lg p-3 text-left transition-all {activeFilter === tile.key ? 'border-primary ring-2 ring-primary/20 shadow-sm' : 'border-gray-200 hover:border-gray-300 hover:shadow-sm'}"
onclick={() => handleTileClick(tile.key)}
aria-pressed={activeFilter === tile.key}
>
<div class="text-xs text-gray-500 mb-1">{$t.datasets?.[tile.label] ?? tile.label}</div>
<div class="text-2xl font-bold text-gray-900">{tile.getCount(stats)}</div>
</button>
{/if}
{/each}
</div>
<!-- #endregion StatsBar -->

View File

@@ -0,0 +1,68 @@
// #region ColumnsTableTest [C:2] [TYPE Module] [SEMANTICS test, datasets, columns, component]
// @BRIEF Contract-focused unit tests for ColumnsTable.svelte component.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(process.cwd(), 'src/routes/datasets/ColumnsTable.svelte');
describe('ColumnsTable Component', () => {
it('component file exists', () => {
expect(fs.existsSync(COMPONENT_PATH)).toBe(true);
});
it('bold verbose_name / italic column_name fallback', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('verbose_name');
expect(src).toContain('font-medium');
expect(src).toContain('italic');
expect(src).toContain('name');
});
it('has type chip color classes', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('getTypeColor');
expect(src).toContain('bg-green-100');
expect(src).toContain('bg-purple-100');
expect(src).toContain('bg-blue-100');
expect(src).toContain('bg-orange-100');
});
it('has inline-edit state machine (editing/saving/error)', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('editingRowId');
expect(src).toContain('editValue');
expect(src).toContain('editError');
expect(src).toContain('isSaving');
expect(src).toContain('startEdit');
expect(src).toContain('cancelEdit');
expect(src).toContain('saveEdit');
});
it('textarea for editing with save/cancel buttons', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('textarea');
expect(src).toContain('save_description');
expect(src).toContain('maxlength');
});
it('shows error message with aria-live', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('aria-live');
expect(src).toContain('editError');
});
it('shows empty state for 0 columns', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('columns.length === 0');
expect(src).toContain('detail_empty');
});
it('calls PUT /datasets/{id}/columns/{col_id}/description', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('/columns/');
expect(src).toContain('/description');
expect(src).toContain('api.requestApi');
});
});

View File

@@ -0,0 +1,70 @@
// #region DatasetListTest [C:2] [TYPE Module] [SEMANTICS test, datasets, cards, component]
// @BRIEF Contract-focused unit tests for DatasetList.svelte component.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(process.cwd(), 'src/routes/datasets/DatasetList.svelte');
describe('DatasetList Component', () => {
it('component file exists', () => {
expect(fs.existsSync(COMPONENT_PATH)).toBe(true);
});
it('renders cards with table_name, schema, column_count, metric_count', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('table_name');
expect(src).toContain('schema');
expect(src).toContain('column_count');
expect(src).toContain('metric_count');
});
it('has progress bar with color logic', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('getProgressClass');
expect(src).toContain('bg-green-500');
expect(src).toContain('bg-yellow-400');
expect(src).toContain('bg-blue-400');
expect(src).toContain('mappedFields');
});
it('has pagination controls', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('onpagechange');
expect(src).toContain('totalPages');
expect(src).toContain('page');
expect(src).toContain('pageSize');
});
it('has search input', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('onsearch');
expect(src).toContain('search_placeholder');
});
it('has checkboxes for bulk selection', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('checkbox');
expect(src).toContain('oncheckbox');
expect(src).toContain('selectedIds');
});
it('has quick-action buttons (map, docs)', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('action_map_columns');
expect(src).toContain('generate_docs');
});
it('shows skeleton when loading', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('animate-pulse');
expect(src).toContain('isLoading');
});
it('shows empty state when no datasets', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('empty');
expect(src).toContain('datasets.length === 0');
});
});

View File

@@ -0,0 +1,62 @@
// #region DatasetPreviewTest [C:2] [TYPE Module] [SEMANTICS test, datasets, detail, preview, component]
// @BRIEF Contract-focused unit tests for DatasetPreview.svelte component.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(process.cwd(), 'src/routes/datasets/DatasetPreview.svelte');
describe('DatasetPreview Component', () => {
it('component file exists', () => {
expect(fs.existsSync(COMPONENT_PATH)).toBe(true);
});
it('shows no-selection placeholder', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('no_selection');
expect(src).toContain('!dataset');
});
it('shows loading skeleton', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('isLoading');
expect(src).toContain('animate-pulse');
expect(src).toContain('aria-busy');
});
it('shows error state with retry', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('error');
expect(src).toContain('onretry');
expect(src).toContain('retry');
});
it('renders header with table_name, schema, counts', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('table_name');
expect(src).toContain('schema');
expect(src).toContain('column_count');
expect(src).toContain('metric_count');
});
it('renders linked dashboards as clickable pills', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('linked_dashboards');
expect(src).toContain('dashboards/');
});
it('includes ColumnsTable and MetricsTable', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('ColumnsTable');
expect(src).toContain('MetricsTable');
});
it('is presentational — receives dataset, isLoading, error props', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('$props()');
expect(src).toContain('dataset = null');
expect(src).toContain('isLoading = false');
expect(src).toContain('error = null');
});
});

View File

@@ -0,0 +1,49 @@
// #region StatsBarTest [C:2] [TYPE Module] [SEMANTICS test, datasets, statsbar, component]
// @BRIEF Contract-focused unit tests for StatsBar.svelte component.
// @LAYER Test
// @RELATION BINDS_TO -> [StatsBar:Component]
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COMPONENT_PATH = path.resolve(process.cwd(), 'src/routes/datasets/StatsBar.svelte');
describe('StatsBar Component', () => {
it('component file exists', () => {
expect(fs.existsSync(COMPONENT_PATH)).toBe(true);
});
it('renders 4 metric tiles', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('all');
expect(src).toContain('without_mapping');
expect(src).toContain('with_mapping');
expect(src).toContain('linked_to_dashboards');
});
it('dispatches onfilter on tile click', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('onfilter');
expect(src).toContain('handleTileClick');
expect(src).toContain('activeFilter === tile.key');
});
it('shows skeleton when loading', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('aria-busy');
expect(src).toContain('animate-pulse');
expect(src).toContain('isLoading');
});
it('uses aria-pressed for active state', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('aria-pressed');
});
it('uses $props for reactivity', () => {
const src = fs.readFileSync(COMPONENT_PATH, 'utf-8');
expect(src).toContain('$props()');
expect(src).toContain('stats = null');
expect(src).toContain('activeFilter');
});
});

View File

@@ -0,0 +1,52 @@
// #region BulkActionsTest [C:2] [TYPE Module] [SEMANTICS test, datasets, bulk, actions, component]
// @BRIEF Contract-focused unit tests for bulk actions in +page.svelte.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const PAGE_PATH = path.resolve(process.cwd(), 'src/routes/datasets/+page.svelte');
describe('Bulk Actions', () => {
it('panel appears at 2+ selected', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('selectedIds.size >= 2');
});
it('panel disappears at 0 selected', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
// Panel is hidden when selectedIds.size === 0 (via {#if selectedIds.size >= 2})
expect(src).toContain('selectedIds.size');
expect(src).toContain('SvelteSet');
});
it('map columns modal opens', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('showMapColumnsModal');
expect(src).toContain('bulk_map_columns');
});
it('generate docs modal opens', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('showGenerateDocsModal');
expect(src).toContain('bulk_docs_generation');
});
it('selected count displays correctly', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('selectedIds.size');
expect(src).toContain('selected');
});
it('has clear/deselect button in panel', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('selectedIds');
expect(src).toContain('SvelteSet');
});
it('opens task drawer on bulk action completion', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('openDrawerForTaskIfPreferred');
expect(src).toContain('task_id');
});
});

View File

@@ -0,0 +1,58 @@
// #region InlineEditTest [C:2] [TYPE Module] [SEMANTICS test, datasets, inline-edit, component]
// @BRIEF Contract-focused unit tests for inline-edit in ColumnsTable and MetricsTable.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const COLUMNS_PATH = path.resolve(process.cwd(), 'src/routes/datasets/ColumnsTable.svelte');
const METRICS_PATH = path.resolve(process.cwd(), 'src/routes/datasets/MetricsTable.svelte');
describe('Inline Edit', () => {
it('click opens textarea with prefill (columns)', () => {
const src = fs.readFileSync(COLUMNS_PATH, 'utf-8');
expect(src).toContain('textarea');
expect(src).toContain('editValue = col.description');
expect(src).toContain('startEdit');
});
it('save calls API and updates description (columns)', () => {
const src = fs.readFileSync(COLUMNS_PATH, 'utf-8');
expect(src).toContain('PUT');
expect(src).toContain('/description');
expect(src).toContain('col.description = result.description');
});
it('cancel reverts to original (columns)', () => {
const src = fs.readFileSync(COLUMNS_PATH, 'utf-8');
expect(src).toContain('cancelEdit');
expect(src).toContain('editingRowId = null');
});
it('save error keeps textarea open (columns)', () => {
const src = fs.readFileSync(COLUMNS_PATH, 'utf-8');
expect(src).toContain('editError');
expect(src).toContain('catch');
expect(src).toContain('isSaving = false');
});
it('save error keeps textarea open (metrics)', () => {
const src = fs.readFileSync(METRICS_PATH, 'utf-8');
expect(src).toContain('editError');
expect(src).toContain('catch');
expect(src).toContain('isSaving = false');
});
it('calls PUT /datasets/{id}/metrics/{metric_id}/description', () => {
const src = fs.readFileSync(METRICS_PATH, 'utf-8');
expect(src).toContain('/metrics/');
expect(src).toContain('/description');
expect(src).toContain('api.requestApi');
});
it('shows expression as hint when no description (metrics)', () => {
const src = fs.readFileSync(METRICS_PATH, 'utf-8');
expect(src).toContain('expression');
expect(src).toContain('font-mono');
});
});

View File

@@ -0,0 +1,62 @@
// #region SplitViewTest [C:2] [TYPE Module] [SEMANTICS test, datasets, split-view, layout, component]
// @BRIEF Contract-focused unit tests for split-view layout in +page.svelte.
// @LAYER Test
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const PAGE_PATH = path.resolve(process.cwd(), 'src/routes/datasets/+page.svelte');
describe('Split-View Layout', () => {
it('page file exists', () => {
expect(fs.existsSync(PAGE_PATH)).toBe(true);
});
it('uses Tailwind grid for split-view layout', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('grid-cols-[40%_60%]');
});
it('has mobile slide-over behavior', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('isMobile');
expect(src).toContain('mobileSlideOpen');
expect(src).toContain('closeDetail');
expect(src).toContain('fixed inset-0 bg-black/30');
});
it('closes slide-over on Escape', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('Escape');
expect(src).toContain('handleEscape');
});
it('wires environmentContextStore', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('environmentContextStore');
expect(src).toContain('initializeEnvironmentContext');
expect(src).toContain('setSelectedEnvironment');
});
it('renders StatsBar, DatasetList, DatasetPreview', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('StatsBar');
expect(src).toContain('DatasetList');
expect(src).toContain('DatasetPreview');
});
it('has bulk action panel for >=2 selected', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('selectedIds.size >= 2');
expect(src).toContain('bulk_map_columns');
expect(src).toContain('showMapColumnsModal');
expect(src).toContain('showGenerateDocsModal');
});
it('uses Svelte 5 runes pattern', () => {
const src = fs.readFileSync(PAGE_PATH, 'utf-8');
expect(src).toContain('$state(');
expect(src).toContain('$effect(');
// Page is a route, not a component, so it doesn't use $props()
});
});