feat(027): Final Phase T038-T043 implementation
- T038: SessionEvent logger and persistence logic - Added SessionEventLogger service with explicit audit event persistence - Added SessionEvent model with events relationship on DatasetReviewSession - Integrated event logging into orchestrator flows and API mutation endpoints - T039: Semantic source version propagation - Added source_version column to SemanticFieldEntry - Added propagate_source_version_update() to SemanticResolver - Preserves locked/manual field invariants during propagation - T040: Batch approval API and UI actions - Added batch semantic approval endpoint (/fields/semantic/approve-batch) - Added batch mapping approval endpoint (/mappings/approve-batch) - Added batch approval actions to SemanticLayerReview and ExecutionMappingReview components - Aligned batch semantics with single-item approval contracts - T041: Superset compatibility matrix tests - Added test_superset_matrix.py with preview and SQL Lab fallback coverage - Tests verify client method preference and matrix fallback behavior - T042: RBAC audit sweep on session-mutation endpoints - Added _require_owner_mutation_scope() helper - Applied owner guards to update_session, delete_session, and all mutation endpoints - Ensured no bypass of existing permission checks - T043: i18n coverage for dataset-review UI - Added workspace state labels (empty/importing/review) to en.json and ru.json - Added batch action labels for semantics and mappings - Fixed workspace state comparison to lowercase strings - Removed hardcoded workspace state display strings Signed-off-by: Implementation Specialist <impl@ss-tools>
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
<!-- [DEF:ClarificationDialog:Component] -->
|
||||
<!-- @COMPLEXITY: 4 -->
|
||||
<!-- @SEMANTICS: dataset-review, clarification, single-question, resumable, expert-review -->
|
||||
<!-- @PURPOSE: Present one clarification question at a time with explicit why-it-matters context, answer actions, and resumable state transitions for US2. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[assistantChat] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
||||
<!-- @PRE: Session id is available and clarification state payload belongs to the current ownership-scoped session. -->
|
||||
<!-- @POST: Users can answer, skip, or route to expert review while the UI keeps only the active clarification question visible and preserves resumable progress. -->
|
||||
<!-- @SIDE_EFFECT: Persists clarification answers and may resume clarification state from dataset orchestration APIs. -->
|
||||
<!-- @UX_STATE: Question -> Active clarification question with options and current guess is visible. -->
|
||||
<!-- @UX_STATE: Saving -> Answer controls are disabled while one mutation is in flight. -->
|
||||
<!-- @UX_STATE: Completed -> No active question remains and resumable clarification summary stays visible. -->
|
||||
<!-- @UX_FEEDBACK: Save results and changed findings surface inline without hiding the active or completed state. -->
|
||||
<!-- @UX_RECOVERY: Users can resume clarification after interruption, skip uncertain questions, or open assistant guidance before escalating to expert review. -->
|
||||
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
import { openAssistantChat } from "$lib/stores/assistantChat.js";
|
||||
|
||||
let {
|
||||
sessionId = "",
|
||||
clarificationState = null,
|
||||
disabled = false,
|
||||
onupdated = () => {},
|
||||
} = $props();
|
||||
|
||||
let selectedOptionValue = $state("");
|
||||
let customAnswerValue = $state("");
|
||||
let localMessage = $state("");
|
||||
let localStatus = $state("");
|
||||
let isSaving = $state(false);
|
||||
let lastQuestionId = $state("");
|
||||
let feedbackQuestionId = $state("");
|
||||
let feedbackQuestionText = $state("");
|
||||
let feedbackSaving = $state("");
|
||||
|
||||
const clarificationSession = $derived(clarificationState?.clarification_session || null);
|
||||
const currentQuestion = $derived(clarificationState?.current_question || null);
|
||||
const dialogState = $derived(
|
||||
isSaving ? "Saving" : currentQuestion ? "Question" : "Completed",
|
||||
);
|
||||
const hasQuestion = $derived(Boolean(currentQuestion?.question_id));
|
||||
const progressLabel = $derived(
|
||||
clarificationSession
|
||||
? `${clarificationSession.resolved_count} / ${
|
||||
clarificationSession.resolved_count + clarificationSession.remaining_count
|
||||
}`
|
||||
: "0 / 0",
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const questionId = currentQuestion?.question_id || "";
|
||||
if (questionId === lastQuestionId) {
|
||||
return;
|
||||
}
|
||||
lastQuestionId = questionId;
|
||||
|
||||
if (!questionId) {
|
||||
selectedOptionValue = "";
|
||||
customAnswerValue = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const recommendedOption = (currentQuestion?.options || []).find(
|
||||
(option) => option.is_recommended,
|
||||
);
|
||||
selectedOptionValue = recommendedOption?.value || "";
|
||||
customAnswerValue = "";
|
||||
});
|
||||
|
||||
function clearFeedback() {
|
||||
localMessage = "";
|
||||
localStatus = "";
|
||||
}
|
||||
|
||||
function normalizeAnswerValue(answerKind) {
|
||||
if (answerKind === "selected") {
|
||||
return selectedOptionValue || null;
|
||||
}
|
||||
if (answerKind === "custom") {
|
||||
return customAnswerValue.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function recordClarificationFeedback(feedback) {
|
||||
if (!sessionId || !feedbackQuestionId || isSaving || feedbackSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
feedbackSaving = feedback;
|
||||
localStatus = "saving";
|
||||
localMessage = "";
|
||||
|
||||
try {
|
||||
await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/clarification/questions/${feedbackQuestionId}/feedback`,
|
||||
"POST",
|
||||
{ feedback },
|
||||
);
|
||||
localStatus = "success";
|
||||
localMessage =
|
||||
feedback === "up"
|
||||
? $t.dataset_review?.clarification?.messages?.feedback_up
|
||||
: $t.dataset_review?.clarification?.messages?.feedback_down;
|
||||
} catch (error) {
|
||||
localStatus = "error";
|
||||
localMessage =
|
||||
error?.message ||
|
||||
$t.dataset_review?.clarification?.messages?.feedback_failed ||
|
||||
$t.common?.error;
|
||||
} finally {
|
||||
feedbackSaving = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeClarification() {
|
||||
if (!sessionId || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
localStatus = "saving";
|
||||
localMessage = "";
|
||||
|
||||
try {
|
||||
const nextState = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/clarification/resume`,
|
||||
"POST",
|
||||
);
|
||||
localStatus = "success";
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.resumed;
|
||||
onupdated(nextState);
|
||||
} catch (error) {
|
||||
localStatus = "error";
|
||||
localMessage =
|
||||
error?.message ||
|
||||
$t.dataset_review?.clarification?.messages?.resume_failed ||
|
||||
$t.common?.error;
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAnswer(answerKind) {
|
||||
if (!sessionId || !currentQuestion?.question_id || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const answerValue = normalizeAnswerValue(answerKind);
|
||||
|
||||
if (answerKind === "selected" && !answerValue) {
|
||||
localStatus = "error";
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.option_required;
|
||||
return;
|
||||
}
|
||||
|
||||
if (answerKind === "custom" && !answerValue) {
|
||||
localStatus = "error";
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.custom_required;
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving = true;
|
||||
localStatus = "saving";
|
||||
localMessage = "";
|
||||
|
||||
try {
|
||||
const answeredQuestionId = currentQuestion.question_id;
|
||||
const answeredQuestionText = currentQuestion.question_text;
|
||||
const result = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/clarification/answers`,
|
||||
"POST",
|
||||
{
|
||||
question_id: answeredQuestionId,
|
||||
answer_kind: answerKind,
|
||||
answer_value: answerValue,
|
||||
},
|
||||
);
|
||||
|
||||
feedbackQuestionId = answeredQuestionId;
|
||||
feedbackQuestionText = answeredQuestionText;
|
||||
localStatus = "success";
|
||||
if (answerKind === "skipped") {
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.skipped;
|
||||
} else if (answerKind === "expert_review") {
|
||||
localMessage =
|
||||
$t.dataset_review?.clarification?.messages?.expert_review_requested;
|
||||
} else {
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.saved;
|
||||
}
|
||||
|
||||
onupdated(result);
|
||||
} catch (error) {
|
||||
localStatus = "error";
|
||||
localMessage =
|
||||
error?.message ||
|
||||
$t.dataset_review?.clarification?.messages?.save_failed ||
|
||||
$t.common?.error;
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function requestAssistantGuidance() {
|
||||
openAssistantChat();
|
||||
localStatus = "success";
|
||||
localMessage = $t.dataset_review?.clarification?.messages?.assistant_opened;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="clarification" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.clarification?.title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.clarification?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-right">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.progress_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">{progressLabel}</div>
|
||||
<div class="mt-1 text-xs text-slate-500">
|
||||
{$t.dataset_review?.clarification?.state_label}: {dialogState}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if clarificationSession}
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.clarification?.status_label}: {clarificationSession.status}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.clarification?.remaining_label}: {clarificationSession.remaining_count}
|
||||
</span>
|
||||
{#if clarificationSession.summary_delta}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.clarification?.summary_delta_label}:
|
||||
{clarificationSession.summary_delta}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasQuestion}
|
||||
<article class="mt-5 rounded-2xl border border-violet-200 bg-violet-50 p-4">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-violet-700">
|
||||
{$t.dataset_review?.clarification?.active_question_label}
|
||||
</p>
|
||||
<h3 class="mt-1 text-lg font-semibold text-slate-900">
|
||||
{currentQuestion.question_text}
|
||||
</h3>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-white/70 bg-white/80 p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.why_it_matters_label}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-700">{currentQuestion.why_it_matters}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-white/70 bg-white/80 p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.current_guess_label}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-700">
|
||||
{currentQuestion.current_guess ||
|
||||
$t.dataset_review?.clarification?.current_guess_empty}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-white/70 bg-white/80 p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.topic_label}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-700">{currentQuestion.topic_ref}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h4 class="text-sm font-semibold text-slate-900">
|
||||
{$t.dataset_review?.clarification?.options_title}
|
||||
</h4>
|
||||
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each currentQuestion.options || [] as option}
|
||||
<label
|
||||
class={`flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition ${
|
||||
selectedOptionValue === option.value
|
||||
? "border-violet-300 bg-white text-slate-900"
|
||||
: "border-white/70 bg-white/80 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="clarification-option"
|
||||
value={option.value}
|
||||
checked={selectedOptionValue === option.value}
|
||||
onchange={() => {
|
||||
selectedOptionValue = option.value;
|
||||
clearFeedback();
|
||||
}}
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium">{option.label}</span>
|
||||
{#if option.is_recommended}
|
||||
<span class="rounded-full bg-violet-100 px-2 py-0.5 text-xs text-violet-700">
|
||||
{$t.dataset_review?.clarification?.recommended_badge}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-slate-500">{option.value}</div>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mt-4 block space-y-2">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.clarification?.custom_answer_label}
|
||||
</span>
|
||||
<textarea
|
||||
class="min-h-24 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-violet-500 focus:ring-2 focus:ring-violet-200"
|
||||
bind:value={customAnswerValue}
|
||||
disabled={disabled || isSaving}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2 lg:w-56 lg:flex-col">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl bg-violet-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-violet-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
onclick={() => submitAnswer("selected")}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.answer_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => submitAnswer("custom")}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.custom_answer_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => submitAnswer("skipped")}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.skip_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => requestAssistantGuidance()}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.assistant_action}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-amber-300 px-4 py-2 text-sm font-medium text-amber-800 transition hover:bg-amber-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => submitAnswer("expert_review")}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.expert_review_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="mt-5 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
|
||||
{$t.dataset_review?.clarification?.completed}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap 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={resumeClarification}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.resume_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => requestAssistantGuidance()}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.assistant_action}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if feedbackQuestionId}
|
||||
<div class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.clarification?.feedback_label}
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-slate-700">
|
||||
{feedbackQuestionText ||
|
||||
$t.dataset_review?.clarification?.feedback_prompt}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => recordClarificationFeedback("up")}
|
||||
disabled={disabled || isSaving || Boolean(feedbackSaving)}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.feedback_up_action}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => recordClarificationFeedback("down")}
|
||||
disabled={disabled || isSaving || Boolean(feedbackSaving)}
|
||||
>
|
||||
{$t.dataset_review?.clarification?.feedback_down_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if localMessage || localStatus === "saving"}
|
||||
<div
|
||||
class={`mt-4 rounded-xl px-3 py-3 text-sm ${
|
||||
localStatus === "error"
|
||||
? "border border-red-200 bg-red-50 text-red-700"
|
||||
: localStatus === "saving"
|
||||
? "border border-violet-200 bg-violet-50 text-violet-700"
|
||||
: "border border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{localStatus === "saving"
|
||||
? $t.dataset_review?.clarification?.messages?.saving
|
||||
: localMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- [/DEF:ClarificationDialog:Component] -->
|
||||
@@ -0,0 +1,247 @@
|
||||
<!-- [DEF:CompiledSQLPreview:Component] -->
|
||||
<!-- @COMPLEXITY: 4 -->
|
||||
<!-- @SEMANTICS: dataset-review, compiled-sql-preview, superset-preview, stale-state, diagnostics -->
|
||||
<!-- @PURPOSE: Present the exact Superset-generated compiled SQL preview, expose readiness or staleness clearly, and preserve readable recovery paths when preview generation fails. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
||||
<!-- @PRE: Session id is available and preview state comes from the current ownership-scoped session detail payload. -->
|
||||
<!-- @POST: Users can distinguish missing, pending, ready, stale, and error preview states and can trigger only Superset-backed preview generation. -->
|
||||
<!-- @SIDE_EFFECT: Requests preview generation through dataset orchestration APIs and updates route shell preview state when Superset responds. -->
|
||||
<!-- @UX_STATE: Missing -> Prompt the user to generate a Superset preview before launch. -->
|
||||
<!-- @UX_STATE: Pending -> Show generation-in-progress feedback without fabricating SQL. -->
|
||||
<!-- @UX_STATE: Ready -> Render read-only SQL preview that is explicitly labeled as compiled by Superset. -->
|
||||
<!-- @UX_STATE: Stale -> Mark the existing preview as outdated after mapping/input changes until regenerated. -->
|
||||
<!-- @UX_STATE: Error -> Show readable Superset compilation diagnostics and preserve remediation action. -->
|
||||
<!-- @UX_FEEDBACK: Preview refresh updates status pill, timestamps, and inline generation feedback. -->
|
||||
<!-- @UX_RECOVERY: Users can retry preview generation and jump back to mapping review when diagnostics point to execution-input issues. -->
|
||||
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
|
||||
let {
|
||||
sessionId = "",
|
||||
preview = null,
|
||||
previewState = "",
|
||||
disabled = false,
|
||||
onupdated = () => {},
|
||||
onjump = () => {},
|
||||
} = $props();
|
||||
|
||||
let localStatus = $state("");
|
||||
let localMessage = $state("");
|
||||
|
||||
const effectiveState = $derived(resolvePreviewState());
|
||||
const compiledBySuperset = $derived(String(preview?.compiled_by || "") === "superset");
|
||||
const hasSql = $derived(Boolean(String(preview?.compiled_sql || "").trim()));
|
||||
const previewTimestamp = $derived(preview?.compiled_at || preview?.created_at || "");
|
||||
|
||||
function resolvePreviewState() {
|
||||
const explicit = String(previewState || "").trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const status = String(preview?.preview_status || "").trim();
|
||||
if (status === "ready" || status === "pending" || status === "failed" || status === "stale") {
|
||||
return status;
|
||||
}
|
||||
|
||||
if (preview?.error_code || preview?.error_details) {
|
||||
return "failed";
|
||||
}
|
||||
|
||||
return "missing";
|
||||
}
|
||||
|
||||
function getStateLabel(state) {
|
||||
return $t.dataset_review?.preview?.state?.[state] || state;
|
||||
}
|
||||
|
||||
function getStateTone(state) {
|
||||
if (state === "ready") {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
if (state === "stale") {
|
||||
return "bg-amber-100 text-amber-700";
|
||||
}
|
||||
if (state === "failed") {
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
if (state === "pending") {
|
||||
return "bg-blue-100 text-blue-700";
|
||||
}
|
||||
return "bg-slate-100 text-slate-700";
|
||||
}
|
||||
|
||||
function getCompilerLabel() {
|
||||
if (compiledBySuperset) {
|
||||
return $t.dataset_review?.preview?.compiled_by_superset;
|
||||
}
|
||||
return $t.dataset_review?.preview?.compiled_source_unknown;
|
||||
}
|
||||
|
||||
function getPreviewBodyText() {
|
||||
if (effectiveState === "pending") {
|
||||
return $t.dataset_review?.preview?.pending_body;
|
||||
}
|
||||
if (effectiveState === "stale") {
|
||||
return $t.dataset_review?.preview?.stale_body;
|
||||
}
|
||||
if (effectiveState === "failed") {
|
||||
return (
|
||||
preview?.error_details ||
|
||||
preview?.error_code ||
|
||||
$t.dataset_review?.preview?.error_body
|
||||
);
|
||||
}
|
||||
if (effectiveState === "missing") {
|
||||
return $t.dataset_review?.preview?.missing_body;
|
||||
}
|
||||
return $t.dataset_review?.preview?.ready_body;
|
||||
}
|
||||
|
||||
async function requestPreview() {
|
||||
if (!sessionId || disabled || localStatus === "saving") {
|
||||
return;
|
||||
}
|
||||
|
||||
localStatus = "saving";
|
||||
localMessage = "";
|
||||
|
||||
try {
|
||||
const response = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/preview`,
|
||||
"POST",
|
||||
);
|
||||
const nextPreview = response?.preview || response;
|
||||
onupdated({
|
||||
preview: nextPreview,
|
||||
preview_state: nextPreview?.preview_status || response?.preview_status || "pending",
|
||||
});
|
||||
localStatus = "success";
|
||||
localMessage = $t.dataset_review?.preview?.messages?.generated;
|
||||
} catch (error) {
|
||||
localStatus = "error";
|
||||
localMessage =
|
||||
error?.message ||
|
||||
$t.dataset_review?.preview?.messages?.generate_failed ||
|
||||
$t.common?.error;
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToMapping() {
|
||||
onjump({ target: "mapping" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="sql-preview" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.preview?.eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.preview?.title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.preview?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class={`rounded-full px-3 py-1 text-xs font-medium ${getStateTone(effectiveState)}`}>
|
||||
{$t.dataset_review?.preview?.state_label}: {getStateLabel(effectiveState)}
|
||||
</span>
|
||||
<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={requestPreview}
|
||||
disabled={disabled || localStatus === "saving"}
|
||||
>
|
||||
{localStatus === "saving"
|
||||
? $t.dataset_review?.preview?.generate_loading
|
||||
: $t.dataset_review?.preview?.generate_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`mt-5 rounded-xl border px-4 py-4 text-sm ${
|
||||
effectiveState === "failed"
|
||||
? "border-red-200 bg-red-50 text-red-700"
|
||||
: effectiveState === "stale"
|
||||
? "border-amber-200 bg-amber-50 text-amber-800"
|
||||
: effectiveState === "pending"
|
||||
? "border-blue-200 bg-blue-50 text-blue-700"
|
||||
: "border-slate-200 bg-slate-50 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{getPreviewBodyText()}
|
||||
</div>
|
||||
|
||||
{#if preview}
|
||||
<div class="mt-4 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.preview?.compiler_label}: {getCompilerLabel()}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.preview?.fingerprint_label}: {preview.preview_fingerprint}
|
||||
</span>
|
||||
{#if previewTimestamp}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.preview?.compiled_at_label}: {previewTimestamp}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if effectiveState === "ready" && hasSql}
|
||||
<div class="mt-5 overflow-hidden rounded-2xl border border-slate-200 bg-slate-950">
|
||||
<div class="flex items-center justify-between border-b border-slate-800 px-4 py-3">
|
||||
<div class="text-sm font-medium text-slate-100">
|
||||
{$t.dataset_review?.preview?.sql_block_title}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400">
|
||||
{$t.dataset_review?.preview?.compiled_truth_note}
|
||||
</div>
|
||||
</div>
|
||||
<pre class="overflow-x-auto px-4 py-4 text-sm leading-6 text-slate-100"><code>{preview.compiled_sql}</code></pre>
|
||||
</div>
|
||||
{:else if effectiveState === "failed"}
|
||||
<div class="mt-5 flex flex-wrap 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"
|
||||
onclick={jumpToMapping}
|
||||
>
|
||||
{$t.dataset_review?.preview?.go_to_mapping_action}
|
||||
</button>
|
||||
</div>
|
||||
{:else if effectiveState === "stale"}
|
||||
<div class="mt-5 flex flex-wrap 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"
|
||||
onclick={jumpToMapping}
|
||||
>
|
||||
{$t.dataset_review?.preview?.review_inputs_action}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if localMessage}
|
||||
<div
|
||||
class={`mt-4 rounded-xl px-3 py-3 text-sm ${
|
||||
localStatus === "error"
|
||||
? "border border-red-200 bg-red-50 text-red-700"
|
||||
: "border border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{localMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- [/DEF:CompiledSQLPreview:Component] -->
|
||||
@@ -0,0 +1,578 @@
|
||||
<!-- [DEF:ExecutionMappingReview:Component] -->
|
||||
<!-- @COMPLEXITY: 4 -->
|
||||
<!-- @SEMANTICS: dataset-review, execution-mapping, warning-approval, manual-override, required-values -->
|
||||
<!-- @PURPOSE: Review imported-filter to template-variable mappings, surface effective values and blockers, and require explicit approval for warning-sensitive execution inputs before preview or launch. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
||||
<!-- @PRE: Session id, execution mappings, imported filters, and template variables belong to the current ownership-scoped session payload. -->
|
||||
<!-- @POST: Users can review effective mapping values, approve warning-sensitive transformations, or manually override them while unresolved required-value blockers remain visible. -->
|
||||
<!-- @SIDE_EFFECT: Persists mapping approvals or manual overrides through dataset orchestration APIs and may invalidate the current preview truth for the route shell. -->
|
||||
<!-- @UX_STATE: Incomplete -> Required mapping values remain missing and blockers stay visible. -->
|
||||
<!-- @UX_STATE: WarningApproval -> Mapping rows with transformation risk require explicit approval before execution can proceed. -->
|
||||
<!-- @UX_STATE: Approved -> All launch-sensitive mappings are approved or no explicit approval is required. -->
|
||||
<!-- @UX_FEEDBACK: Mapping approvals and manual overrides expose inline success, saving, and error feedback per row. -->
|
||||
<!-- @UX_RECOVERY: Users can replace transformed values manually instead of approving them as-is and can retry failed mutations in place. -->
|
||||
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
|
||||
let {
|
||||
sessionId = "",
|
||||
mappings = [],
|
||||
importedFilters = [],
|
||||
templateVariables = [],
|
||||
disabled = false,
|
||||
onupdated = () => {},
|
||||
} = $props();
|
||||
|
||||
let editingMappingId = $state("");
|
||||
let overrideValueDraft = $state("");
|
||||
let overrideNoteDraft = $state("");
|
||||
let savingMappingId = $state("");
|
||||
let batchSaving = $state(false);
|
||||
let rowMessages = $state({});
|
||||
|
||||
const filterLookup = $derived(
|
||||
Object.fromEntries((importedFilters || []).map((item) => [item.filter_id, item])),
|
||||
);
|
||||
const variableLookup = $derived(
|
||||
Object.fromEntries((templateVariables || []).map((item) => [item.variable_id, item])),
|
||||
);
|
||||
|
||||
const sortedMappings = $derived(
|
||||
[...(mappings || [])].sort((left, right) => {
|
||||
const leftVariable = variableLookup[left.variable_id];
|
||||
const rightVariable = variableLookup[right.variable_id];
|
||||
const leftRequired = Number(Boolean(leftVariable?.is_required));
|
||||
const rightRequired = Number(Boolean(rightVariable?.is_required));
|
||||
if (leftRequired !== rightRequired) {
|
||||
return rightRequired - leftRequired;
|
||||
}
|
||||
|
||||
const leftNeedsApproval = Number(
|
||||
Boolean(left.requires_explicit_approval && left.approval_state !== "approved"),
|
||||
);
|
||||
const rightNeedsApproval = Number(
|
||||
Boolean(right.requires_explicit_approval && right.approval_state !== "approved"),
|
||||
);
|
||||
if (leftNeedsApproval !== rightNeedsApproval) {
|
||||
return rightNeedsApproval - leftNeedsApproval;
|
||||
}
|
||||
|
||||
return String(leftVariable?.variable_name || left.variable_id || "").localeCompare(
|
||||
String(rightVariable?.variable_name || right.variable_id || ""),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const missingRequiredCount = $derived(
|
||||
sortedMappings.filter((mapping) => getRowState(mapping).missingRequiredValue).length,
|
||||
);
|
||||
const pendingApprovalCount = $derived(
|
||||
sortedMappings.filter((mapping) => getRowState(mapping).needsExplicitApproval).length,
|
||||
);
|
||||
const reviewState = $derived(
|
||||
missingRequiredCount > 0
|
||||
? "Incomplete"
|
||||
: pendingApprovalCount > 0
|
||||
? "WarningApproval"
|
||||
: "Approved",
|
||||
);
|
||||
|
||||
function setRowMessage(mappingId, patch) {
|
||||
rowMessages = {
|
||||
...rowMessages,
|
||||
[mappingId]: {
|
||||
...(rowMessages[mappingId] || {}),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetRowMessage(mappingId) {
|
||||
rowMessages = {
|
||||
...rowMessages,
|
||||
[mappingId]: { status: "", text: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function getApprovalLabel(approvalState) {
|
||||
const normalized = String(approvalState || "pending");
|
||||
return $t.dataset_review?.mapping?.approval_state?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function getWarningLabel(warningLevel) {
|
||||
const normalized = String(warningLevel || "none");
|
||||
return $t.dataset_review?.mapping?.warning_level?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function getMethodLabel(method) {
|
||||
const normalized = String(method || "direct_match");
|
||||
return $t.dataset_review?.mapping?.method?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function stringifyValue(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return $t.common?.not_available || "N/A";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (_error) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function getEffectiveValue(mapping) {
|
||||
const importedFilter = filterLookup[mapping.filter_id];
|
||||
const templateVariable = variableLookup[mapping.variable_id];
|
||||
|
||||
if (mapping.effective_value !== null && mapping.effective_value !== undefined) {
|
||||
return mapping.effective_value;
|
||||
}
|
||||
if (importedFilter?.normalized_value !== null && importedFilter?.normalized_value !== undefined) {
|
||||
return importedFilter.normalized_value;
|
||||
}
|
||||
if (importedFilter?.raw_value !== null && importedFilter?.raw_value !== undefined) {
|
||||
return importedFilter.raw_value;
|
||||
}
|
||||
if (templateVariable?.default_value !== null && templateVariable?.default_value !== undefined) {
|
||||
return templateVariable.default_value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRowState(mapping) {
|
||||
const templateVariable = variableLookup[mapping.variable_id];
|
||||
const effectiveValue = getEffectiveValue(mapping);
|
||||
const missingRequiredValue = Boolean(templateVariable?.is_required) && effectiveValue === null;
|
||||
const needsExplicitApproval = Boolean(
|
||||
mapping.requires_explicit_approval && mapping.approval_state !== "approved",
|
||||
);
|
||||
return {
|
||||
templateVariable,
|
||||
importedFilter: filterLookup[mapping.filter_id],
|
||||
effectiveValue,
|
||||
missingRequiredValue,
|
||||
needsExplicitApproval,
|
||||
};
|
||||
}
|
||||
|
||||
function startManualOverride(mapping) {
|
||||
const rowState = getRowState(mapping);
|
||||
editingMappingId = mapping.mapping_id;
|
||||
overrideValueDraft =
|
||||
rowState.effectiveValue === null ? "" : stringifyValue(rowState.effectiveValue);
|
||||
overrideNoteDraft = mapping.transformation_note || "";
|
||||
resetRowMessage(mapping.mapping_id);
|
||||
}
|
||||
|
||||
function cancelManualOverride(mappingId) {
|
||||
if (editingMappingId !== mappingId) {
|
||||
return;
|
||||
}
|
||||
editingMappingId = "";
|
||||
overrideValueDraft = "";
|
||||
overrideNoteDraft = "";
|
||||
resetRowMessage(mappingId);
|
||||
}
|
||||
|
||||
function normalizeOverrideValue() {
|
||||
const trimmed = String(overrideValueDraft || "").trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch (_error) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveMapping(mapping) {
|
||||
if (!sessionId || !mapping?.mapping_id || savingMappingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingMappingId = mapping.mapping_id;
|
||||
setRowMessage(mapping.mapping_id, { status: "saving", text: "" });
|
||||
|
||||
try {
|
||||
const updatedMapping = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/mappings/${mapping.mapping_id}/approve`,
|
||||
"POST",
|
||||
mapping.transformation_note ? { approval_note: mapping.transformation_note } : {},
|
||||
);
|
||||
setRowMessage(mapping.mapping_id, {
|
||||
status: "success",
|
||||
text: $t.dataset_review?.mapping?.messages?.approval_saved,
|
||||
});
|
||||
onupdated({
|
||||
mapping: updatedMapping,
|
||||
preview_state: "unchanged",
|
||||
});
|
||||
} catch (error) {
|
||||
setRowMessage(mapping.mapping_id, {
|
||||
status: "error",
|
||||
text:
|
||||
error?.message ||
|
||||
$t.dataset_review?.mapping?.messages?.approval_failed ||
|
||||
$t.common?.error,
|
||||
});
|
||||
} finally {
|
||||
savingMappingId = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function approveAllMappings() {
|
||||
if (!sessionId || batchSaving || pendingApprovalCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
batchSaving = true;
|
||||
try {
|
||||
const mappingIds = sortedMappings
|
||||
.filter((mapping) => getRowState(mapping).needsExplicitApproval)
|
||||
.map((mapping) => mapping.mapping_id);
|
||||
const updatedMappings = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/mappings/approve-batch`,
|
||||
"POST",
|
||||
{ mapping_ids: mappingIds },
|
||||
);
|
||||
onupdated({
|
||||
mappings: updatedMappings,
|
||||
preview_state: "unchanged",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error?.message ||
|
||||
$t.dataset_review?.mapping?.messages?.approval_failed ||
|
||||
$t.common?.error;
|
||||
for (const mapping of sortedMappings.filter((item) => getRowState(item).needsExplicitApproval)) {
|
||||
setRowMessage(mapping.mapping_id, { status: "error", text: message });
|
||||
}
|
||||
} finally {
|
||||
batchSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveManualOverride(mapping) {
|
||||
if (!sessionId || !mapping?.mapping_id || savingMappingId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveValue = normalizeOverrideValue();
|
||||
const templateVariable = variableLookup[mapping.variable_id];
|
||||
if (effectiveValue === null && templateVariable?.is_required) {
|
||||
setRowMessage(mapping.mapping_id, {
|
||||
status: "error",
|
||||
text: $t.dataset_review?.mapping?.messages?.required_value_missing,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
savingMappingId = mapping.mapping_id;
|
||||
setRowMessage(mapping.mapping_id, { status: "saving", text: "" });
|
||||
|
||||
try {
|
||||
const updatedMapping = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/mappings/${mapping.mapping_id}`,
|
||||
"PATCH",
|
||||
{
|
||||
effective_value: effectiveValue,
|
||||
mapping_method: "manual_override",
|
||||
transformation_note: String(overrideNoteDraft || "").trim() || null,
|
||||
},
|
||||
);
|
||||
setRowMessage(mapping.mapping_id, {
|
||||
status: "success",
|
||||
text: $t.dataset_review?.mapping?.messages?.override_saved,
|
||||
});
|
||||
editingMappingId = "";
|
||||
overrideValueDraft = "";
|
||||
overrideNoteDraft = "";
|
||||
onupdated({
|
||||
mapping: updatedMapping,
|
||||
preview_state: "stale",
|
||||
});
|
||||
} catch (error) {
|
||||
setRowMessage(mapping.mapping_id, {
|
||||
status: "error",
|
||||
text:
|
||||
error?.message ||
|
||||
$t.dataset_review?.mapping?.messages?.override_failed ||
|
||||
$t.common?.error,
|
||||
});
|
||||
} finally {
|
||||
savingMappingId = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="mapping" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.mapping?.eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.mapping?.title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.mapping?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`rounded-full px-3 py-1 text-xs font-medium ${
|
||||
reviewState === "Incomplete"
|
||||
? "bg-red-100 text-red-700"
|
||||
: reviewState === "WarningApproval"
|
||||
? "bg-amber-100 text-amber-700"
|
||||
: "bg-emerald-100 text-emerald-700"
|
||||
}`}
|
||||
>
|
||||
{$t.dataset_review?.mapping?.state_label}: {$t.dataset_review?.mapping?.state?.[reviewState]}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
|
||||
{$t.dataset_review?.mapping?.pending_approvals_label}: {pendingApprovalCount}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
|
||||
{$t.dataset_review?.mapping?.required_values_label}: {missingRequiredCount}
|
||||
</span>
|
||||
<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={approveAllMappings}
|
||||
disabled={disabled || batchSaving || pendingApprovalCount === 0}
|
||||
>
|
||||
{$t.dataset_review?.mapping?.approve_all_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if sortedMappings.length === 0}
|
||||
<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?.mapping?.empty}
|
||||
</div>
|
||||
{:else}
|
||||
{#if missingRequiredCount > 0}
|
||||
<div class="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
|
||||
{$t.dataset_review?.mapping?.required_blockers_notice.replace(
|
||||
"{count}",
|
||||
String(missingRequiredCount),
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingApprovalCount > 0}
|
||||
<div class="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-800">
|
||||
{$t.dataset_review?.mapping?.approval_notice.replace(
|
||||
"{count}",
|
||||
String(pendingApprovalCount),
|
||||
)}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-5 space-y-4">
|
||||
{#each sortedMappings as mapping}
|
||||
{@const rowState = getRowState(mapping)}
|
||||
{@const templateVariable = rowState.templateVariable}
|
||||
{@const importedFilter = rowState.importedFilter}
|
||||
{@const isEditing = editingMappingId === mapping.mapping_id}
|
||||
{@const isSaving = savingMappingId === mapping.mapping_id}
|
||||
{@const rowMessage = rowMessages[mapping.mapping_id] || { status: "", text: "" }}
|
||||
|
||||
<article
|
||||
class={`rounded-2xl border p-4 shadow-sm ${
|
||||
rowState.missingRequiredValue
|
||||
? "border-red-200 bg-red-50"
|
||||
: rowState.needsExplicitApproval
|
||||
? "border-amber-200 bg-amber-50"
|
||||
: "border-slate-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div class="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="text-base font-semibold text-slate-900">
|
||||
{importedFilter?.display_name || importedFilter?.filter_name || mapping.mapping_id}
|
||||
</h3>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">
|
||||
{$t.dataset_review?.mapping?.to_variable_label}: {templateVariable?.variable_name ||
|
||||
mapping.variable_id}
|
||||
</span>
|
||||
{#if templateVariable?.is_required}
|
||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs text-red-700">
|
||||
{$t.dataset_review?.mapping?.required_badge}
|
||||
</span>
|
||||
{/if}
|
||||
{#if rowState.needsExplicitApproval}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||
{$t.dataset_review?.mapping?.approval_required_badge}
|
||||
</span>
|
||||
{/if}
|
||||
{#if mapping.approval_state === "approved"}
|
||||
<span class="rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">
|
||||
{$t.dataset_review?.mapping?.approved_badge}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.mapping?.source_filter_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{importedFilter?.filter_name || mapping.filter_id}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.source_value_label}:
|
||||
{stringifyValue(importedFilter?.raw_value)}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.confidence_label}:
|
||||
{importedFilter?.confidence_state || ($t.common?.unknown || "unknown")}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.recovery_label}:
|
||||
{importedFilter?.recovery_status || ($t.common?.unknown || "unknown")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.mapping?.effective_value_label}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-slate-900">
|
||||
{stringifyValue(rowState.effectiveValue)}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.method_label}: {getMethodLabel(mapping.mapping_method)}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.approval_label}: {getApprovalLabel(mapping.approval_state)}
|
||||
</span>
|
||||
{#if mapping.warning_level}
|
||||
<span class="rounded-full bg-slate-100 px-2 py-1">
|
||||
{$t.dataset_review?.mapping?.warning_label}: {getWarningLabel(mapping.warning_level)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if mapping.transformation_note}
|
||||
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-3 py-3 text-sm text-slate-700">
|
||||
<span class="font-medium text-slate-900">
|
||||
{$t.dataset_review?.mapping?.transformation_note_label}:
|
||||
</span>
|
||||
{mapping.transformation_note}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rowState.missingRequiredValue}
|
||||
<div class="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-3 text-sm text-red-700">
|
||||
{$t.dataset_review?.mapping?.missing_required_value}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isEditing}
|
||||
<div class="mt-4 grid gap-3">
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.mapping?.manual_value_label}
|
||||
</span>
|
||||
<input
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
bind:value={overrideValueDraft}
|
||||
disabled={disabled || isSaving}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.mapping?.manual_note_label}
|
||||
</span>
|
||||
<textarea
|
||||
class="min-h-24 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
bind:value={overrideNoteDraft}
|
||||
disabled={disabled || isSaving}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="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={() => saveManualOverride(mapping)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.mapping?.save_override_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => cancelManualOverride(mapping.mapping_id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{$t.common?.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if rowMessage.text || rowMessage.status === "saving"}
|
||||
<div
|
||||
class={`mt-4 rounded-xl px-3 py-3 text-sm ${
|
||||
rowMessage.status === "error"
|
||||
? "border border-red-200 bg-red-50 text-red-700"
|
||||
: rowMessage.status === "saving"
|
||||
? "border border-blue-200 bg-blue-50 text-blue-700"
|
||||
: "border border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{rowMessage.status === "saving"
|
||||
? $t.dataset_review?.mapping?.messages?.saving
|
||||
: rowMessage.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2 xl:w-56 xl:flex-col">
|
||||
<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={() => startManualOverride(mapping)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.mapping?.manual_override_action}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl bg-amber-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-amber-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
onclick={() => approveMapping(mapping)}
|
||||
disabled={disabled || isSaving || !rowState.needsExplicitApproval}
|
||||
>
|
||||
{$t.dataset_review?.mapping?.approve_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- [/DEF:ExecutionMappingReview:Component] -->
|
||||
@@ -0,0 +1,372 @@
|
||||
<!-- [DEF:LaunchConfirmationPanel:Component] -->
|
||||
<!-- @COMPLEXITY: 4 -->
|
||||
<!-- @SEMANTICS: dataset-review, launch-confirmation, run-gates, sql-lab, audited-execution -->
|
||||
<!-- @PURPOSE: Summarize final execution context, surface launch blockers explicitly, and confirm only a gate-complete SQL Lab launch request. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
||||
<!-- @PRE: Session detail, mappings, findings, preview state, and latest run context belong to the current ownership-scoped session payload. -->
|
||||
<!-- @POST: Users can see why launch is blocked or confirm a run-ready launch with explicit SQL Lab handoff evidence. -->
|
||||
<!-- @SIDE_EFFECT: Submits the launch request through dataset orchestration APIs and updates the workspace with returned run context state. -->
|
||||
<!-- @UX_STATE: Blocked -> Explicit blocker list prevents hidden bypass of approvals, readiness, or preview-fingerprint gates. -->
|
||||
<!-- @UX_STATE: Ready -> Final reviewed run context is visible before confirmation. -->
|
||||
<!-- @UX_STATE: Submitted -> SQL Lab handoff and audited run context reference are shown after launch request succeeds. -->
|
||||
<!-- @UX_FEEDBACK: Launch button, blocker list, and success state all reflect current gate truth instead of generic confirmation copy. -->
|
||||
<!-- @UX_RECOVERY: Blocked launch state provides jump paths back to mapping review, preview generation, or validation remediation. -->
|
||||
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
|
||||
let {
|
||||
sessionId = "",
|
||||
session = null,
|
||||
findings = [],
|
||||
mappings = [],
|
||||
preview = null,
|
||||
previewState = "",
|
||||
latestRunContext = null,
|
||||
disabled = false,
|
||||
onupdated = () => {},
|
||||
onjump = () => {},
|
||||
} = $props();
|
||||
|
||||
let launchStatus = $state("");
|
||||
let launchMessage = $state("");
|
||||
let launchResult = $state(null);
|
||||
|
||||
const activeRunContext = $derived(launchResult?.run_context || latestRunContext || null);
|
||||
const effectivePreviewState = $derived(
|
||||
String(previewState || preview?.preview_status || "missing"),
|
||||
);
|
||||
const approvedMappingsCount = $derived(
|
||||
(mappings || []).filter((mapping) => mapping.approval_state === "approved").length,
|
||||
);
|
||||
const launchBlockers = $derived(buildLaunchBlockers());
|
||||
const panelState = $derived(
|
||||
activeRunContext ? "Submitted" : launchBlockers.length > 0 ? "Blocked" : "Ready",
|
||||
);
|
||||
const launchButtonLabel = $derived(
|
||||
launchStatus === "saving"
|
||||
? $t.dataset_review?.launch?.launch_loading
|
||||
: $t.dataset_review?.launch?.launch_action,
|
||||
);
|
||||
|
||||
function buildLaunchBlockers() {
|
||||
const blockers = [];
|
||||
|
||||
for (const finding of findings || []) {
|
||||
if (
|
||||
finding.severity === "blocking" &&
|
||||
finding.resolution_state !== "resolved" &&
|
||||
finding.resolution_state !== "approved"
|
||||
) {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.blocking_finding ||
|
||||
"Blocking findings remain unresolved",
|
||||
detail: finding.title || finding.message || finding.code,
|
||||
target: "summary",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const mapping of mappings || []) {
|
||||
if (mapping.requires_explicit_approval && mapping.approval_state !== "approved") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.mapping_approval_required ||
|
||||
"Mapping approval is still required",
|
||||
detail: mapping.transformation_note || mapping.mapping_id,
|
||||
target: "mapping",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (effectivePreviewState === "missing") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.preview_missing ||
|
||||
"Superset preview is required before launch",
|
||||
detail: "",
|
||||
target: "sql-preview",
|
||||
});
|
||||
} else if (effectivePreviewState === "pending") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.preview_pending ||
|
||||
"Preview generation is still in progress",
|
||||
detail: "",
|
||||
target: "sql-preview",
|
||||
});
|
||||
} else if (effectivePreviewState === "stale") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.preview_stale ||
|
||||
"Preview no longer matches the current execution inputs",
|
||||
detail: "",
|
||||
target: "sql-preview",
|
||||
});
|
||||
} else if (effectivePreviewState === "failed") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.preview_failed ||
|
||||
"Preview failed and launch remains blocked",
|
||||
detail: preview?.error_details || preview?.error_code || "",
|
||||
target: "sql-preview",
|
||||
});
|
||||
}
|
||||
|
||||
if (!activeRunContext && String(session?.readiness_state || "") !== "run_ready") {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.readiness_not_run_ready ||
|
||||
"Session is not yet in run-ready state",
|
||||
detail: session?.readiness_state || "",
|
||||
target: "next-action",
|
||||
});
|
||||
}
|
||||
|
||||
if (!activeRunContext && !preview?.preview_fingerprint) {
|
||||
blockers.push({
|
||||
label:
|
||||
$t.dataset_review?.launch?.blockers?.preview_fingerprint_missing ||
|
||||
"Preview fingerprint is missing, so launch cannot be trusted",
|
||||
detail: "",
|
||||
target: "sql-preview",
|
||||
});
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
function getStateLabel(state) {
|
||||
return $t.dataset_review?.launch?.state?.[state] || state;
|
||||
}
|
||||
|
||||
function getStateClass(state) {
|
||||
if (state === "Submitted") {
|
||||
return "bg-emerald-100 text-emerald-700";
|
||||
}
|
||||
if (state === "Ready") {
|
||||
return "bg-blue-100 text-blue-700";
|
||||
}
|
||||
return "bg-red-100 text-red-700";
|
||||
}
|
||||
|
||||
function jumpTo(target) {
|
||||
onjump({ target });
|
||||
}
|
||||
|
||||
async function confirmLaunch() {
|
||||
if (!sessionId || disabled || panelState !== "Ready" || launchStatus === "saving") {
|
||||
return;
|
||||
}
|
||||
|
||||
launchStatus = "saving";
|
||||
launchMessage = "";
|
||||
|
||||
try {
|
||||
const result = await requestApi(`/dataset-orchestration/sessions/${sessionId}/launch`, "POST");
|
||||
launchResult = result;
|
||||
launchStatus = "success";
|
||||
launchMessage = $t.dataset_review?.launch?.messages?.launch_started;
|
||||
onupdated({
|
||||
launch_result: result,
|
||||
preview_state: "unchanged",
|
||||
});
|
||||
} catch (error) {
|
||||
launchStatus = "error";
|
||||
launchMessage =
|
||||
error?.message ||
|
||||
$t.dataset_review?.launch?.messages?.launch_failed ||
|
||||
$t.common?.error;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="launch" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.launch?.title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.launch?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class={`rounded-full px-3 py-1 text-xs font-medium ${getStateClass(panelState)}`}>
|
||||
{$t.dataset_review?.launch?.state_label}: {getStateLabel(panelState)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if panelState === "Blocked"}
|
||||
<div class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4">
|
||||
<h3 class="text-sm font-semibold text-red-800">
|
||||
{$t.dataset_review?.launch?.blocked_title}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-red-700">
|
||||
{$t.dataset_review?.launch?.blocked_body}
|
||||
</p>
|
||||
|
||||
<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>
|
||||
{#if blocker.detail}
|
||||
<div class="mt-1 break-all text-xs text-slate-600">{blocker.detail}</div>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 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"
|
||||
onclick={() => jumpTo(blocker.target)}
|
||||
>
|
||||
{$t.dataset_review?.launch?.resolve_action}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panelState === "Ready"}
|
||||
<div class="mt-5 space-y-4">
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<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">
|
||||
{session?.dataset_ref || ($t.common?.unknown || "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.readiness_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{session?.readiness_state || ($t.common?.unknown || "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.approved_mappings_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{approvedMappingsCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.preview_fingerprint_label}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-slate-900">
|
||||
{preview?.preview_fingerprint || ($t.common?.not_available || "N/A")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<h3 class="text-sm font-semibold text-slate-900">
|
||||
{$t.dataset_review?.launch?.sql_lab_target_title}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
{$t.dataset_review?.launch?.sql_lab_target_body}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.launch?.preview_status_label}: {preview?.preview_status}
|
||||
</span>
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.launch?.compiled_by_label}: {preview?.compiled_by}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirmLaunch}
|
||||
disabled={disabled || launchStatus === "saving"}
|
||||
class="inline-flex w-full items-center justify-center rounded-xl bg-blue-600 px-4 py-3 text-sm font-medium text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{launchButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if panelState === "Submitted"}
|
||||
<div class="mt-5 space-y-4 rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-emerald-900">
|
||||
{$t.dataset_review?.launch?.submitted_title}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-emerald-800">
|
||||
{$t.dataset_review?.launch?.submitted_body}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-emerald-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.run_context_label}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-slate-900">
|
||||
{activeRunContext?.run_context_id}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-emerald-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.sql_lab_session_label}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-slate-900">
|
||||
{activeRunContext?.sql_lab_session_ref}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-emerald-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.launch_status_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{activeRunContext?.launch_status}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-emerald-200 bg-white p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.launch?.preview_ref_label}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-sm font-medium text-slate-900">
|
||||
{activeRunContext?.preview_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if launchMessage}
|
||||
<div
|
||||
class={`mt-4 rounded-xl px-3 py-3 text-sm ${
|
||||
launchStatus === "error"
|
||||
? "border border-red-200 bg-red-50 text-red-700"
|
||||
: launchStatus === "saving"
|
||||
? "border border-blue-200 bg-blue-50 text-blue-700"
|
||||
: "border border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{launchMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- [/DEF:LaunchConfirmationPanel:Component] -->
|
||||
@@ -0,0 +1,598 @@
|
||||
<!-- [DEF:SemanticLayerReview:Component] -->
|
||||
<!-- @COMPLEXITY: 4 -->
|
||||
<!-- @SEMANTICS: dataset-review, semantic-layer, candidate-review, manual-override, field-lock -->
|
||||
<!-- @PURPOSE: Surface field-level semantic decisions with provenance, confidence, candidate acceptance, and manual override safeguards for US2 review flow. -->
|
||||
<!-- @LAYER: UI -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[api_module] -->
|
||||
<!-- @RELATION: [BINDS_TO] ->[DatasetReviewWorkspace] -->
|
||||
<!-- @PRE: Session id is available and semantic field entries come from the current ownership-scoped session detail payload. -->
|
||||
<!-- @POST: Users can review the current semantic value, accept a candidate, apply manual override, and lock or unlock field state without violating backend provenance rules. -->
|
||||
<!-- @SIDE_EFFECT: Persists semantic field decisions, lock state, and optional thumbs feedback through dataset orchestration endpoints. -->
|
||||
<!-- @UX_STATE: Conflicted -> Multiple candidates or review-needed fields remain visible with explicit acceptance actions. -->
|
||||
<!-- @UX_STATE: Manual -> One field enters local draft mode and persists as locked manual override on save. -->
|
||||
<!-- @UX_FEEDBACK: Save, lock, unlock, and feedback actions expose inline success or error state on the affected field. -->
|
||||
<!-- @UX_RECOVERY: Users can cancel local edits, unlock a manual override for re-review, or retry failed mutations in place. -->
|
||||
<!-- @UX_REACTIVITY: Uses $props, $state, and $derived only; no legacy reactive syntax. -->
|
||||
<script>
|
||||
import { t } from "$lib/i18n";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
|
||||
let {
|
||||
sessionId = "",
|
||||
fields = [],
|
||||
semanticSources = [],
|
||||
disabled = false,
|
||||
onupdated = () => {},
|
||||
} = $props();
|
||||
|
||||
let editingFieldId = $state("");
|
||||
let draftVerboseName = $state("");
|
||||
let draftDescription = $state("");
|
||||
let draftDisplayFormat = $state("");
|
||||
let savingFieldId = $state("");
|
||||
let batchSaving = $state(false);
|
||||
let fieldMessages = $state({});
|
||||
|
||||
const sortedFields = $derived(
|
||||
[...(fields || [])].sort((left, right) => {
|
||||
const leftPriority = Number(Boolean(left?.needs_review)) + Number(Boolean(left?.has_conflict));
|
||||
const rightPriority =
|
||||
Number(Boolean(right?.needs_review)) + Number(Boolean(right?.has_conflict));
|
||||
if (leftPriority !== rightPriority) {
|
||||
return rightPriority - leftPriority;
|
||||
}
|
||||
return String(left?.field_name || "").localeCompare(String(right?.field_name || ""));
|
||||
}),
|
||||
);
|
||||
|
||||
const activeFieldCount = $derived(sortedFields.length);
|
||||
|
||||
function updateFieldMessage(fieldId, patch) {
|
||||
fieldMessages = {
|
||||
...fieldMessages,
|
||||
[fieldId]: {
|
||||
...(fieldMessages[fieldId] || {}),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resetFieldMessage(fieldId) {
|
||||
fieldMessages = {
|
||||
...fieldMessages,
|
||||
[fieldId]: {
|
||||
status: "",
|
||||
text: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startManualEdit(field) {
|
||||
editingFieldId = field.field_id;
|
||||
draftVerboseName = field.verbose_name || "";
|
||||
draftDescription = field.description || "";
|
||||
draftDisplayFormat = field.display_format || "";
|
||||
resetFieldMessage(field.field_id);
|
||||
}
|
||||
|
||||
function cancelManualEdit(fieldId) {
|
||||
if (editingFieldId !== fieldId) {
|
||||
return;
|
||||
}
|
||||
editingFieldId = "";
|
||||
draftVerboseName = "";
|
||||
draftDescription = "";
|
||||
draftDisplayFormat = "";
|
||||
resetFieldMessage(fieldId);
|
||||
}
|
||||
|
||||
function getSourceLabel(sourceId) {
|
||||
const source = (semanticSources || []).find((item) => item.source_id === sourceId);
|
||||
if (!source) {
|
||||
return $t.dataset_review?.semantics?.unknown_source;
|
||||
}
|
||||
return `${source.display_name} • ${source.source_version}`;
|
||||
}
|
||||
|
||||
function getCandidateStatusLabel(status) {
|
||||
const normalized = String(status || "pending");
|
||||
return $t.dataset_review?.semantics?.candidate_status?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function getProvenanceLabel(provenance) {
|
||||
const normalized = String(provenance || "unresolved");
|
||||
return $t.dataset_review?.semantics?.provenance?.[normalized] || normalized;
|
||||
}
|
||||
|
||||
function getConfidenceLabel(rank) {
|
||||
if (rank === null || rank === undefined) {
|
||||
return $t.dataset_review?.semantics?.confidence_unset;
|
||||
}
|
||||
return `${$t.dataset_review?.semantics?.confidence_rank_label} #${rank}`;
|
||||
}
|
||||
|
||||
function getCurrentValueSummary(field) {
|
||||
const parts = [
|
||||
field.verbose_name || $t.dataset_review?.semantics?.empty_value,
|
||||
field.description || "",
|
||||
field.display_format || "",
|
||||
].filter(Boolean);
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
async function persistFieldSemantic(fieldId, payload, successKey) {
|
||||
if (!sessionId || !fieldId || savingFieldId) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingFieldId = fieldId;
|
||||
updateFieldMessage(fieldId, { status: "saving", text: "" });
|
||||
|
||||
try {
|
||||
const updatedField = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/fields/${fieldId}/semantic`,
|
||||
"PATCH",
|
||||
payload,
|
||||
);
|
||||
updateFieldMessage(fieldId, {
|
||||
status: "success",
|
||||
text: $t.dataset_review?.semantics?.messages?.[successKey],
|
||||
});
|
||||
if (editingFieldId === fieldId) {
|
||||
editingFieldId = "";
|
||||
}
|
||||
onupdated(updatedField);
|
||||
} catch (error) {
|
||||
updateFieldMessage(fieldId, {
|
||||
status: "error",
|
||||
text:
|
||||
error?.message ||
|
||||
$t.dataset_review?.semantics?.messages?.save_failed ||
|
||||
$t.common?.error,
|
||||
});
|
||||
} finally {
|
||||
savingFieldId = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptCandidate(field, candidateId, lockField) {
|
||||
await persistFieldSemantic(
|
||||
field.field_id,
|
||||
{
|
||||
candidate_id: candidateId,
|
||||
lock_field: lockField,
|
||||
},
|
||||
lockField ? "candidate_locked" : "candidate_applied",
|
||||
);
|
||||
}
|
||||
|
||||
async function saveManualOverride(field) {
|
||||
const payload = {
|
||||
verbose_name: draftVerboseName.trim() || null,
|
||||
description: draftDescription.trim() || null,
|
||||
display_format: draftDisplayFormat.trim() || null,
|
||||
lock_field: true,
|
||||
};
|
||||
const hasManualValue =
|
||||
payload.verbose_name || payload.description || payload.display_format;
|
||||
|
||||
if (!hasManualValue) {
|
||||
updateFieldMessage(field.field_id, {
|
||||
status: "error",
|
||||
text: $t.dataset_review?.semantics?.messages?.manual_override_required,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await persistFieldSemantic(field.field_id, payload, "manual_saved");
|
||||
}
|
||||
|
||||
async function mutateLock(field, action) {
|
||||
if (!sessionId || !field?.field_id || savingFieldId) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingFieldId = field.field_id;
|
||||
updateFieldMessage(field.field_id, { status: "saving", text: "" });
|
||||
|
||||
try {
|
||||
const updatedField = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/fields/${field.field_id}/${action}`,
|
||||
"POST",
|
||||
);
|
||||
updateFieldMessage(field.field_id, {
|
||||
status: "success",
|
||||
text:
|
||||
action === "lock"
|
||||
? $t.dataset_review?.semantics?.messages?.locked
|
||||
: $t.dataset_review?.semantics?.messages?.unlocked,
|
||||
});
|
||||
onupdated(updatedField);
|
||||
} catch (error) {
|
||||
updateFieldMessage(field.field_id, {
|
||||
status: "error",
|
||||
text:
|
||||
error?.message ||
|
||||
$t.dataset_review?.semantics?.messages?.save_failed ||
|
||||
$t.common?.error,
|
||||
});
|
||||
} finally {
|
||||
savingFieldId = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function approveAllCandidates() {
|
||||
if (!sessionId || batchSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = sortedFields
|
||||
.filter((field) => !field.is_locked && field.candidates?.length)
|
||||
.map((field) => ({
|
||||
field_id: field.field_id,
|
||||
candidate_id: field.candidates[0].candidate_id,
|
||||
lock_field: false,
|
||||
}));
|
||||
if (!items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
batchSaving = true;
|
||||
try {
|
||||
const updatedFields = await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/fields/semantic/approve-batch`,
|
||||
"POST",
|
||||
{ items },
|
||||
);
|
||||
onupdated({ fields: updatedFields });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error?.message ||
|
||||
$t.dataset_review?.semantics?.messages?.save_failed ||
|
||||
$t.common?.error;
|
||||
for (const item of items) {
|
||||
updateFieldMessage(item.field_id, { status: "error", text: message });
|
||||
}
|
||||
} finally {
|
||||
batchSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recordFeedback(fieldId, feedback) {
|
||||
if (!sessionId || !fieldId || savingFieldId) {
|
||||
return;
|
||||
}
|
||||
|
||||
savingFieldId = fieldId;
|
||||
updateFieldMessage(fieldId, { status: "saving", text: "" });
|
||||
|
||||
try {
|
||||
await requestApi(
|
||||
`/dataset-orchestration/sessions/${sessionId}/fields/${fieldId}/feedback`,
|
||||
"POST",
|
||||
{ feedback },
|
||||
);
|
||||
updateFieldMessage(fieldId, {
|
||||
status: "success",
|
||||
text:
|
||||
feedback === "up"
|
||||
? $t.dataset_review?.semantics?.messages?.feedback_up
|
||||
: $t.dataset_review?.semantics?.messages?.feedback_down,
|
||||
});
|
||||
} catch (error) {
|
||||
updateFieldMessage(fieldId, {
|
||||
status: "error",
|
||||
text:
|
||||
error?.message ||
|
||||
$t.dataset_review?.semantics?.messages?.save_failed ||
|
||||
$t.common?.error,
|
||||
});
|
||||
} finally {
|
||||
savingFieldId = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section id="semantics" class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.semantics?.eyebrow}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold text-slate-900">
|
||||
{$t.dataset_review?.semantics?.title}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-slate-600">
|
||||
{$t.dataset_review?.semantics?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<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?.semantics?.field_count_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">{activeFieldCount}</div>
|
||||
</div>
|
||||
<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={approveAllCandidates}
|
||||
disabled={disabled || batchSaving || activeFieldCount === 0}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.approve_all_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if activeFieldCount === 0}
|
||||
<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?.semantics?.empty}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-5 space-y-4">
|
||||
{#each sortedFields as field}
|
||||
{@const message = fieldMessages[field.field_id] || { status: "", text: "" }}
|
||||
{@const isEditing = editingFieldId === field.field_id}
|
||||
{@const isSaving = savingFieldId === field.field_id}
|
||||
|
||||
<article
|
||||
class={`rounded-2xl border p-4 shadow-sm ${
|
||||
field.has_conflict || field.needs_review
|
||||
? "border-amber-200 bg-amber-50"
|
||||
: "border-slate-200 bg-white"
|
||||
}`}
|
||||
>
|
||||
<div class="flex flex-col gap-4 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-base font-semibold text-slate-900">{field.field_name}</h3>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">
|
||||
{field.field_kind}
|
||||
</span>
|
||||
<span
|
||||
class={`rounded-full px-2 py-0.5 text-xs ${
|
||||
field.is_locked
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{field.is_locked
|
||||
? $t.dataset_review?.semantics?.locked_badge
|
||||
: $t.dataset_review?.semantics?.unlocked_badge}
|
||||
</span>
|
||||
{#if field.has_conflict}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||
{$t.dataset_review?.semantics?.conflict_badge}
|
||||
</span>
|
||||
{/if}
|
||||
{#if field.needs_review}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs text-amber-800">
|
||||
{$t.dataset_review?.semantics?.needs_review_badge}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-slate-500">
|
||||
{$t.dataset_review?.semantics?.active_value_label}
|
||||
</div>
|
||||
<div class="mt-1 text-sm font-medium text-slate-900">
|
||||
{getCurrentValueSummary(field)}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.semantics?.provenance_label}:
|
||||
{getProvenanceLabel(field.provenance)}
|
||||
</span>
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.semantics?.confidence_label}:
|
||||
{getConfidenceLabel(field.confidence_rank)}
|
||||
</span>
|
||||
{#if field.source_id}
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.semantics?.source_label}: {getSourceLabel(field.source_id)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="rounded-full bg-white px-2 py-1">
|
||||
{$t.dataset_review?.semantics?.changed_by_label}: {field.last_changed_by}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isEditing}
|
||||
<div class="mt-4 grid gap-3">
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.semantics?.manual_verbose_name_label}
|
||||
</span>
|
||||
<input
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
bind:value={draftVerboseName}
|
||||
disabled={isSaving || disabled}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.semantics?.manual_description_label}
|
||||
</span>
|
||||
<textarea
|
||||
class="min-h-24 w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
bind:value={draftDescription}
|
||||
disabled={isSaving || disabled}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="space-y-1">
|
||||
<span class="text-sm font-medium text-slate-700">
|
||||
{$t.dataset_review?.semantics?.manual_display_format_label}
|
||||
</span>
|
||||
<input
|
||||
class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||
bind:value={draftDisplayFormat}
|
||||
disabled={isSaving || disabled}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="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={() => saveManualOverride(field)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.save_manual_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => cancelManualEdit(field.field_id)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{$t.common?.cancel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 class="text-sm font-semibold text-slate-900">
|
||||
{$t.dataset_review?.semantics?.candidates_title}
|
||||
</h4>
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs text-slate-700">
|
||||
{field.candidates?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !field.candidates?.length}
|
||||
<p class="mt-2 text-sm text-slate-600">
|
||||
{$t.dataset_review?.semantics?.candidates_empty}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3">
|
||||
{#each field.candidates as candidate}
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<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">
|
||||
<div class="text-sm font-medium text-slate-900">
|
||||
{candidate.proposed_verbose_name ||
|
||||
$t.dataset_review?.semantics?.empty_value}
|
||||
</div>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">
|
||||
{candidate.match_type}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">
|
||||
{$t.dataset_review?.semantics?.score_label}:
|
||||
{candidate.confidence_score}
|
||||
</span>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-700">
|
||||
{getCandidateStatusLabel(candidate.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-slate-700">
|
||||
{candidate.proposed_description ||
|
||||
$t.dataset_review?.semantics?.candidate_description_empty}
|
||||
</p>
|
||||
|
||||
{#if candidate.proposed_display_format}
|
||||
<p class="mt-1 text-xs text-slate-500">
|
||||
{$t.dataset_review?.semantics?.display_format_label}:
|
||||
{candidate.proposed_display_format}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => acceptCandidate(field, candidate.candidate_id, false)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.apply_candidate_action}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
onclick={() => acceptCandidate(field, candidate.candidate_id, true)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.apply_and_lock_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if message.text || message.status === "saving"}
|
||||
<div
|
||||
class={`mt-4 rounded-xl px-3 py-3 text-sm ${
|
||||
message.status === "error"
|
||||
? "border border-red-200 bg-red-50 text-red-700"
|
||||
: message.status === "saving"
|
||||
? "border border-blue-200 bg-blue-50 text-blue-700"
|
||||
: "border border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
}`}
|
||||
>
|
||||
{message.status === "saving"
|
||||
? $t.dataset_review?.semantics?.messages?.saving
|
||||
: message.text}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-wrap gap-2 lg:w-52 lg:flex-col">
|
||||
<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={() => startManualEdit(field)}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.manual_override_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 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => mutateLock(field, field.is_locked ? "unlock" : "lock")}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{field.is_locked
|
||||
? $t.dataset_review?.semantics?.unlock_action
|
||||
: $t.dataset_review?.semantics?.lock_action}
|
||||
</button>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => recordFeedback(field.field_id, 'up')}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.feedback_up_action}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center rounded-xl border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onclick={() => recordFeedback(field.field_id, 'down')}
|
||||
disabled={disabled || isSaving}
|
||||
>
|
||||
{$t.dataset_review?.semantics?.feedback_down_action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- [/DEF:SemanticLayerReview:Component] -->
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// @ts-nocheck
|
||||
// [DEF:DatasetReviewUs2UxTests:Module]
|
||||
// @COMPLEXITY: 3
|
||||
// @SEMANTICS: dataset-review, semantics, clarification, ux-tests, field-review
|
||||
// @PURPOSE: Verify US2 semantic field review and one-question clarification UX contracts.
|
||||
// @LAYER: UI
|
||||
// @RELATION: [VERIFIES] ->[SemanticLayerReview]
|
||||
// @RELATION: [VERIFIES] ->[ClarificationDialog]
|
||||
// @UX_STATE: Conflicted -> Semantic candidates and manual override affordances remain visible.
|
||||
// @UX_STATE: Question -> Clarification shows one active question, why-it-matters context, and resumable actions.
|
||||
// @TEST_CONTRACT: Us2DatasetReviewProps -> ObservableSemanticAndClarificationUx
|
||||
// @TEST_SCENARIO: semantic_review_surfaces_active_value_and_candidates -> Field-level semantic review shows provenance, candidates, and mutation callbacks.
|
||||
// @TEST_SCENARIO: clarification_dialog_shows_active_question_and_actions -> Clarification dialog renders one active question with answer and escalation actions.
|
||||
// @TEST_EDGE: missing_field -> Missing optional values still keep semantic summary readable.
|
||||
// @TEST_EDGE: external_fail -> Failed request surfaces inline recovery message.
|
||||
// @TEST_INVARIANT: us2_components_remain_actionable -> VERIFIED_BY: [semantic_review_surfaces_active_value_and_candidates, clarification_dialog_shows_active_question_and_actions]
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
|
||||
import SemanticLayerReview from "../SemanticLayerReview.svelte";
|
||||
import ClarificationDialog from "../ClarificationDialog.svelte";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
import { openAssistantChat } from "$lib/stores/assistantChat.js";
|
||||
|
||||
const mockedRequestApi = /** @type {any} */ (requestApi);
|
||||
const mockedOpenAssistantChat = /** @type {any} */ (openAssistantChat);
|
||||
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
requestApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/stores/assistantChat.js", () => ({
|
||||
openAssistantChat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/i18n", () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
common: {
|
||||
error: "Common error",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
dataset_review: {
|
||||
semantics: {
|
||||
eyebrow: "Semantic layer",
|
||||
title: "Review semantic field values",
|
||||
description: "Review semantic candidates",
|
||||
field_count_label: "Fields in review",
|
||||
empty: "No semantic fields",
|
||||
unknown_source: "Unknown source",
|
||||
active_value_label: "Active semantic value",
|
||||
provenance_label: "Provenance",
|
||||
confidence_label: "Confidence",
|
||||
confidence_unset: "No confidence rank",
|
||||
confidence_rank_label: "Rank",
|
||||
source_label: "Source",
|
||||
changed_by_label: "Changed by",
|
||||
locked_badge: "Locked",
|
||||
unlocked_badge: "Unlocked",
|
||||
conflict_badge: "Conflict",
|
||||
needs_review_badge: "Needs review",
|
||||
manual_verbose_name_label: "Manual verbose name",
|
||||
manual_description_label: "Manual description",
|
||||
manual_display_format_label: "Manual display format",
|
||||
manual_override_action: "Manual override",
|
||||
save_manual_action: "Save manual value",
|
||||
lock_action: "Lock field",
|
||||
unlock_action: "Unlock field",
|
||||
feedback_up_action: "Thumbs up",
|
||||
feedback_down_action: "Thumbs down",
|
||||
candidates_title: "Candidate options",
|
||||
candidates_empty: "No candidates",
|
||||
candidate_description_empty: "No candidate description",
|
||||
display_format_label: "Display format",
|
||||
score_label: "Score",
|
||||
apply_candidate_action: "Apply",
|
||||
apply_and_lock_action: "Apply and lock",
|
||||
empty_value: "No value",
|
||||
provenance: {
|
||||
unresolved: "Unresolved",
|
||||
dictionary_exact: "Dictionary exact match",
|
||||
manual_override: "Manual override",
|
||||
},
|
||||
candidate_status: {
|
||||
pending: "Pending",
|
||||
accepted: "Accepted",
|
||||
superseded: "Superseded",
|
||||
},
|
||||
messages: {
|
||||
saving: "Saving semantic decision...",
|
||||
save_failed: "Failed to save semantic decision.",
|
||||
manual_override_required: "Manual value required",
|
||||
manual_saved: "Manual semantic value saved and locked.",
|
||||
candidate_applied: "Candidate value applied.",
|
||||
candidate_locked: "Candidate value applied and locked.",
|
||||
locked: "Field locked against silent overwrite.",
|
||||
unlocked: "Field unlocked for renewed review.",
|
||||
feedback_up: "Positive feedback recorded.",
|
||||
feedback_down: "Negative feedback recorded.",
|
||||
},
|
||||
},
|
||||
clarification: {
|
||||
eyebrow: "Clarification",
|
||||
title: "Resolve one ambiguity at a time",
|
||||
description: "One question at a time",
|
||||
progress_label: "Clarification progress",
|
||||
state_label: "Dialog state",
|
||||
status_label: "Status",
|
||||
remaining_label: "Remaining questions",
|
||||
summary_delta_label: "Summary delta",
|
||||
active_question_label: "Active question",
|
||||
why_it_matters_label: "Why it matters",
|
||||
current_guess_label: "Current guess",
|
||||
current_guess_empty: "No current guess",
|
||||
topic_label: "Topic",
|
||||
options_title: "Available options",
|
||||
recommended_badge: "Recommended",
|
||||
custom_answer_label: "Custom answer",
|
||||
answer_action: "Answer with selected option",
|
||||
custom_answer_action: "Submit custom answer",
|
||||
skip_action: "Skip for now",
|
||||
assistant_action: "Open assistant guidance",
|
||||
expert_review_action: "Send to expert review",
|
||||
resume_action: "Resume clarification",
|
||||
completed: "No active clarification question remains.",
|
||||
feedback_label: "Clarification feedback",
|
||||
feedback_prompt: "Record whether the clarification result was useful.",
|
||||
feedback_up_action: "Helpful",
|
||||
feedback_down_action: "Not helpful",
|
||||
messages: {
|
||||
saving: "Saving clarification answer...",
|
||||
saved: "Clarification answer saved.",
|
||||
skipped: "Question skipped and left resumable.",
|
||||
expert_review_requested: "Question escalated to expert review.",
|
||||
assistant_opened: "Assistant guidance opened.",
|
||||
resumed: "Clarification resumed.",
|
||||
resume_failed: "Failed to resume clarification.",
|
||||
save_failed: "Failed to save clarification answer.",
|
||||
feedback_up: "Positive clarification feedback recorded.",
|
||||
feedback_down: "Negative clarification feedback recorded.",
|
||||
feedback_failed: "Failed to save clarification feedback.",
|
||||
option_required: "Option required",
|
||||
custom_required: "Custom answer required",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Dataset review US2 component UX", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("semantic_review_surfaces_active_value_and_candidates", async () => {
|
||||
requestApi.mockResolvedValue({
|
||||
field_id: "field-1",
|
||||
field_name: "customer_name",
|
||||
field_kind: "dimension",
|
||||
verbose_name: "Customer name",
|
||||
description: "Customer display name",
|
||||
display_format: "text",
|
||||
provenance: "dictionary_exact",
|
||||
source_id: "source-1",
|
||||
confidence_rank: 1,
|
||||
is_locked: false,
|
||||
has_conflict: false,
|
||||
needs_review: false,
|
||||
last_changed_by: "user",
|
||||
candidates: [],
|
||||
});
|
||||
|
||||
const onupdated = vi.fn();
|
||||
|
||||
render(SemanticLayerReview, {
|
||||
sessionId: "session-1",
|
||||
semanticSources: [
|
||||
{
|
||||
source_id: "source-1",
|
||||
display_name: "Trusted dictionary",
|
||||
source_version: "v1",
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
field_id: "field-1",
|
||||
field_name: "customer_name",
|
||||
field_kind: "dimension",
|
||||
verbose_name: "Customer name",
|
||||
description: "Resolved from trusted dictionary",
|
||||
display_format: "text",
|
||||
provenance: "dictionary_exact",
|
||||
source_id: "source-1",
|
||||
confidence_rank: 1,
|
||||
is_locked: false,
|
||||
has_conflict: true,
|
||||
needs_review: true,
|
||||
last_changed_by: "system",
|
||||
candidates: [
|
||||
{
|
||||
candidate_id: "candidate-1",
|
||||
match_type: "exact",
|
||||
confidence_score: 0.98,
|
||||
proposed_verbose_name: "Customer name",
|
||||
proposed_description: "Customer display name",
|
||||
proposed_display_format: "text",
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
onupdated,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Review semantic field values")).toBeDefined();
|
||||
expect(screen.getByText("customer_name")).toBeDefined();
|
||||
expect(screen.getByText("Conflict")).toBeDefined();
|
||||
expect(screen.getByText("Needs review")).toBeDefined();
|
||||
expect(screen.getByText("Candidate options")).toBeDefined();
|
||||
expect(screen.getByText(/Trusted dictionary/)).toBeDefined();
|
||||
expect(screen.getByText(/v1/)).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Apply" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/fields/field-1/semantic",
|
||||
"PATCH",
|
||||
{
|
||||
candidate_id: "candidate-1",
|
||||
lock_field: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(onupdated).toHaveBeenCalled();
|
||||
expect(screen.getByText("Candidate value applied.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("clarification_dialog_shows_active_question_and_actions", async () => {
|
||||
requestApi.mockResolvedValue({
|
||||
clarification_state: {
|
||||
clarification_session: {
|
||||
clarification_session_id: "clarification-1",
|
||||
session_id: "session-1",
|
||||
status: "active",
|
||||
current_question_id: "question-2",
|
||||
resolved_count: 2,
|
||||
remaining_count: 1,
|
||||
summary_delta: "Updated summary",
|
||||
},
|
||||
current_question: {
|
||||
question_id: "question-2",
|
||||
clarification_session_id: "clarification-1",
|
||||
topic_ref: "profile.summary",
|
||||
question_text: "Which label should reviewers see?",
|
||||
why_it_matters: "This label is reused in the workspace summary.",
|
||||
current_guess: "Customer name",
|
||||
options: [
|
||||
{
|
||||
option_id: "option-1",
|
||||
value: "Customer name",
|
||||
label: "Customer name",
|
||||
is_recommended: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
session: {
|
||||
session_id: "session-1",
|
||||
readiness_state: "clarification_active",
|
||||
recommended_action: "answer_next_question",
|
||||
},
|
||||
changed_findings: [],
|
||||
});
|
||||
|
||||
const onupdated = vi.fn();
|
||||
|
||||
render(ClarificationDialog, {
|
||||
sessionId: "session-1",
|
||||
clarificationState: {
|
||||
clarification_session: {
|
||||
clarification_session_id: "clarification-1",
|
||||
session_id: "session-1",
|
||||
status: "active",
|
||||
current_question_id: "question-1",
|
||||
resolved_count: 1,
|
||||
remaining_count: 2,
|
||||
summary_delta: "Awaiting confirmation",
|
||||
},
|
||||
current_question: {
|
||||
question_id: "question-1",
|
||||
clarification_session_id: "clarification-1",
|
||||
topic_ref: "profile.summary",
|
||||
question_text: "Which customer label should be used?",
|
||||
why_it_matters: "This label is shown to reviewers.",
|
||||
current_guess: "Customer name",
|
||||
options: [
|
||||
{
|
||||
option_id: "option-1",
|
||||
value: "Customer name",
|
||||
label: "Customer name",
|
||||
is_recommended: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
onupdated,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Resolve one ambiguity at a time")).toBeDefined();
|
||||
expect(screen.getByText("Which customer label should be used?")).toBeDefined();
|
||||
expect(screen.getByText("This label is shown to reviewers.")).toBeDefined();
|
||||
expect(screen.getAllByText("Customer name").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("Recommended")).toBeDefined();
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Open assistant guidance" }),
|
||||
);
|
||||
|
||||
expect(openAssistantChat).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("Assistant guidance opened.")).toBeDefined();
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Answer with selected option" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/clarification/answers",
|
||||
"POST",
|
||||
{
|
||||
question_id: "question-1",
|
||||
answer_kind: "selected",
|
||||
answer_value: "Customer name",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(onupdated).toHaveBeenCalled();
|
||||
expect(screen.getByText("Clarification answer saved.")).toBeDefined();
|
||||
|
||||
mockedRequestApi.mockResolvedValueOnce({
|
||||
target_id: "question-1",
|
||||
feedback: "up",
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Helpful" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/clarification/questions/question-1/feedback",
|
||||
"POST",
|
||||
{ feedback: "up" },
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText("Positive clarification feedback recorded."),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("clarification_dialog_external_fail_surfaces_inline_error", async () => {
|
||||
requestApi.mockRejectedValue(new Error("Clarification backend unavailable"));
|
||||
|
||||
render(ClarificationDialog, {
|
||||
sessionId: "session-1",
|
||||
clarificationState: {
|
||||
clarification_session: {
|
||||
clarification_session_id: "clarification-1",
|
||||
session_id: "session-1",
|
||||
status: "active",
|
||||
current_question_id: "question-1",
|
||||
resolved_count: 1,
|
||||
remaining_count: 2,
|
||||
},
|
||||
current_question: {
|
||||
question_id: "question-1",
|
||||
clarification_session_id: "clarification-1",
|
||||
topic_ref: "profile.summary",
|
||||
question_text: "Which customer label should be used?",
|
||||
why_it_matters: "This label is shown to reviewers.",
|
||||
current_guess: "Customer name",
|
||||
options: [
|
||||
{
|
||||
option_id: "option-1",
|
||||
value: "Customer name",
|
||||
label: "Customer name",
|
||||
is_recommended: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Answer with selected option" }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Clarification backend unavailable")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
// [/DEF:DatasetReviewUs2UxTests:Module]
|
||||
@@ -0,0 +1,499 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
// @ts-nocheck
|
||||
// [DEF:DatasetReviewUs3UxTests:Module]
|
||||
// @COMPLEXITY: 3
|
||||
// @SEMANTICS: dataset-review, execution, mapping, preview, launch, ux-tests
|
||||
// @PURPOSE: Verify US3 mapping review, Superset preview, and launch confirmation UX contracts.
|
||||
// @LAYER: UI
|
||||
// @RELATION: [VERIFIES] ->[ExecutionMappingReview]
|
||||
// @RELATION: [VERIFIES] ->[CompiledSQLPreview]
|
||||
// @RELATION: [VERIFIES] ->[LaunchConfirmationPanel]
|
||||
// @UX_STATE: WarningApproval -> Mapping review requires explicit approval or manual override.
|
||||
// @UX_STATE: Ready -> Preview and launch panels expose reviewed Superset-backed execution context.
|
||||
// @UX_STATE: Blocked -> Launch panel lists blockers instead of allowing hidden bypass.
|
||||
// @TEST_CONTRACT: Us3ExecutionProps -> ObservableExecutionUx
|
||||
// @TEST_SCENARIO: mapping_review_approves_warning_sensitive_row -> Approval persists and keeps blockers visible until cleared.
|
||||
// @TEST_SCENARIO: preview_panel_requests_superset_compilation_and_renders_sql -> Preview stays explicitly Superset-derived.
|
||||
// @TEST_SCENARIO: launch_panel_blocks_then_submits_sql_lab_launch -> Launch lists gates first and shows audited handoff after success.
|
||||
// @TEST_EDGE: missing_field -> Missing preview or run context remains readable.
|
||||
// @TEST_EDGE: external_fail -> Failed preview request surfaces inline error.
|
||||
// @TEST_EDGE: invalid_type -> Mixed values stringify without crashing.
|
||||
// @TEST_INVARIANT: execution_gates_remain_visible -> VERIFIED_BY: [mapping_review_approves_warning_sensitive_row, preview_panel_requests_superset_compilation_and_renders_sql, launch_panel_blocks_then_submits_sql_lab_launch]
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { requestApi } from "$lib/api.js";
|
||||
import ExecutionMappingReview from "../ExecutionMappingReview.svelte";
|
||||
import CompiledSQLPreview from "../CompiledSQLPreview.svelte";
|
||||
import LaunchConfirmationPanel from "../LaunchConfirmationPanel.svelte";
|
||||
|
||||
vi.mock("$lib/api.js", () => ({
|
||||
requestApi: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/i18n", () => ({
|
||||
t: {
|
||||
subscribe: (fn) => {
|
||||
fn({
|
||||
common: {
|
||||
error: "Common error",
|
||||
cancel: "Cancel",
|
||||
not_available: "N/A",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
dataset_review: {
|
||||
mapping: {
|
||||
eyebrow: "Template mapping",
|
||||
title: "Review filter-to-template mappings",
|
||||
description: "Verify imported filter values, effective execution values, and warning-sensitive transformations before preview or launch.",
|
||||
state_label: "Mapping state",
|
||||
state: {
|
||||
Incomplete: "Incomplete",
|
||||
WarningApproval: "Approval required",
|
||||
Approved: "Approved",
|
||||
},
|
||||
pending_approvals_label: "Pending approvals",
|
||||
required_values_label: "Missing required values",
|
||||
empty: "No execution mappings are available yet.",
|
||||
required_blockers_notice: "{count} required values still need attention before preview or launch can proceed.",
|
||||
approval_notice: "{count} mapping transformations still need explicit approval.",
|
||||
to_variable_label: "To variable",
|
||||
required_badge: "Required",
|
||||
approval_required_badge: "Approval required",
|
||||
approved_badge: "Approved",
|
||||
source_filter_label: "Source filter",
|
||||
source_value_label: "Source value",
|
||||
confidence_label: "Confidence",
|
||||
recovery_label: "Recovery",
|
||||
effective_value_label: "Effective value",
|
||||
method_label: "Method",
|
||||
approval_label: "Approval",
|
||||
warning_label: "Warning",
|
||||
transformation_note_label: "Transformation note",
|
||||
missing_required_value: "This mapping still lacks a required effective value.",
|
||||
manual_value_label: "Manual effective value",
|
||||
manual_note_label: "Manual override note",
|
||||
save_override_action: "Save manual override",
|
||||
manual_override_action: "Manual override",
|
||||
approve_action: "Approve mapping",
|
||||
approve_all_action: "Approve all pending mappings",
|
||||
approval_state: {
|
||||
pending: "Pending",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
not_required: "Not required",
|
||||
},
|
||||
warning_level: {
|
||||
none: "None",
|
||||
low: "Low",
|
||||
medium: "Medium",
|
||||
high: "High",
|
||||
},
|
||||
method: {
|
||||
direct_match: "Direct match",
|
||||
heuristic_match: "Heuristic match",
|
||||
semantic_match: "Semantic match",
|
||||
manual_override: "Manual override",
|
||||
},
|
||||
messages: {
|
||||
saving: "Saving mapping decision...",
|
||||
approval_saved: "Mapping approval recorded.",
|
||||
approval_failed: "Failed to save mapping approval.",
|
||||
override_saved: "Manual mapping override saved. Preview should be refreshed.",
|
||||
override_failed: "Failed to save manual mapping override.",
|
||||
required_value_missing: "Provide a required value before saving the manual override.",
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
eyebrow: "Compiled SQL preview",
|
||||
title: "Review Superset-compiled SQL",
|
||||
description: "Preview truth comes only from Superset. Regenerate the preview whenever mappings or required inputs change.",
|
||||
state_label: "Preview state",
|
||||
state: {
|
||||
missing: "Missing",
|
||||
pending: "Pending",
|
||||
ready: "Ready",
|
||||
stale: "Stale",
|
||||
failed: "Error",
|
||||
},
|
||||
generate_action: "Generate SQL preview",
|
||||
generate_loading: "Generating preview...",
|
||||
missing_body: "No Superset preview is available yet. Generate one before attempting launch.",
|
||||
pending_body: "Superset is compiling the current execution context. Launch stays blocked until preview completes.",
|
||||
ready_body: "This SQL preview was compiled by Superset for the current execution inputs.",
|
||||
stale_body: "Mappings or effective values changed after the last successful preview. Regenerate before launch.",
|
||||
error_body: "Superset could not compile the current execution context.",
|
||||
compiler_label: "Compiled source",
|
||||
compiled_by_superset: "Compiled by Superset",
|
||||
compiled_source_unknown: "Compilation source unavailable",
|
||||
fingerprint_label: "Preview fingerprint",
|
||||
compiled_at_label: "Compiled at",
|
||||
sql_block_title: "Superset SQL",
|
||||
compiled_truth_note: "Exact SQL returned by Superset",
|
||||
go_to_mapping_action: "Review mapping inputs",
|
||||
review_inputs_action: "Review changed inputs",
|
||||
messages: {
|
||||
generated: "Superset preview refreshed.",
|
||||
generate_failed: "Failed to generate Superset preview.",
|
||||
},
|
||||
},
|
||||
launch: {
|
||||
eyebrow: "Launch confirmation",
|
||||
title: "Confirm SQL Lab launch",
|
||||
description: "Launch remains blocked until preview truth, approvals, and readiness gates all match the reviewed execution context.",
|
||||
state_label: "Launch state",
|
||||
state: {
|
||||
Blocked: "Blocked",
|
||||
Ready: "Ready",
|
||||
Submitted: "Submitted",
|
||||
},
|
||||
blocked_title: "Launch blockers",
|
||||
blocked_body: "Resolve the items below before sending this dataset run to SQL Lab.",
|
||||
resolve_action: "Open related area",
|
||||
dataset_ref_label: "Dataset reference",
|
||||
readiness_label: "Readiness",
|
||||
approved_mappings_label: "Approved mappings",
|
||||
preview_fingerprint_label: "Preview fingerprint",
|
||||
sql_lab_target_title: "Launch target",
|
||||
sql_lab_target_body: "The canonical launch target is a Superset SQL Lab session using the reviewed preview and effective execution inputs.",
|
||||
preview_status_label: "Preview status",
|
||||
compiled_by_label: "Compiled by",
|
||||
launch_action: "Launch dataset",
|
||||
launch_loading: "Launching dataset...",
|
||||
submitted_title: "Launch submitted",
|
||||
submitted_body: "SQL Lab handoff and audited run context were recorded for this launch request.",
|
||||
run_context_label: "Run context",
|
||||
sql_lab_session_label: "SQL Lab session",
|
||||
launch_status_label: "Launch status",
|
||||
preview_ref_label: "Preview reference",
|
||||
blockers: {
|
||||
blocking_finding: "Blocking findings remain unresolved",
|
||||
mapping_approval_required: "Mapping approval is still required",
|
||||
preview_missing: "Superset preview is required before launch",
|
||||
preview_pending: "Preview generation is still in progress",
|
||||
preview_stale: "Preview no longer matches the current execution inputs",
|
||||
preview_failed: "Preview failed and launch remains blocked",
|
||||
readiness_not_run_ready: "Session is not yet in run-ready state",
|
||||
preview_fingerprint_missing: "Preview fingerprint is missing, so launch cannot be trusted",
|
||||
},
|
||||
messages: {
|
||||
launch_started: "Dataset launch request sent to SQL Lab.",
|
||||
launch_failed: "Failed to launch dataset in SQL Lab.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Dataset review US3 execution UX", () => {
|
||||
beforeEach(() => {
|
||||
requestApi.mockReset();
|
||||
});
|
||||
|
||||
it("mapping_review_approves_warning_sensitive_row", async () => {
|
||||
requestApi.mockResolvedValueOnce({
|
||||
mapping_id: "map-1",
|
||||
filter_id: "filter-1",
|
||||
variable_id: "var-1",
|
||||
mapping_method: "direct_match",
|
||||
raw_input_value: "DE",
|
||||
effective_value: "DE",
|
||||
transformation_note: "Trimmed imported value",
|
||||
warning_level: "medium",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "approved",
|
||||
});
|
||||
|
||||
const onupdated = vi.fn();
|
||||
|
||||
render(ExecutionMappingReview, {
|
||||
sessionId: "session-1",
|
||||
mappings: [
|
||||
{
|
||||
mapping_id: "map-1",
|
||||
filter_id: "filter-1",
|
||||
variable_id: "var-1",
|
||||
mapping_method: "direct_match",
|
||||
raw_input_value: "DE",
|
||||
effective_value: "DE",
|
||||
transformation_note: "Trimmed imported value",
|
||||
warning_level: "medium",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "pending",
|
||||
},
|
||||
],
|
||||
importedFilters: [
|
||||
{
|
||||
filter_id: "filter-1",
|
||||
filter_name: "country",
|
||||
display_name: "Country",
|
||||
raw_value: "DE",
|
||||
normalized_value: "DE",
|
||||
confidence_state: "imported",
|
||||
recovery_status: "recovered",
|
||||
},
|
||||
],
|
||||
templateVariables: [
|
||||
{
|
||||
variable_id: "var-1",
|
||||
variable_name: "country",
|
||||
is_required: true,
|
||||
default_value: null,
|
||||
},
|
||||
],
|
||||
onupdated,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Review filter-to-template mappings")).toBeDefined();
|
||||
expect(screen.getByText("Approval required")).toBeDefined();
|
||||
expect(screen.getByText("Country")).toBeDefined();
|
||||
expect(screen.getByText(/Trimmed imported value/)).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Approve mapping" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/mappings/map-1/approve",
|
||||
"POST",
|
||||
{ approval_note: "Trimmed imported value" },
|
||||
);
|
||||
});
|
||||
|
||||
expect(onupdated).toHaveBeenCalledWith({
|
||||
mapping: expect.objectContaining({
|
||||
mapping_id: "map-1",
|
||||
approval_state: "approved",
|
||||
}),
|
||||
preview_state: "unchanged",
|
||||
});
|
||||
expect(screen.getByText("Mapping approval recorded.")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mapping_review_batch_approves_pending_rows", async () => {
|
||||
requestApi.mockResolvedValueOnce([
|
||||
{
|
||||
mapping_id: "map-1",
|
||||
filter_id: "filter-1",
|
||||
variable_id: "var-1",
|
||||
mapping_method: "direct_match",
|
||||
raw_input_value: "DE",
|
||||
effective_value: "DE",
|
||||
transformation_note: "Trimmed imported value",
|
||||
warning_level: "medium",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "approved",
|
||||
},
|
||||
]);
|
||||
|
||||
const onupdated = vi.fn();
|
||||
|
||||
render(ExecutionMappingReview, {
|
||||
sessionId: "session-1",
|
||||
mappings: [
|
||||
{
|
||||
mapping_id: "map-1",
|
||||
filter_id: "filter-1",
|
||||
variable_id: "var-1",
|
||||
mapping_method: "direct_match",
|
||||
raw_input_value: "DE",
|
||||
effective_value: "DE",
|
||||
transformation_note: "Trimmed imported value",
|
||||
warning_level: "medium",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "pending",
|
||||
},
|
||||
],
|
||||
importedFilters: [
|
||||
{
|
||||
filter_id: "filter-1",
|
||||
filter_name: "country",
|
||||
display_name: "Country",
|
||||
raw_value: "DE",
|
||||
normalized_value: "DE",
|
||||
confidence_state: "imported",
|
||||
recovery_status: "recovered",
|
||||
},
|
||||
],
|
||||
templateVariables: [
|
||||
{
|
||||
variable_id: "var-1",
|
||||
variable_name: "country",
|
||||
is_required: true,
|
||||
default_value: null,
|
||||
},
|
||||
],
|
||||
onupdated,
|
||||
});
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Approve all pending mappings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/mappings/approve-batch",
|
||||
"POST",
|
||||
{ mapping_ids: ["map-1"] },
|
||||
);
|
||||
});
|
||||
|
||||
expect(onupdated).toHaveBeenCalledWith({
|
||||
mappings: expect.any(Array),
|
||||
preview_state: "unchanged",
|
||||
});
|
||||
});
|
||||
|
||||
it("preview_panel_requests_superset_compilation_and_renders_sql", async () => {
|
||||
requestApi.mockResolvedValueOnce({
|
||||
preview_id: "preview-1",
|
||||
session_id: "session-1",
|
||||
preview_status: "ready",
|
||||
compiled_sql: "SELECT * FROM sales WHERE country = 'DE'",
|
||||
preview_fingerprint: "fingerprint-1",
|
||||
compiled_by: "superset",
|
||||
compiled_at: "2026-03-17T09:00:00Z",
|
||||
});
|
||||
|
||||
const onupdated = vi.fn();
|
||||
|
||||
render(CompiledSQLPreview, {
|
||||
sessionId: "session-1",
|
||||
preview: null,
|
||||
previewState: "missing",
|
||||
onupdated,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Review Superset-compiled SQL")).toBeDefined();
|
||||
expect(screen.getByText("No Superset preview is available yet. Generate one before attempting launch.")).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Generate SQL preview" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/preview",
|
||||
"POST",
|
||||
);
|
||||
});
|
||||
|
||||
expect(onupdated).toHaveBeenCalledWith({
|
||||
preview: expect.objectContaining({
|
||||
preview_id: "preview-1",
|
||||
compiled_by: "superset",
|
||||
}),
|
||||
preview_state: "ready",
|
||||
});
|
||||
|
||||
requestApi.mockRejectedValueOnce(new Error("Superset compile failed"));
|
||||
|
||||
const onjump = vi.fn();
|
||||
render(CompiledSQLPreview, {
|
||||
sessionId: "session-1",
|
||||
preview: {
|
||||
preview_id: "preview-err",
|
||||
preview_status: "failed",
|
||||
preview_fingerprint: "fingerprint-err",
|
||||
compiled_by: "superset",
|
||||
error_details: "Variable country is invalid",
|
||||
},
|
||||
previewState: "failed",
|
||||
onjump,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Variable country is invalid")).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Review mapping inputs" }));
|
||||
expect(onjump).toHaveBeenCalledWith({ target: "mapping" });
|
||||
});
|
||||
|
||||
it("launch_panel_blocks_then_submits_sql_lab_launch", async () => {
|
||||
const onjump = vi.fn();
|
||||
const { rerender } = render(LaunchConfirmationPanel, {
|
||||
sessionId: "session-1",
|
||||
session: {
|
||||
dataset_ref: "public.sales",
|
||||
readiness_state: "mapping_review_needed",
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
severity: "blocking",
|
||||
resolution_state: "open",
|
||||
title: "Missing required value",
|
||||
message: "country is required",
|
||||
code: "REQ_COUNTRY",
|
||||
},
|
||||
],
|
||||
mappings: [
|
||||
{
|
||||
mapping_id: "map-1",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "pending",
|
||||
transformation_note: "Europe -> EU",
|
||||
},
|
||||
],
|
||||
preview: null,
|
||||
previewState: "missing",
|
||||
onjump,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Launch blockers")).toBeDefined();
|
||||
expect(screen.getByText("Blocking findings remain unresolved")).toBeDefined();
|
||||
expect(screen.getByText("Mapping approval is still required")).toBeDefined();
|
||||
expect(screen.getByText("Superset preview is required before launch")).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getAllByRole("button", { name: "Open related area" })[1]);
|
||||
expect(onjump).toHaveBeenCalledWith({ target: "mapping" });
|
||||
|
||||
requestApi.mockResolvedValueOnce({
|
||||
run_context: {
|
||||
run_context_id: "run-1",
|
||||
sql_lab_session_ref: "sql-lab-77",
|
||||
launch_status: "started",
|
||||
preview_id: "preview-1",
|
||||
},
|
||||
});
|
||||
|
||||
await rerender({
|
||||
sessionId: "session-1",
|
||||
session: {
|
||||
dataset_ref: "public.sales",
|
||||
readiness_state: "run_ready",
|
||||
},
|
||||
findings: [],
|
||||
mappings: [
|
||||
{
|
||||
mapping_id: "map-1",
|
||||
requires_explicit_approval: true,
|
||||
approval_state: "approved",
|
||||
transformation_note: "Europe -> EU",
|
||||
},
|
||||
],
|
||||
preview: {
|
||||
preview_id: "preview-1",
|
||||
preview_status: "ready",
|
||||
preview_fingerprint: "fingerprint-1",
|
||||
compiled_by: "superset",
|
||||
},
|
||||
previewState: "ready",
|
||||
onupdated: vi.fn(),
|
||||
onjump,
|
||||
});
|
||||
|
||||
expect(screen.getByText("Confirm SQL Lab launch")).toBeDefined();
|
||||
expect(screen.getByText("Launch state: Ready")).toBeDefined();
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Launch dataset" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestApi).toHaveBeenCalledWith(
|
||||
"/dataset-orchestration/sessions/session-1/launch",
|
||||
"POST",
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Dataset launch request sent to SQL Lab.")).toBeDefined();
|
||||
});
|
||||
});
|
||||
// [/DEF:DatasetReviewUs3UxTests:Module]
|
||||
@@ -664,6 +664,11 @@
|
||||
"export_failed": "Failed to export the requested review artifact.",
|
||||
"empty_state_title": "No session loaded",
|
||||
"state_label": "Workspace",
|
||||
"state": {
|
||||
"empty": "Empty",
|
||||
"importing": "Importing",
|
||||
"review": "Review"
|
||||
},
|
||||
"readiness_label": "Readiness",
|
||||
"source_badge_fallback": "review session",
|
||||
"save_session_action": "Save session",
|
||||
@@ -721,6 +726,8 @@
|
||||
"preview_compiler_label": "Compiled by",
|
||||
"preview_pending_note": "Compiled preview is not part of this US1 batch yet, but the workspace keeps the state visible when present.",
|
||||
"jump_target_label": "Focused area",
|
||||
"semantic_review_visible": "Semantic review is active for field-level source decisions.",
|
||||
"clarification_visible": "Clarification is available with one active question at a time.",
|
||||
"resume_action": "Resume session",
|
||||
"pause_action": "Pause session",
|
||||
"actions": {
|
||||
@@ -765,6 +772,245 @@
|
||||
"low_confidence": "Low confidence",
|
||||
"unresolved": "Unresolved"
|
||||
}
|
||||
},
|
||||
"semantics": {
|
||||
"eyebrow": "Semantic layer",
|
||||
"title": "Review semantic field values",
|
||||
"description": "Compare the active semantic value with ranked candidates, preserve provenance, and lock or unlock manual decisions explicitly.",
|
||||
"field_count_label": "Fields in review",
|
||||
"empty": "No semantic fields need review right now.",
|
||||
"unknown_source": "Unknown source",
|
||||
"active_value_label": "Active semantic value",
|
||||
"provenance_label": "Provenance",
|
||||
"confidence_label": "Confidence",
|
||||
"confidence_unset": "No confidence rank",
|
||||
"confidence_rank_label": "Rank",
|
||||
"source_label": "Source",
|
||||
"changed_by_label": "Changed by",
|
||||
"locked_badge": "Locked",
|
||||
"unlocked_badge": "Unlocked",
|
||||
"conflict_badge": "Conflict",
|
||||
"needs_review_badge": "Needs review",
|
||||
"manual_verbose_name_label": "Manual verbose name",
|
||||
"manual_description_label": "Manual description",
|
||||
"manual_display_format_label": "Manual display format",
|
||||
"manual_override_action": "Manual override",
|
||||
"approve_all_action": "Approve visible candidates",
|
||||
"save_manual_action": "Save manual value",
|
||||
"lock_action": "Lock field",
|
||||
"unlock_action": "Unlock field",
|
||||
"feedback_up_action": "👍",
|
||||
"feedback_down_action": "👎",
|
||||
"candidates_title": "Candidate options",
|
||||
"candidates_empty": "No ranked candidate options were returned for this field.",
|
||||
"candidate_description_empty": "No description proposed for this candidate.",
|
||||
"display_format_label": "Display format",
|
||||
"score_label": "Score",
|
||||
"apply_candidate_action": "Apply",
|
||||
"apply_and_lock_action": "Apply & lock",
|
||||
"empty_value": "No current value",
|
||||
"provenance": {
|
||||
"unresolved": "Unresolved",
|
||||
"dictionary_exact": "Dictionary exact match",
|
||||
"reference_imported": "Imported reference",
|
||||
"ai_generated": "AI generated",
|
||||
"fuzzy_inferred": "Fuzzy inferred",
|
||||
"manual_override": "Manual override"
|
||||
},
|
||||
"candidate_status": {
|
||||
"pending": "Pending",
|
||||
"accepted": "Accepted",
|
||||
"superseded": "Superseded",
|
||||
"rejected": "Rejected"
|
||||
},
|
||||
"messages": {
|
||||
"saving": "Saving semantic decision...",
|
||||
"save_failed": "Failed to save semantic decision.",
|
||||
"manual_override_required": "Provide at least one manual semantic value before saving.",
|
||||
"manual_saved": "Manual semantic value saved and locked.",
|
||||
"candidate_applied": "Candidate value applied.",
|
||||
"candidate_locked": "Candidate value applied and locked.",
|
||||
"locked": "Field locked against silent overwrite.",
|
||||
"unlocked": "Field unlocked for renewed review.",
|
||||
"feedback_up": "Positive feedback recorded for this semantic value.",
|
||||
"feedback_down": "Negative feedback recorded for this semantic value."
|
||||
}
|
||||
},
|
||||
"clarification": {
|
||||
"eyebrow": "Clarification",
|
||||
"title": "Resolve one ambiguity at a time",
|
||||
"description": "Answer the active clarification question, skip it, or send it to expert review while progress stays resumable.",
|
||||
"progress_label": "Clarification progress",
|
||||
"state_label": "Dialog state",
|
||||
"status_label": "Status",
|
||||
"remaining_label": "Remaining questions",
|
||||
"summary_delta_label": "Summary delta",
|
||||
"active_question_label": "Active question",
|
||||
"why_it_matters_label": "Why it matters",
|
||||
"current_guess_label": "Current guess",
|
||||
"current_guess_empty": "No current guess is available.",
|
||||
"topic_label": "Topic reference",
|
||||
"options_title": "Available options",
|
||||
"recommended_badge": "Recommended",
|
||||
"custom_answer_label": "Custom answer",
|
||||
"answer_action": "Answer with selected option",
|
||||
"custom_answer_action": "Submit custom answer",
|
||||
"skip_action": "Skip for now",
|
||||
"assistant_action": "Open assistant guidance",
|
||||
"expert_review_action": "Send to expert review",
|
||||
"resume_action": "Resume clarification",
|
||||
"completed": "No active clarification question remains. The session can be resumed later if new ambiguity appears.",
|
||||
"messages": {
|
||||
"saving": "Saving clarification answer...",
|
||||
"saved": "Clarification answer saved.",
|
||||
"skipped": "Question skipped and left resumable.",
|
||||
"expert_review_requested": "Question escalated to expert review.",
|
||||
"assistant_opened": "Assistant guidance opened for this clarification step.",
|
||||
"resumed": "Clarification resumed from the highest-priority unresolved question.",
|
||||
"resume_failed": "Failed to resume clarification.",
|
||||
"save_failed": "Failed to save clarification answer.",
|
||||
"option_required": "Select an option before submitting the answer.",
|
||||
"custom_required": "Enter a custom answer before submitting."
|
||||
}
|
||||
},
|
||||
"mapping": {
|
||||
"eyebrow": "Template mapping",
|
||||
"title": "Review filter-to-template mappings",
|
||||
"description": "Verify imported filter values, effective execution values, and warning-sensitive transformations before preview or launch.",
|
||||
"state_label": "Mapping state",
|
||||
"state": {
|
||||
"Incomplete": "Incomplete",
|
||||
"WarningApproval": "Approval required",
|
||||
"Approved": "Approved"
|
||||
},
|
||||
"pending_approvals_label": "Pending approvals",
|
||||
"required_values_label": "Missing required values",
|
||||
"empty": "No execution mappings are available yet.",
|
||||
"required_blockers_notice": "{count} required values still need attention before preview or launch can proceed.",
|
||||
"approval_notice": "{count} mapping transformations still need explicit approval.",
|
||||
"to_variable_label": "To variable",
|
||||
"required_badge": "Required",
|
||||
"approval_required_badge": "Approval required",
|
||||
"approved_badge": "Approved",
|
||||
"source_filter_label": "Source filter",
|
||||
"source_value_label": "Source value",
|
||||
"confidence_label": "Confidence",
|
||||
"recovery_label": "Recovery",
|
||||
"effective_value_label": "Effective value",
|
||||
"method_label": "Method",
|
||||
"approval_label": "Approval",
|
||||
"warning_label": "Warning",
|
||||
"transformation_note_label": "Transformation note",
|
||||
"missing_required_value": "This mapping still lacks a required effective value.",
|
||||
"manual_value_label": "Manual effective value",
|
||||
"manual_note_label": "Manual override note",
|
||||
"save_override_action": "Save manual override",
|
||||
"manual_override_action": "Manual override",
|
||||
"approve_action": "Approve mapping",
|
||||
"approve_all_action": "Approve all pending mappings",
|
||||
"approval_state": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"not_required": "Not required"
|
||||
},
|
||||
"warning_level": {
|
||||
"none": "None",
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High"
|
||||
},
|
||||
"method": {
|
||||
"direct_match": "Direct match",
|
||||
"heuristic_match": "Heuristic match",
|
||||
"semantic_match": "Semantic match",
|
||||
"manual_override": "Manual override"
|
||||
},
|
||||
"messages": {
|
||||
"saving": "Saving mapping decision...",
|
||||
"approval_saved": "Mapping approval recorded.",
|
||||
"approval_failed": "Failed to save mapping approval.",
|
||||
"override_saved": "Manual mapping override saved. Preview should be refreshed.",
|
||||
"override_failed": "Failed to save manual mapping override.",
|
||||
"required_value_missing": "Provide a required value before saving the manual override."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"eyebrow": "Compiled SQL preview",
|
||||
"title": "Review Superset-compiled SQL",
|
||||
"description": "Preview truth comes only from Superset. Regenerate the preview whenever mappings or required inputs change.",
|
||||
"state_label": "Preview state",
|
||||
"state": {
|
||||
"missing": "Missing",
|
||||
"pending": "Pending",
|
||||
"ready": "Ready",
|
||||
"stale": "Stale",
|
||||
"failed": "Error"
|
||||
},
|
||||
"generate_action": "Generate SQL preview",
|
||||
"generate_loading": "Generating preview...",
|
||||
"missing_body": "No Superset preview is available yet. Generate one before attempting launch.",
|
||||
"pending_body": "Superset is compiling the current execution context. Launch stays blocked until preview completes.",
|
||||
"ready_body": "This SQL preview was compiled by Superset for the current execution inputs.",
|
||||
"stale_body": "Mappings or effective values changed after the last successful preview. Regenerate before launch.",
|
||||
"error_body": "Superset could not compile the current execution context.",
|
||||
"compiler_label": "Compiled source",
|
||||
"compiled_by_superset": "Compiled by Superset",
|
||||
"compiled_source_unknown": "Compilation source unavailable",
|
||||
"fingerprint_label": "Preview fingerprint",
|
||||
"compiled_at_label": "Compiled at",
|
||||
"sql_block_title": "Superset SQL",
|
||||
"compiled_truth_note": "Exact SQL returned by Superset",
|
||||
"go_to_mapping_action": "Review mapping inputs",
|
||||
"review_inputs_action": "Review changed inputs",
|
||||
"messages": {
|
||||
"generated": "Superset preview refreshed.",
|
||||
"generate_failed": "Failed to generate Superset preview."
|
||||
}
|
||||
},
|
||||
"launch": {
|
||||
"eyebrow": "Launch confirmation",
|
||||
"title": "Confirm SQL Lab launch",
|
||||
"description": "Launch remains blocked until preview truth, approvals, and readiness gates all match the reviewed execution context.",
|
||||
"state_label": "Launch state",
|
||||
"state": {
|
||||
"Blocked": "Blocked",
|
||||
"Ready": "Ready",
|
||||
"Submitted": "Submitted"
|
||||
},
|
||||
"blocked_title": "Launch blockers",
|
||||
"blocked_body": "Resolve the items below before sending this dataset run to SQL Lab.",
|
||||
"resolve_action": "Open related area",
|
||||
"dataset_ref_label": "Dataset reference",
|
||||
"readiness_label": "Readiness",
|
||||
"approved_mappings_label": "Approved mappings",
|
||||
"preview_fingerprint_label": "Preview fingerprint",
|
||||
"sql_lab_target_title": "Launch target",
|
||||
"sql_lab_target_body": "The canonical launch target is a Superset SQL Lab session using the reviewed preview and effective execution inputs.",
|
||||
"preview_status_label": "Preview status",
|
||||
"compiled_by_label": "Compiled by",
|
||||
"launch_action": "Launch dataset",
|
||||
"launch_loading": "Launching dataset...",
|
||||
"submitted_title": "Launch submitted",
|
||||
"submitted_body": "SQL Lab handoff and audited run context were recorded for this launch request.",
|
||||
"run_context_label": "Run context",
|
||||
"sql_lab_session_label": "SQL Lab session",
|
||||
"launch_status_label": "Launch status",
|
||||
"preview_ref_label": "Preview reference",
|
||||
"blockers": {
|
||||
"blocking_finding": "Blocking findings remain unresolved",
|
||||
"mapping_approval_required": "Mapping approval is still required",
|
||||
"preview_missing": "Superset preview is required before launch",
|
||||
"preview_pending": "Preview generation is still in progress",
|
||||
"preview_stale": "Preview no longer matches the current execution inputs",
|
||||
"preview_failed": "Preview failed and launch remains blocked",
|
||||
"readiness_not_run_ready": "Session is not yet in run-ready state",
|
||||
"preview_fingerprint_missing": "Preview fingerprint is missing, so launch cannot be trusted"
|
||||
},
|
||||
"messages": {
|
||||
"launch_started": "Dataset launch request sent to SQL Lab.",
|
||||
"launch_failed": "Failed to launch dataset in SQL Lab."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
|
||||
@@ -662,6 +662,11 @@
|
||||
"export_failed": "Не удалось экспортировать выбранный артефакт review.",
|
||||
"empty_state_title": "Сессия не загружена",
|
||||
"state_label": "Workspace",
|
||||
"state": {
|
||||
"empty": "Пусто",
|
||||
"importing": "Импорт",
|
||||
"review": "Проверка"
|
||||
},
|
||||
"readiness_label": "Готовность",
|
||||
"source_badge_fallback": "review-сессия",
|
||||
"save_session_action": "Сохранить сессию",
|
||||
@@ -719,6 +724,8 @@
|
||||
"preview_compiler_label": "Скомпилировано",
|
||||
"preview_pending_note": "Скомпилированный preview не входит в этот пакет US1, но workspace сохраняет его видимым, если он уже существует.",
|
||||
"jump_target_label": "Выбранная зона",
|
||||
"semantic_review_visible": "Семантический review активен для полей с решениями по источникам.",
|
||||
"clarification_visible": "Уточнение доступно в режиме одного активного вопроса.",
|
||||
"resume_action": "Возобновить сессию",
|
||||
"pause_action": "Поставить сессию на паузу",
|
||||
"actions": {
|
||||
@@ -763,6 +770,245 @@
|
||||
"low_confidence": "Низкая уверенность",
|
||||
"unresolved": "Не разрешено"
|
||||
}
|
||||
},
|
||||
"semantics": {
|
||||
"eyebrow": "Семантический слой",
|
||||
"title": "Проверьте семантические значения полей",
|
||||
"description": "Сравнивайте активное семантическое значение с ранжированными кандидатами, сохраняйте provenance и явно блокируйте или разблокируйте ручные решения.",
|
||||
"field_count_label": "Поля в review",
|
||||
"empty": "Семантические поля сейчас не требуют review.",
|
||||
"unknown_source": "Неизвестный источник",
|
||||
"active_value_label": "Активное семантическое значение",
|
||||
"provenance_label": "Provenance",
|
||||
"confidence_label": "Уверенность",
|
||||
"confidence_unset": "Ранг уверенности отсутствует",
|
||||
"confidence_rank_label": "Ранг",
|
||||
"source_label": "Источник",
|
||||
"changed_by_label": "Изменил",
|
||||
"locked_badge": "Заблокировано",
|
||||
"unlocked_badge": "Разблокировано",
|
||||
"conflict_badge": "Конфликт",
|
||||
"needs_review_badge": "Нужен review",
|
||||
"manual_verbose_name_label": "Ручное verbose name",
|
||||
"manual_description_label": "Ручное описание",
|
||||
"manual_display_format_label": "Ручной display format",
|
||||
"manual_override_action": "Ручное переопределение",
|
||||
"approve_all_action": "Подтвердить видимые кандидаты",
|
||||
"save_manual_action": "Сохранить ручное значение",
|
||||
"lock_action": "Заблокировать поле",
|
||||
"unlock_action": "Разблокировать поле",
|
||||
"feedback_up_action": "👍",
|
||||
"feedback_down_action": "👎",
|
||||
"candidates_title": "Варианты кандидатов",
|
||||
"candidates_empty": "Для этого поля не возвращены ранжированные кандидаты.",
|
||||
"candidate_description_empty": "Для этого кандидата описание не предложено.",
|
||||
"display_format_label": "Формат отображения",
|
||||
"score_label": "Оценка",
|
||||
"apply_candidate_action": "Применить",
|
||||
"apply_and_lock_action": "Применить и заблокировать",
|
||||
"empty_value": "Текущее значение отсутствует",
|
||||
"provenance": {
|
||||
"unresolved": "Не разрешено",
|
||||
"dictionary_exact": "Точное совпадение словаря",
|
||||
"reference_imported": "Импортированная ссылка",
|
||||
"ai_generated": "Сгенерировано AI",
|
||||
"fuzzy_inferred": "Нечеткий вывод",
|
||||
"manual_override": "Ручное переопределение"
|
||||
},
|
||||
"candidate_status": {
|
||||
"pending": "В ожидании",
|
||||
"accepted": "Принято",
|
||||
"superseded": "Заменено",
|
||||
"rejected": "Отклонено"
|
||||
},
|
||||
"messages": {
|
||||
"saving": "Сохранение семантического решения...",
|
||||
"save_failed": "Не удалось сохранить семантическое решение.",
|
||||
"manual_override_required": "Перед сохранением укажите хотя бы одно ручное семантическое значение.",
|
||||
"manual_saved": "Ручное семантическое значение сохранено и заблокировано.",
|
||||
"candidate_applied": "Значение кандидата применено.",
|
||||
"candidate_locked": "Значение кандидата применено и заблокировано.",
|
||||
"locked": "Поле заблокировано от тихой перезаписи.",
|
||||
"unlocked": "Поле разблокировано для повторного review.",
|
||||
"feedback_up": "Позитивный feedback для этого семантического значения сохранен.",
|
||||
"feedback_down": "Негативный feedback для этого семантического значения сохранен."
|
||||
}
|
||||
},
|
||||
"clarification": {
|
||||
"eyebrow": "Уточнение",
|
||||
"title": "Разрешайте неоднозначности по одному вопросу",
|
||||
"description": "Ответьте на активный вопрос уточнения, пропустите его или отправьте на экспертный review, сохраняя возможность возобновления.",
|
||||
"progress_label": "Прогресс уточнения",
|
||||
"state_label": "Состояние диалога",
|
||||
"status_label": "Статус",
|
||||
"remaining_label": "Оставшиеся вопросы",
|
||||
"summary_delta_label": "Изменение summary",
|
||||
"active_question_label": "Активный вопрос",
|
||||
"why_it_matters_label": "Почему это важно",
|
||||
"current_guess_label": "Текущая гипотеза",
|
||||
"current_guess_empty": "Текущая гипотеза отсутствует.",
|
||||
"topic_label": "Ссылка на тему",
|
||||
"options_title": "Доступные варианты",
|
||||
"recommended_badge": "Рекомендовано",
|
||||
"custom_answer_label": "Свой ответ",
|
||||
"answer_action": "Ответить выбранным вариантом",
|
||||
"custom_answer_action": "Отправить свой ответ",
|
||||
"skip_action": "Пропустить пока",
|
||||
"assistant_action": "Открыть помощь ассистента",
|
||||
"expert_review_action": "Передать на экспертный review",
|
||||
"resume_action": "Возобновить уточнение",
|
||||
"completed": "Активных вопросов уточнения не осталось. Сессию можно возобновить позже, если появится новая неоднозначность.",
|
||||
"messages": {
|
||||
"saving": "Сохранение ответа на уточнение...",
|
||||
"saved": "Ответ на уточнение сохранен.",
|
||||
"skipped": "Вопрос пропущен и сохранен как возобновляемый.",
|
||||
"expert_review_requested": "Вопрос отправлен на экспертный review.",
|
||||
"assistant_opened": "Для этого шага уточнения открыта помощь ассистента.",
|
||||
"resumed": "Уточнение возобновлено с самого приоритетного неразрешенного вопроса.",
|
||||
"resume_failed": "Не удалось возобновить уточнение.",
|
||||
"save_failed": "Не удалось сохранить ответ на уточнение.",
|
||||
"option_required": "Перед отправкой выберите один вариант.",
|
||||
"custom_required": "Перед отправкой введите свой ответ."
|
||||
}
|
||||
},
|
||||
"mapping": {
|
||||
"eyebrow": "Маппинг шаблонов",
|
||||
"title": "Проверьте маппинг фильтров и шаблонов",
|
||||
"description": "Проверьте импортированные значения фильтров, итоговые execution values и warning-преобразования перед preview или запуском.",
|
||||
"state_label": "Состояние маппинга",
|
||||
"state": {
|
||||
"Incomplete": "Неполный",
|
||||
"WarningApproval": "Требуется подтверждение",
|
||||
"Approved": "Подтверждено"
|
||||
},
|
||||
"pending_approvals_label": "Ожидают подтверждения",
|
||||
"required_values_label": "Отсутствуют обязательные значения",
|
||||
"empty": "Execution mappings пока отсутствуют.",
|
||||
"required_blockers_notice": "Еще {count} обязательных значений требуют внимания перед preview или запуском.",
|
||||
"approval_notice": "Еще {count} преобразований маппинга требуют явного подтверждения.",
|
||||
"to_variable_label": "В переменную",
|
||||
"required_badge": "Обязательно",
|
||||
"approval_required_badge": "Нужно подтверждение",
|
||||
"approved_badge": "Подтверждено",
|
||||
"source_filter_label": "Исходный фильтр",
|
||||
"source_value_label": "Исходное значение",
|
||||
"confidence_label": "Уверенность",
|
||||
"recovery_label": "Восстановление",
|
||||
"effective_value_label": "Итоговое значение",
|
||||
"method_label": "Метод",
|
||||
"approval_label": "Подтверждение",
|
||||
"warning_label": "Предупреждение",
|
||||
"transformation_note_label": "Комментарий к преобразованию",
|
||||
"missing_required_value": "Для этого маппинга все еще отсутствует обязательное итоговое значение.",
|
||||
"manual_value_label": "Ручное итоговое значение",
|
||||
"manual_note_label": "Комментарий к ручному переопределению",
|
||||
"save_override_action": "Сохранить ручное переопределение",
|
||||
"manual_override_action": "Ручное переопределение",
|
||||
"approve_action": "Подтвердить маппинг",
|
||||
"approve_all_action": "Подтвердить все ожидающие маппинги",
|
||||
"approval_state": {
|
||||
"pending": "Ожидает",
|
||||
"approved": "Подтверждено",
|
||||
"rejected": "Отклонено",
|
||||
"not_required": "Не требуется"
|
||||
},
|
||||
"warning_level": {
|
||||
"none": "Нет",
|
||||
"low": "Низкий",
|
||||
"medium": "Средний",
|
||||
"high": "Высокий"
|
||||
},
|
||||
"method": {
|
||||
"direct_match": "Прямое совпадение",
|
||||
"heuristic_match": "Эвристическое совпадение",
|
||||
"semantic_match": "Семантическое совпадение",
|
||||
"manual_override": "Ручное переопределение"
|
||||
},
|
||||
"messages": {
|
||||
"saving": "Сохранение решения по маппингу...",
|
||||
"approval_saved": "Подтверждение маппинга сохранено.",
|
||||
"approval_failed": "Не удалось сохранить подтверждение маппинга.",
|
||||
"override_saved": "Ручное переопределение маппинга сохранено. Preview нужно обновить.",
|
||||
"override_failed": "Не удалось сохранить ручное переопределение маппинга.",
|
||||
"required_value_missing": "Перед сохранением ручного переопределения укажите обязательное значение."
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"eyebrow": "Скомпилированный SQL preview",
|
||||
"title": "Проверьте SQL, скомпилированный Superset",
|
||||
"description": "Истина preview приходит только от Superset. Перегенерируйте preview после изменений маппинга или обязательных входных значений.",
|
||||
"state_label": "Состояние preview",
|
||||
"state": {
|
||||
"missing": "Отсутствует",
|
||||
"pending": "Генерируется",
|
||||
"ready": "Готов",
|
||||
"stale": "Устарел",
|
||||
"failed": "Ошибка"
|
||||
},
|
||||
"generate_action": "Сгенерировать SQL preview",
|
||||
"generate_loading": "Генерация preview...",
|
||||
"missing_body": "Preview от Superset пока отсутствует. Сгенерируйте его перед запуском.",
|
||||
"pending_body": "Superset компилирует текущий execution context. Запуск остается заблокированным до завершения preview.",
|
||||
"ready_body": "Этот SQL preview был скомпилирован Superset для текущих execution inputs.",
|
||||
"stale_body": "После последнего успешного preview были изменены маппинги или итоговые значения. Перед запуском нужно перегенерировать preview.",
|
||||
"error_body": "Superset не смог скомпилировать текущий execution context.",
|
||||
"compiler_label": "Источник компиляции",
|
||||
"compiled_by_superset": "Скомпилировано Superset",
|
||||
"compiled_source_unknown": "Источник компиляции недоступен",
|
||||
"fingerprint_label": "Fingerprint preview",
|
||||
"compiled_at_label": "Время компиляции",
|
||||
"sql_block_title": "SQL от Superset",
|
||||
"compiled_truth_note": "Точный SQL, возвращенный Superset",
|
||||
"go_to_mapping_action": "Проверить mapping inputs",
|
||||
"review_inputs_action": "Проверить измененные inputs",
|
||||
"messages": {
|
||||
"generated": "Preview от Superset обновлен.",
|
||||
"generate_failed": "Не удалось сгенерировать preview от Superset."
|
||||
}
|
||||
},
|
||||
"launch": {
|
||||
"eyebrow": "Подтверждение запуска",
|
||||
"title": "Подтвердите запуск в SQL Lab",
|
||||
"description": "Запуск остается заблокированным, пока preview, approvals и readiness gates не соответствуют проверенному execution context.",
|
||||
"state_label": "Состояние запуска",
|
||||
"state": {
|
||||
"Blocked": "Заблокирован",
|
||||
"Ready": "Готов",
|
||||
"Submitted": "Отправлен"
|
||||
},
|
||||
"blocked_title": "Блокеры запуска",
|
||||
"blocked_body": "Разрешите следующие пункты перед отправкой запуска датасета в SQL Lab.",
|
||||
"resolve_action": "Открыть связанный раздел",
|
||||
"dataset_ref_label": "Ссылка на датасет",
|
||||
"readiness_label": "Готовность",
|
||||
"approved_mappings_label": "Подтвержденные маппинги",
|
||||
"preview_fingerprint_label": "Fingerprint preview",
|
||||
"sql_lab_target_title": "Цель запуска",
|
||||
"sql_lab_target_body": "Каноническая цель запуска — сессия Superset SQL Lab с использованием проверенного preview и итоговых execution inputs.",
|
||||
"preview_status_label": "Статус preview",
|
||||
"compiled_by_label": "Скомпилировано",
|
||||
"launch_action": "Запустить датасет",
|
||||
"launch_loading": "Запуск датасета...",
|
||||
"submitted_title": "Запуск отправлен",
|
||||
"submitted_body": "Передача в SQL Lab и аудитируемый run context были записаны для этого запроса запуска.",
|
||||
"run_context_label": "Run context",
|
||||
"sql_lab_session_label": "Сессия SQL Lab",
|
||||
"launch_status_label": "Статус запуска",
|
||||
"preview_ref_label": "Ссылка на preview",
|
||||
"blockers": {
|
||||
"blocking_finding": "Есть неразрешенные блокирующие findings",
|
||||
"mapping_approval_required": "Маппинг все еще требует подтверждения",
|
||||
"preview_missing": "Перед запуском требуется preview от Superset",
|
||||
"preview_pending": "Preview все еще генерируется",
|
||||
"preview_stale": "Preview больше не соответствует текущим execution inputs",
|
||||
"preview_failed": "Preview завершился ошибкой, запуск заблокирован",
|
||||
"readiness_not_run_ready": "Сессия еще не находится в состоянии run-ready",
|
||||
"preview_fingerprint_missing": "Fingerprint preview отсутствует, поэтому запуску нельзя доверять"
|
||||
},
|
||||
"messages": {
|
||||
"launch_started": "Запрос запуска датасета отправлен в SQL Lab.",
|
||||
"launch_failed": "Не удалось запустить датасет в SQL Lab."
|
||||
}
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
|
||||
Reference in New Issue
Block a user