Task 008 implement boundary questions loop

This commit is contained in:
2026-05-21 19:46:26 +03:00
parent 8aa92ea728
commit b7e2fa34b2
27 changed files with 1883 additions and 24 deletions
@@ -0,0 +1,157 @@
"use client";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import type { BoundaryQuestionSummary } from "@pipeline/shared";
import { ApiError, generateBoundaryQuestions, submitBoundaryQuestions, updateBoundaryQuestion } from "@/shared/pipeline-api";
import {
buildBoundaryQuestionRows,
validateRequiredAnswers,
type BoundaryQuestionErrors,
type BoundaryQuestionFormRow,
} from "./model";
type BoundaryQuestionsEditorProps = {
articleId: string;
initialQuestions: readonly BoundaryQuestionSummary[];
};
export function BoundaryQuestionsEditor({
articleId,
initialQuestions,
}: BoundaryQuestionsEditorProps) {
const router = useRouter();
const [rows, setRows] = useState<BoundaryQuestionFormRow[]>(() =>
buildBoundaryQuestionRows(initialQuestions),
);
const [errors, setErrors] = useState<BoundaryQuestionErrors>({});
const [formMessage, setFormMessage] = useState("");
const [isBusy, setBusy] = useState(false);
const requiredProgress = useMemo(() => {
const requiredRows = rows.filter((row) => row.isRequired);
const answeredRows = requiredRows.filter((row) => row.answer.trim());
return `${answeredRows.length}/${requiredRows.length}`;
}, [rows]);
function updateRow(questionId: string, answer: string) {
setRows((current) =>
current.map((row) => (row.id === questionId ? { ...row, answer } : row)),
);
}
async function handleGenerate() {
setBusy(true);
setFormMessage("");
try {
const response = await generateBoundaryQuestions(articleId);
setRows(buildBoundaryQuestionRows(response.questions));
setErrors({});
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to generate questions");
} finally {
setBusy(false);
}
}
async function handleSave(row: BoundaryQuestionFormRow) {
setBusy(true);
setFormMessage("");
try {
const response = await updateBoundaryQuestion(articleId, row.id, {
answer: row.answer.trim() || null,
});
setRows((current) =>
current.map((candidate) =>
candidate.id === row.id
? buildBoundaryQuestionRows([response.question])[0]
: candidate,
),
);
setErrors((current) => {
const next = { ...current };
delete next[row.id];
return next;
});
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to save answer");
} finally {
setBusy(false);
}
}
async function handleSubmit() {
const nextErrors = validateRequiredAnswers(rows);
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors);
return;
}
setBusy(true);
setFormMessage("");
try {
await submitBoundaryQuestions(articleId);
router.push(`/articles/${articleId}`);
router.refresh();
} catch (error) {
setFormMessage(error instanceof ApiError ? error.message : "Unable to submit answers");
} finally {
setBusy(false);
}
}
return (
<section className="boundaryQuestions">
<div className="sectionHeader">
<div>
<h2>Boundary questions</h2>
<p>Required answers: {requiredProgress}</p>
</div>
<button type="button" onClick={handleGenerate} disabled={isBusy}>
{rows.length > 0 ? "Regenerate" : "Generate"}
</button>
</div>
{formMessage ? <p className="formError">Error: {formMessage}</p> : null}
{rows.length === 0 ? (
<p className="emptyState">No boundary questions generated yet.</p>
) : (
<div className="questionList">
{rows.map((row) => (
<article className="questionItem" key={row.id}>
<div className="questionHeader">
<div>
<strong>{row.category.replaceAll("_", " ")}</strong>
<p>{row.question}</p>
</div>
<span className={row.isRequired ? "requiredBadge" : "optionalBadge"}>
{row.stateLabel}
</span>
</div>
<textarea
value={row.answer}
onChange={(event) => updateRow(row.id, event.target.value)}
/>
{errors[row.id] ? <p className="fieldError">{errors[row.id]}</p> : null}
<div className="formActions">
<button type="button" onClick={() => handleSave(row)} disabled={isBusy}>
Save
</button>
</div>
</article>
))}
</div>
)}
<div className="formActions">
<button type="button" onClick={handleSubmit} disabled={isBusy || rows.length === 0}>
Submit boundary answers
</button>
</div>
</section>
);
}