feat(us1): add dataset review orchestration automatic review slice

This commit is contained in:
2026-03-17 10:57:49 +03:00
parent e916cb1f17
commit 023bacde39
24 changed files with 4870 additions and 131 deletions

View File

@@ -0,0 +1,332 @@
<!-- [DEF:SourceIntakePanel:Component] -->
<!-- @COMPLEXITY: 3 -->
<!-- @SEMANTICS: dataset-review, intake, superset-link, dataset-selection, validation -->
<!-- @PURPOSE: Collect initial dataset source input through Superset link paste or dataset selection entry paths. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
<!-- @UX_STATE: Idle -> Empty intake form with two clear entry paths. -->
<!-- @UX_STATE: Validating -> Lightweight inline validation feedback. -->
<!-- @UX_STATE: Rejected -> Input error shown with corrective hint. -->
<!-- @UX_FEEDBACK: Recognized links are acknowledged before deeper recovery finishes. -->
<!-- @UX_RECOVERY: Users can correct invalid input in place without resetting the page. -->
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
<script>
import { t } from "$lib/i18n";
let {
environments = [],
selectedEnvironmentId = "",
initialSourceKind = "superset_link",
initialSourceInput = "",
submitting = false,
acknowledgment = "",
onsubmit = async () => {},
} = $props();
let sourceKind = $state("superset_link");
let sourceInput = $state("");
let environmentId = $state("");
let touched = $state(false);
let localError = $state("");
let submitAttempted = $state(false);
const normalizedSourceInput = $derived(sourceInput.trim());
const normalizedEnvironmentId = $derived(String(environmentId || "").trim());
const isSupersetLinkMode = $derived(sourceKind === "superset_link");
const linkLooksRecognized = $derived(
/^https?:\/\/.+\/superset\/|^https?:\/\/.+\/explore\/|^https?:\/\/.+\/dashboard\//i.test(
normalizedSourceInput,
),
);
const intakeState = $derived(
localError
? "Rejected"
: submitting || touched
? "Validating"
: "Idle",
);
const canSubmit = $derived(
normalizedEnvironmentId.length > 0 && normalizedSourceInput.length > 0 && !submitting,
);
$effect(() => {
if (!touched && !submitAttempted) {
sourceKind = initialSourceKind || "superset_link";
}
if (!environmentId) {
environmentId = selectedEnvironmentId || "";
}
if (!sourceInput) {
sourceInput = initialSourceInput || "";
}
});
function setMode(nextMode) {
sourceKind = nextMode;
localError = "";
touched = false;
submitAttempted = false;
}
function getInlineHint() {
if (!normalizedEnvironmentId) {
return $t.dataset_review?.source?.environment_required;
}
if (!normalizedSourceInput) {
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_hint
: $t.dataset_review?.source?.dataset_selection_hint;
}
if (localError) {
return localError;
}
if (isSupersetLinkMode && linkLooksRecognized) {
return (
acknowledgment ||
$t.dataset_review?.source?.recognized_link_hint ||
$t.dataset_review?.source?.superset_link_hint
);
}
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_hint
: $t.dataset_review?.source?.dataset_selection_hint;
}
function validatePayload() {
if (!normalizedEnvironmentId) {
return $t.dataset_review?.source?.environment_required;
}
if (!normalizedSourceInput) {
return isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_required
: $t.dataset_review?.source?.dataset_selection_required;
}
if (isSupersetLinkMode && !/^https?:\/\//i.test(normalizedSourceInput)) {
return $t.dataset_review?.source?.superset_link_invalid;
}
return "";
}
async function submitPayload() {
touched = true;
submitAttempted = true;
localError = validatePayload();
if (localError) {
return;
}
try {
await onsubmit({
source_kind: sourceKind,
source_input: normalizedSourceInput,
environment_id: normalizedEnvironmentId,
});
localError = "";
} catch (error) {
localError =
error?.message ||
$t.dataset_review?.source?.submit_failed ||
$t.common?.error;
}
}
function handleInput() {
touched = true;
if (submitAttempted) {
localError = validatePayload();
}
}
async function submitSupersetLink(event) {
event?.preventDefault?.();
sourceKind = "superset_link";
await submitPayload();
}
async function submitDatasetSelection(event) {
event?.preventDefault?.();
sourceKind = "dataset_selection";
await submitPayload();
}
async function handleSubmit(event) {
if (isSupersetLinkMode) {
await submitSupersetLink(event);
return;
}
await submitDatasetSelection(event);
}
</script>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
{$t.dataset_review?.source?.eyebrow}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{$t.dataset_review?.source?.title}
</h2>
<p class="mt-1 max-w-2xl text-sm text-slate-600">
{$t.dataset_review?.source?.description}
</p>
</div>
<div
class={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${
intakeState === "Rejected"
? "bg-red-100 text-red-700"
: intakeState === "Validating"
? "bg-amber-100 text-amber-700"
: "bg-slate-100 text-slate-700"
}`}
>
{#if intakeState === "Rejected"}
{$t.dataset_review?.source?.state_rejected}
{:else if intakeState === "Validating"}
{$t.dataset_review?.source?.state_validating}
{:else}
{$t.dataset_review?.source?.state_idle}
{/if}
</div>
</div>
<div class="mt-5 grid gap-3 md:grid-cols-2">
<button
type="button"
class={`rounded-xl border px-4 py-3 text-left transition ${
isSupersetLinkMode
? "border-blue-500 bg-blue-50 text-blue-900 shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
}`}
onclick={() => setMode("superset_link")}
>
<div class="text-sm font-semibold">
{$t.dataset_review?.source?.superset_link_tab}
</div>
<div class="mt-1 text-xs text-slate-500">
{$t.dataset_review?.source?.superset_link_tab_hint}
</div>
</button>
<button
type="button"
class={`rounded-xl border px-4 py-3 text-left transition ${
!isSupersetLinkMode
? "border-blue-500 bg-blue-50 text-blue-900 shadow-sm"
: "border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:bg-slate-50"
}`}
onclick={() => setMode("dataset_selection")}
>
<div class="text-sm font-semibold">
{$t.dataset_review?.source?.dataset_selection_tab}
</div>
<div class="mt-1 text-xs text-slate-500">
{$t.dataset_review?.source?.dataset_selection_tab_hint}
</div>
</button>
</div>
<form class="mt-5 space-y-4" onsubmit={handleSubmit}>
<div class="grid gap-4 lg:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]">
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">
{$t.dataset_review?.source?.environment_label}
</span>
<select
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
bind:value={environmentId}
onchange={handleInput}
>
<option value="">{$t.common?.choose_environment}</option>
{#each environments as environment}
<option value={environment.id}>{environment.name}</option>
{/each}
</select>
</label>
<label class="space-y-2">
<span class="text-sm font-medium text-slate-700">
{isSupersetLinkMode
? $t.dataset_review?.source?.superset_link_label
: $t.dataset_review?.source?.dataset_selection_label}
</span>
{#if isSupersetLinkMode}
<input
class={`w-full rounded-xl border px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:ring-2 ${
localError
? "border-red-300 bg-red-50 focus:border-red-400 focus:ring-red-100"
: "border-slate-300 bg-white focus:border-blue-500 focus:ring-blue-200"
}`}
type="url"
placeholder={$t.dataset_review?.source?.superset_link_placeholder}
bind:value={sourceInput}
oninput={handleInput}
/>
{:else}
<input
class={`w-full rounded-xl border px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:ring-2 ${
localError
? "border-red-300 bg-red-50 focus:border-red-400 focus:ring-red-100"
: "border-slate-300 bg-white focus:border-blue-500 focus:ring-blue-200"
}`}
type="text"
placeholder={$t.dataset_review?.source?.dataset_selection_placeholder}
bind:value={sourceInput}
oninput={handleInput}
/>
{/if}
</label>
</div>
<div
class={`rounded-xl border px-4 py-3 text-sm ${
localError
? "border-red-200 bg-red-50 text-red-700"
: linkLooksRecognized && isSupersetLinkMode
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-slate-200 bg-slate-50 text-slate-600"
}`}
>
{getInlineHint()}
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-slate-500">
{#if isSupersetLinkMode}
{$t.dataset_review?.source?.superset_link_recovery_note}
{:else}
{$t.dataset_review?.source?.dataset_selection_recovery_note}
{/if}
</div>
<button
type="submit"
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
disabled={!canSubmit}
>
{#if submitting}
{$t.dataset_review?.source?.submitting}
{:else if isSupersetLinkMode}
{$t.dataset_review?.source?.submit_superset_link}
{:else}
{$t.dataset_review?.source?.submit_dataset_selection}
{/if}
</button>
</div>
</form>
</div>
<!-- [/DEF:SourceIntakePanel:Component] -->

View File

@@ -0,0 +1,332 @@
<!-- [DEF:ValidationFindingsPanel:Component] -->
<!-- @COMPLEXITY: 3 -->
<!-- @SEMANTICS: dataset-review, findings, severity, readiness, actionability -->
<!-- @PURPOSE: Present validation findings grouped by severity with explicit resolution and actionability signals. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
<!-- @UX_STATE: Blocking -> Blocking findings are visually dominant and block launch flow. -->
<!-- @UX_STATE: Warning -> Warnings remain visible with explicit approval or defer actions. -->
<!-- @UX_STATE: Informational -> Low-priority findings are collapsed or secondary. -->
<!-- @UX_FEEDBACK: Resolving or approving an item updates readiness state immediately. -->
<!-- @UX_RECOVERY: Users can jump from a finding directly to the relevant remediation area. -->
<!-- @UX_REACTIVITY: Uses $props and $derived only; no legacy reactive syntax. -->
<script>
import { t } from "$lib/i18n";
let {
findings = [],
recommendedAction = "",
onjump = () => {},
} = $props();
const groupedFindings = $derived(groupFindingsBySeverity(findings || []));
const blockingFindings = $derived(groupedFindings.blocking);
const warningFindings = $derived(groupedFindings.warning);
const informationalFindings = $derived(groupedFindings.informational);
const totalFindings = $derived(
blockingFindings.length + warningFindings.length + informationalFindings.length,
);
function groupFindingsBySeverity(items) {
return (items || []).reduce(
(accumulator, finding) => {
const severity = String(finding?.severity || "informational");
if (severity === "blocking") {
accumulator.blocking.push(finding);
return accumulator;
}
if (severity === "warning") {
accumulator.warning.push(finding);
return accumulator;
}
accumulator.informational.push(finding);
return accumulator;
},
{ blocking: [], warning: [], informational: [] },
);
}
function buildTargetFromFinding(finding) {
const area = String(finding?.area || "");
if (area === "source_intake") return "intake";
if (area === "dataset_profile") return "summary";
if (area === "semantic_enrichment") return "semantics";
if (area === "clarification") return "clarification";
if (area === "filter_recovery") return "filters";
if (area === "template_mapping") return "mappings";
if (area === "compiled_preview") return "preview";
if (area === "launch") return "launch";
return "summary";
}
function jumpToFindingTarget(finding) {
onjump({
target: buildTargetFromFinding(finding),
finding,
});
}
function getResolutionLabel(resolutionState) {
const normalized = String(resolutionState || "open");
return $t.dataset_review?.findings?.resolution?.[normalized] || normalized;
}
function getAreaLabel(area) {
const normalized = String(area || "dataset_profile");
return $t.dataset_review?.findings?.areas?.[normalized] || normalized;
}
function getRecommendedActionLabel(action) {
const normalized = String(action || "");
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
}
</script>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dataset_review?.findings?.eyebrow}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{$t.dataset_review?.findings?.title}
</h2>
<p class="mt-1 max-w-3xl text-sm text-slate-600">
{$t.dataset_review?.findings?.description}
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.findings?.next_action_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{getRecommendedActionLabel(recommendedAction)}
</div>
</div>
</div>
{#if totalFindings === 0}
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
{$t.dataset_review?.findings?.empty}
</div>
{:else}
<div class="mt-5 space-y-4">
<section class="rounded-2xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.blocking_title}
</h3>
<span class="rounded-full bg-red-100 px-2.5 py-1 text-xs font-medium text-red-700">
{blockingFindings.length}
</span>
</div>
{#if blockingFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.blocking_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each blockingFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
<section class="rounded-2xl border border-amber-200 bg-amber-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.warning_title}
</h3>
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">
{warningFindings.length}
</span>
</div>
{#if warningFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.warning_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each warningFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
<section class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.findings?.informational_title}
</h3>
<span class="rounded-full bg-slate-200 px-2.5 py-1 text-xs font-medium text-slate-700">
{informationalFindings.length}
</span>
</div>
{#if informationalFindings.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.findings?.informational_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each informationalFindings as finding}
<article class="rounded-xl border border-white/70 bg-white/80 p-4 shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-slate-900">
{finding.title}
</h4>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
{finding.code}
</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{getAreaLabel(finding.area)}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-700">
{finding.message}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.resolution_label}:
{getResolutionLabel(finding.resolution_state)}
</span>
{#if finding.caused_by_ref}
<span class="rounded-full bg-slate-100 px-2 py-1 text-slate-600">
{$t.dataset_review?.findings?.reference_label}:
{finding.caused_by_ref}
</span>
{/if}
</div>
{#if finding.resolution_note}
<p class="mt-2 text-xs text-slate-500">{finding.resolution_note}</p>
{/if}
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100"
onclick={() => jumpToFindingTarget(finding)}
>
{$t.dataset_review?.findings?.jump_action}
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
</div>
{/if}
</div>
<!-- [/DEF:ValidationFindingsPanel:Component] -->

View File

@@ -0,0 +1,161 @@
/**
* @vitest-environment jsdom
*/
// [DEF:SourceIntakePanelUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, source-intake, ux-tests, validation, recovery
// @PURPOSE: Verify source intake entry paths, validation feedback, and submit payload behavior for US1.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[SourceIntakePanel]
// @UX_STATE: Idle -> Default intake renders both entry paths and waits for user input.
// @UX_STATE: Validating -> Inline feedback confirms recognized links and local payload validation.
// @UX_STATE: Rejected -> Invalid source input remains local and exposes recovery guidance.
// @TEST_CONTRACT: SourceIntakePanelProps -> ObservableIntakeUX
// @TEST_SCENARIO: invalid_superset_link_shows_rejected_state -> Invalid URL input keeps submit local and shows rejection feedback.
// @TEST_SCENARIO: recognized_superset_link_submits_payload -> Recognized link path submits normalized payload.
// @TEST_SCENARIO: dataset_selection_mode_changes_cta -> Dataset selection path switches CTA and payload source kind.
// @TEST_EDGE: missing_environment -> Required environment guard blocks submit.
// @TEST_EDGE: invalid_type -> Invalid Superset URL shows recovery hint.
// @TEST_EDGE: external_fail -> Submit callback failure is rendered inline.
// @TEST_INVARIANT: intake_contract_remains_observable -> VERIFIED_BY: [invalid_superset_link_shows_rejected_state, recognized_superset_link_submits_payload, dataset_selection_mode_changes_cta]
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/svelte";
import SourceIntakePanel from "../SourceIntakePanel.svelte";
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
common: {
choose_environment: "Choose environment",
error: "Common error",
},
dataset_review: {
source: {
eyebrow: "Source intake",
title: "Start dataset review",
description: "Paste link or provide dataset reference.",
state_idle: "Idle",
state_validating: "Validating",
state_rejected: "Rejected",
environment_label: "Environment",
environment_required: "Environment is required",
superset_link_tab: "Superset link",
superset_link_tab_hint: "Paste dashboard or explore URL",
dataset_selection_tab: "Dataset selection",
dataset_selection_tab_hint: "Enter dataset ref",
superset_link_label: "Superset link",
dataset_selection_label: "Dataset reference",
superset_link_placeholder: "https://superset.local/dashboard/10",
dataset_selection_placeholder: "public.sales",
superset_link_hint: "Paste a full Superset URL",
dataset_selection_hint: "Provide schema.dataset reference",
recognized_link_hint: "Recognized Superset link",
superset_link_required: "Superset link is required",
dataset_selection_required: "Dataset reference is required",
superset_link_invalid: "Superset link must start with http",
submit_failed: "Submit failed",
superset_link_recovery_note: "You can fix the link inline",
dataset_selection_recovery_note: "You can fix the dataset inline",
submitting: "Submitting",
submit_superset_link: "Start from link",
submit_dataset_selection: "Start from dataset",
dataset_selection_acknowledged: "Dataset selection acknowledged",
},
},
});
return () => {};
},
},
}));
describe("SourceIntakePanel UX Contract", () => {
const environments = [{ id: "env-1", name: "DEV" }];
it("invalid_superset_link_shows_rejected_state", async () => {
const onsubmit = vi.fn();
const { container } = render(SourceIntakePanel, {
environments,
onsubmit,
});
await fireEvent.change(screen.getByRole("combobox"), {
target: { value: "env-1" },
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "not-a-url" },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).not.toHaveBeenCalled();
expect(screen.getByText("Rejected")).toBeDefined();
expect(screen.getByText("Superset link must start with http")).toBeDefined();
});
it("recognized_superset_link_submits_payload", async () => {
const onsubmit = vi.fn().mockResolvedValue(undefined);
const { container } = render(SourceIntakePanel, {
environments,
selectedEnvironmentId: "env-1",
acknowledgment: "Recognized Superset link",
onsubmit,
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "https://demo.local/superset/dashboard/42 " },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).toHaveBeenCalledWith({
environment_id: "env-1",
source_input: "https://demo.local/superset/dashboard/42",
source_kind: "superset_link",
});
expect(screen.getByText("Recognized Superset link")).toBeDefined();
});
it("dataset_selection_mode_changes_cta", async () => {
const onsubmit = vi.fn().mockResolvedValue(undefined);
const { container } = render(SourceIntakePanel, {
environments,
onsubmit,
});
await fireEvent.click(
screen.getByRole("button", {
name: "Dataset selection Enter dataset ref",
}),
);
await fireEvent.change(screen.getByRole("combobox"), {
target: { value: "env-1" },
});
await fireEvent.input(screen.getByPlaceholderText("public.sales"), {
target: { value: " public.sales " },
});
await fireEvent.submit(container.querySelector("form"));
expect(onsubmit).toHaveBeenCalledWith({
environment_id: "env-1",
source_input: "public.sales",
source_kind: "dataset_selection",
});
});
it("external_fail_renders_inline_error", async () => {
const onsubmit = vi.fn().mockRejectedValue(new Error("Backend rejected source"));
const { container } = render(SourceIntakePanel, {
environments,
selectedEnvironmentId: "env-1",
onsubmit,
});
await fireEvent.input(screen.getByPlaceholderText("https://superset.local/dashboard/10"), {
target: { value: "https://demo.local/dashboard/42" },
});
await fireEvent.submit(container.querySelector("form"));
expect(screen.getByText("Backend rejected source")).toBeDefined();
});
});
// [/DEF:SourceIntakePanelUxTests:Module]

View File

@@ -0,0 +1,141 @@
/**
* @vitest-environment jsdom
*/
// [DEF:ValidationFindingsPanelUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, findings, severity, jump, ux-tests
// @PURPOSE: Verify grouped findings visibility, empty state, and remediation jump behavior for US1.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[ValidationFindingsPanel]
// @UX_STATE: Blocking -> Blocking findings dominate the panel and expose remediation jumps.
// @UX_STATE: Warning -> Warning findings remain visible with explicit resolution state.
// @UX_STATE: Informational -> Informational notes stay readable without competing with blockers.
// @TEST_CONTRACT: FindingsPanelProps -> ObservableFindingsUX
// @TEST_SCENARIO: blocking_warning_info_groups_render -> Findings render in the proper severity groups with counts.
// @TEST_SCENARIO: jump_action_maps_area_to_workspace_target -> Jump action emits normalized remediation target.
// @TEST_SCENARIO: empty_findings_show_success_state -> Empty list shows ready feedback.
// @TEST_EDGE: missing_field -> Missing optional fields do not crash rendering.
// @TEST_EDGE: invalid_type -> Unknown severity falls back to informational grouping.
// @TEST_EDGE: external_fail -> Jump callback remains optional.
// @TEST_INVARIANT: findings_groups_remain_actionable -> VERIFIED_BY: [blocking_warning_info_groups_render, jump_action_maps_area_to_workspace_target, empty_findings_show_success_state]
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/svelte";
import ValidationFindingsPanel from "../ValidationFindingsPanel.svelte";
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
dataset_review: {
findings: {
eyebrow: "Validation findings",
title: "Findings",
description: "Review validation results",
next_action_label: "Next action",
empty: "No findings",
blocking_title: "Blocking",
blocking_empty: "No blocking findings",
warning_title: "Warnings",
warning_empty: "No warning findings",
informational_title: "Info",
informational_empty: "No informational findings",
resolution_label: "Resolution",
reference_label: "Reference",
jump_action: "Jump to area",
resolution: {
open: "Open",
approved: "Approved",
},
areas: {
source_intake: "Source intake",
dataset_profile: "Dataset summary",
semantic_enrichment: "Semantics",
},
},
workspace: {
actions: {
review_documentation: "Review documentation",
import_from_superset: "Import from Superset",
},
},
},
});
return () => {};
},
},
}));
describe("ValidationFindingsPanel UX Contract", () => {
const findings = [
{
finding_id: "f-1",
severity: "blocking",
code: "REQ_ENV",
area: "source_intake",
title: "Environment required",
message: "Select environment",
resolution_state: "open",
},
{
finding_id: "f-2",
severity: "warning",
code: "PARTIAL_RECOVERY",
area: "dataset_profile",
title: "Partial recovery",
message: "Some metadata needs review",
resolution_state: "approved",
caused_by_ref: "dashboard:42",
},
{
finding_id: "f-3",
severity: "unexpected",
code: "INFO_NOTE",
area: "semantic_enrichment",
title: "Dictionary note",
message: "Trusted source used",
resolution_state: "open",
},
];
it("blocking_warning_info_groups_render", () => {
render(ValidationFindingsPanel, {
findings,
recommendedAction: "review_documentation",
});
expect(screen.getByText("Blocking")).toBeDefined();
expect(screen.getByText("Warnings")).toBeDefined();
expect(screen.getByText("Info")).toBeDefined();
expect(screen.getByText("Environment required")).toBeDefined();
expect(screen.getByText("Partial recovery")).toBeDefined();
expect(screen.getByText("Dictionary note")).toBeDefined();
expect(screen.getByText("Review documentation")).toBeDefined();
});
it("jump_action_maps_area_to_workspace_target", async () => {
const onjump = vi.fn();
render(ValidationFindingsPanel, {
findings: [findings[0]],
recommendedAction: "import_from_superset",
onjump,
});
await fireEvent.click(screen.getByRole("button", { name: "Jump to area" }));
expect(onjump).toHaveBeenCalledWith({
target: "intake",
finding: findings[0],
});
});
it("empty_findings_show_success_state", () => {
render(ValidationFindingsPanel, {
findings: [],
recommendedAction: "review_documentation",
});
expect(screen.getByText("No findings")).toBeDefined();
});
});
// [/DEF:ValidationFindingsPanelUxTests:Module]

View File

@@ -586,6 +586,187 @@
"task_failed": "Failed",
"task_waiting": "Waiting"
},
"dataset_review": {
"source": {
"eyebrow": "Source intake",
"title": "Start dataset review",
"description": "Paste a Superset link or provide a dataset reference to begin a resumable review session.",
"state_idle": "Idle",
"state_validating": "Validating",
"state_rejected": "Rejected",
"environment_label": "Environment",
"environment_required": "Select the Superset environment before starting review.",
"superset_link_tab": "Superset link",
"superset_link_tab_hint": "Import dashboard or explore context and recover filters progressively.",
"dataset_selection_tab": "Dataset selection",
"dataset_selection_tab_hint": "Start from a known dataset reference when you already know the target.",
"superset_link_label": "Superset URL",
"superset_link_placeholder": "https://superset.example.com/superset/dashboard/42/?native_filters=...",
"superset_link_hint": "Recognized Superset links create a session immediately, then continue partial recovery in place.",
"superset_link_required": "Paste a Superset link to continue.",
"superset_link_invalid": "Enter a full Superset URL starting with http:// or https://.",
"superset_link_recovery_note": "Partial recovery stays visible. Missing filters or unresolved context are disclosed instead of hidden.",
"recognized_link_hint": "Superset link recognized. Session recovery will continue after the shell is created.",
"dataset_selection_label": "Dataset reference",
"dataset_selection_placeholder": "dataset_id:123 or sales.daily_revenue",
"dataset_selection_hint": "Use a dataset id or stable dataset reference from the selected environment.",
"dataset_selection_required": "Enter a dataset reference to continue.",
"dataset_selection_recovery_note": "Dataset-based intake still surfaces confidence and unresolved context explicitly.",
"dataset_selection_acknowledged": "Dataset reference accepted. Loading the resumable review shell.",
"submit_superset_link": "Start from link",
"submit_dataset_selection": "Start from dataset",
"submitting": "Starting review...",
"submit_failed": "Unable to start the dataset review session."
},
"findings": {
"eyebrow": "Validation findings",
"title": "Review blockers, warnings, and informational notes",
"description": "Findings stay grouped by severity so the highest-risk issues remain visible while partial recovery stays usable.",
"next_action_label": "Recommended next step",
"empty": "No findings recorded yet. The session can continue with the current recovered context.",
"blocking_title": "Blocking",
"blocking_empty": "No blocking findings are currently open.",
"warning_title": "Warnings",
"warning_empty": "No warning-level findings need attention right now.",
"informational_title": "Informational",
"informational_empty": "No informational findings are currently recorded.",
"resolution_label": "Resolution",
"reference_label": "Reference",
"jump_action": "Open related area",
"resolution": {
"open": "Open",
"resolved": "Resolved",
"approved": "Approved",
"skipped": "Skipped",
"deferred": "Deferred",
"expert_review": "Expert review"
},
"areas": {
"source_intake": "Source intake",
"dataset_profile": "Dataset profile",
"semantic_enrichment": "Semantic enrichment",
"clarification": "Clarification",
"filter_recovery": "Filter recovery",
"template_mapping": "Template mapping",
"compiled_preview": "Compiled preview",
"launch": "Launch",
"audit": "Audit"
}
},
"workspace": {
"eyebrow": "Dataset orchestration",
"title": "Dataset review workspace",
"description": "Resume a prior session or inspect recovered context, findings, provenance, and exports in one place.",
"loading": "Loading review workspace...",
"load_failed": "Failed to load the dataset review session.",
"resume_failed": "Failed to resume the current review session.",
"save_failed": "Failed to update the review session.",
"export_failed": "Failed to export the requested review artifact.",
"empty_state_title": "No session loaded",
"state_label": "Workspace",
"readiness_label": "Readiness",
"source_badge_fallback": "review session",
"save_session_action": "Save session",
"export_summary_action": "Export summary",
"save_summary_action": "Save summary",
"summary_edit_saved": "Summary draft updated for the current review workspace.",
"import_progress_eyebrow": "Import progress",
"import_progress_title": "Recovering dataset context progressively",
"import_progress_body": "The workspace reveals milestones as source, filters, variables, and semantic candidates become available.",
"import_milestones": {
"recognized": "Dataset recognized",
"filters": "Saved native filters recovered",
"variables": "Dataset template variables detected",
"semantic_sources": "Nearby semantic sources identified",
"summary": "Preliminary summary prepared"
},
"source_session_title": "Source & session",
"source_label": "Dataset source",
"import_status_title": "Superset import status",
"recent_actions_title": "Recent actions timeline",
"timeline": {
"source": "Source accepted",
"status": "Session state updated",
"filters": "Recovered filters",
"findings": "Validation findings recorded",
"exports": "Latest export generated"
},
"next_action_label": "Next action",
"next_action_card_eyebrow": "Primary action",
"next_action_card_body": "The main action always reflects the highest-value next step for the current session state.",
"session_label": "Session summary",
"session_id_label": "Session ID",
"summary_source_label": "Summary source",
"confidence_label": "Confidence",
"business_summary_title": "Business summary",
"summary_missing": "No readable business summary is available yet.",
"partial_recovery_badge": "Partial recovery",
"phase_label": "Phase",
"status_label": "Status",
"active_task_label": "Active task",
"recovery_title": "Recovered filters and provenance",
"recovery_empty": "No imported filters have been recovered yet.",
"provenance_label": "Provenance",
"recovered_value_label": "Recovered value",
"health_title": "Findings overview",
"open_findings_label": "Open findings",
"exports_title": "Exports",
"exports_description": "Generate inline documentation and validation artifacts from the current reviewed state.",
"export_documentation_json": "Export documentation (JSON)",
"export_documentation_markdown": "Export documentation (Markdown)",
"export_validation_json": "Export validation (JSON)",
"export_validation_markdown": "Export validation (Markdown)",
"preview_title": "Preview status",
"preview_status_label": "Status",
"preview_compiler_label": "Compiled by",
"preview_pending_note": "Compiled preview is not part of this US1 batch yet, but the workspace keeps the state visible when present.",
"jump_target_label": "Focused area",
"resume_action": "Resume session",
"pause_action": "Pause session",
"actions": {
"import_from_superset": "Import from Superset",
"review_documentation": "Review documentation",
"apply_semantic_source": "Apply semantic source",
"start_clarification": "Start clarification",
"answer_next_question": "Answer next question",
"approve_mapping": "Approve mapping",
"generate_sql_preview": "Generate SQL preview",
"complete_required_values": "Complete required values",
"launch_dataset": "Launch dataset",
"resume_session": "Resume session",
"export_outputs": "Export outputs"
},
"readiness": {
"empty": "Empty",
"importing": "Importing",
"review_ready": "Review ready",
"semantic_source_review_needed": "Semantic source review needed",
"clarification_needed": "Clarification needed",
"clarification_active": "Clarification active",
"mapping_review_needed": "Mapping review needed",
"compiled_preview_ready": "Compiled preview ready",
"partially_ready": "Partially ready",
"run_ready": "Run ready",
"run_in_progress": "Run in progress",
"completed": "Completed",
"recovery_required": "Recovery required"
},
"summary_sources": {
"confirmed": "Confirmed",
"imported": "Imported",
"inferred": "Inferred",
"ai_draft": "AI draft",
"manual_override": "Manual override"
},
"confidence": {
"confirmed": "Confirmed",
"mostly_confirmed": "Mostly confirmed",
"mixed": "Mixed",
"low_confidence": "Low confidence",
"unresolved": "Unresolved"
}
}
},
"tasks": {
"management": "Task Management",
"run_backup": "Run Backup",

View File

@@ -584,6 +584,187 @@
"task_failed": "Ошибка",
"task_waiting": "Ожидание"
},
"dataset_review": {
"source": {
"eyebrow": "Источник",
"title": "Запуск review датасета",
"description": "Вставьте ссылку Superset или укажите ссылку на датасет, чтобы начать возобновляемую review-сессию.",
"state_idle": "Ожидание",
"state_validating": "Проверка",
"state_rejected": "Отклонено",
"environment_label": "Окружение",
"environment_required": "Перед запуском review выберите окружение Superset.",
"superset_link_tab": "Ссылка Superset",
"superset_link_tab_hint": "Импортирует контекст dashboard/explore и поэтапно восстанавливает фильтры.",
"dataset_selection_tab": "Выбор датасета",
"dataset_selection_tab_hint": "Используйте известную ссылку на датасет, если цель уже определена.",
"superset_link_label": "URL Superset",
"superset_link_placeholder": "https://superset.example.com/superset/dashboard/42/?native_filters=...",
"superset_link_hint": "Распознанная ссылка Superset сразу создает сессию, а затем продолжает частичное восстановление в фоне.",
"superset_link_required": "Вставьте ссылку Superset, чтобы продолжить.",
"superset_link_invalid": "Введите полный URL Superset, начинающийся с http:// или https://.",
"superset_link_recovery_note": "Частичное восстановление остается видимым. Отсутствующие фильтры и неразрешенный контекст явно показываются.",
"recognized_link_hint": "Ссылка Superset распознана. Восстановление продолжится после создания оболочки сессии.",
"dataset_selection_label": "Ссылка на датасет",
"dataset_selection_placeholder": "dataset_id:123 или sales.daily_revenue",
"dataset_selection_hint": "Используйте id датасета или стабильную ссылку на датасет из выбранного окружения.",
"dataset_selection_required": "Введите ссылку на датасет, чтобы продолжить.",
"dataset_selection_recovery_note": "Для intake по датасету также явно показываются confidence и неразрешенный контекст.",
"dataset_selection_acknowledged": "Ссылка на датасет принята. Загружается возобновляемая review-оболочка.",
"submit_superset_link": "Старт по ссылке",
"submit_dataset_selection": "Старт по датасету",
"submitting": "Запуск review...",
"submit_failed": "Не удалось запустить review-сессию датасета."
},
"findings": {
"eyebrow": "Результаты валидации",
"title": "Проверьте блокеры, предупреждения и информационные заметки",
"description": "Результаты сгруппированы по severity, чтобы самые важные риски оставались видимыми, а частичное восстановление оставалось пригодным к использованию.",
"next_action_label": "Рекомендованный следующий шаг",
"empty": "Результаты пока не записаны. Сессию можно продолжать с текущим восстановленным контекстом.",
"blocking_title": "Блокеры",
"blocking_empty": "Открытых блокирующих результатов сейчас нет.",
"warning_title": "Предупреждения",
"warning_empty": "Сейчас нет warning-результатов, требующих внимания.",
"informational_title": "Информационные",
"informational_empty": "Информационные результаты сейчас отсутствуют.",
"resolution_label": "Статус",
"reference_label": "Ссылка",
"jump_action": "Открыть связанный раздел",
"resolution": {
"open": "Открыто",
"resolved": "Решено",
"approved": "Подтверждено",
"skipped": "Пропущено",
"deferred": "Отложено",
"expert_review": "Экспертная проверка"
},
"areas": {
"source_intake": "Источник",
"dataset_profile": "Профиль датасета",
"semantic_enrichment": "Семантическое обогащение",
"clarification": "Уточнение",
"filter_recovery": "Восстановление фильтров",
"template_mapping": "Маппинг шаблонов",
"compiled_preview": "Скомпилированный preview",
"launch": "Запуск",
"audit": "Аудит"
}
},
"workspace": {
"eyebrow": "Оркестрация датасета",
"title": "Workspace review датасета",
"description": "Возобновляйте предыдущую сессию или просматривайте восстановленный контекст, findings, provenance и exports в одном месте.",
"loading": "Загрузка review workspace...",
"load_failed": "Не удалось загрузить review-сессию датасета.",
"resume_failed": "Не удалось возобновить текущую review-сессию.",
"save_failed": "Не удалось обновить review-сессию.",
"export_failed": "Не удалось экспортировать выбранный артефакт review.",
"empty_state_title": "Сессия не загружена",
"state_label": "Workspace",
"readiness_label": "Готовность",
"source_badge_fallback": "review-сессия",
"save_session_action": "Сохранить сессию",
"export_summary_action": "Экспортировать summary",
"save_summary_action": "Сохранить summary",
"summary_edit_saved": "Черновик summary обновлен для текущего review workspace.",
"import_progress_eyebrow": "Прогресс импорта",
"import_progress_title": "Контекст датасета восстанавливается поэтапно",
"import_progress_body": "Workspace последовательно показывает этапы распознавания источника, фильтров, переменных и семантических кандидатов.",
"import_milestones": {
"recognized": "Датасет распознан",
"filters": "Сохраненные native filters восстановлены",
"variables": "Переменные шаблона датасета обнаружены",
"semantic_sources": "Найдены ближайшие семантические источники",
"summary": "Подготовлено предварительное summary"
},
"source_session_title": "Источник и сессия",
"source_label": "Источник датасета",
"import_status_title": "Статус импорта Superset",
"recent_actions_title": "Таймлайн последних действий",
"timeline": {
"source": "Источник принят",
"status": "Состояние сессии обновлено",
"filters": "Фильтры восстановлены",
"findings": "Findings записаны",
"exports": "Последний экспорт создан"
},
"next_action_label": "Следующее действие",
"next_action_card_eyebrow": "Основное действие",
"next_action_card_body": "Основное действие всегда соответствует самому ценному следующему шагу для текущего состояния сессии.",
"session_label": "Сводка сессии",
"session_id_label": "ID сессии",
"summary_source_label": "Источник summary",
"confidence_label": "Уверенность",
"business_summary_title": "Бизнес-summary",
"summary_missing": "Читаемое бизнес-summary пока недоступно.",
"partial_recovery_badge": "Частичное восстановление",
"phase_label": "Фаза",
"status_label": "Статус",
"active_task_label": "Активная задача",
"recovery_title": "Восстановленные фильтры и provenance",
"recovery_empty": "Импортированные фильтры пока не восстановлены.",
"provenance_label": "Provenance",
"recovered_value_label": "Восстановленное значение",
"health_title": "Обзор findings",
"open_findings_label": "Открытые findings",
"exports_title": "Экспорт",
"exports_description": "Генерируйте inline-артефакты документации и валидации из текущего review-состояния.",
"export_documentation_json": "Экспорт документации (JSON)",
"export_documentation_markdown": "Экспорт документации (Markdown)",
"export_validation_json": "Экспорт валидации (JSON)",
"export_validation_markdown": "Экспорт валидации (Markdown)",
"preview_title": "Статус preview",
"preview_status_label": "Статус",
"preview_compiler_label": "Скомпилировано",
"preview_pending_note": "Скомпилированный preview не входит в этот пакет US1, но workspace сохраняет его видимым, если он уже существует.",
"jump_target_label": "Выбранная зона",
"resume_action": "Возобновить сессию",
"pause_action": "Поставить сессию на паузу",
"actions": {
"import_from_superset": "Импорт из Superset",
"review_documentation": "Проверить документацию",
"apply_semantic_source": "Применить семантический источник",
"start_clarification": "Начать уточнение",
"answer_next_question": "Ответить на следующий вопрос",
"approve_mapping": "Подтвердить маппинг",
"generate_sql_preview": "Сгенерировать SQL preview",
"complete_required_values": "Заполнить обязательные значения",
"launch_dataset": "Запустить датасет",
"resume_session": "Возобновить сессию",
"export_outputs": "Экспортировать результаты"
},
"readiness": {
"empty": "Пусто",
"importing": "Импорт",
"review_ready": "Готово к review",
"semantic_source_review_needed": "Нужен review источника семантики",
"clarification_needed": "Нужно уточнение",
"clarification_active": "Уточнение активно",
"mapping_review_needed": "Нужен review маппинга",
"compiled_preview_ready": "Скомпилированный preview готов",
"partially_ready": "Частично готово",
"run_ready": "Готово к запуску",
"run_in_progress": "Запуск выполняется",
"completed": "Завершено",
"recovery_required": "Требуется восстановление"
},
"summary_sources": {
"confirmed": "Подтверждено",
"imported": "Импортировано",
"inferred": "Выведено",
"ai_draft": "Черновик AI",
"manual_override": "Ручное переопределение"
},
"confidence": {
"confirmed": "Подтверждено",
"mostly_confirmed": "Почти подтверждено",
"mixed": "Смешанное",
"low_confidence": "Низкая уверенность",
"unresolved": "Не разрешено"
}
}
},
"tasks": {
"management": "Управление задачами",
"run_backup": "Запустить бэкап",

View File

@@ -0,0 +1,16 @@
// [DEF:state_mock:Module]
// @COMPLEXITY: 2
// @PURPOSE: Mock for $app/state in vitest route/component tests.
// @LAYER: UI Tests
export const page = {
params: {},
route: { id: "test" },
url: new URL("http://localhost"),
status: 200,
error: null,
data: {},
form: null,
};
// [/DEF:state_mock:Module]

View File

@@ -1,9 +1,10 @@
// [DEF:frontend.src.lib.stores.__tests__.test_datasetReviewSession:Module]
// [DEF:DatasetReviewSessionStoreTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, store, session, tests
// @PURPOSE: Unit tests for dataset review session store
// @PURPOSE: Unit tests for dataset review session store.
// @LAYER: UI
// @RELATION: VERIFIES -> [datasetReviewSession:Store]
// @UX_STATE: Idle -> Store helpers are exercised without asynchronous UI transitions.
import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -109,4 +110,4 @@ describe('datasetReviewSession store', () => {
});
});
// [/DEF:frontend.src.lib.stores.__tests__.test_datasetReviewSession:Module]
// [/DEF:DatasetReviewSessionStoreTests:Module]

View File

@@ -1,8 +1,12 @@
// [DEF:datasetReviewSession:Store]
// @COMPLEXITY: 4
// @PURPOSE: Manage active dataset review session state, including loading, updates, and navigation guards.
// @PURPOSE: Manage active dataset review session state, including loading, local edits, error capture, and reset semantics for the active review workspace.
// @LAYER: UI
// @RELATION: DEPENDS_ON -> api_module (requestApi/fetchApi)
// @RELATION: DEPENDS_ON -> [api_module]
// @PRE: Consumers provide session-shaped payloads when setting or patching state and initialize the store before reading derived session fields.
// @POST: Store transitions keep session, loading, error, and dirty flags internally consistent after each helper call.
// @SIDE_EFFECT: Mutates the writable dataset review session store in frontend memory.
// @DATA_CONTRACT: Input[SessionDetail | Partial<SessionDetail> | boolean | string | null] -> Output[DatasetReviewSessionStoreState]
//
// @UX_STATE: Loading -> Session detail is being fetched.
// @UX_STATE: Ready -> Session detail is available for UI binding.

View File

@@ -0,0 +1,896 @@
<!-- [DEF:DatasetReviewWorkspace:Component] -->
<!-- @COMPLEXITY: 5 -->
<!-- @SEMANTICS: dataset-review, workspace, session-resume, intake, findings, exports -->
<!-- @PURPOSE: Main dataset review workspace coordinating session state, progressive recovery, semantic review, clarification, preview, and launch UX. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
<!-- @RELATION: [BINDS_TO] ->[assistantChat] -->
<!-- @RELATION: [BINDS_TO] ->[taskDrawer] -->
<!-- @PRE: Review route has either a resumable session id or receives new intake input from the workspace form. -->
<!-- @POST: Session detail is loaded or created, highest-value next action is visible, and partial recovery remains readable and usable. -->
<!-- @SIDE_EFFECT: Reads and mutates ownership-scoped review session state through dataset orchestration APIs and may navigate to a newly created session route. -->
<!-- @DATA_CONTRACT: Input[route:id|StartSessionRequest] -> Output[SessionDetail|null] -->
<!-- @INVARIANT: Navigation away from dirty session state must require explicit confirmation. -->
<!-- @UX_STATE: Empty -> Show source intake with Superset link and dataset-selection entry actions. -->
<!-- @UX_STATE: Importing -> Show progressive recovery milestones as context is assembled. -->
<!-- @UX_STATE: Review -> Show summary, findings, semantic layer, filters, mapping, and next action. -->
<!-- @UX_STATE: Clarification -> Reserved for later batch; current session still exposes the next-step intent. -->
<!-- @UX_STATE: Ready -> Reserved for later batch; warnings stay visible and never disappear silently. -->
<!-- @UX_FEEDBACK: Main CTA changes by readiness state and reflects current highest-value next action. -->
<!-- @UX_RECOVERY: Users can save, resume, or reopen an unfinished session without losing context. -->
<!-- @UX_REACTIVITY: Uses Svelte runes for session, readiness, preview, and task state derivation. -->
<script>
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { t } from "$lib/i18n";
import { api } from "$lib/api.js";
import SourceIntakePanel from "$lib/components/dataset-review/SourceIntakePanel.svelte";
import ValidationFindingsPanel from "$lib/components/dataset-review/ValidationFindingsPanel.svelte";
import {
setError,
setLoading,
setSession,
resetSession,
} from "$lib/stores/datasetReviewSession.js";
import {
environmentContextStore,
initializeEnvironmentContext,
} from "$lib/stores/environmentContext.js";
import { fromStore } from "svelte/store";
const environmentContextState = fromStore(environmentContextStore);
let session = $state(null);
let loadError = $state("");
let submitError = $state("");
let exportMessage = $state("");
let summaryFeedback = $state("");
let isBootstrapping = $state(true);
let isSubmitting = $state(false);
let isExporting = $state(false);
let isEditingSummary = $state(false);
let summaryDraft = $state("");
let jumpTarget = $state("");
let intakeAcknowledgment = $state("");
const routeSessionId = $derived(page.params.id || "");
const environments = $derived(environmentContextState.current?.environments || []);
const selectedEnvironmentId = $derived(
session?.environment_id ||
environmentContextState.current?.selectedEnvId ||
"",
);
const currentWorkspaceState = $derived(
!session
? (isSubmitting ? "Importing" : "Empty")
: session.readiness_state === "importing"
? "Importing"
: "Review",
);
const profile = $derived(session?.profile || null);
const findings = $derived(session?.findings || []);
const importedFilters = $derived(session?.imported_filters || []);
const latestPreview = $derived(
session?.previews?.length ? session.previews[session.previews.length - 1] : null,
);
const exportDisabled = $derived(!session || isExporting);
const saveDisabled = $derived(!session || isSubmitting);
const blockingCount = $derived(
findings.filter((finding) => finding.severity === "blocking").length,
);
const warningCount = $derived(
findings.filter((finding) => finding.severity === "warning").length,
);
const infoCount = $derived(
findings.filter((finding) => finding.severity === "informational").length,
);
const openFindingsCount = $derived(findings.length);
const nextActionLabel = $derived(getRecommendedActionLabel(session?.recommended_action));
const readinessLabel = $derived(
$t.dataset_review?.workspace?.readiness?.[session?.readiness_state || "empty"] ||
session?.readiness_state ||
$t.dataset_review?.workspace?.empty_state_title,
);
const summarySourceLabel = $derived(
$t.dataset_review?.workspace?.summary_sources?.[
profile?.business_summary_source || "ai_draft"
] || profile?.business_summary_source || "",
);
const confidenceLabel = $derived(
$t.dataset_review?.workspace?.confidence?.[profile?.confidence_state || "unresolved"] ||
profile?.confidence_state ||
"",
);
const importMilestones = $derived(buildImportMilestones());
const recentActions = $derived(buildRecentActions());
function getRecommendedActionLabel(action) {
const normalized = String(action || "import_from_superset");
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
}
function buildImportMilestones() {
const recognizedDone = Boolean(intakeAcknowledgment || session?.dataset_ref);
const filtersDone = importedFilters.length > 0;
const variablesDone = Boolean(importedFilters.length || latestPreview);
const semanticsDone = Boolean(profile?.business_summary || findings.length > 0);
const importState = currentWorkspaceState === "Importing";
return [
{
key: "recognized",
label: $t.dataset_review?.workspace?.import_milestones?.recognized,
state: recognizedDone ? "done" : importState ? "active" : "pending",
},
{
key: "filters",
label: $t.dataset_review?.workspace?.import_milestones?.filters,
state: filtersDone ? "done" : importState ? "active" : "pending",
},
{
key: "variables",
label: $t.dataset_review?.workspace?.import_milestones?.variables,
state: variablesDone ? "done" : importState ? "active" : "pending",
},
{
key: "semantic_sources",
label: $t.dataset_review?.workspace?.import_milestones?.semantic_sources,
state: semanticsDone ? "done" : importState ? "active" : "pending",
},
{
key: "summary",
label: $t.dataset_review?.workspace?.import_milestones?.summary,
state: semanticsDone ? "done" : importState ? "active" : "pending",
},
];
}
function buildRecentActions() {
if (!session) {
return [];
}
const actions = [
{
key: "source",
title: $t.dataset_review?.workspace?.timeline?.source,
detail: session.source_input || session.dataset_ref || "—",
},
{
key: "status",
title: $t.dataset_review?.workspace?.timeline?.status,
detail: `${session.status}${readinessLabel}`,
},
];
if (importedFilters.length > 0) {
actions.push({
key: "filters",
title: $t.dataset_review?.workspace?.timeline?.filters,
detail: `${importedFilters.length}`,
});
}
if (findings.length > 0) {
actions.push({
key: "findings",
title: $t.dataset_review?.workspace?.timeline?.findings,
detail: `${findings.length}`,
});
}
if (exportMessage) {
actions.push({
key: "exports",
title: $t.dataset_review?.workspace?.timeline?.exports,
detail: exportMessage,
});
}
return actions;
}
function buildSessionUrl(sessionId) {
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
}
async function loadSessionDetail(sessionId) {
if (!sessionId) {
session = null;
loadError = "";
summaryDraft = "";
resetSession();
return;
}
setLoading(true);
loadError = "";
submitError = "";
try {
const detail = await api.fetchApi(`/dataset-orchestration/sessions/${sessionId}`);
session = detail;
summaryDraft = detail?.profile?.business_summary || "";
setSession(detail);
} catch (error) {
loadError =
error?.message ||
$t.dataset_review?.workspace?.load_failed ||
$t.common?.error;
setError(loadError);
} finally {
setLoading(false);
}
}
async function bootstrapWorkspace() {
isBootstrapping = true;
await initializeEnvironmentContext();
await loadSessionDetail(routeSessionId);
isBootstrapping = false;
}
async function handleSourceSubmit(payload) {
isSubmitting = true;
submitError = "";
exportMessage = "";
summaryFeedback = "";
loadError = "";
intakeAcknowledgment =
payload.source_kind === "superset_link"
? $t.dataset_review?.source?.recognized_link_hint
: $t.dataset_review?.source?.dataset_selection_acknowledged;
try {
const summary = await api.postApi("/dataset-orchestration/sessions", payload);
await goto(buildSessionUrl(summary.session_id));
await loadSessionDetail(summary.session_id);
} catch (error) {
submitError =
error?.message ||
$t.dataset_review?.source?.submit_failed ||
$t.common?.error;
throw error;
} finally {
isSubmitting = false;
}
}
async function handleResumeSession() {
if (!session?.session_id) {
return;
}
try {
const updated = await api.requestApi(
`/dataset-orchestration/sessions/${session.session_id}`,
"PATCH",
{ status: "active" },
);
await goto(buildSessionUrl(updated.session_id));
await loadSessionDetail(updated.session_id);
} catch (error) {
loadError =
error?.message ||
$t.dataset_review?.workspace?.resume_failed ||
$t.common?.error;
}
}
async function updateSessionLifecycle(statusValue) {
if (!session?.session_id) {
return;
}
try {
await api.requestApi(
`/dataset-orchestration/sessions/${session.session_id}`,
"PATCH",
{ status: statusValue },
);
await loadSessionDetail(session.session_id);
} catch (error) {
loadError =
error?.message ||
$t.dataset_review?.workspace?.save_failed ||
$t.common?.error;
}
}
async function exportArtifact(kind, format) {
if (!session?.session_id) {
return;
}
isExporting = true;
exportMessage = "";
try {
const artifact = await api.fetchApi(
`/dataset-orchestration/sessions/${session.session_id}/exports/${kind}?format=${format}`,
);
exportMessage = `${artifact.artifact_type}${artifact.format}${artifact.storage_ref}`;
} catch (error) {
exportMessage =
error?.message ||
$t.dataset_review?.workspace?.export_failed ||
$t.common?.error;
} finally {
isExporting = false;
}
}
function handleFindingJump(event) {
jumpTarget = event?.target || "summary";
}
function startSummaryEdit() {
summaryDraft = profile?.business_summary || "";
summaryFeedback = "";
isEditingSummary = true;
}
function cancelSummaryEdit() {
summaryDraft = profile?.business_summary || "";
summaryFeedback = "";
isEditingSummary = false;
}
function saveSummaryDraft() {
summaryFeedback = $t.dataset_review?.workspace?.summary_edit_saved;
isEditingSummary = false;
}
function primaryActionHandler() {
if (!session) {
return;
}
if (session.recommended_action === "resume_session") {
void handleResumeSession();
return;
}
if (session.status === "active") {
void updateSessionLifecycle("paused");
}
}
bootstrapWorkspace();
</script>
<div class="mx-auto w-full max-w-7xl space-y-5 px-4 py-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-blue-700">
{$t.dataset_review?.workspace?.eyebrow}
</p>
<h1 class="text-2xl font-bold text-slate-900">
{$t.dataset_review?.workspace?.title}
</h1>
<p class="mt-1 max-w-3xl text-sm text-slate-600">
{$t.dataset_review?.workspace?.description}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
{#if session}
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{session.source_kind || $t.dataset_review?.workspace?.source_badge_fallback}
</span>
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{profile?.dataset_name || session.dataset_ref}
</span>
{/if}
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{$t.dataset_review?.workspace?.state_label}: {currentWorkspaceState}
</span>
{#if session}
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
{$t.dataset_review?.workspace?.readiness_label}: {readinessLabel}
</span>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => updateSessionLifecycle("paused")}
disabled={saveDisabled}
>
{$t.dataset_review?.workspace?.save_session_action}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => exportArtifact("documentation", "markdown")}
disabled={exportDisabled}
>
{$t.dataset_review?.workspace?.export_summary_action}
</button>
{/if}
</div>
</div>
{#if isBootstrapping}
<div class="rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-500 shadow-sm">
{$t.dataset_review?.workspace?.loading}
</div>
{:else}
{#if loadError}
<div class="rounded-2xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
{loadError}
</div>
{/if}
{#if !session}
<SourceIntakePanel
environments={environments}
selectedEnvironmentId={selectedEnvironmentId}
submitting={isSubmitting}
acknowledgment={intakeAcknowledgment}
onsubmit={handleSourceSubmit}
/>
{#if currentWorkspaceState === "Importing"}
<section class="rounded-2xl border border-blue-200 bg-blue-50 p-5 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
{$t.dataset_review?.workspace?.import_progress_eyebrow}
</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{$t.dataset_review?.workspace?.import_progress_title}
</h2>
<p class="mt-1 text-sm text-slate-700">
{$t.dataset_review?.workspace?.import_progress_body}
</p>
</div>
<span class="rounded-full bg-white px-3 py-1 text-xs font-medium text-blue-700">
{currentWorkspaceState}
</span>
</div>
<ol class="mt-4 grid gap-3 md:grid-cols-2">
{#each importMilestones as milestone}
<li class="rounded-xl border border-white/70 bg-white/80 p-3">
<div class="flex items-center gap-2">
<span class={`inline-flex h-2.5 w-2.5 rounded-full ${
milestone.state === "done"
? "bg-emerald-500"
: milestone.state === "active"
? "bg-blue-500"
: "bg-slate-300"
}`}></span>
<span class="text-sm font-medium text-slate-900">{milestone.label}</span>
</div>
</li>
{/each}
</ol>
</section>
{/if}
{#if submitError}
<div class="rounded-2xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
{submitError}
</div>
{/if}
{:else}
<div class="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div class="space-y-5">
<section
id="intake"
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.source_session_title}
</p>
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.source_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{session.source_input || session.dataset_ref}
</div>
</div>
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-2">
<div>
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.import_status_title}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{currentWorkspaceState}
</div>
</div>
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-slate-700">
{readinessLabel}
</span>
</div>
<ol class="mt-4 space-y-2">
{#each importMilestones as milestone}
<li class="flex items-center gap-2 text-sm text-slate-700">
<span class={`inline-flex h-2.5 w-2.5 rounded-full ${
milestone.state === "done"
? "bg-emerald-500"
: milestone.state === "active"
? "bg-blue-500"
: "bg-slate-300"
}`}></span>
<span>{milestone.label}</span>
</li>
{/each}
</ol>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
onclick={() => updateSessionLifecycle("paused")}
disabled={saveDisabled}
>
{$t.dataset_review?.workspace?.save_session_action}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
onclick={handleResumeSession}
>
{$t.dataset_review?.workspace?.resume_action}
</button>
</div>
<div class="mt-4">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.workspace?.recent_actions_title}
</h3>
<ol class="mt-3 space-y-3">
{#each recentActions as action}
<li class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-sm font-medium text-slate-900">{action.title}</div>
<div class="mt-1 text-xs text-slate-600">{action.detail}</div>
</li>
{/each}
</ol>
</div>
</section>
</div>
<div class="space-y-5">
<section
id="summary"
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.session_label}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{profile?.dataset_name || session.dataset_ref}
</h2>
<p class="mt-1 text-sm text-slate-600">
{profile?.schema_name || "—"}{profile?.database_name || "—"}
</p>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.next_action_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{nextActionLabel}
</div>
</div>
</div>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.summary_source_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{summarySourceLabel}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.confidence_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{confidenceLabel}
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.session_id_label}
</div>
<div class="mt-1 break-all text-sm font-medium text-slate-900">
{session.session_id}
</div>
</div>
</div>
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.workspace?.business_summary_title}
</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
onclick={startSummaryEdit}
>
[{$t.common?.edit}]
</button>
{#if session.readiness_state === "recovery_required" || session.readiness_state === "partially_ready"}
<span class="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-700">
{$t.dataset_review?.workspace?.partial_recovery_badge}
</span>
{/if}
</div>
</div>
{#if isEditingSummary}
<textarea
class="mt-3 min-h-32 w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
bind:value={summaryDraft}
></textarea>
<div class="mt-3 flex flex-wrap gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
onclick={saveSummaryDraft}
>
{$t.dataset_review?.workspace?.save_summary_action}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
onclick={cancelSummaryEdit}
>
{$t.common?.cancel}
</button>
</div>
{:else}
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-slate-700">
{summaryDraft || profile?.business_summary || $t.dataset_review?.workspace?.summary_missing}
</p>
{/if}
{#if summaryFeedback}
<div class="mt-3 rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-3 text-xs text-emerald-800">
{summaryFeedback}
</div>
{/if}
<div class="mt-3 flex flex-wrap gap-2 text-xs">
<span class="rounded-full bg-slate-200 px-2 py-1 text-slate-700">
{$t.dataset_review?.workspace?.phase_label}: {session.current_phase}
</span>
<span class="rounded-full bg-slate-200 px-2 py-1 text-slate-700">
{$t.dataset_review?.workspace?.status_label}: {session.status}
</span>
{#if session.active_task_id}
<span class="rounded-full bg-blue-100 px-2 py-1 text-blue-700">
{$t.dataset_review?.workspace?.active_task_label}: {session.active_task_id}
</span>
{/if}
</div>
</div>
<div
id="filters"
class="rounded-xl border border-slate-200 bg-white p-4"
>
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-semibold text-slate-900">
{$t.dataset_review?.workspace?.recovery_title}
</h3>
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">
{importedFilters.length}
</span>
</div>
{#if importedFilters.length === 0}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.workspace?.recovery_empty}
</p>
{:else}
<div class="mt-3 space-y-3">
{#each importedFilters as filterItem}
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium text-slate-900">
{filterItem.display_name || filterItem.filter_name}
</div>
<span class="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
{$t.dataset_review?.workspace?.provenance_label}:
{filterItem.source}
</span>
<span class="rounded-full bg-slate-200 px-2 py-0.5 text-xs text-slate-700">
{$t.dataset_review?.workspace?.confidence_label}:
{filterItem.confidence_state}
</span>
<span class={`rounded-full px-2 py-0.5 text-xs ${
filterItem.recovery_status === "recovered"
? "bg-emerald-100 text-emerald-700"
: filterItem.recovery_status === "partial"
? "bg-amber-100 text-amber-700"
: "bg-red-100 text-red-700"
}`}>
{filterItem.recovery_status}
</span>
</div>
<p class="mt-2 text-sm text-slate-700">
{$t.dataset_review?.workspace?.recovered_value_label}:
{JSON.stringify(filterItem.normalized_value ?? filterItem.raw_value)}
</p>
{#if filterItem.notes}
<p class="mt-1 text-xs text-slate-500">{filterItem.notes}</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<ValidationFindingsPanel
findings={findings}
recommendedAction={session.recommended_action}
onjump={handleFindingJump}
/>
</section>
</div>
<aside class="space-y-5">
<section
id="next-action"
class="rounded-2xl border border-blue-200 bg-blue-50 p-5 shadow-sm"
>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
{$t.dataset_review?.workspace?.next_action_card_eyebrow}
</p>
<h2 class="mt-1 text-lg font-semibold text-slate-900">
{nextActionLabel}
</h2>
<p class="mt-2 text-sm text-slate-700">
{$t.dataset_review?.workspace?.next_action_card_body}
</p>
<button
type="button"
class="mt-4 inline-flex w-full items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
onclick={primaryActionHandler}
>
{session.recommended_action === "resume_session"
? $t.dataset_review?.workspace?.resume_action
: $t.dataset_review?.workspace?.pause_action}
</button>
{#if jumpTarget}
<p class="mt-3 text-xs text-blue-700">
{$t.dataset_review?.workspace?.jump_target_label}: {jumpTarget}
</p>
{/if}
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">
{$t.dataset_review?.workspace?.health_title}
</h2>
<div class="mt-4 grid gap-3">
<div class="rounded-xl border border-red-100 bg-red-50 p-3">
<div class="text-xs uppercase tracking-wide text-red-700">
{$t.dataset_review?.findings?.blocking_title}
</div>
<div class="mt-1 text-xl font-semibold text-red-800">{blockingCount}</div>
</div>
<div class="rounded-xl border border-amber-100 bg-amber-50 p-3">
<div class="text-xs uppercase tracking-wide text-amber-700">
{$t.dataset_review?.findings?.warning_title}
</div>
<div class="mt-1 text-xl font-semibold text-amber-800">{warningCount}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.findings?.informational_title}
</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{infoCount}</div>
</div>
<div class="rounded-xl border border-slate-200 bg-slate-50 p-3">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.open_findings_label}
</div>
<div class="mt-1 text-xl font-semibold text-slate-800">{openFindingsCount}</div>
</div>
</div>
</section>
<section
id="exports"
class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm"
>
<h2 class="text-lg font-semibold text-slate-900">
{$t.dataset_review?.workspace?.exports_title}
</h2>
<p class="mt-1 text-sm text-slate-600">
{$t.dataset_review?.workspace?.exports_description}
</p>
<div class="mt-4 grid gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => exportArtifact("documentation", "json")}
disabled={exportDisabled}
>
{$t.dataset_review?.workspace?.export_documentation_json}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => exportArtifact("documentation", "markdown")}
disabled={exportDisabled}
>
{$t.dataset_review?.workspace?.export_documentation_markdown}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => exportArtifact("validation", "json")}
disabled={exportDisabled}
>
{$t.dataset_review?.workspace?.export_validation_json}
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => exportArtifact("validation", "markdown")}
disabled={exportDisabled}
>
{$t.dataset_review?.workspace?.export_validation_markdown}
</button>
</div>
{#if exportMessage}
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-xs text-slate-600">
{exportMessage}
</div>
{/if}
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">
{$t.dataset_review?.workspace?.preview_title}
</h2>
{#if latestPreview}
<div class="mt-3 space-y-2 text-sm text-slate-700">
<p>
{$t.dataset_review?.workspace?.preview_status_label}:
{latestPreview.preview_status}
</p>
<p>
{$t.dataset_review?.workspace?.preview_compiler_label}:
{latestPreview.compiled_by}
</p>
</div>
{:else}
<p class="mt-3 text-sm text-slate-600">
{$t.dataset_review?.workspace?.preview_pending_note}
</p>
{/if}
</section>
</aside>
</div>
{/if}
{/if}
</div>
<!-- [/DEF:DatasetReviewWorkspace:Component] -->

View File

@@ -0,0 +1,453 @@
/**
* @vitest-environment jsdom
*/
// @ts-nocheck
// [DEF:DatasetReviewWorkspaceUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, workspace, route, ux-tests, review-state, exports, recovery
// @PURPOSE: Verify US1 dataset review workspace flow evidence for empty, load, import, recovery, and export behaviors.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[DatasetReviewWorkspace]
// @UX_STATE: Empty -> Intake-first workspace renders clear starting actions.
// @UX_STATE: Importing -> Progressive milestones remain visible while review context is assembled.
// @UX_STATE: Review -> Workspace exposes summary controls, timeline, findings, and export affordances.
// @TEST_CONTRACT: DatasetReviewWorkspaceDependencies -> ObservableWorkspaceUX
// @TEST_SCENARIO: empty_state_renders_source_intake -> No session route data shows intake-first workspace.
// @TEST_SCENARIO: session_load_surfaces_partial_recovery_state -> Existing session route shows review summary, findings, and partial recovery evidence.
// @TEST_SCENARIO: import_submission_transitions_to_review_state -> Intake submit creates a session, navigates, and renders review state.
// @TEST_SCENARIO: export_feedback_surfaces_success_and_error -> Export action exposes both artifact success detail and failure feedback.
// @TEST_EDGE: missing_field -> Empty session context still renders stable workspace copy.
// @TEST_EDGE: invalid_type -> Unknown route params default to empty-state handling.
// @TEST_EDGE: external_fail -> API failures surface explicit feedback without collapsing the workspace.
// @TEST_INVARIANT: us1_workspace_remains_readable_and_actionable -> VERIFIED_BY: [empty_state_renders_source_intake, session_load_surfaces_partial_recovery_state, import_submission_transitions_to_review_state, export_feedback_surfaces_success_and_error]
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/svelte";
import DatasetReviewWorkspace from "../+page.svelte";
import { api } from "$lib/api.js";
const mockedGoto = vi.mocked(await import("$app/navigation")).goto;
const mockedPage = vi.mocked(await import("$app/state")).page;
const routeState = mockedPage.params;
function createSessionDetail(overrides = {}) {
return {
session_id: "session-1",
environment_id: "env-1",
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
dataset_ref: "public.sales",
readiness_state: "partially_ready",
recommended_action: "review_documentation",
status: "active",
current_phase: "review",
active_task_id: "task-77",
findings: [
{
title: "Missing semantic description",
code: "SEM-1",
area: "dataset_profile",
severity: "warning",
message: "Business summary needs confirmation",
resolution_state: "open",
caused_by_ref: "profile.summary",
resolution_note: "Confirm with reviewer",
},
],
imported_filters: [
{
filter_name: "region",
display_name: "Region",
source: "superset",
confidence_state: "mostly_confirmed",
recovery_status: "partial",
normalized_value: ["EMEA"],
raw_value: ["EMEA"],
notes: "Recovered from dashboard state",
},
],
previews: [
{
preview_status: "compiled",
compiled_by: "semantic-review",
},
],
profile: {
dataset_name: "Sales Dataset",
schema_name: "public",
database_name: "analytics",
business_summary_source: "imported",
confidence_state: "mostly_confirmed",
business_summary: "Imported business description",
},
...overrides,
};
}
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
vi.mock("$app/state", () => ({
page: {
params: {
id: "",
},
},
}));
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
common: {
error: "Common error",
choose_environment: "Choose environment",
edit: "Edit",
cancel: "Cancel",
},
dataset_review: {
source: {
eyebrow: "Source intake",
title: "Start dataset review",
description: "Paste link or provide dataset reference.",
state_idle: "Idle",
state_validating: "Validating",
state_rejected: "Rejected",
environment_label: "Environment",
environment_required: "Environment is required",
superset_link_tab: "Superset link",
superset_link_tab_hint: "Paste dashboard or explore URL",
dataset_selection_tab: "Dataset selection",
dataset_selection_tab_hint: "Enter dataset ref",
superset_link_label: "Superset link",
dataset_selection_label: "Dataset reference",
superset_link_placeholder: "https://superset.local/dashboard/10",
dataset_selection_placeholder: "public.sales",
superset_link_hint: "Paste a full Superset URL",
dataset_selection_hint: "Provide schema.dataset reference",
recognized_link_hint: "Recognized Superset link",
superset_link_required: "Superset link is required",
dataset_selection_required: "Dataset reference is required",
superset_link_invalid: "Superset link must start with http",
submit_failed: "Submit failed",
superset_link_recovery_note: "You can fix the link inline",
dataset_selection_recovery_note: "You can fix the dataset inline",
submitting: "Submitting",
submit_superset_link: "Start from link",
submit_dataset_selection: "Start from dataset",
dataset_selection_acknowledged: "Dataset selection acknowledged",
},
findings: {
eyebrow: "Validation findings",
title: "Findings",
description: "Review validation results",
next_action_label: "Next action",
empty: "No findings",
blocking_title: "Blocking",
blocking_empty: "No blocking findings",
warning_title: "Warnings",
warning_empty: "No warning findings",
informational_title: "Info",
informational_empty: "No informational findings",
resolution_label: "Resolution",
reference_label: "Reference",
jump_action: "Jump to area",
resolution: {
open: "Open",
},
areas: {
source_intake: "Source intake",
dataset_profile: "Dataset summary",
filter_recovery: "Recovered filters",
},
},
workspace: {
eyebrow: "Dataset review",
title: "Dataset review workspace",
description: "Review imported dataset context.",
state_label: "Workspace state",
readiness_label: "Readiness",
loading: "Loading workspace",
load_failed: "Failed to load review session",
resume_failed: "Failed to resume session",
save_failed: "Failed to save session",
export_failed: "Failed to export artifact",
empty_state_title: "No session loaded",
source_badge_fallback: "review session",
save_session_action: "Save session",
export_summary_action: "Export summary",
save_summary_action: "Save summary",
summary_edit_saved: "Summary draft updated for the current review workspace.",
import_progress_eyebrow: "Import progress",
import_progress_title: "Recovering dataset context progressively",
import_progress_body:
"The workspace reveals milestones as source, filters, variables, and semantic candidates become available.",
import_milestones: {
recognized: "Dataset recognized",
filters: "Saved native filters recovered",
variables: "Dataset template variables detected",
semantic_sources: "Nearby semantic sources identified",
summary: "Preliminary summary prepared",
},
source_session_title: "Source & session",
source_label: "Dataset source",
import_status_title: "Superset import status",
recent_actions_title: "Recent actions timeline",
timeline: {
source: "Source accepted",
status: "Session state updated",
filters: "Recovered filters",
findings: "Validation findings recorded",
exports: "Latest export generated",
},
summary_sources: {
ai_draft: "AI draft",
imported: "Imported",
},
confidence: {
unresolved: "Unresolved",
mostly_confirmed: "Mostly confirmed",
},
actions: {
import_from_superset: "Import from Superset",
review_documentation: "Review documentation",
resume_session: "Resume session",
},
readiness: {
empty: "Empty",
partially_ready: "Partially ready",
review_ready: "Review ready",
},
session_label: "Session",
next_action_label: "Next action",
summary_source_label: "Summary source",
confidence_label: "Confidence",
session_id_label: "Session ID",
business_summary_title: "Business summary",
partial_recovery_badge: "Partial recovery",
summary_missing: "Summary unavailable",
phase_label: "Phase",
status_label: "Status",
active_task_label: "Active task",
recovery_title: "Recovered filters",
recovery_empty: "No recovered filters",
provenance_label: "Provenance",
recovered_value_label: "Recovered value",
next_action_card_eyebrow: "Recommended next step",
next_action_card_body: "Use the recommended action to continue review.",
resume_action: "Resume session",
pause_action: "Pause session",
jump_target_label: "Jump target",
health_title: "Review health",
open_findings_label: "Open findings",
exports_title: "Exports",
exports_description: "Export review artifacts.",
export_documentation_json: "Documentation JSON",
export_documentation_markdown: "Documentation Markdown",
export_validation_json: "Validation JSON",
export_validation_markdown: "Validation Markdown",
preview_title: "Preview",
preview_pending_note: "Preview not generated yet",
preview_status_label: "Preview status",
preview_compiler_label: "Compiled by",
},
},
});
return () => {};
},
},
}));
vi.mock("$lib/api.js", () => ({
api: {
fetchApi: vi.fn(),
postApi: vi.fn(),
requestApi: vi.fn(),
},
}));
vi.mock("$lib/stores/datasetReviewSession.js", () => ({
setError: vi.fn(),
setLoading: vi.fn(),
setSession: vi.fn(),
resetSession: vi.fn(),
}));
vi.mock("$lib/stores/environmentContext.js", () => ({
environmentContextStore: {
subscribe: (run) => {
run({
environments: [{ id: "env-1", name: "DEV" }],
selectedEnvId: "env-1",
});
return () => {};
},
},
initializeEnvironmentContext: vi.fn().mockResolvedValue(undefined),
}));
describe("DatasetReviewWorkspace UX Contract", () => {
beforeEach(() => {
routeState.id = "";
vi.clearAllMocks();
api.fetchApi.mockReset();
api.postApi.mockReset();
api.requestApi.mockReset();
mockedGoto.mockReset();
});
it("empty_state_renders_source_intake", async () => {
api.fetchApi.mockResolvedValue(undefined);
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(screen.getByText("Start dataset review")).toBeDefined();
});
expect(screen.getByText("Workspace state: Empty")).toBeDefined();
expect(screen.getByText("Paste link or provide dataset reference.")).toBeDefined();
});
it("session_load_surfaces_partial_recovery_state", async () => {
routeState.id = "session-1";
api.fetchApi.mockResolvedValue(createSessionDetail());
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(api.fetchApi).toHaveBeenCalledWith("/dataset-orchestration/sessions/session-1");
});
expect(screen.getByText("Workspace state: Review")).toBeDefined();
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
expect(screen.getAllByText("Sales Dataset").length).toBeGreaterThan(0);
expect(screen.getByText("Partial recovery")).toBeDefined();
expect(screen.getAllByText("Recovered filters").length).toBeGreaterThan(0);
expect(screen.getByText("Imported business description")).toBeDefined();
expect(screen.getAllByRole("button", { name: "Save session" }).length).toBeGreaterThan(0);
expect(screen.getAllByRole("button", { name: "Export summary" }).length).toBeGreaterThan(0);
expect(screen.getByText("Source & session")).toBeDefined();
expect(screen.getByText("Recent actions timeline")).toBeDefined();
expect(screen.getByText("Source accepted")).toBeDefined();
});
it("summary_edit_affordance_updates_visible_draft", async () => {
routeState.id = "session-1";
api.fetchApi.mockResolvedValue(createSessionDetail());
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(screen.getByText("Business summary")).toBeDefined();
});
await fireEvent.click(screen.getByRole("button", { name: "[Edit]" }));
const summaryEditor = screen.getByRole("textbox");
await fireEvent.input(summaryEditor, {
target: { value: "Updated analyst-facing summary" },
});
await fireEvent.click(screen.getByRole("button", { name: "Save summary" }));
expect(screen.getByText("Updated analyst-facing summary")).toBeDefined();
expect(
screen.getByText("Summary draft updated for the current review workspace."),
).toBeDefined();
});
it("import_submission_transitions_to_review_state", async () => {
const createdSession = createSessionDetail();
let resolveSessionLoad;
api.postApi.mockResolvedValue({ session_id: "session-1" });
api.fetchApi.mockImplementation(
() =>
new Promise((resolve) => {
resolveSessionLoad = resolve;
}),
);
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(screen.getByText("Start dataset review")).toBeDefined();
});
const environmentSelect = screen.getByRole("combobox");
const sourceInput = screen.getByPlaceholderText("https://superset.local/dashboard/10");
const submitButton = screen.getByRole("button", { name: "Start from link" });
await fireEvent.change(environmentSelect, { target: { value: "env-1" } });
await fireEvent.input(sourceInput, {
target: { value: "https://superset.local/dashboard/10" },
});
await fireEvent.click(submitButton);
await waitFor(() => {
expect(api.postApi).toHaveBeenCalledWith("/dataset-orchestration/sessions", {
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
environment_id: "env-1",
});
});
await waitFor(() => {
expect(screen.getByText("Workspace state: Importing")).toBeDefined();
});
expect(screen.getByText("Import progress")).toBeDefined();
expect(screen.getByText("Recovering dataset context progressively")).toBeDefined();
expect(screen.getByText("Dataset recognized")).toBeDefined();
expect(screen.getByText("Saved native filters recovered")).toBeDefined();
resolveSessionLoad(createdSession);
await waitFor(() => {
expect(mockedGoto).toHaveBeenCalledWith("/datasets/review/session-1");
});
await waitFor(() => {
expect(screen.getByText("Workspace state: Review")).toBeDefined();
});
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
expect(screen.getAllByText("Review documentation").length).toBeGreaterThan(0);
});
it("export_feedback_surfaces_success_and_error", async () => {
routeState.id = "session-1";
api.fetchApi
.mockResolvedValueOnce(createSessionDetail())
.mockResolvedValueOnce({
artifact_type: "documentation",
format: "json",
storage_ref: "artifacts/doc.json",
})
.mockRejectedValueOnce(new Error("Export unavailable"));
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(screen.getByText("Exports")).toBeDefined();
});
const documentationJsonButton = screen.getByRole("button", {
name: "Documentation JSON",
});
const documentationMarkdownButton = screen.getByRole("button", {
name: "Documentation Markdown",
});
await fireEvent.click(documentationJsonButton);
await waitFor(() => {
expect(screen.getAllByText("documentation • json • artifacts/doc.json").length).toBeGreaterThan(0);
});
await fireEvent.click(documentationMarkdownButton);
await waitFor(() => {
expect(screen.getAllByText("Export unavailable").length).toBeGreaterThan(0);
});
});
});
// [/DEF:DatasetReviewWorkspaceUxTests:Module]