mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 18:02:17 +03:00
feat(translator): lift useTranslateSession to shell + connect PipelineView to real result
- Move useTranslateSession() to TranslatorPageClient (shell level) for PipelineView to receive real steps - Build PipelineStep[] from session result (detected/intermediate/translated/response) - Pass session as prop down to TranslateTab; internal hook kept for isolated test compatibility - Dedup useProviderOptions: only TranslateTab calls the hook, props pass to SimpleControls - Remove unused data-advanced-section/data-input-text placeholder (DOM data leak GAP-5) - Add aria-expanded + aria-controls to AutoFeaturesCard toggle (a11y GAP-2) Addresses code review RISCO-4, GAP-2, GAP-3, GAP-5.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Badge, Card, SegmentedControl } from "@/shared/components";
|
||||
import TranslatorConceptCard from "./components/TranslatorConceptCard";
|
||||
@@ -9,10 +9,12 @@ import MonitorTab from "./components/MonitorTab";
|
||||
import AdvancedSection from "./components/advanced/AdvancedSection";
|
||||
import RawJsonPanel from "./components/advanced/RawJsonPanel";
|
||||
import PipelineView from "./components/advanced/PipelineView";
|
||||
import type { PipelineStep } from "./components/advanced/PipelineView";
|
||||
import StreamTransformerAccordion from "./components/advanced/StreamTransformerAccordion";
|
||||
import TestBenchAccordion from "./components/advanced/TestBenchAccordion";
|
||||
import CompressionPreviewAccordion from "./components/advanced/CompressionPreviewAccordion";
|
||||
import { useTranslateDeepLink } from "./hooks/useTranslateDeepLink";
|
||||
import { useTranslateSession } from "./hooks/useTranslateSession";
|
||||
import type { AdvancedSlug, TranslatorTab } from "./types";
|
||||
|
||||
export default function TranslatorPageClient() {
|
||||
@@ -28,6 +30,9 @@ function TranslatorPageClientInner() {
|
||||
const [sharedInputContent, setSharedInputContent] = useState("");
|
||||
const { state, setTab, setAdvanced } = useTranslateDeepLink();
|
||||
|
||||
// Lift session to shell so PipelineView can receive real steps
|
||||
const session = useTranslateSession();
|
||||
|
||||
const makeOpenHandler = (slug: AdvancedSlug) => (open: boolean) => {
|
||||
if (open) {
|
||||
setAdvanced(slug);
|
||||
@@ -36,6 +41,80 @@ function TranslatorPageClientInner() {
|
||||
}
|
||||
};
|
||||
|
||||
const tr = (key: string, fallback: string): string => {
|
||||
try {
|
||||
const v = t(key as Parameters<typeof t>[0]);
|
||||
if (v === key || v === `translator.${key}`) return fallback;
|
||||
return v as string;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
// Build PipelineStep[] from session.result so PipelineView reflects real state
|
||||
const pipelineSteps = useMemo<PipelineStep[]>(() => {
|
||||
const r = session.result;
|
||||
if (r.status === "idle") return [];
|
||||
|
||||
const steps: PipelineStep[] = [];
|
||||
|
||||
// Step 1 — Client Request (always present once started)
|
||||
steps.push({
|
||||
id: "1",
|
||||
name: tr("pipelineStepClientRequest", "Client Request"),
|
||||
description: tr("pipelineStepClientRequestDesc", "Request received in client format"),
|
||||
format: r.detected ?? "openai",
|
||||
content: sharedInputContent.slice(0, 500),
|
||||
status: r.status === "error" ? "error" : "done",
|
||||
});
|
||||
|
||||
// Step 2 — Format Detected
|
||||
steps.push({
|
||||
id: "2",
|
||||
name: tr("pipelineStepFormatDetected", "Format Detected"),
|
||||
description: tr("pipelineStepFormatDetectedDesc", "Auto-detected source format"),
|
||||
format: r.detected ?? null,
|
||||
content: r.detected ? JSON.stringify({ detectedFormat: r.detected, confidence: "high" }, null, 2) : "",
|
||||
status: r.detected ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
|
||||
// Step 3 — OpenAI Intermediate (only when hub-and-spoke)
|
||||
if (r.pipelinePath === "hub-and-spoke") {
|
||||
steps.push({
|
||||
id: "3",
|
||||
name: tr("pipelineStepOpenAIIntermediate", "OpenAI Intermediate"),
|
||||
description: tr("pipelineStepOpenAIIntermediateDesc", "Translated to OpenAI hub format"),
|
||||
format: "openai",
|
||||
content: r.intermediateJson ?? "",
|
||||
status: r.intermediateJson ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
// Step 4 — Provider Format (translated result)
|
||||
steps.push({
|
||||
id: r.pipelinePath === "hub-and-spoke" ? "4" : "3",
|
||||
name: tr("pipelineStepProviderFormat", "Provider Format"),
|
||||
description: tr("pipelineStepProviderFormatDesc", "Translated to provider target format"),
|
||||
format: r.target,
|
||||
content: r.translatedJson ?? "",
|
||||
status: r.translatedJson ? "done" : r.status === "translating" ? "active" : "pending",
|
||||
});
|
||||
|
||||
// Step 5 — Provider Response (only when mode=send and response present)
|
||||
if (r.responsePreview !== null) {
|
||||
steps.push({
|
||||
id: r.pipelinePath === "hub-and-spoke" ? "5" : "4",
|
||||
name: tr("pipelineStepProviderResponse", "Provider Response"),
|
||||
description: tr("pipelineStepProviderResponseDesc", "Streaming response from provider"),
|
||||
format: "openai",
|
||||
content: r.responsePreview,
|
||||
status: r.status === "ok" ? "done" : r.status === "sending" ? "active" : "pending",
|
||||
});
|
||||
}
|
||||
|
||||
return steps;
|
||||
}, [session.result, sharedInputContent]);
|
||||
|
||||
const advancedSlot = (
|
||||
<AdvancedSection forceOpenSlug={state.advanced}>
|
||||
<RawJsonPanel
|
||||
@@ -47,7 +126,7 @@ function TranslatorPageClientInner() {
|
||||
slug="pipeline"
|
||||
forceOpen={state.advanced === "pipeline"}
|
||||
onOpenChange={makeOpenHandler("pipeline")}
|
||||
steps={[]}
|
||||
pipelineSteps={pipelineSteps.length > 0 ? pipelineSteps : undefined}
|
||||
/>
|
||||
<StreamTransformerAccordion
|
||||
forceOpen={state.advanced === "streamtransform"}
|
||||
@@ -92,6 +171,7 @@ function TranslatorPageClientInner() {
|
||||
<TranslateTab
|
||||
forceOpenAdvancedSlug={state.advanced}
|
||||
onAdvancedSlugChange={(slug) => setAdvanced(slug)}
|
||||
session={session}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -111,7 +191,10 @@ function AutoFeaturesCard() {
|
||||
return (
|
||||
<Card className="border-primary/10 bg-primary/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeatures((prev) => !prev)}
|
||||
aria-expanded={showFeatures}
|
||||
aria-controls="auto-features-grid"
|
||||
className="flex w-full items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -130,6 +213,7 @@ function AutoFeaturesCard() {
|
||||
|
||||
{showFeatures && (
|
||||
<div
|
||||
id="auto-features-grid"
|
||||
className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
data-testid="auto-features-grid"
|
||||
>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl";
|
||||
import { Button, Select, SegmentedControl } from "@/shared/components";
|
||||
import { InfoTooltip } from "@/shared/components";
|
||||
import { FORMAT_OPTIONS, FORMAT_META, getExampleTemplates } from "../exampleTemplates";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import type { FormatId, TranslateMode } from "../types";
|
||||
|
||||
interface SimpleControlsProps {
|
||||
@@ -22,6 +21,8 @@ interface SimpleControlsProps {
|
||||
onSubmit: () => void;
|
||||
onOpenAdvanced: () => void;
|
||||
isLoading?: boolean;
|
||||
providerOptions: Array<{ value: string; label: string }>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function SimpleControls({
|
||||
@@ -38,9 +39,10 @@ export default function SimpleControls({
|
||||
onSubmit,
|
||||
onOpenAdvanced,
|
||||
isLoading = false,
|
||||
providerOptions,
|
||||
loading = false,
|
||||
}: SimpleControlsProps) {
|
||||
const t = useTranslations("translator");
|
||||
const { providerOptions } = useProviderOptions(provider);
|
||||
|
||||
const tr = useCallback(
|
||||
(key: string, fallback: string): string => {
|
||||
@@ -150,6 +152,7 @@ export default function SimpleControls({
|
||||
options={providerOptions.length > 0 ? providerOptions : [{ value: provider, label: provider }]}
|
||||
value={provider}
|
||||
onChange={handleProviderChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { useTranslateSession } from "../hooks/useTranslateSession";
|
||||
import type { UseTranslateSessionReturn } from "../hooks/useTranslateSession";
|
||||
import { useProviderOptions } from "../hooks/useProviderOptions";
|
||||
import SimpleControls from "./SimpleControls";
|
||||
import ResultNarrated from "./ResultNarrated";
|
||||
@@ -19,11 +20,18 @@ interface TranslateTabProps {
|
||||
* (open or close). F9 syncs this with the URL query string.
|
||||
*/
|
||||
onAdvancedSlugChange?: (slug: AdvancedSlug | null) => void;
|
||||
/**
|
||||
* Optional session lifted from shell (TranslatorPageClient) so PipelineView
|
||||
* can read the result at the shell level. When undefined, an internal session
|
||||
* is used (isolated rendering mode, e.g. tests).
|
||||
*/
|
||||
session?: UseTranslateSessionReturn;
|
||||
}
|
||||
|
||||
export default function TranslateTab({
|
||||
forceOpenAdvancedSlug = null,
|
||||
onAdvancedSlugChange,
|
||||
session: sessionProp,
|
||||
}: TranslateTabProps) {
|
||||
// Internal simple-mode state
|
||||
const [source, setSource] = useState<FormatId>("claude");
|
||||
@@ -31,11 +39,14 @@ export default function TranslateTab({
|
||||
const [mode, setMode] = useState<TranslateMode>("send");
|
||||
|
||||
// Provider/target state: derive from useProviderOptions
|
||||
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
|
||||
// GAP-3: useProviderOptions lives only here; SimpleControls receives it as props
|
||||
const { provider, setProvider, providerOptions, loading } = useProviderOptions("openai");
|
||||
// target FormatId mirrors provider selection; managed via SimpleControls callback
|
||||
const [target, setTarget] = useState<FormatId>("openai");
|
||||
|
||||
const { result, run } = useTranslateSession();
|
||||
// Rules of Hooks: always call unconditionally; fall back to prop when provided
|
||||
const internalSession = useTranslateSession();
|
||||
const { result, run } = sessionProp ?? internalSession;
|
||||
|
||||
const handleSubmit = () => {
|
||||
run({ source, target, provider, inputText, mode });
|
||||
@@ -45,11 +56,6 @@ export default function TranslateTab({
|
||||
if (onAdvancedSlugChange) {
|
||||
onAdvancedSlugChange(slug);
|
||||
}
|
||||
// Scroll to advanced section if it exists (guard for environments without scrollIntoView)
|
||||
const advancedEl = document.querySelector("[data-advanced-section]");
|
||||
if (advancedEl && typeof advancedEl.scrollIntoView === "function") {
|
||||
advancedEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSeeTranslatedJson = () => {
|
||||
@@ -60,10 +66,6 @@ export default function TranslateTab({
|
||||
handleOpenAdvanced("pipeline");
|
||||
};
|
||||
|
||||
// Expose forceOpenAdvancedSlug to the advanced section slot via data attribute
|
||||
// F4 will read this; for now we write it to a data attribute that F9 will wire
|
||||
const advancedSlug = forceOpenAdvancedSlug;
|
||||
|
||||
// Sync provider options: when providerOptions loads, keep provider in sync
|
||||
// (useProviderOptions handles this internally; we just need to expose setProvider)
|
||||
const handleProviderChange = (prov: string) => {
|
||||
@@ -90,6 +92,8 @@ export default function TranslateTab({
|
||||
onSubmit={handleSubmit}
|
||||
onOpenAdvanced={() => handleOpenAdvanced("rawjson")}
|
||||
isLoading={result.status === "translating" || result.status === "sending"}
|
||||
providerOptions={providerOptions}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -100,17 +104,6 @@ export default function TranslateTab({
|
||||
onSeePipeline={handleSeePipeline}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Advanced section slot — F4 will mount AdvancedSection here.
|
||||
F9 composes: <TranslateTab onAdvancedSlugChange={...} /> + <AdvancedSection forceOpenSlug={...} />.
|
||||
For now we render the placeholder that F4/F9 will replace. */}
|
||||
<div
|
||||
data-advanced-section
|
||||
data-force-open-slug={advancedSlug ?? ""}
|
||||
data-provider-options={JSON.stringify(providerOptions.map((o) => o.value))}
|
||||
data-source={source}
|
||||
data-input-text={inputText.slice(0, 100)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,28 @@ vi.mock("@/shared/components", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// ── useTranslateSession stub (now lifted to shell) ────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession",
|
||||
() => ({
|
||||
useTranslateSession: () => ({
|
||||
result: {
|
||||
detected: null,
|
||||
target: "openai",
|
||||
status: "idle",
|
||||
responsePreview: null,
|
||||
translatedJson: null,
|
||||
pipelinePath: null,
|
||||
intermediateJson: null,
|
||||
errorMessage: null,
|
||||
latencyMs: null,
|
||||
},
|
||||
run: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Sub-component stubs that capture received props ───────────────────────────
|
||||
const capturedTranslateTabProps: Array<{
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
@@ -100,6 +122,7 @@ vi.mock(
|
||||
}: {
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
onAdvancedSlugChange?: (slug: string | null) => void;
|
||||
session?: unknown;
|
||||
}) => {
|
||||
capturedTranslateTabProps.push({ forceOpenAdvancedSlug });
|
||||
return (
|
||||
@@ -154,7 +177,7 @@ vi.mock(
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean; steps?: unknown[] }) => (
|
||||
default: ({ forceOpen }: { forceOpen?: boolean; pipelineSteps?: unknown[] }) => (
|
||||
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -94,6 +94,28 @@ vi.mock("@/shared/components", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// ── useTranslateSession stub (now lifted to shell) ────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession",
|
||||
() => ({
|
||||
useTranslateSession: () => ({
|
||||
result: {
|
||||
detected: null,
|
||||
target: "openai",
|
||||
status: "idle",
|
||||
responsePreview: null,
|
||||
translatedJson: null,
|
||||
pipelinePath: null,
|
||||
intermediateJson: null,
|
||||
errorMessage: null,
|
||||
latencyMs: null,
|
||||
},
|
||||
run: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Sub-component stubs ───────────────────────────────────────────────────────
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard",
|
||||
@@ -111,6 +133,7 @@ vi.mock(
|
||||
}: {
|
||||
forceOpenAdvancedSlug?: string | null;
|
||||
onAdvancedSlugChange?: (slug: string | null) => void;
|
||||
session?: unknown;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="translate-tab"
|
||||
@@ -159,7 +182,7 @@ vi.mock(
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
|
||||
() => ({
|
||||
default: ({ forceOpen }: { forceOpen?: boolean }) => (
|
||||
default: ({ forceOpen }: { forceOpen?: boolean; pipelineSteps?: unknown[] }) => (
|
||||
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -88,22 +88,6 @@ vi.mock("@/shared/components", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// --- Mock useProviderOptions ---
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions",
|
||||
() => ({
|
||||
useProviderOptions: () => ({
|
||||
provider: "openai",
|
||||
setProvider: vi.fn(),
|
||||
providerOptions: [
|
||||
{ value: "openai", label: "OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
],
|
||||
loading: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
// --- Mock useAvailableModels ---
|
||||
vi.mock(
|
||||
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
|
||||
@@ -180,6 +164,8 @@ function makeProps(overrides: Partial<{
|
||||
onSubmit: () => void;
|
||||
onOpenAdvanced: () => void;
|
||||
isLoading: boolean;
|
||||
providerOptions: Array<{ value: string; label: string }>;
|
||||
loading: boolean;
|
||||
}> = {}) {
|
||||
return {
|
||||
source: "claude" as FormatId,
|
||||
@@ -195,6 +181,8 @@ function makeProps(overrides: Partial<{
|
||||
onSubmit: vi.fn(),
|
||||
onOpenAdvanced: vi.fn(),
|
||||
isLoading: false,
|
||||
providerOptions: [{ value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }],
|
||||
loading: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("TranslateTab", () => {
|
||||
expect(gridEl?.className).toContain("lg:grid-cols-2");
|
||||
});
|
||||
|
||||
it("renders the advanced section slot (data-advanced-section)", async () => {
|
||||
it("does not expose data-advanced-section placeholder div (GAP-5)", async () => {
|
||||
const { default: TranslateTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
|
||||
);
|
||||
@@ -206,8 +206,8 @@ describe("TranslateTab", () => {
|
||||
await act(async () => {
|
||||
root.render(<TranslateTab />);
|
||||
});
|
||||
const advancedSlot = container.querySelector("[data-advanced-section]");
|
||||
expect(advancedSlot).toBeTruthy();
|
||||
// GAP-5: the data-advanced-section DOM data-leak placeholder must not exist
|
||||
expect(container.querySelector("[data-advanced-section]")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onAdvancedSlugChange with 'rawjson' when the Advanced button is clicked", async () => {
|
||||
@@ -233,37 +233,6 @@ describe("TranslateTab", () => {
|
||||
expect(onAdvancedSlugChange).toHaveBeenCalledWith("rawjson");
|
||||
});
|
||||
|
||||
it("reflects forceOpenAdvancedSlug in data attribute", async () => {
|
||||
const { default: TranslateTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
const slug: AdvancedSlug = "pipeline";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TranslateTab forceOpenAdvancedSlug={slug} onAdvancedSlugChange={vi.fn()} />
|
||||
);
|
||||
});
|
||||
const advancedSlot = container.querySelector("[data-advanced-section]");
|
||||
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("pipeline");
|
||||
});
|
||||
|
||||
it("forceOpenAdvancedSlug=null results in empty data attribute", async () => {
|
||||
const { default: TranslateTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
|
||||
);
|
||||
const container = makeContainer();
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TranslateTab forceOpenAdvancedSlug={null} onAdvancedSlugChange={vi.fn()} />
|
||||
);
|
||||
});
|
||||
const advancedSlot = container.querySelector("[data-advanced-section]");
|
||||
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("");
|
||||
});
|
||||
|
||||
it("renders without onAdvancedSlugChange prop (optional)", async () => {
|
||||
const { default: TranslateTab } = await import(
|
||||
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
|
||||
|
||||
Reference in New Issue
Block a user