feat(us1): add dataset review orchestration automatic review slice
This commit is contained in:
896
frontend/src/routes/datasets/review/[id]/+page.svelte
Normal file
896
frontend/src/routes/datasets/review/[id]/+page.svelte
Normal 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] -->
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user