mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
fix(ui): isolate pending question drafts by request identity
Remount question state before persistence when request identity changes, retaining same-request edits. Preserve exact canonical text at the compact-card API boundary while keeping legacy and custom-answer trimming. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c5a4714add
commit
f308b14dae
4 files changed
+68
-2
No files matched your search
@@ -52,6 +52,34 @@ describe("QuestionForm initial text", () => {
|
||||
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.disabled).toBe(true);
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("isolates pending requests with the same field ids and restores each edited draft when switching back", async () => {
|
||||
async function showRequest(id: string, initialText: string) {
|
||||
const set = { ...questionSet, questions: [{ ...questionSet.questions[0]!, initialText }] };
|
||||
await act(async () => root.render(
|
||||
<QuestionForm id={id} questionSet={set} draftKey={`question:${id}`} onSubmit={submit} />,
|
||||
));
|
||||
}
|
||||
const storedText = (id: string) => JSON.parse(localStorage.getItem(`question:${id}`)!).answers.draft.text;
|
||||
|
||||
await showRequest("request-a", "Provider draft A");
|
||||
await edit("Operator edit A");
|
||||
await showRequest("request-b", "Provider draft B");
|
||||
expect(input().value).toBe("Provider draft B");
|
||||
expect(storedText("request-a")).toBe("Operator edit A");
|
||||
expect(storedText("request-b")).toBe("Provider draft B");
|
||||
await edit("Operator edit B");
|
||||
|
||||
await showRequest("request-a", "Refreshed provider draft A");
|
||||
expect(input().value).toBe("Operator edit A");
|
||||
await showRequest("request-b", "Refreshed provider draft B");
|
||||
expect(input().value).toBe("Operator edit B");
|
||||
expect(storedText("request-a")).toBe("Operator edit A");
|
||||
expect(storedText("request-b")).toBe("Operator edit B");
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Submit answers")!.click());
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith({ schema: "paperclip.question_response.v1", answers: { draft: { text: "Operator edit B" } } });
|
||||
});
|
||||
|
||||
it("prefers an existing response and keeps text constraints on defaults", async () => {
|
||||
await render(questionSet, { schema: "paperclip.question_response.v1", answers: { draft: { text: "Existing answer" } } });
|
||||
expect(input().value).toBe("Existing answer");
|
||||
|
||||
@@ -230,7 +230,13 @@ export function QuestionResponseSummary({
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionForm({
|
||||
export function QuestionForm(props: QuestionFormProps) {
|
||||
// Takeovers can reuse this component for another pending request. Remount
|
||||
// before draft persistence runs so one request cannot overwrite another.
|
||||
return <QuestionFormForRequest key={JSON.stringify([props.id, props.draftKey])} {...props} />;
|
||||
}
|
||||
|
||||
function QuestionFormForRequest({
|
||||
id,
|
||||
questionSet,
|
||||
initialResponse,
|
||||
|
||||
@@ -757,7 +757,7 @@ function AskUserQuestionsCard({
|
||||
const answer = response.answers[question.id];
|
||||
const otherText =
|
||||
question.answerMode === "text"
|
||||
? answer?.text?.trim()
|
||||
? (interaction.payload.questionSet ? answer?.text : answer?.text?.trim())
|
||||
: answer?.customText?.trim();
|
||||
return {
|
||||
questionId: question.id,
|
||||
|
||||
@@ -683,6 +683,38 @@ describe("TaskChatInteractionCard", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["canonical_text", "canonical_custom", "legacy_custom"])("preserves the %s answer mapping", async (mode) => {
|
||||
const submit = vi.fn();
|
||||
const interaction = structuredClone(pendingAskUserQuestionsInteraction);
|
||||
interaction.payload.questions = [{
|
||||
id: "draft", prompt: "Edit", selectionMode: "single", required: true,
|
||||
options: [{ id: "preset", label: "Preset" }, { id: "other", label: "Custom answer", freeText: true }],
|
||||
}];
|
||||
interaction.payload.questionSet = mode === "legacy_custom" ? undefined : {
|
||||
schema: "paperclip.question_set.v1",
|
||||
questions: [mode === "canonical_text"
|
||||
? { id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText: "Provider draft" }
|
||||
: { id: "draft", prompt: "Edit", required: true, answerMode: "single_select", options: [{ id: "preset", label: "Preset" }], customAnswer: { enabled: true, label: "Custom answer" } }],
|
||||
};
|
||||
await act(async () => root.render(
|
||||
<TooltipProvider><ThemeProvider>
|
||||
<TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionAnswers={submit} />
|
||||
</ThemeProvider></TooltipProvider>,
|
||||
));
|
||||
if (mode !== "canonical_text") {
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Custom answer"))!.click());
|
||||
}
|
||||
const edited = "\n Operator 漢字 edit\nLiteral \\n stays literal. \n";
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(container.querySelector("textarea")!, edited);
|
||||
container.querySelector("textarea")!.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Send answers" || button.textContent?.trim() === "Submit answers")!.click());
|
||||
expect(submit).toHaveBeenCalledExactlyOnceWith(interaction, [{
|
||||
questionId: "draft", optionIds: [], otherText: mode === "canonical_text" ? edited : edited.trim(),
|
||||
}]);
|
||||
});
|
||||
|
||||
it("paginates item verdicts instead of expanding the whole review set", async () => {
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
|
||||
Reference in new issue
Block a user