feat(ask): refine inline answer and navigation UX
This commit is contained in:
@@ -114,9 +114,9 @@ export function enterOptionNoteMode(
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||
export function toggleCurrentOption(state: AskState): AskState {
|
||||
const question = getCurrentQuestion(state);
|
||||
if (!question || question.type === "single") {
|
||||
if (!question) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -128,9 +128,28 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||
return setView(state, inputView(question.id));
|
||||
}
|
||||
|
||||
return updateAnswer(state, question.id, (answer) =>
|
||||
toggleSelection(answer, option, state.activeOptionIndex),
|
||||
);
|
||||
if (question.type === "multi") {
|
||||
return updateAnswer(state, question.id, (answer) =>
|
||||
toggleSelection(answer, option, state.activeOptionIndex),
|
||||
);
|
||||
}
|
||||
|
||||
return updateAnswer(state, question.id, (answer) => {
|
||||
const isSelected = answer.selected.some(
|
||||
(selection) => selection.value === option.value,
|
||||
);
|
||||
if (isSelected) {
|
||||
return {
|
||||
...answer,
|
||||
selected: [],
|
||||
};
|
||||
}
|
||||
return setSingleSelection(answer, option, state.activeOptionIndex);
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleCurrentMultiOption(state: AskState): AskState {
|
||||
return toggleCurrentOption(state);
|
||||
}
|
||||
|
||||
export function confirmCurrentSelection(state: AskState): AskState {
|
||||
|
||||
+2
-2
@@ -23,6 +23,6 @@ export const UI_TEXT = {
|
||||
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
|
||||
footerDefault:
|
||||
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
|
||||
hintSave: "{newLineHint} newline · Enter save · Esc save and close",
|
||||
hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close",
|
||||
editorPlaceholderInput: "Type your answer...",
|
||||
editorPlaceholderNote: "Add a note...",
|
||||
} as const;
|
||||
|
||||
+21
-10
@@ -25,7 +25,7 @@ import {
|
||||
} from "../state.ts";
|
||||
import type { AskParams, AskResult, AskState } from "../types.ts";
|
||||
import type { AskInputCommand } from "./input.ts";
|
||||
import { formatKeybindingLabel, getInputCommand } from "./input.ts";
|
||||
import { getInputCommand } from "./input.ts";
|
||||
import { renderAskScreen } from "./render.ts";
|
||||
|
||||
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
|
||||
@@ -41,7 +41,6 @@ interface AskFlowController {
|
||||
state: AskState;
|
||||
cachedLines: string[] | undefined;
|
||||
suppressAutoInputForSelection: boolean;
|
||||
newLineHint: string;
|
||||
tui: Tui;
|
||||
theme: Theme;
|
||||
editor: Editor;
|
||||
@@ -58,7 +57,7 @@ export async function runAskFlow(
|
||||
}
|
||||
|
||||
function createAskFlowController(
|
||||
[tui, theme, keybindings, done]: [
|
||||
[tui, theme, _keybindings, done]: [
|
||||
Tui,
|
||||
Theme,
|
||||
Keybindings,
|
||||
@@ -70,9 +69,6 @@ function createAskFlowController(
|
||||
state: createInitialState(params),
|
||||
cachedLines: undefined,
|
||||
suppressAutoInputForSelection: false,
|
||||
newLineHint: formatKeybindingLabel(
|
||||
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
|
||||
),
|
||||
tui,
|
||||
theme,
|
||||
editor: createEditor(tui, theme),
|
||||
@@ -103,7 +99,6 @@ function renderController(
|
||||
theme: controller.theme,
|
||||
width,
|
||||
editor: controller.editor,
|
||||
newLineHint: controller.newLineHint,
|
||||
});
|
||||
}
|
||||
return controller.cachedLines;
|
||||
@@ -129,6 +124,12 @@ function handleEditingCommand(
|
||||
refresh(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveOption") {
|
||||
controller.state = saveEditorAndMoveOption(controller, command.delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editClose") {
|
||||
controller.state = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection =
|
||||
@@ -154,7 +155,7 @@ function handleNavigationCommand(
|
||||
navigateOptions(controller, command.delta);
|
||||
return;
|
||||
case "toggleMulti":
|
||||
handleToggleMulti(controller);
|
||||
handleToggleCurrentOption(controller);
|
||||
return;
|
||||
case "openQuestionNote":
|
||||
openQuestionNote(controller);
|
||||
@@ -178,6 +179,7 @@ function handleNavigationCommand(
|
||||
return;
|
||||
case "ignore":
|
||||
case "editMoveTab":
|
||||
case "editMoveOption":
|
||||
case "editClose":
|
||||
case "delegateToEditor":
|
||||
return;
|
||||
@@ -200,9 +202,9 @@ function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function handleToggleMulti(controller: AskFlowController) {
|
||||
function handleToggleCurrentOption(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (question?.type !== "multi") {
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
@@ -319,6 +321,15 @@ function saveEditorAndMoveTab(
|
||||
return moveTab(nextState, delta);
|
||||
}
|
||||
|
||||
function saveEditorAndMoveOption(
|
||||
controller: AskFlowController,
|
||||
delta: 1 | -1,
|
||||
): AskState {
|
||||
const nextState = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
return moveOption(nextState, delta);
|
||||
}
|
||||
|
||||
function createEditor(tui: Tui, theme: Theme) {
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (text) => theme.fg("accent", text),
|
||||
|
||||
@@ -11,6 +11,7 @@ export type AskInputCommand =
|
||||
| { kind: "cancel" }
|
||||
| { kind: "numberShortcut"; digit: number }
|
||||
| { kind: "editMoveTab"; delta: 1 | -1 }
|
||||
| { kind: "editMoveOption"; delta: 1 | -1 }
|
||||
| { kind: "editClose" }
|
||||
| { kind: "delegateToEditor" }
|
||||
| { kind: "ignore" };
|
||||
@@ -45,6 +46,12 @@ function getEditingInputCommand(data: string): AskInputCommand {
|
||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||
return { kind: "editMoveTab", delta: -1 };
|
||||
}
|
||||
if (matchesKey(data, Key.up)) {
|
||||
return { kind: "editMoveOption", delta: -1 };
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
return { kind: "editMoveOption", delta: 1 };
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
return { kind: "editClose" };
|
||||
}
|
||||
|
||||
+42
-11
@@ -34,11 +34,12 @@ export function renderInputLine(
|
||||
line: string,
|
||||
availableWidth: number,
|
||||
theme: Theme,
|
||||
color: ThemeColor = "text",
|
||||
): string {
|
||||
const innerWidth = Math.max(4, availableWidth - 2);
|
||||
const truncated = truncateToWidth(line, innerWidth);
|
||||
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
||||
return theme.bg("toolPendingBg", ` ${truncated}${padding} `);
|
||||
return theme.bg("selectedBg", ` ${theme.fg(color, truncated)}${padding} `);
|
||||
}
|
||||
|
||||
export function renderEditorBlock(args: {
|
||||
@@ -48,23 +49,53 @@ export function renderEditorBlock(args: {
|
||||
theme: Theme;
|
||||
indent: string;
|
||||
availableWidth: number;
|
||||
hint: string;
|
||||
placeholder?: string;
|
||||
placeholderColor?: ThemeColor;
|
||||
contentColor?: ThemeColor;
|
||||
isEmpty?: boolean;
|
||||
}) {
|
||||
const { lines, editorLines, width, theme, indent, availableWidth, hint } =
|
||||
args;
|
||||
for (const editorLine of editorLines) {
|
||||
const {
|
||||
lines,
|
||||
editorLines,
|
||||
width,
|
||||
theme,
|
||||
indent,
|
||||
availableWidth,
|
||||
placeholder,
|
||||
placeholderColor = "muted",
|
||||
contentColor = "text",
|
||||
isEmpty = false,
|
||||
} = args;
|
||||
const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines;
|
||||
|
||||
if (isEmpty && placeholder) {
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
`${indent}${renderInputLine(editorLine, availableWidth, theme)}`,
|
||||
`${indent}${renderInputLine(
|
||||
placeholder,
|
||||
availableWidth,
|
||||
theme,
|
||||
placeholderColor,
|
||||
)}`,
|
||||
width,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const editorLine of innerLines) {
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
`${indent}${renderInputLine(
|
||||
editorLine,
|
||||
availableWidth,
|
||||
theme,
|
||||
contentColor,
|
||||
)}`,
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push(truncateToWidth(`${indent}${theme.fg("dim", hint)}`, width));
|
||||
}
|
||||
|
||||
export function renderHint(template: string, newLineHint: string): string {
|
||||
return template.replace("{newLineHint}", newLineHint);
|
||||
}
|
||||
|
||||
export function renderBox(
|
||||
|
||||
+44
-20
@@ -15,7 +15,7 @@ import {
|
||||
mergeColumns,
|
||||
pushWrappedText,
|
||||
renderEditorBlock,
|
||||
renderHint,
|
||||
renderInputLine,
|
||||
renderPreviewPaneContent,
|
||||
} from "./render-helpers.ts";
|
||||
import type {
|
||||
@@ -39,7 +39,7 @@ export function renderQuestionScreen(context: QuestionRenderContext) {
|
||||
}
|
||||
|
||||
function renderQuestionNote(context: QuestionRenderContext) {
|
||||
const { lines, state, question, theme, width, editor, newLineHint } = context;
|
||||
const { lines, state, question, theme, width, editor } = context;
|
||||
if (isQuestionNoteOpen(state, question.id)) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
@@ -62,7 +62,8 @@ function renderQuestionNote(context: QuestionRenderContext) {
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 3,
|
||||
hint: renderHint(UI_TEXT.hintSave, newLineHint),
|
||||
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
lines.push("");
|
||||
return;
|
||||
@@ -105,6 +106,10 @@ function renderStandardOption(
|
||||
const pointer = selected ? "❯ " : " ";
|
||||
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
|
||||
const optionColor = getOptionColor(isAnsweredOption, selected);
|
||||
if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) {
|
||||
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
|
||||
return;
|
||||
}
|
||||
|
||||
pushWrappedText(
|
||||
lines,
|
||||
@@ -321,25 +326,13 @@ function renderOptionEditor(
|
||||
context: OptionDetailRenderContext,
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>,
|
||||
): boolean {
|
||||
const { lines, editor, width, theme, newLineHint } = context;
|
||||
const { lines, editor, width, theme } = context;
|
||||
if (renderState.noteOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
renderHint(UI_TEXT.hintSave, newLineHint),
|
||||
);
|
||||
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote);
|
||||
return true;
|
||||
}
|
||||
if (renderState.inputOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
renderHint(UI_TEXT.hintSubmit, newLineHint),
|
||||
);
|
||||
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -389,7 +382,7 @@ function renderIndentedEditor(
|
||||
editor: QuestionRenderContext["editor"],
|
||||
width: number,
|
||||
theme: Theme,
|
||||
hint: string,
|
||||
placeholder: string,
|
||||
) {
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
@@ -403,7 +396,8 @@ function renderIndentedEditor(
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 5,
|
||||
hint,
|
||||
placeholder,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -418,6 +412,36 @@ function getOptionPrefix(
|
||||
return `[${isAnsweredOption ? "x" : " "}] `;
|
||||
}
|
||||
|
||||
function renderInteractiveCustomOption(
|
||||
context: QuestionRenderContext,
|
||||
index: number,
|
||||
pointer: string,
|
||||
prefix: string,
|
||||
optionColor: ReturnType<typeof getOptionColor>,
|
||||
) {
|
||||
const { lines, editor, theme, width } = context;
|
||||
const label = `${index + 1}. ${prefix}`;
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
width - visibleWidth(pointer) - visibleWidth(label),
|
||||
);
|
||||
const text = editor.getText().replace(/\n/g, " ");
|
||||
const hasText = text.trim().length > 0;
|
||||
const inputText = hasText ? text : UI_TEXT.editorPlaceholderInput;
|
||||
const inputColor = hasText ? "text" : "muted";
|
||||
lines.push(
|
||||
truncateToWidth(
|
||||
`${pointer}${theme.fg(optionColor, label)}${renderInputLine(
|
||||
inputText,
|
||||
availableWidth,
|
||||
theme,
|
||||
inputColor,
|
||||
)}`,
|
||||
width,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
|
||||
if (isAnsweredOption) {
|
||||
return "success";
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface QuestionRenderContext {
|
||||
theme: Theme;
|
||||
width: number;
|
||||
editor: Editor;
|
||||
newLineHint: string;
|
||||
}
|
||||
|
||||
export interface OptionDetailRenderContext {
|
||||
@@ -29,7 +28,6 @@ export interface OptionDetailRenderContext {
|
||||
theme: Theme;
|
||||
width: number;
|
||||
editor: Editor;
|
||||
newLineHint: string;
|
||||
selected?: boolean;
|
||||
withGap?: boolean;
|
||||
}
|
||||
|
||||
+1
-3
@@ -14,9 +14,8 @@ export function renderAskScreen(args: {
|
||||
theme: Theme;
|
||||
width: number;
|
||||
editor: QuestionRenderContext["editor"];
|
||||
newLineHint: string;
|
||||
}): string[] {
|
||||
const { state, theme, width, editor, newLineHint } = args;
|
||||
const { state, theme, width, editor } = args;
|
||||
const lines: string[] = [];
|
||||
const question = getCurrentQuestion(state);
|
||||
const options = getRenderableOptions(question);
|
||||
@@ -34,7 +33,6 @@ export function renderAskScreen(args: {
|
||||
theme,
|
||||
width,
|
||||
editor,
|
||||
newLineHint,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { applyNumberShortcut, createInitialState, enterQuestionNoteMode } from "../src/state.ts";
|
||||
import { getInputCommand } from "../src/ui/input.ts";
|
||||
|
||||
function inputState() {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Question?",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
state = applyNumberShortcut(state, 2);
|
||||
return state;
|
||||
}
|
||||
|
||||
test("editing views let up and down move navigation", () => {
|
||||
const input = inputState();
|
||||
assert.deepEqual(getInputCommand(input, "\x1b[A"), {
|
||||
kind: "editMoveOption",
|
||||
delta: -1,
|
||||
});
|
||||
assert.deepEqual(getInputCommand(input, "\x1b[B"), {
|
||||
kind: "editMoveOption",
|
||||
delta: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("note editing views let up and down move navigation", () => {
|
||||
const state = enterQuestionNoteMode(createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Question?",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
}), "q1");
|
||||
|
||||
assert.deepEqual(getInputCommand(state, "\x1b[A"), {
|
||||
kind: "editMoveOption",
|
||||
delta: -1,
|
||||
});
|
||||
assert.deepEqual(getInputCommand(state, "\x1b[B"), {
|
||||
kind: "editMoveOption",
|
||||
delta: 1,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { renderEditorBlock } from "../src/ui/render-helpers.ts";
|
||||
|
||||
function mockTheme() {
|
||||
return {
|
||||
fg(color: string, text: string) {
|
||||
return `<${color}>${text}</${color}>`;
|
||||
},
|
||||
bg(color: string, text: string) {
|
||||
return `{${color}}${text}{/${color}}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("renderEditorBlock strips editor borders", () => {
|
||||
const lines: string[] = [];
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: ["┌──┐", "│abc│", "└──┘"],
|
||||
width: 40,
|
||||
theme: mockTheme() as never,
|
||||
indent: " ",
|
||||
availableWidth: 40,
|
||||
});
|
||||
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /abc/);
|
||||
assert.doesNotMatch(lines[0], /┌|└/);
|
||||
});
|
||||
|
||||
test("renderEditorBlock shows a muted placeholder when empty", () => {
|
||||
const lines: string[] = [];
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: ["┌──┐", "│ │", "└──┘"],
|
||||
width: 40,
|
||||
theme: mockTheme() as never,
|
||||
indent: " ",
|
||||
availableWidth: 40,
|
||||
placeholder: "Type your",
|
||||
isEmpty: true,
|
||||
});
|
||||
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /Type your/);
|
||||
assert.doesNotMatch(lines[0], /┌|└/);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { applyNumberShortcut, createInitialState } from "../src/state.ts";
|
||||
import { getRenderableOptions } from "../src/state/selectors.ts";
|
||||
import { renderQuestionScreen } from "../src/ui/render-question.ts";
|
||||
|
||||
function mockTheme() {
|
||||
return {
|
||||
fg(color: string, text: string) {
|
||||
return `<${color}>${text}</${color}>`;
|
||||
},
|
||||
bg(color: string, text: string) {
|
||||
return `{${color}}${text}{/${color}}`;
|
||||
},
|
||||
bold(text: string) {
|
||||
return text;
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function mockEditor(text = "") {
|
||||
return {
|
||||
getText() {
|
||||
return text;
|
||||
},
|
||||
render() {
|
||||
return [];
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
test("custom option stays labeled before selection", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick one",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const lines: string[] = [];
|
||||
renderQuestionScreen({
|
||||
lines,
|
||||
state,
|
||||
question: state.questions[0],
|
||||
options: getRenderableOptions(state.questions[0]),
|
||||
theme: mockTheme(),
|
||||
width: 80,
|
||||
editor: mockEditor(),
|
||||
});
|
||||
|
||||
assert(lines.some((line) => line.includes("Type your own")));
|
||||
assert(!lines.some((line) => line.includes("Type your answer...")));
|
||||
});
|
||||
|
||||
test("selected custom option becomes inline input", () => {
|
||||
let state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Pick one",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
state = applyNumberShortcut(state, 2);
|
||||
|
||||
const lines: string[] = [];
|
||||
renderQuestionScreen({
|
||||
lines,
|
||||
state,
|
||||
question: state.questions[0],
|
||||
options: getRenderableOptions(state.questions[0]),
|
||||
theme: mockTheme(),
|
||||
width: 80,
|
||||
editor: mockEditor(""),
|
||||
});
|
||||
|
||||
assert(lines.some((line) => line.includes("Type your answer...")));
|
||||
assert(!lines.some((line) => line.includes("Type your own answer")));
|
||||
});
|
||||
@@ -122,6 +122,20 @@ test("multi-select space toggles current option and enter advances to submit", (
|
||||
assert.equal(state.completed, false);
|
||||
});
|
||||
|
||||
test("single-select space toggles the active option without advancing", () => {
|
||||
let state = createInitialState(sampleParams());
|
||||
state = toggleCurrentMultiOption(state);
|
||||
|
||||
assert.equal(state.answers.lang.selected[0].label, "Python");
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
|
||||
state = toggleCurrentMultiOption(state);
|
||||
assert.equal(state.answers.lang, undefined);
|
||||
assert.equal(state.activeTabIndex, 0);
|
||||
assert.equal(state.view.kind, "navigate");
|
||||
});
|
||||
|
||||
test("submit can complete even when some questions are unanswered", () => {
|
||||
let state = createInitialState(sampleParams());
|
||||
state = moveTab(state, -1);
|
||||
|
||||
Reference in New Issue
Block a user