From d5ca38e621f701af466a9b67005708ebd176bbf0 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sun, 19 Apr 2026 16:30:30 +0200 Subject: [PATCH] feat: refine ask submit screen layout --- src/constants.ts | 2 +- src/ui/constants.ts | 1 - src/ui/render-submit.ts | 122 +++++++++++++++++---------- tests/render-submit.test.ts | 161 ++++++++++++++++++++++++++++++++++++ 4 files changed, 241 insertions(+), 45 deletions(-) create mode 100644 tests/render-submit.test.ts diff --git a/src/constants.ts b/src/constants.ts index cb69397..590a12e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,6 @@ export const OTHER_OPTION_VALUE = "__other__"; export const OTHER_OPTION_LABEL = "Type your own"; -export const SUBMIT_CHOICES = ["Submit answers", "Cancel"] as const; +export const SUBMIT_CHOICES = ["Submit", "Cancel"] as const; export const NO_PREVIEW_TEXT = "No preview available"; export const CANCELLED_SUMMARY = "User cancelled the ask flow"; export const SUBMITTED_SUMMARY = "User submitted the ask flow"; diff --git a/src/ui/constants.ts b/src/ui/constants.ts index e567c63..ebc6bf9 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -14,7 +14,6 @@ export const UI_DIMENSIONS = { export const UI_TEXT = { questionNoteTitle: "Note:", reviewTitle: "Review answers", - readyToSubmit: "Submit answers?", unanswered: "→ unanswered", footerInput: " Enter submit · Esc close", footerNote: " Enter save · Esc close", diff --git a/src/ui/render-submit.ts b/src/ui/render-submit.ts index 4d605ca..f3f6a9e 100644 --- a/src/ui/render-submit.ts +++ b/src/ui/render-submit.ts @@ -1,4 +1,4 @@ -import { truncateToWidth } from "@mariozechner/pi-tui"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { SUBMIT_CHOICES } from "../constants.ts"; import { toAskResult } from "../state/result.ts"; import type { AskResult, AskState } from "../types.ts"; @@ -16,7 +16,8 @@ export function renderSubmitScreen( lines.push(""); const submittedAnswers = toAskResult(state).answers; - for (const question of state.questions) { + for (let index = 0; index < state.questions.length; index++) { + const question = state.questions[index]; renderSubmittedQuestion( lines, question, @@ -24,18 +25,11 @@ export function renderSubmitScreen( theme, width ); + if (index < state.questions.length - 1) { + lines.push(""); + } } - lines.push(""); - pushWrappedText( - lines, - UI_TEXT.readyToSubmit, - width, - theme, - "success", - " ", - " " - ); lines.push(""); renderSubmitActions(lines, state, theme, width); } @@ -47,10 +41,10 @@ function renderSubmittedQuestion( theme: Theme, width: number ) { - pushWrappedText(lines, question.prompt, width, theme, "muted", " ● ", " "); + pushWrappedText(lines, question.label, width, theme, "text", " ", " "); if (!answer) { lines.push( - truncateToWidth(` ${theme.fg("warning", UI_TEXT.unanswered)}`, width) + truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width) ); return; } @@ -64,48 +58,90 @@ function renderSubmittedAnswer( theme: Theme, width: number ) { - const answerText = answer.labels.join(", "); - if (answerText) { - pushWrappedText( - lines, - `→ ${answerText}`, - width, - theme, - isCustomOnlyAnswer(answer) ? "text" : "success", - " ", - " " - ); - } if (answer.note) { + renderSubmittedNote(lines, answer.note, theme, width); + } + + if (shouldRenderAnswersIndividually(answer)) { + renderSubmittedSelections(lines, answer, theme, width); + return; + } + + const answerText = answer.labels.join(", "); + if (!answerText) { + return; + } + + pushWrappedText( + lines, + `→ ${answerText}`, + width, + theme, + isCustomOnlyAnswer(answer) ? "text" : "success", + " ", + " " + ); +} + +function renderSubmittedSelections( + lines: string[], + answer: AskResult["answers"][string], + theme: Theme, + width: number +) { + for (let index = 0; index < answer.labels.length; index++) { + const label = answer.labels[index]; + const value = answer.values[index] ?? label; pushWrappedText( lines, - `Question note: ${answer.note}`, + `→ ${label}`, width, theme, - "muted", + "success", " ", " " ); - } - for (let index = 0; index < answer.values.length; index++) { - const value = answer.values[index]; - const label = answer.labels[index] ?? value; + const note = answer.optionNotes?.[value]; - if (!note) { - continue; + if (note) { + renderSubmittedNote(lines, note, theme, width); } - pushWrappedText( - lines, - `${label} note: ${note}`, - width, - theme, - "muted", - " ", - " " - ); } } +function shouldRenderAnswersIndividually( + answer: AskResult["answers"][string] +): boolean { + if (!answer.labels.length) { + return false; + } + + return ( + answer.labels.length > 1 || + Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0) + ); +} + +function renderSubmittedNote( + lines: string[], + note: string, + theme: Theme, + width: number +) { + const indent = " "; + const notePrefix = `${indent}${theme.fg("syntaxString", UI_TEXT.questionNoteTitle)} `; + const continuationPrefix = `${indent}${" ".repeat(visibleWidth(UI_TEXT.questionNoteTitle) + 1)}`; + pushWrappedText( + lines, + note, + width, + theme, + "muted", + notePrefix, + continuationPrefix + ); +} + function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean { return answer.indices.length === 0 && !!answer.customText; } diff --git a/tests/render-submit.test.ts b/tests/render-submit.test.ts new file mode 100644 index 0000000..720852f --- /dev/null +++ b/tests/render-submit.test.ts @@ -0,0 +1,161 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createInitialState } from "../src/state/create.ts"; +import { + applyNumberShortcut, + enterOptionNoteMode, + enterQuestionNoteMode, + saveNote, +} from "../src/state/transitions.ts"; +import { renderSubmitScreen } from "../src/ui/render-submit.ts"; + +function mockTheme() { + return { + fg(color: string, text: string) { + return `<${color}>${text}`; + }, + bg(color: string, text: string) { + return `{${color}}${text}{/${color}}`; + }, + bold(text: string) { + return text; + }, + } as never; +} + +test("submit screen uses shorter action labels without extra prompt text", () => { + const state = createInitialState({ + questions: [ + { + id: "q1", + label: "Single", + prompt: "Pick one primary demo style.", + options: [{ value: "code", label: "Code demo" }], + }, + ], + }); + + const lines: string[] = []; + renderSubmitScreen(lines, state, mockTheme(), 80); + + assert(!lines.some((line) => line.includes("Submit answers?"))); + assert(lines.some((line) => line.includes("1. Submit"))); + assert(lines.some((line) => line.includes("2. Cancel"))); + assert(!lines.some((line) => line.includes("1. Submit answers"))); +}); + +test("submit screen renders indented notes before answer and separates questions", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + label: "Single", + prompt: "Pick one primary demo style.", + options: [{ value: "code", label: "Code demo" }], + }, + { + id: "q2", + label: "Multi", + prompt: "Pick any extra things to include.", + options: [{ value: "extra", label: "Extra" }], + }, + ], + }); + + state = enterQuestionNoteMode(state, "q1"); + state = saveNote(state, "Question note"); + state = applyNumberShortcut(state, 1); + state = enterOptionNoteMode(state, "q1", "code"); + state = saveNote(state, "Option note"); + state = enterQuestionNoteMode(state, "q2"); + state = saveNote(state, "Second note"); + + const lines: string[] = []; + renderSubmitScreen(lines, state, mockTheme(), 80); + + const firstQuestionIndex = lines.findIndex((line) => + line.includes("Single") + ); + const firstQuestionNoteIndex = lines.findIndex((line) => + line.includes("Question note") + ); + const firstAnswerIndex = lines.findIndex((line) => + line.includes("→ Code demo") + ); + const optionNoteIndex = lines.findIndex((line) => + line.includes("Option note") + ); + const secondQuestionIndex = lines.findIndex((line) => + line.includes("Multi") + ); + const secondQuestionNoteIndex = lines.findIndex((line) => + line.includes("Second note") + ); + + assert.notEqual(firstQuestionIndex, -1); + assert.notEqual(firstQuestionNoteIndex, -1); + assert.notEqual(firstAnswerIndex, -1); + assert.notEqual(optionNoteIndex, -1); + assert.notEqual(secondQuestionIndex, -1); + assert.notEqual(secondQuestionNoteIndex, -1); + assert(firstQuestionNoteIndex < firstAnswerIndex); + assert(optionNoteIndex > firstAnswerIndex); + assert(lines[firstQuestionNoteIndex]?.startsWith(" ")); + assert(lines[optionNoteIndex]?.startsWith(" ")); + assert.equal(lines[secondQuestionIndex - 1], ""); + assert( + lines.some((line) => line.includes("Note:")) + ); + assert(!lines.some((line) => line.includes("Question note:"))); + assert(!lines.some((line) => line.includes("Code demo note:"))); + assert(!lines.some((line) => line.includes("Pick one primary demo style."))); + assert( + !lines.some((line) => line.includes("Pick any extra things to include.")) + ); +}); + +test("submit screen renders multi-select option notes under their related answers", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + label: "Multi", + prompt: "Pick extras", + type: "multi", + options: [ + { value: "follow-up", label: "Follow-up action" }, + { value: "docs", label: "Docs demo" }, + ], + }, + ], + }); + + state = applyNumberShortcut(state, 1); + state = applyNumberShortcut(state, 2); + state = enterOptionNoteMode(state, "q1", "follow-up"); + state = saveNote(state, "First note"); + state = enterOptionNoteMode(state, "q1", "docs"); + state = saveNote(state, "Second note"); + + const lines: string[] = []; + renderSubmitScreen(lines, state, mockTheme(), 80); + + const firstAnswerIndex = lines.findIndex((line) => + line.includes("→ Follow-up action") + ); + const firstNoteIndex = lines.findIndex((line) => line.includes("First note")); + const secondAnswerIndex = lines.findIndex((line) => + line.includes("→ Docs demo") + ); + const secondNoteIndex = lines.findIndex((line) => + line.includes("Second note") + ); + + assert.notEqual(firstAnswerIndex, -1); + assert.notEqual(firstNoteIndex, -1); + assert.notEqual(secondAnswerIndex, -1); + assert.notEqual(secondNoteIndex, -1); + assert(firstAnswerIndex < firstNoteIndex); + assert(firstNoteIndex < secondAnswerIndex); + assert(secondAnswerIndex < secondNoteIndex); +});