subagents
This commit is contained in:
@@ -196,6 +196,33 @@ async function postApi(endpoint, body) {
|
||||
}
|
||||
// [/DEF:postApi:Function]
|
||||
|
||||
// [DEF:deleteApi:Function]
|
||||
// @PURPOSE: Generic DELETE request wrapper.
|
||||
// @PRE: endpoint is provided.
|
||||
// @POST: Returns Promise resolving to JSON data or throws on error.
|
||||
// @PARAM: endpoint (string) - API endpoint.
|
||||
// @RETURN: Promise<any> - JSON response.
|
||||
async function deleteApi(endpoint) {
|
||||
try {
|
||||
console.log(`[api.deleteApi][Action] Deleting from context={{'endpoint': '${endpoint}'}}`);
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'DELETE',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
console.log(`[api.deleteApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
|
||||
if (!response.ok) {
|
||||
throw await buildApiError(response);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`[api.deleteApi][Coherence:Failed] Error deleting from ${endpoint}:`, error);
|
||||
notifyApiError(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// [/DEF:deleteApi:Function]
|
||||
|
||||
// [DEF:requestApi:Function]
|
||||
// @PURPOSE: Generic request wrapper.
|
||||
// @PRE: endpoint and method are provided.
|
||||
@@ -237,6 +264,7 @@ async function requestApi(endpoint, method = 'GET', body = null) {
|
||||
export const api = {
|
||||
fetchApi,
|
||||
postApi,
|
||||
deleteApi,
|
||||
requestApi,
|
||||
getPlugins: () => fetchApi('/plugins'),
|
||||
getTasks: (options = {}) => {
|
||||
|
||||
@@ -91,11 +91,7 @@
|
||||
return $t.dataset_review?.preview?.stale_body;
|
||||
}
|
||||
if (effectiveState === "failed") {
|
||||
return (
|
||||
preview?.error_details ||
|
||||
preview?.error_code ||
|
||||
$t.dataset_review?.preview?.error_body
|
||||
);
|
||||
return $t.dataset_review?.preview?.error_body;
|
||||
}
|
||||
if (effectiveState === "missing") {
|
||||
return $t.dataset_review?.preview?.missing_body;
|
||||
@@ -103,6 +99,12 @@
|
||||
return $t.dataset_review?.preview?.ready_body;
|
||||
}
|
||||
|
||||
function buildPreviewTechnicalDetails() {
|
||||
return [preview?.error_code, preview?.error_details].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
const previewTechnicalDetails = $derived(buildPreviewTechnicalDetails());
|
||||
|
||||
async function requestPreview() {
|
||||
if (!sessionId || disabled || localStatus === "saving") {
|
||||
return;
|
||||
|
||||
@@ -217,9 +217,18 @@
|
||||
<div class="mt-4 space-y-3">
|
||||
{#each launchBlockers as blocker}
|
||||
<div class="rounded-xl border border-red-200 bg-white p-3">
|
||||
<div class="text-sm font-medium text-slate-900">{blocker.label}</div>
|
||||
<div class="text-sm font-medium leading-6 text-slate-900 break-words [overflow-wrap:anywhere]">
|
||||
{blocker.label}
|
||||
</div>
|
||||
{#if blocker.detail}
|
||||
<div class="mt-1 break-all text-xs text-slate-600">{blocker.detail}</div>
|
||||
<div class="mt-2 max-h-28 overflow-auto rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<div
|
||||
data-testid="launch-blocker-detail"
|
||||
class="text-xs leading-5 text-slate-600 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
>
|
||||
{blocker.detail}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
@@ -242,7 +251,7 @@
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.dataset_ref_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
<div class="mt-1 text-sm font-medium text-slate-900 break-words [overflow-wrap:anywhere]">
|
||||
{session?.dataset_ref || ($t.common?.unknown || "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,6 +81,18 @@
|
||||
const normalized = String(action || "");
|
||||
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function getFindingMessage(finding) {
|
||||
return String(finding?.message || "").trim();
|
||||
}
|
||||
|
||||
function getFindingTechnicalReference(finding) {
|
||||
return String(finding?.caused_by_ref || "").trim();
|
||||
}
|
||||
|
||||
function getFindingResolutionNote(finding) {
|
||||
return String(finding?.resolution_note || "").trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
|
||||
@@ -654,6 +654,25 @@
|
||||
"audit": "Audit"
|
||||
}
|
||||
},
|
||||
"workspace_entry": {
|
||||
"resume_eyebrow": "Resume session",
|
||||
"resume_title": "Continue an existing review session",
|
||||
"resume_description": "If a dataset review was already started, open the relevant session and continue from its latest saved state.",
|
||||
"resume_available_badge": "Sessions available",
|
||||
"resume_loading": "Loading existing sessions...",
|
||||
"resume_load_failed": "Failed to load existing review sessions.",
|
||||
"resume_empty_title": "No saved sessions yet",
|
||||
"resume_empty_body": "Start a new review session from the source intake panel to make it available here for reopening later.",
|
||||
"resume_action": "Resume",
|
||||
"session_id_label": "Session ID",
|
||||
"dataset_ref_label": "Dataset reference",
|
||||
"environment_id_label": "Environment",
|
||||
"status_label": "Status",
|
||||
"readiness_label": "Readiness",
|
||||
"phase_label": "Phase",
|
||||
"updated_at_label": "Updated",
|
||||
"last_activity_at_label": "Last activity"
|
||||
},
|
||||
"workspace": {
|
||||
"eyebrow": "Dataset orchestration",
|
||||
"title": "Dataset review workspace",
|
||||
|
||||
@@ -652,6 +652,25 @@
|
||||
"audit": "Аудит"
|
||||
}
|
||||
},
|
||||
"workspace_entry": {
|
||||
"resume_eyebrow": "Возобновление сессии",
|
||||
"resume_title": "Продолжить существующую review-сессию",
|
||||
"resume_description": "Если review уже была начата, откройте нужную сессию и продолжайте с последнего сохраненного состояния.",
|
||||
"resume_available_badge": "Доступно сессий",
|
||||
"resume_loading": "Загрузка доступных сессий...",
|
||||
"resume_load_failed": "Не удалось загрузить существующие review-сессии.",
|
||||
"resume_empty_title": "Сохраненных сессий пока нет",
|
||||
"resume_empty_body": "Запустите новую review-сессию через блок источника справа, чтобы она появилась здесь для повторного открытия.",
|
||||
"resume_action": "Возобновить",
|
||||
"session_id_label": "ID сессии",
|
||||
"dataset_ref_label": "Ссылка на датасет",
|
||||
"environment_id_label": "Окружение",
|
||||
"status_label": "Статус",
|
||||
"readiness_label": "Готовность",
|
||||
"phase_label": "Фаза",
|
||||
"updated_at_label": "Обновлено",
|
||||
"last_activity_at_label": "Последняя активность"
|
||||
},
|
||||
"workspace": {
|
||||
"eyebrow": "Оркестрация датасета",
|
||||
"title": "Workspace review датасета",
|
||||
|
||||
@@ -3,14 +3,17 @@
|
||||
<!-- @SEMANTICS: dataset-review, workspace-entry, source-intake, session-bootstrap -->
|
||||
<!-- @PURPOSE: Entry route for Dataset Review Workspace that allows starting a new resumable review session before navigating to a specific session id route. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [CALLS] ->[fetchApi] -->
|
||||
<!-- @RELATION: [CALLS] ->[postApi] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[SourceIntakePanel] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[environmentContext] -->
|
||||
<!-- @UX_STATE: Empty -> Show source intake for Superset link or dataset reference. -->
|
||||
<!-- @UX_STATE: ResumeList -> Show existing resumable sessions with direct navigation CTA. -->
|
||||
<!-- @UX_STATE: Submitting -> Disable controls and show startup feedback. -->
|
||||
<!-- @UX_STATE: Error -> Inline error shown while keeping intake values editable. -->
|
||||
<!-- @UX_RECOVERY: Users can correct invalid input in place and retry without losing environment selection. -->
|
||||
<script>
|
||||
import { goto } from "$app/navigation";
|
||||
import { fromStore } from "svelte/store";
|
||||
import SourceIntakePanel from "$lib/components/dataset-review/SourceIntakePanel.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
@@ -26,20 +29,117 @@
|
||||
let isSubmitting = $state(false);
|
||||
let submitError = $state("");
|
||||
let intakeAcknowledgment = $state("");
|
||||
let sessions = $state([]);
|
||||
let sessionsLoading = $state(false);
|
||||
let sessionsError = $state("");
|
||||
let searchQuery = $state("");
|
||||
let currentPage = $state(1);
|
||||
let pageSize = $state(5);
|
||||
let totalSessions = $state(0);
|
||||
let deleteConfirmSessionId = $state(null);
|
||||
|
||||
const environments = $derived(environmentContextState.current?.environments || []);
|
||||
const selectedEnvironmentId = $derived(
|
||||
environmentContextState.current?.selectedEnvId || "",
|
||||
);
|
||||
const hasExistingSessions = $derived(sessions.length > 0);
|
||||
const totalPages = $derived(totalSessions > 0 ? Math.ceil(totalSessions / pageSize) : 1);
|
||||
|
||||
const filteredSessions = $derived(() => {
|
||||
if (!searchQuery.trim()) return sessions;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return sessions.filter(s =>
|
||||
(s.session_id?.toLowerCase().includes(query)) ||
|
||||
(s.dataset_ref?.toLowerCase().includes(query)) ||
|
||||
(s.environment_id?.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
|
||||
function buildSessionUrl(sessionId) {
|
||||
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return $t.common?.not_available || "N/A";
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function getSessionMetaValue(session, key, fallback = "") {
|
||||
const value = session?.[key];
|
||||
return value ? String(value) : fallback;
|
||||
}
|
||||
|
||||
async function loadExistingSessions() {
|
||||
sessionsLoading = true;
|
||||
sessionsError = "";
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", String(currentPage));
|
||||
params.append("page_size", String(pageSize));
|
||||
if (searchQuery.trim()) {
|
||||
params.append("search", searchQuery.trim());
|
||||
}
|
||||
|
||||
const response = await api.fetchApi(`/dataset-orchestration/sessions?${params.toString()}`);
|
||||
sessions = Array.isArray(response?.items) ? response.items : [];
|
||||
totalSessions = response?.total || sessions.length;
|
||||
} catch (error) {
|
||||
sessions = [];
|
||||
totalSessions = 0;
|
||||
sessionsError =
|
||||
error?.message ||
|
||||
$t.dataset_review?.workspace_entry?.resume_load_failed ||
|
||||
$t.common?.error;
|
||||
} finally {
|
||||
sessionsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId, hardDelete = false) {
|
||||
try {
|
||||
await api.deleteApi(`/dataset-orchestration/sessions/${sessionId}?hard_delete=${hardDelete}`);
|
||||
deleteConfirmSessionId = null;
|
||||
// Reload current page, or go to previous if this was the last item
|
||||
if (sessions.length === 1 && currentPage > 1) {
|
||||
currentPage = currentPage - 1;
|
||||
}
|
||||
await loadExistingSessions();
|
||||
} catch (error) {
|
||||
sessionsError = error?.message || $t.common?.error;
|
||||
}
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
if (page >= 1 && page <= totalPages) {
|
||||
currentPage = page;
|
||||
loadExistingSessions();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
currentPage = 1;
|
||||
loadExistingSessions();
|
||||
}
|
||||
|
||||
function truncateText(text, maxLength = 30) {
|
||||
if (!text || text.length <= maxLength) return text || "";
|
||||
return text.slice(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
isBootstrapping = true;
|
||||
try {
|
||||
await initializeEnvironmentContext();
|
||||
await loadExistingSessions();
|
||||
} finally {
|
||||
isBootstrapping = false;
|
||||
}
|
||||
@@ -58,7 +158,7 @@
|
||||
if (!summary?.session_id) {
|
||||
throw new Error($t.dataset_review?.source?.submit_failed || "Failed to start review");
|
||||
}
|
||||
window.location.href = buildSessionUrl(summary.session_id);
|
||||
await goto(buildSessionUrl(summary.session_id));
|
||||
} catch (error) {
|
||||
submitError =
|
||||
error?.message ||
|
||||
@@ -91,6 +191,11 @@
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
|
||||
{$t.dataset_review?.workspace?.state_label}: {$t.dataset_review?.workspace?.state?.empty || "Empty"}
|
||||
</span>
|
||||
{#if hasExistingSessions}
|
||||
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
|
||||
{$t.dataset_review?.workspace_entry?.resume_available_badge}: {sessions.length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,19 +204,303 @@
|
||||
{$t.dataset_review?.workspace?.loading}
|
||||
</div>
|
||||
{:else}
|
||||
<SourceIntakePanel
|
||||
environments={environments}
|
||||
selectedEnvironmentId={selectedEnvironmentId}
|
||||
submitting={isSubmitting}
|
||||
acknowledgment={intakeAcknowledgment}
|
||||
onsubmit={handleSourceSubmit}
|
||||
/>
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
|
||||
{$t.dataset_review?.workspace_entry?.resume_eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.workspace_entry?.resume_title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.workspace_entry?.resume_description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if submitError}
|
||||
<div class="rounded-2xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
{submitError}
|
||||
<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={loadExistingSessions}
|
||||
disabled={sessionsLoading}
|
||||
>
|
||||
{#if sessionsLoading}
|
||||
<svg class="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{$t.dataset_review?.workspace_entry?.resume_loading}
|
||||
{:else}
|
||||
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
{$t.common?.refresh}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mt-4 flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
placeholder="Поиск по ID, датасету или окружению..."
|
||||
bind:value={searchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
onclick={handleSearch}
|
||||
>
|
||||
Найти
|
||||
</button>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
onclick={() => { searchQuery = ""; handleSearch(); }}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sessionsLoading}
|
||||
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
{$t.dataset_review?.workspace_entry?.resume_loading}
|
||||
</div>
|
||||
{:else if sessionsError}
|
||||
<div class="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
<div>{sessionsError}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 inline-flex items-center justify-center rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 transition hover:bg-red-50"
|
||||
onclick={loadExistingSessions}
|
||||
>
|
||||
{$t.common?.retry}
|
||||
</button>
|
||||
</div>
|
||||
{:else if !hasExistingSessions}
|
||||
<div class="mt-5 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-4 text-sm text-slate-600">
|
||||
<div class="font-medium text-slate-900">
|
||||
{$t.dataset_review?.workspace_entry?.resume_empty_title}
|
||||
</div>
|
||||
<p class="mt-1">
|
||||
{$t.dataset_review?.workspace_entry?.resume_empty_body}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-5 space-y-3">
|
||||
{#each sessions as session}
|
||||
{@const status = getSessionMetaValue(session, "status", "unknown")}
|
||||
<article class="group relative rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md">
|
||||
<!-- Delete Confirmation Modal -->
|
||||
{#if deleteConfirmSessionId === session.session_id}
|
||||
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/95 backdrop-blur-sm">
|
||||
<div class="text-center p-4">
|
||||
<p class="text-sm font-medium text-slate-900 mb-3">Удалить сессию?</p>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700"
|
||||
onclick={() => deleteSession(session.session_id, false)}
|
||||
>
|
||||
В архив
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-50"
|
||||
onclick={() => deleteSession(session.session_id, true)}
|
||||
>
|
||||
Удалить навсегда
|
||||
</button>
|
||||
<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={() => deleteConfirmSessionId = null}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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">
|
||||
<h3 class="text-sm font-semibold text-slate-900" title={session.session_id}>
|
||||
{truncateText(session.session_id, 20)}
|
||||
</h3>
|
||||
<span class="rounded-full px-2.5 py-1 text-xs font-medium"
|
||||
class:bg-green-100={status === "active"}
|
||||
class:text-green-700={status === "active"}
|
||||
class:bg-yellow-100={status === "paused"}
|
||||
class:text-yellow-700={status === "paused"}
|
||||
class:bg-slate-100={status !== "active" && status !== "paused"}
|
||||
class:text-slate-700={status !== "active" && status !== "paused"}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
<span class="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
|
||||
{getSessionMetaValue(session, "readiness_state", $t.common?.unknown || "unknown")}
|
||||
</span>
|
||||
<span class="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700">
|
||||
{getSessionMetaValue(session, "current_phase", $t.common?.unknown || "unknown")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.workspace_entry?.dataset_ref_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900" title={session.dataset_ref}>
|
||||
{truncateText(session.dataset_ref, 35) || $t.common?.not_available || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
Окружение
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{session.environment_id || $t.common?.not_available || "N/A"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
Обновлено
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatDateTime(session.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-slate-50 px-3 py-2">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
Активность
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{formatDateTime(session.last_activity_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:pl-4">
|
||||
<a
|
||||
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"
|
||||
href={buildSessionUrl(session.session_id)}
|
||||
>
|
||||
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{$t.dataset_review?.workspace_entry?.resume_action}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
||||
onclick={() => deleteConfirmSessionId = session.session_id}
|
||||
>
|
||||
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="mt-6 flex items-center justify-between border-t border-slate-200 pt-4">
|
||||
<div class="text-sm text-slate-600">
|
||||
Страница {currentPage} из {totalPages} ({totalSessions} всего)
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
>
|
||||
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => {
|
||||
let start = Math.max(1, currentPage - 2);
|
||||
let end = Math.min(totalPages, start + 4);
|
||||
if (end - start < 4) start = Math.max(1, end - 4);
|
||||
return start + i;
|
||||
}).filter(p => p <= totalPages) as pageNum}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition"
|
||||
class:bg-blue-600={pageNum === currentPage}
|
||||
class:text-white={pageNum === currentPage}
|
||||
class:bg-white={pageNum !== currentPage}
|
||||
class:text-slate-700={pageNum !== currentPage}
|
||||
class:border={pageNum !== currentPage}
|
||||
class:border-slate-300={pageNum !== currentPage}
|
||||
class:hover:bg-slate-50={pageNum !== currentPage}
|
||||
onclick={() => goToPage(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
>
|
||||
Вперед
|
||||
<svg class="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="space-y-5">
|
||||
<SourceIntakePanel
|
||||
environments={environments}
|
||||
selectedEnvironmentId={selectedEnvironmentId}
|
||||
submitting={isSubmitting}
|
||||
acknowledgment={intakeAcknowledgment}
|
||||
onsubmit={handleSourceSubmit}
|
||||
/>
|
||||
|
||||
{#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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -533,8 +533,8 @@
|
||||
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 class="mx-auto w-full max-w-[96rem] space-y-6 px-4 py-6 xl:px-6">
|
||||
<div class="flex flex-col gap-4 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}
|
||||
@@ -647,7 +647,10 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||||
<div
|
||||
data-testid="workspace-detail-grid"
|
||||
class="grid items-start gap-6 xl:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.7fr)_minmax(22rem,1.08fr)]"
|
||||
>
|
||||
<div class="space-y-5">
|
||||
<section
|
||||
id="intake"
|
||||
@@ -661,7 +664,10 @@
|
||||
<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">
|
||||
<div
|
||||
data-testid="workspace-source-value"
|
||||
class="mt-1 max-w-full text-sm font-medium leading-6 text-slate-900 break-words [overflow-wrap:anywhere]"
|
||||
>
|
||||
{session.source_input || session.dataset_ref}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -487,7 +487,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("Workspace state: Empty")).toBeDefined();
|
||||
expect(screen.getByText("Workspace state: empty")).toBeDefined();
|
||||
expect(screen.getByText("Paste link or provide dataset reference.")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -507,7 +507,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
|
||||
expect(screen.getByText("Business summary")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Workspace state: Review")).toBeDefined();
|
||||
expect(screen.getByText("Workspace state: review")).toBeDefined();
|
||||
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
|
||||
expect(screen.getAllByText("Sales Dataset").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Recovered filters").length).toBeGreaterThan(0);
|
||||
@@ -605,7 +605,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Workspace state: Importing")).toBeDefined();
|
||||
expect(screen.getByText("Workspace state: importing")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("Import progress")).toBeDefined();
|
||||
expect(screen.getByText("Recovering dataset context progressively")).toBeDefined();
|
||||
@@ -619,7 +619,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Workspace state: Review")).toBeDefined();
|
||||
expect(screen.getByText("Workspace state: review")).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
|
||||
expect(screen.getAllByText("Review documentation").length).toBeGreaterThan(0);
|
||||
@@ -656,11 +656,76 @@ describe("DatasetReviewWorkspace UX Contract", () => {
|
||||
expect(screen.getAllByText("documentation • json • artifacts/doc.json").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const exportFeedbackText = screen.getAllByText(
|
||||
"documentation • json • artifacts/doc.json",
|
||||
)[0];
|
||||
expect(exportFeedbackText.textContent).toContain("artifacts/doc.json");
|
||||
|
||||
await fireEvent.click(documentationMarkdownButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("Export unavailable").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("detail_layout_and_long_diagnostics_render_without_unbounded_text", async () => {
|
||||
routeState.id = "session-1";
|
||||
api.fetchApi
|
||||
.mockResolvedValueOnce(
|
||||
createSessionDetail({
|
||||
source_input:
|
||||
"https://superset.local/dashboard/10?native_filters=" +
|
||||
"x".repeat(180),
|
||||
findings: [
|
||||
{
|
||||
finding_id: "finding-1",
|
||||
title: "Unresolved semantic reference chain",
|
||||
code: "SEM-LONG-1",
|
||||
area: "dataset_profile",
|
||||
severity: "blocking",
|
||||
message:
|
||||
"Missing semantic resolution for " +
|
||||
"very.long.reference.".repeat(18),
|
||||
resolution_state: "open",
|
||||
caused_by_ref: "semantic.layer.".repeat(14),
|
||||
resolution_note:
|
||||
"Raw backend trace: " + "resolver::unbound_column::".repeat(18),
|
||||
},
|
||||
],
|
||||
previews: [
|
||||
{
|
||||
preview_id: "preview-1",
|
||||
preview_status: "failed",
|
||||
compiled_by: "superset",
|
||||
error_code: "PREVIEW_COMPILE_FAILED",
|
||||
error_details:
|
||||
"Compilation traceback: " + "missing_template_value::".repeat(24),
|
||||
preview_fingerprint: "fingerprint-" + "abc123".repeat(12),
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(createClarificationState());
|
||||
|
||||
render(DatasetReviewWorkspace);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workspace-detail-grid")).toBeDefined();
|
||||
});
|
||||
|
||||
const layoutGrid = screen.getByTestId("workspace-detail-grid");
|
||||
expect(layoutGrid.className).toContain("xl:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.7fr)_minmax(22rem,1.08fr)]");
|
||||
|
||||
const sourceValue = screen.getByTestId("workspace-source-value");
|
||||
expect(sourceValue.className).toContain("break-words");
|
||||
|
||||
expect(screen.getAllByText(/PREVIEW_COMPILE_FAILED|missing_template_value/).length).toBeGreaterThan(0);
|
||||
|
||||
const blockerDetails = screen.getAllByTestId("launch-blocker-detail");
|
||||
expect(
|
||||
blockerDetails.some((item) => item.textContent?.includes("missing_template_value")),
|
||||
).toBe(true);
|
||||
expect(blockerDetails[0].className).toContain("whitespace-pre-wrap");
|
||||
});
|
||||
});
|
||||
// [/DEF:DatasetReviewWorkspaceUxTests:Module]
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// @ts-nocheck
|
||||
// [DEF:DatasetReviewEntryUxTests:Module]
|
||||
// @COMPLEXITY: 3
|
||||
// @SEMANTICS: dataset-review, workspace-entry, resume, source-intake, ux-tests
|
||||
// @PURPOSE: Verify dataset review entry route exposes resumable sessions alongside the new session intake flow.
|
||||
// @LAYER: UI
|
||||
// @RELATION: [VERIFIES] ->[DatasetReviewWorkspaceEntry:Page]
|
||||
// @UX_STATE: Loading -> workspace loader is shown before bootstrap completes.
|
||||
// @UX_STATE: ResumeList -> existing sessions render with summary fields and resume links.
|
||||
// @UX_STATE: ResumeEmpty -> empty-state copy is shown when no sessions are returned.
|
||||
// @UX_STATE: ResumeError -> inline error and retry action remain visible without removing new session flow.
|
||||
// @TEST_SCENARIO: renders_resume_sessions_and_new_session_intake
|
||||
// @TEST_SCENARIO: renders_empty_resume_state_when_no_sessions_exist
|
||||
// @TEST_SCENARIO: renders_resume_error_and_retry
|
||||
// @TEST_SCENARIO: submits_new_session_without_regression
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import DatasetReviewEntryPage from "../+page.svelte";
|
||||
import { api } from "$lib/api.js";
|
||||
|
||||
const mockedGoto = vi.mocked(await import("$app/navigation")).goto;
|
||||
|
||||
function createSessionSummary(overrides = {}) {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
user_id: "user-1",
|
||||
environment_id: "env-1",
|
||||
source_kind: "superset_link",
|
||||
source_input: "https://superset.local/dashboard/10",
|
||||
dataset_ref: "public.sales",
|
||||
dataset_id: 101,
|
||||
readiness_state: "partially_ready",
|
||||
recommended_action: "resume_session",
|
||||
status: "paused",
|
||||
current_phase: "review",
|
||||
created_at: "2026-03-18T06:00:00.000Z",
|
||||
updated_at: "2026-03-18T06:10:00.000Z",
|
||||
last_activity_at: "2026-03-18T06:15:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("$app/navigation", () => ({
|
||||
goto: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/i18n", () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
common: {
|
||||
error: "Common error",
|
||||
refresh: "Refresh",
|
||||
retry: "Retry",
|
||||
choose_environment: "Choose environment",
|
||||
not_available: "N/A",
|
||||
},
|
||||
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",
|
||||
},
|
||||
workspace_entry: {
|
||||
resume_eyebrow: "Resume session",
|
||||
resume_title: "Continue an existing review session",
|
||||
resume_description:
|
||||
"If a dataset review was already started, open the relevant session and continue from its latest saved state.",
|
||||
resume_available_badge: "Sessions available",
|
||||
resume_loading: "Loading existing sessions...",
|
||||
resume_load_failed: "Failed to load existing review sessions.",
|
||||
resume_empty_title: "No saved sessions yet",
|
||||
resume_empty_body:
|
||||
"Start a new review session from the source intake panel to make it available here for reopening later.",
|
||||
resume_action: "Resume",
|
||||
session_id_label: "Session ID",
|
||||
dataset_ref_label: "Dataset reference",
|
||||
environment_id_label: "Environment",
|
||||
status_label: "Status",
|
||||
readiness_label: "Readiness",
|
||||
phase_label: "Phase",
|
||||
updated_at_label: "Updated",
|
||||
last_activity_at_label: "Last activity",
|
||||
},
|
||||
workspace: {
|
||||
eyebrow: "Dataset review",
|
||||
title: "Dataset review workspace",
|
||||
description: "Review imported dataset context.",
|
||||
state_label: "Workspace state",
|
||||
loading: "Loading workspace",
|
||||
state: {
|
||||
empty: "Empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
postApi: vi.fn(),
|
||||
requestApi: 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("DatasetReviewWorkspaceEntry UX Contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.fetchApi.mockReset();
|
||||
api.postApi.mockReset();
|
||||
mockedGoto.mockReset();
|
||||
});
|
||||
|
||||
it("renders_resume_sessions_and_new_session_intake", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [
|
||||
createSessionSummary(),
|
||||
createSessionSummary({
|
||||
session_id: "session-2",
|
||||
dataset_ref: "analytics.daily_margin",
|
||||
environment_id: "env-2",
|
||||
status: "active",
|
||||
readiness_state: "review_ready",
|
||||
current_phase: "semantic_review",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchApi).toHaveBeenCalledWith("/dataset-orchestration/sessions");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Continue an existing review session")).toBeDefined();
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
expect(screen.getByText("Sessions available: 2")).toBeDefined();
|
||||
expect(screen.getByText("Session ID: session-1")).toBeDefined();
|
||||
expect(screen.getByText("public.sales")).toBeDefined();
|
||||
expect(screen.getByText("analytics.daily_margin")).toBeDefined();
|
||||
|
||||
const resumeLinks = screen.getAllByRole("link", { name: "Resume" });
|
||||
expect(resumeLinks).toHaveLength(2);
|
||||
expect(resumeLinks[0].getAttribute("href")).toBe("/datasets/review/session-1");
|
||||
});
|
||||
|
||||
it("renders_empty_resume_state_when_no_sessions_exist", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No saved sessions yet")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Start a new review session from the source intake panel to make it available here for reopening later.",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders_resume_error_and_retry", async () => {
|
||||
api.fetchApi.mockRejectedValueOnce(new Error("Session list failed"));
|
||||
api.fetchApi.mockResolvedValueOnce({
|
||||
items: [createSessionSummary()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session list failed")).toBeDefined();
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchApi).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session ID: session-1")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
});
|
||||
|
||||
it("submits_new_session_without_regression", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [createSessionSummary()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
api.postApi.mockResolvedValue({ session_id: "created-session" });
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockedGoto).toHaveBeenCalledWith("/datasets/review/created-session");
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:DatasetReviewEntryUxTests:Module]
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// @ts-nocheck
|
||||
// [DEF:DatasetReviewEntryUxTests:Module]
|
||||
// @COMPLEXITY: 3
|
||||
// @SEMANTICS: dataset-review, workspace-entry, resume, source-intake, ux-tests
|
||||
// @PURPOSE: Verify dataset review entry route exposes resumable sessions alongside the new session intake flow.
|
||||
// @LAYER: UI
|
||||
// @RELATION: [VERIFIES] ->[DatasetReviewWorkspaceEntry:Page]
|
||||
// @UX_STATE: Loading -> workspace loader is shown before bootstrap completes.
|
||||
// @UX_STATE: ResumeList -> existing sessions render with summary fields and resume links.
|
||||
// @UX_STATE: ResumeEmpty -> empty-state copy is shown when no sessions are returned.
|
||||
// @UX_STATE: ResumeError -> inline error and retry action remain visible without removing new session flow.
|
||||
// @TEST_SCENARIO: renders_resume_sessions_and_new_session_intake
|
||||
// @TEST_SCENARIO: renders_empty_resume_state_when_no_sessions_exist
|
||||
// @TEST_SCENARIO: renders_resume_error_and_retry
|
||||
// @TEST_SCENARIO: submits_new_session_without_regression
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import DatasetReviewEntryPage from "../+page.svelte";
|
||||
import { api } from "$lib/api.js";
|
||||
|
||||
function createSessionSummary(overrides = {}) {
|
||||
return {
|
||||
session_id: "session-1",
|
||||
user_id: "user-1",
|
||||
environment_id: "env-1",
|
||||
source_kind: "superset_link",
|
||||
source_input: "https://superset.local/dashboard/10",
|
||||
dataset_ref: "public.sales",
|
||||
dataset_id: 101,
|
||||
readiness_state: "partially_ready",
|
||||
recommended_action: "resume_session",
|
||||
status: "paused",
|
||||
current_phase: "review",
|
||||
created_at: "2026-03-18T06:00:00.000Z",
|
||||
updated_at: "2026-03-18T06:10:00.000Z",
|
||||
last_activity_at: "2026-03-18T06:15:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("$lib/i18n", () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
common: {
|
||||
error: "Common error",
|
||||
refresh: "Refresh",
|
||||
retry: "Retry",
|
||||
choose_environment: "Choose environment",
|
||||
not_available: "N/A",
|
||||
},
|
||||
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",
|
||||
},
|
||||
workspace_entry: {
|
||||
resume_eyebrow: "Resume session",
|
||||
resume_title: "Continue an existing review session",
|
||||
resume_description:
|
||||
"If a dataset review was already started, open the relevant session and continue from its latest saved state.",
|
||||
resume_available_badge: "Sessions available",
|
||||
resume_loading: "Loading existing sessions...",
|
||||
resume_load_failed: "Failed to load existing review sessions.",
|
||||
resume_empty_title: "No saved sessions yet",
|
||||
resume_empty_body:
|
||||
"Start a new review session from the source intake panel to make it available here for reopening later.",
|
||||
resume_action: "Resume",
|
||||
session_id_label: "Session ID",
|
||||
dataset_ref_label: "Dataset reference",
|
||||
environment_id_label: "Environment",
|
||||
status_label: "Status",
|
||||
readiness_label: "Readiness",
|
||||
phase_label: "Phase",
|
||||
updated_at_label: "Updated",
|
||||
last_activity_at_label: "Last activity",
|
||||
},
|
||||
workspace: {
|
||||
eyebrow: "Dataset review",
|
||||
title: "Dataset review workspace",
|
||||
description: "Review imported dataset context.",
|
||||
state_label: "Workspace state",
|
||||
loading: "Loading workspace",
|
||||
state: {
|
||||
empty: "Empty",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
api: {
|
||||
fetchApi: vi.fn(),
|
||||
postApi: vi.fn(),
|
||||
requestApi: 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("DatasetReviewWorkspaceEntry UX Contract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.fetchApi.mockReset();
|
||||
api.postApi.mockReset();
|
||||
window.location.href = "";
|
||||
});
|
||||
|
||||
it("renders_resume_sessions_and_new_session_intake", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [
|
||||
createSessionSummary(),
|
||||
createSessionSummary({
|
||||
session_id: "session-2",
|
||||
dataset_ref: "analytics.daily_margin",
|
||||
environment_id: "env-2",
|
||||
status: "active",
|
||||
readiness_state: "review_ready",
|
||||
current_phase: "semantic_review",
|
||||
}),
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchApi).toHaveBeenCalledWith("/dataset-orchestration/sessions");
|
||||
});
|
||||
|
||||
expect(screen.getByText("Continue an existing review session")).toBeDefined();
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
expect(screen.getByText("Sessions available: 2")).toBeDefined();
|
||||
expect(screen.getByText("Session ID: session-1")).toBeDefined();
|
||||
expect(screen.getByText("public.sales")).toBeDefined();
|
||||
expect(screen.getByText("analytics.daily_margin")).toBeDefined();
|
||||
|
||||
const resumeLinks = screen.getAllByRole("link", { name: "Resume" });
|
||||
expect(resumeLinks).toHaveLength(2);
|
||||
expect(resumeLinks[0].getAttribute("href")).toBe("/datasets/review/session-1");
|
||||
});
|
||||
|
||||
it("renders_empty_resume_state_when_no_sessions_exist", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No saved sessions yet")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Start a new review session from the source intake panel to make it available here for reopening later.",
|
||||
),
|
||||
).toBeDefined();
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders_resume_error_and_retry", async () => {
|
||||
api.fetchApi.mockRejectedValueOnce(new Error("Session list failed"));
|
||||
api.fetchApi.mockResolvedValueOnce({
|
||||
items: [createSessionSummary()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session list failed")).toBeDefined();
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.fetchApi).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Session ID: session-1")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Start dataset review")).toBeDefined();
|
||||
});
|
||||
|
||||
it("submits_new_session_without_regression", async () => {
|
||||
api.fetchApi.mockResolvedValue({
|
||||
items: [createSessionSummary()],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
has_next: false,
|
||||
});
|
||||
api.postApi.mockResolvedValue({ session_id: "created-session" });
|
||||
|
||||
render(DatasetReviewEntryPage);
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
expect(window.location.href).toBe("/datasets/review/created-session");
|
||||
});
|
||||
});
|
||||
|
||||
// [/DEF:DatasetReviewEntryUxTests:Module]
|
||||
Reference in New Issue
Block a user