Files
ss-tools/frontend/src/routes/datasets/DatasetPreview.svelte
busya b529e8082a 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)
2026-05-21 13:58:47 +03:00

84 lines
3.8 KiB
Svelte

<!-- #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 -->