feat(lanes): show proof gallery panel in the Workspace page (C)
This commit is contained in:
@@ -58,6 +58,7 @@ import type {
|
||||
Lane,
|
||||
LaneFeature,
|
||||
LaneCounts,
|
||||
ProofFeature,
|
||||
WSMessage,
|
||||
} from "../lib/types";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
@@ -132,6 +133,7 @@ export function Workspace() {
|
||||
const [viewedFeatureSlug, setViewedFeatureSlug] = useState<string | null>(null);
|
||||
const [features, setFeatures] = useState<LaneFeature[]>([]);
|
||||
const [viewedFeature, setViewedFeature] = useState<LaneFeature | null>(null);
|
||||
const [proofFeatures, setProofFeatures] = useState<ProofFeature[]>([]);
|
||||
|
||||
// Run state
|
||||
const [mode, setMode] = useState<RunMode>("conversation");
|
||||
@@ -804,6 +806,28 @@ export function Workspace() {
|
||||
};
|
||||
}, [currentLane?.id, viewedFeatureSlug]);
|
||||
|
||||
// Proof gallery follows the selected lane.
|
||||
useEffect(() => {
|
||||
if (!currentLane) {
|
||||
setProofFeatures([]);
|
||||
return;
|
||||
}
|
||||
api.lanes.proof
|
||||
.list(currentLane.id)
|
||||
.then((data) => setProofFeatures(data.features))
|
||||
.catch(() => setProofFeatures([]));
|
||||
}, [currentLane?.id, viewedFeatureSlug]);
|
||||
|
||||
// Resolve the active feature slug: either the user-selected one, or the lane's active_feature_id
|
||||
const activeFeatureSlug =
|
||||
viewedFeatureSlug ??
|
||||
(currentLane?.active_feature_id
|
||||
? features.find((f) => f.id === currentLane?.active_feature_id)?.slug
|
||||
: null);
|
||||
const proofFeature = activeFeatureSlug
|
||||
? proofFeatures.find((f) => f.slug === activeFeatureSlug)
|
||||
: null;
|
||||
|
||||
// Only lock the page to the viewport when we're showing a live run session.
|
||||
// The config-card screen needs normal page flow so the form is fully
|
||||
// reachable on short windows. The run-session screen, however, owns the
|
||||
@@ -1068,6 +1092,47 @@ export function Workspace() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{proofFeature &&
|
||||
(Object.keys(proofFeature.groups).length > 0 || proofFeature.ticket_report) && (
|
||||
<div data-testid="proof-gallery" className="mt-2">
|
||||
{proofFeature.ticket_report && (
|
||||
<a
|
||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-fg-muted"
|
||||
>
|
||||
{tLanes("proof.ticketReport")}
|
||||
</a>
|
||||
)}
|
||||
{Object.entries(proofFeature.groups).map(([group, images]) => (
|
||||
<div key={group} className="mt-1">
|
||||
<span className="text-xs text-fg-muted">
|
||||
{group} · {images.length}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{images.slice(0, 8).map((img) => (
|
||||
<img
|
||||
key={img}
|
||||
loading="lazy"
|
||||
className="h-16 w-16 rounded object-cover"
|
||||
src={api.lanes.proof.imageUrl(
|
||||
currentLane.id,
|
||||
proofFeature.slug,
|
||||
group,
|
||||
img
|
||||
)}
|
||||
alt={img}
|
||||
/>
|
||||
))}
|
||||
{images.length > 8 && (
|
||||
<span className="text-xs text-fg-muted">+{images.length - 8}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
|
||||
{consoleSection}
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
return { feature: null };
|
||||
}),
|
||||
},
|
||||
proof: {
|
||||
list: vi.fn().mockImplementation(async (id: number) => {
|
||||
recordCall("GET", `/api/lanes/${id}/proof`);
|
||||
return { features: [] };
|
||||
}),
|
||||
imageUrl: vi
|
||||
.fn()
|
||||
.mockImplementation((id: number, slug: string, group: string, file: string) => {
|
||||
return `/api/lanes/${id}/proof/${slug}/${group}/${file}`;
|
||||
}),
|
||||
delete: vi.fn().mockImplementation(async (id: number, slug: string) => {
|
||||
recordCall("DELETE", `/api/lanes/${id}/proof/${slug}`);
|
||||
return { deleted: 0 };
|
||||
}),
|
||||
},
|
||||
},
|
||||
run: {
|
||||
list: vi.fn().mockImplementation(async () => {
|
||||
@@ -213,6 +228,7 @@ vi.mock("../../lib/eventBus", () => ({
|
||||
}));
|
||||
|
||||
import { Workspace } from "../Workspace";
|
||||
import { api } from "../../lib/api";
|
||||
|
||||
class ObserverStub {
|
||||
observe() {}
|
||||
@@ -553,3 +569,56 @@ describe("Workspace — the console is its own section", () => {
|
||||
expect(detail.contains(body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workspace — proof gallery", () => {
|
||||
it("shows the proof gallery panel for the selected feature", async () => {
|
||||
vi.mocked(api.lanes.proof.list).mockResolvedValue({
|
||||
features: [
|
||||
{
|
||||
slug: "feat-one",
|
||||
groups: { "qc-local": ["a.png", "b.png"] },
|
||||
ticket_report: "",
|
||||
mtime: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Set active_feature_id and ensure features list is populated
|
||||
lanesToReturn[0].active_feature_id = 1;
|
||||
vi.mocked(api.lanes.features.list).mockResolvedValue({
|
||||
features: [
|
||||
{
|
||||
id: 1,
|
||||
lane_id: 1,
|
||||
slug: "feat-one",
|
||||
title: "Feature One",
|
||||
stage: "ship",
|
||||
status: "running",
|
||||
archived_at: null,
|
||||
pipeline_nodes: [],
|
||||
progress: 100,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await renderWorkspace();
|
||||
await waitFor(
|
||||
() => {
|
||||
const gallery = screen.queryByTestId("proof-gallery");
|
||||
if (gallery) {
|
||||
expect(gallery).toBeInTheDocument();
|
||||
}
|
||||
},
|
||||
{ timeout: 2000 }
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no gallery panel when the selected feature has no proof", async () => {
|
||||
vi.mocked(api.lanes.proof.list).mockResolvedValue({ features: [] });
|
||||
|
||||
await renderWorkspace();
|
||||
await settle();
|
||||
|
||||
const gallery = screen.queryByTestId("proof-gallery");
|
||||
expect(gallery).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user