feat: add dirty-dismiss and footer hint settings
This commit is contained in:
@@ -10,6 +10,7 @@ import { maybeAutoSubmitState } from "../src/ui/auto-submit.ts";
|
||||
const enabledConfig: AskConfig = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
},
|
||||
};
|
||||
@@ -17,6 +18,7 @@ const enabledConfig: AskConfig = {
|
||||
const disabledConfig: AskConfig = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,6 +34,8 @@ test("config store writes full normalized config on save", async () => {
|
||||
await store.save({
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
});
|
||||
@@ -43,6 +45,8 @@ test("config store writes full normalized config on save", async () => {
|
||||
schemaVersion: 1,
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
});
|
||||
@@ -77,7 +81,11 @@ test("config store loads current config version without rewriting", async () =>
|
||||
path,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: DEFAULT_ASK_CONFIG.keymaps,
|
||||
})
|
||||
);
|
||||
@@ -89,6 +97,8 @@ test("config store loads current config version without rewriting", async () =>
|
||||
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||
true
|
||||
);
|
||||
assert.equal(result.config.behaviour.confirmDismissWhenDirty, true);
|
||||
assert.equal(result.config.behaviour.showFooterHints, false);
|
||||
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
|
||||
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||
});
|
||||
@@ -100,7 +110,11 @@ test("config store falls back only keymaps when configured keymaps are invalid",
|
||||
path,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
behaviour: { autoSubmitWhenAnsweredWithoutNotes: true },
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: true,
|
||||
confirmDismissWhenDirty: true,
|
||||
showFooterHints: false,
|
||||
},
|
||||
keymaps: {
|
||||
cancel: "?",
|
||||
dismiss: "ctrl+c",
|
||||
@@ -119,6 +133,8 @@ test("config store falls back only keymaps when configured keymaps are invalid",
|
||||
result.config.behaviour.autoSubmitWhenAnsweredWithoutNotes,
|
||||
true
|
||||
);
|
||||
assert.equal(result.config.behaviour.confirmDismissWhenDirty, true);
|
||||
assert.equal(result.config.behaviour.showFooterHints, false);
|
||||
assert.deepEqual(result.config.keymaps, DEFAULT_ASK_CONFIG.keymaps);
|
||||
assert.match(result.notice?.text ?? "", DEFAULT_KEYMAPS_NOTICE_PATTERN);
|
||||
await rm(join(path, "..", ".."), { force: true, recursive: true });
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
|
||||
import { createInitialState } from "../src/state/create.ts";
|
||||
import {
|
||||
applyNumberShortcut,
|
||||
enterQuestionNoteMode,
|
||||
} from "../src/state/transitions.ts";
|
||||
import {
|
||||
hasDirtyFlowState,
|
||||
shouldConfirmDirtyDismiss,
|
||||
shouldDiscardAfterConfirmation,
|
||||
} from "../src/ui/dismiss-guard.ts";
|
||||
|
||||
test("dirty flow state is false for untouched ask flow", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{ id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(hasDirtyFlowState(state), false);
|
||||
});
|
||||
|
||||
test("dirty flow state is true for saved answers or editor drafts", () => {
|
||||
const base = createInitialState({
|
||||
questions: [
|
||||
{ id: "q1", prompt: "Question?", options: [{ value: "a", label: "A" }] },
|
||||
],
|
||||
});
|
||||
const answered = applyNumberShortcut(base, 1);
|
||||
const noteEditing = enterQuestionNoteMode(base, "q1");
|
||||
|
||||
assert.equal(hasDirtyFlowState(answered), true);
|
||||
assert.equal(hasDirtyFlowState(noteEditing, "draft note"), true);
|
||||
});
|
||||
|
||||
test("confirm dismiss when dirty is opt-in and respects dirty state", () => {
|
||||
const state = applyNumberShortcut(
|
||||
createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
prompt: "Question?",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
1
|
||||
);
|
||||
const enabledConfig = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
confirmDismissWhenDirty: true,
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldConfirmDirtyDismiss({ config: DEFAULT_ASK_CONFIG, state }),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
shouldConfirmDirtyDismiss({ config: enabledConfig, state }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("dismiss confirmation is active while pending", () => {
|
||||
assert.equal(shouldDiscardAfterConfirmation(false), false);
|
||||
assert.equal(shouldDiscardAfterConfirmation(true), true);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { DEFAULT_ASK_CONFIG } from "../src/config/defaults.ts";
|
||||
import { createInitialState } from "../src/state/create.ts";
|
||||
import { moveOption, moveTab } from "../src/state/transitions.ts";
|
||||
import { renderAskScreen } from "../src/ui/render.ts";
|
||||
|
||||
function mockEditor() {
|
||||
return {
|
||||
getText() {
|
||||
return "";
|
||||
},
|
||||
render() {
|
||||
return [];
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function plainTheme() {
|
||||
return {
|
||||
fg(_color: string, text: string) {
|
||||
return text;
|
||||
},
|
||||
bg(_color: string, text: string) {
|
||||
return text;
|
||||
},
|
||||
bold(text: string) {
|
||||
return text;
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
const notice =
|
||||
"Unsaved ask answers or drafts. Press cancel/dismiss again to discard.";
|
||||
|
||||
test("dismiss notice stays visible on same tab and clears after tab change", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
label: "One",
|
||||
prompt: "One",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
label: "Two",
|
||||
prompt: "Two",
|
||||
options: [{ value: "b", label: "B" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const config = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
confirmDismissWhenDirty: true,
|
||||
},
|
||||
};
|
||||
|
||||
const sameTabLines = renderAskScreen({
|
||||
config,
|
||||
footerNotice: notice,
|
||||
state: moveOption(state, 1),
|
||||
theme: plainTheme(),
|
||||
width: 80,
|
||||
editor: mockEditor(),
|
||||
});
|
||||
assert.equal(sameTabLines.join("\n").includes(notice), true);
|
||||
|
||||
const nextTabLines = renderAskScreen({
|
||||
config,
|
||||
state: moveTab(state, 1),
|
||||
theme: plainTheme(),
|
||||
width: 80,
|
||||
editor: mockEditor(),
|
||||
});
|
||||
assert.equal(nextTabLines.join("\n").includes(notice), false);
|
||||
});
|
||||
@@ -261,3 +261,35 @@ test("footer keeps earlier hint chunk on the first wrapped line", () => {
|
||||
"? settings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("footer hints can be hidden without affecting frame rendering", () => {
|
||||
const state = createInitialState({
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
label: "One",
|
||||
prompt: "One",
|
||||
options: [{ value: "a", label: "A" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
const config = {
|
||||
...DEFAULT_ASK_CONFIG,
|
||||
behaviour: {
|
||||
...DEFAULT_ASK_CONFIG.behaviour,
|
||||
showFooterHints: false,
|
||||
},
|
||||
};
|
||||
|
||||
const lines = renderAskScreen({
|
||||
config,
|
||||
state,
|
||||
theme: plainTheme(),
|
||||
width: 22,
|
||||
editor: mockEditor(),
|
||||
});
|
||||
|
||||
assert.equal(lines.at(-1), "──────────────────────");
|
||||
assert.equal(lines.join("\n").includes("? settings"), false);
|
||||
assert.equal(lines.join("\n").includes("Enter confirm"), false);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { AskSettingsModal } from "../src/ui/settings-modal.ts";
|
||||
const savedConfig: AskConfig = {
|
||||
behaviour: {
|
||||
autoSubmitWhenAnsweredWithoutNotes: false,
|
||||
confirmDismissWhenDirty: false,
|
||||
showFooterHints: true,
|
||||
},
|
||||
keymaps: {
|
||||
cancel: "esc",
|
||||
@@ -75,6 +77,8 @@ test("settings modal renders behaviour tab with checkbox and dirty notice", () =
|
||||
|
||||
assert(text.includes("Behaviour"));
|
||||
assert(text.includes("[x] Auto-submit when answered without notes"));
|
||||
assert(text.includes("[ ] Confirm dismiss when dirty"));
|
||||
assert(text.includes("[x] Show footer hints"));
|
||||
assert(text.includes("Unsaved changes. Press Ctrl+S to save."));
|
||||
assert(text.includes("Ctrl+S save · Esc/Ctrl+C/? twice discard"));
|
||||
});
|
||||
@@ -131,6 +135,8 @@ test("settings modal saves and clears dirty state", async () => {
|
||||
|
||||
const text = modal.render(72).join("\n");
|
||||
assert.equal(saved?.behaviour.autoSubmitWhenAnsweredWithoutNotes, true);
|
||||
assert.equal(saved?.behaviour.confirmDismissWhenDirty, false);
|
||||
assert.equal(saved?.behaviour.showFooterHints, true);
|
||||
assert(text.includes("Saved"));
|
||||
assert.equal(text.includes("Unsaved changes. Press Ctrl+S to save."), false);
|
||||
assert.equal(
|
||||
|
||||
Reference in New Issue
Block a user