fix: multi-select custom answer toggling
This commit is contained in:
@@ -172,6 +172,8 @@ This document defines the stable external behavior. It does not explain internal
|
||||
- on single-select questions, saving free-form text clears selected options for that question
|
||||
- on multi-select questions, `values` and `labels` include both selected options and `customText` when both are present
|
||||
- on multi-select questions, selected options keep their original order and `customText` is appended last
|
||||
- submitting free-form text on a multi-select question stays on the same question tab and marks the custom row selected
|
||||
- on multi-select questions, toggling an empty custom row opens the free-form editor, while toggling a custom row with saved free-form text selects or deselects it without opening the editor or clearing the text
|
||||
- saving or clearing free-form text on a multi-select question does not clear other selected options
|
||||
- `note` stores a question-level note
|
||||
- `optionNotes` includes only notes for selected options
|
||||
|
||||
+22
-4
@@ -17,6 +17,7 @@ export function emptyAnswer(): AskStateAnswer {
|
||||
export function cloneAnswer(answer: AskStateAnswer): AskStateAnswer {
|
||||
return {
|
||||
selected: answer.selected.map(cloneSelection),
|
||||
customSelected: answer.customSelected,
|
||||
customText: answer.customText,
|
||||
note: answer.note,
|
||||
optionNotes: answer.optionNotes ? { ...answer.optionNotes } : undefined,
|
||||
@@ -87,13 +88,24 @@ export function saveCustomText(
|
||||
next.selected = [];
|
||||
}
|
||||
if (!trimmed) {
|
||||
next.customSelected = undefined;
|
||||
next.customText = undefined;
|
||||
return next;
|
||||
}
|
||||
next.customSelected = true;
|
||||
next.customText = rawValue;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function setCustomSelected(
|
||||
answer: AskStateAnswer,
|
||||
selected: boolean
|
||||
): AskStateAnswer {
|
||||
const next = cloneAnswer(answer);
|
||||
next.customSelected = selected || undefined;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function saveQuestionNote(
|
||||
answer: AskStateAnswer,
|
||||
rawValue: string
|
||||
@@ -137,7 +149,10 @@ export function isAnswerAnswered(answer?: AskStateAnswer): boolean {
|
||||
if (!answer) {
|
||||
return false;
|
||||
}
|
||||
return answer.selected.length > 0 || !!answer.customText?.trim();
|
||||
return (
|
||||
answer.selected.length > 0 ||
|
||||
!!(answer.customSelected && answer.customText?.trim())
|
||||
);
|
||||
}
|
||||
|
||||
export function hasAnswerNotes(answer?: AskStateAnswer): boolean {
|
||||
@@ -178,13 +193,16 @@ export function isOptionSelected(
|
||||
}
|
||||
|
||||
export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
|
||||
const selectedCustomText = answer.customSelected
|
||||
? answer.customText
|
||||
: undefined;
|
||||
const values = [
|
||||
...answer.selected.map((selection) => selection.value),
|
||||
...(answer.customText ? [answer.customText] : []),
|
||||
...(selectedCustomText ? [selectedCustomText] : []),
|
||||
];
|
||||
const labels = [
|
||||
...answer.selected.map((selection) => selection.label),
|
||||
...(answer.customText ? [answer.customText] : []),
|
||||
...(selectedCustomText ? [selectedCustomText] : []),
|
||||
];
|
||||
const indices = answer.selected.map((selection) => selection.index);
|
||||
const selectedNotes = answer.optionNotes
|
||||
@@ -202,7 +220,7 @@ export function serializeAnswer(answer: AskStateAnswer): AskResultAnswer {
|
||||
values,
|
||||
labels,
|
||||
indices,
|
||||
customText: answer.customText,
|
||||
customText: selectedCustomText,
|
||||
note: answer.note,
|
||||
optionNotes:
|
||||
selectedNotes && Object.keys(selectedNotes).length > 0
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
saveCustomText,
|
||||
saveOptionNote,
|
||||
saveQuestionNote,
|
||||
setCustomSelected,
|
||||
setSingleSelection,
|
||||
toggleSelection,
|
||||
} from "./answers.ts";
|
||||
@@ -187,7 +188,7 @@ function activateCurrentOption(
|
||||
return state;
|
||||
}
|
||||
if (option.isCustomOption) {
|
||||
return setView(state, inputView(question.id));
|
||||
return activateCustomOption(state, question.id, question.type, trigger);
|
||||
}
|
||||
if (question.type === "multi") {
|
||||
if (trigger === "confirm") {
|
||||
@@ -215,6 +216,23 @@ function activateCurrentOption(
|
||||
return trigger === "toggle" ? nextState : advanceToNextTab(nextState);
|
||||
}
|
||||
|
||||
function activateCustomOption(
|
||||
state: AskState,
|
||||
questionId: string,
|
||||
questionType: AskState["questions"][number]["type"],
|
||||
trigger: "toggle" | "confirm" | "digit"
|
||||
): AskState {
|
||||
if (questionType === "multi" && trigger !== "confirm") {
|
||||
const answer = getAnswer(state, questionId);
|
||||
if (answer?.customText?.trim()) {
|
||||
return updateAnswer(state, questionId, (currentAnswer) =>
|
||||
setCustomSelected(currentAnswer, !answer.customSelected)
|
||||
);
|
||||
}
|
||||
}
|
||||
return setView(state, inputView(questionId));
|
||||
}
|
||||
|
||||
function saveInputValue(
|
||||
state: AskState,
|
||||
rawValue: string,
|
||||
@@ -239,7 +257,10 @@ function saveInputValue(
|
||||
question.type === "multi" ? "multi" : "single"
|
||||
)
|
||||
);
|
||||
if (!(submit && isAnswerAnswered(nextState.answers[question.id]))) {
|
||||
if (
|
||||
question.type === "multi" ||
|
||||
!(submit && isAnswerAnswered(nextState.answers[question.id]))
|
||||
) {
|
||||
return nextState;
|
||||
}
|
||||
return advanceToNextTab(nextState);
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface AskSelectedOption {
|
||||
}
|
||||
|
||||
export interface AskStateAnswer {
|
||||
customSelected?: boolean;
|
||||
customText?: string;
|
||||
note?: string;
|
||||
optionNotes?: Record<string, string>;
|
||||
|
||||
@@ -88,7 +88,7 @@ function buildOptionRowModel(
|
||||
const answer = getAnswer(state, question.id);
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const answered = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
? !!(answer?.customSelected && answer.customText?.trim())
|
||||
: isOptionSelected(answer, option.value);
|
||||
const pointer = getOptionPointer(option, selected);
|
||||
return {
|
||||
@@ -180,10 +180,10 @@ function getPreviewLayout(
|
||||
|
||||
function getOptionPrefix(
|
||||
questionType: QuestionRenderContext["question"]["type"],
|
||||
option: AskDisplayOption,
|
||||
_option: AskDisplayOption,
|
||||
answered: boolean
|
||||
): string {
|
||||
if (questionType !== "multi" || option.isCustomOption) {
|
||||
if (questionType !== "multi") {
|
||||
return "";
|
||||
}
|
||||
return `[${answered ? "x" : " "}] `;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getRenderableOptions } from "../src/state/selectors.ts";
|
||||
import {
|
||||
applyNumberShortcut,
|
||||
enterQuestionNoteMode,
|
||||
submitCustomAnswer,
|
||||
} from "../src/state/transitions.ts";
|
||||
import { buildQuestionScreenModel } from "../src/ui/view-models/question.ts";
|
||||
|
||||
@@ -55,6 +56,26 @@ test("question view model marks active custom option as inline editor", () => {
|
||||
assert.equal(model.rows[1]?.detail?.placeholder, "Type answer...");
|
||||
});
|
||||
|
||||
test("question view model marks multi custom answer as checked", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick many",
|
||||
type: "multi",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
state = applyNumberShortcut(state, 2);
|
||||
state = submitCustomAnswer(state, "Other answer");
|
||||
|
||||
const model = buildQuestionScreenModel(buildContext(state));
|
||||
assert.equal(model.rows[1]?.prefix, "[x] ");
|
||||
assert.equal(model.rows[1]?.color, "success");
|
||||
assert.equal(model.rows[1]?.detail?.kind, "custom-text");
|
||||
});
|
||||
|
||||
test("question view model exposes saved question notes", () => {
|
||||
let state = enterQuestionNoteMode(
|
||||
createInitialState({
|
||||
|
||||
+40
-2
@@ -390,7 +390,7 @@ test("multi-select keeps existing selections when leaving custom input empty", (
|
||||
assert.equal(state.answers.q1.customText, undefined);
|
||||
});
|
||||
|
||||
test("multi-select keeps existing selections when saving custom text", () => {
|
||||
test("multi-select keeps existing selections and stays on tab when submitting custom text", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
@@ -414,7 +414,8 @@ test("multi-select keeps existing selections when saving custom text", () => {
|
||||
state = applyNumberShortcut(state, 3);
|
||||
state = submitCustomAnswer(state, "SolidStart");
|
||||
|
||||
assert.equal(state.activeTabIndex, 1);
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
assert.deepEqual(
|
||||
state.answers.q1.selected.map((selection) => selection.value),
|
||||
["react"]
|
||||
@@ -541,6 +542,43 @@ test("deselecting an option keeps its note in state but omits it from submission
|
||||
});
|
||||
});
|
||||
|
||||
test("multi-select toggles saved custom text selection with space or number shortcuts", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick frameworks",
|
||||
type: "multi",
|
||||
options: [{ value: "react", label: "React" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
state = applyNumberShortcut(state, 2);
|
||||
state = submitCustomAnswer(state, "SolidStart");
|
||||
assert.equal(state.answers.q1.customText, "SolidStart");
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
|
||||
state = toggleCurrentMultiOption(state);
|
||||
assert.equal(state.answers.q1.customText, "SolidStart");
|
||||
assert.equal(state.answers.q1.customSelected, undefined);
|
||||
assert.equal(toAskResult(state).answers.q1, undefined);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
|
||||
state = applyNumberShortcut(state, 2);
|
||||
assert.equal(state.answers.q1.customText, "SolidStart");
|
||||
assert.equal(state.answers.q1.customSelected, true);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
assert.deepEqual(toAskResult(state).answers.q1?.values, ["SolidStart"]);
|
||||
|
||||
state = applyNumberShortcut(state, 2);
|
||||
assert.equal(state.answers.q1.customText, "SolidStart");
|
||||
assert.equal(state.answers.q1.customSelected, undefined);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
assert.equal(toAskResult(state).answers.q1, undefined);
|
||||
});
|
||||
|
||||
test("multi-select keeps custom text when toggling additional options", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
|
||||
Reference in New Issue
Block a user