Task 008 implement boundary questions loop
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user