@ryan_nookpi/pi-extension-ask-user-question 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -5
- package/constants.ts +10 -0
- package/controller.test.ts +281 -0
- package/controller.ts +280 -0
- package/form-ui.test.ts +57 -0
- package/form-ui.ts +38 -0
- package/index.test.ts +113 -0
- package/index.ts +62 -510
- package/output.test.ts +102 -0
- package/output.ts +89 -0
- package/package.json +2 -2
- package/schema.ts +37 -0
- package/state.test.ts +136 -0
- package/state.ts +131 -0
- package/types.ts +85 -0
- package/view.test.ts +337 -0
- package/view.ts +218 -0
package/output.test.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
buildRenderCallText,
|
|
5
|
+
buildRenderResultText,
|
|
6
|
+
errorResult,
|
|
7
|
+
formatResultContent,
|
|
8
|
+
renderCall,
|
|
9
|
+
renderResult,
|
|
10
|
+
} from "./output.ts";
|
|
11
|
+
import type { FormResult, RenderTheme } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const theme: RenderTheme = {
|
|
14
|
+
fg: (_color, text) => text,
|
|
15
|
+
bg: (_color, text) => `[${text}]`,
|
|
16
|
+
bold: (text) => `**${text}**`,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const formResult: FormResult = {
|
|
20
|
+
title: "Form",
|
|
21
|
+
cancelled: false,
|
|
22
|
+
questions: [
|
|
23
|
+
{ id: "q1", type: "radio", prompt: "Q1", label: "One", options: [], allowOther: true, required: true },
|
|
24
|
+
{ id: "q2", type: "checkbox", prompt: "Q2", label: "Two", options: [], allowOther: true, required: true },
|
|
25
|
+
{ id: "q3", type: "text", prompt: "Q3", label: "Three", options: [], allowOther: false, required: true },
|
|
26
|
+
],
|
|
27
|
+
answers: [
|
|
28
|
+
{ id: "q1", type: "radio", value: "custom", wasCustom: true },
|
|
29
|
+
{ id: "q2", type: "checkbox", value: [], wasCustom: false },
|
|
30
|
+
{ id: "q3", type: "text", value: "", wasCustom: true },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("ask-user-question/output", () => {
|
|
35
|
+
it("builds error results", () => {
|
|
36
|
+
expect(errorResult("boom")).toEqual({
|
|
37
|
+
content: [{ type: "text", text: "boom" }],
|
|
38
|
+
details: { questions: [], answers: [], cancelled: true },
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("formats successful result content", () => {
|
|
43
|
+
expect(formatResultContent(formResult)).toBe("One: (wrote) custom\nTwo: (none selected)\nThree: (empty)");
|
|
44
|
+
expect(
|
|
45
|
+
formatResultContent({
|
|
46
|
+
...formResult,
|
|
47
|
+
answers: [
|
|
48
|
+
{ id: "missing", type: "radio", value: "picked", wasCustom: false },
|
|
49
|
+
{ id: "q2", type: "checkbox", value: ["a", "b"], wasCustom: false },
|
|
50
|
+
{ id: "q3", type: "text", value: "filled", wasCustom: true },
|
|
51
|
+
],
|
|
52
|
+
}),
|
|
53
|
+
).toBe("missing: picked\nTwo: a, b\nThree: filled");
|
|
54
|
+
expect(
|
|
55
|
+
formatResultContent({
|
|
56
|
+
...formResult,
|
|
57
|
+
answers: [{ id: "q2", type: "checkbox", value: "solo", wasCustom: false }],
|
|
58
|
+
}),
|
|
59
|
+
).toBe("Two: solo");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("builds render-call summaries", () => {
|
|
63
|
+
expect(
|
|
64
|
+
buildRenderCallText({ title: "Title", questions: [{ id: "x", type: "radio", prompt: "Q" }] }, theme),
|
|
65
|
+
).toContain("Title 1 question (radio)");
|
|
66
|
+
expect(buildRenderCallText({}, theme)).toContain("0 questions");
|
|
67
|
+
expect(renderCall({ title: "Title", questions: [] }, theme)).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("builds render-result text across branches", () => {
|
|
71
|
+
expect(buildRenderResultText({ content: [{ type: "text", text: "plain" }] }, theme)).toBe("plain");
|
|
72
|
+
expect(buildRenderResultText({ content: [{ type: "text" }] }, theme)).toBe("");
|
|
73
|
+
expect(buildRenderResultText({ content: [{ type: "image", text: "ignored" }] }, theme)).toBe("");
|
|
74
|
+
expect(buildRenderResultText({}, theme)).toBe("");
|
|
75
|
+
expect(buildRenderResultText({ details: { ...formResult, cancelled: true } }, theme)).toBe("Cancelled");
|
|
76
|
+
expect(buildRenderResultText({ details: formResult }, theme)).toBe(
|
|
77
|
+
"✓ One: (wrote) custom\n✓ Two: (none)\n✓ Three: (empty)",
|
|
78
|
+
);
|
|
79
|
+
expect(
|
|
80
|
+
buildRenderResultText(
|
|
81
|
+
{
|
|
82
|
+
details: {
|
|
83
|
+
...formResult,
|
|
84
|
+
answers: [
|
|
85
|
+
{ id: "missing", type: "radio", value: "picked", wasCustom: false },
|
|
86
|
+
{ id: "q2", type: "checkbox", value: ["a"], wasCustom: false },
|
|
87
|
+
{ id: "q3", type: "text", value: "filled", wasCustom: true },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
theme,
|
|
92
|
+
),
|
|
93
|
+
).toBe("✓ missing: picked\n✓ Two: a\n✓ Three: filled");
|
|
94
|
+
expect(
|
|
95
|
+
buildRenderResultText(
|
|
96
|
+
{ details: { ...formResult, answers: [{ id: "q2", type: "checkbox", value: "solo", wasCustom: false }] } },
|
|
97
|
+
theme,
|
|
98
|
+
),
|
|
99
|
+
).toBe("✓ Two: solo");
|
|
100
|
+
expect(renderResult({ details: formResult }, theme)).toBeTruthy();
|
|
101
|
+
});
|
|
102
|
+
});
|
package/output.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { SYM } from "./constants.ts";
|
|
4
|
+
import type { FormResult, Question, RenderTheme } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export function errorResult(message: string): {
|
|
7
|
+
content: { type: "text"; text: string }[];
|
|
8
|
+
details: FormResult;
|
|
9
|
+
} {
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: "text", text: message }],
|
|
12
|
+
details: { questions: [], answers: [], cancelled: true },
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatResultContent(result: FormResult): string {
|
|
17
|
+
return result.answers
|
|
18
|
+
.map((answer) => {
|
|
19
|
+
const question = result.questions.find((candidate) => candidate.id === answer.id);
|
|
20
|
+
const label = question?.label || answer.id;
|
|
21
|
+
if (answer.type === "radio") {
|
|
22
|
+
const prefix = answer.wasCustom ? "(wrote) " : "";
|
|
23
|
+
return `${label}: ${prefix}${answer.value}`;
|
|
24
|
+
}
|
|
25
|
+
if (answer.type === "checkbox") {
|
|
26
|
+
const values = Array.isArray(answer.value) ? answer.value : [answer.value];
|
|
27
|
+
return values.length === 0 ? `${label}: (none selected)` : `${label}: ${values.join(", ")}`;
|
|
28
|
+
}
|
|
29
|
+
return `${label}: ${answer.value || "(empty)"}`;
|
|
30
|
+
})
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildRenderCallText(args: { questions?: Question[]; title?: string }, theme: RenderTheme): string {
|
|
35
|
+
const questions = args.questions || [];
|
|
36
|
+
let text = theme.fg("toolTitle", theme.bold("ask_user_question "));
|
|
37
|
+
if (args.title) {
|
|
38
|
+
text += `${theme.fg("accent", args.title)} `;
|
|
39
|
+
}
|
|
40
|
+
text += theme.fg("muted", `${questions.length} question${questions.length !== 1 ? "s" : ""}`);
|
|
41
|
+
const types = [...new Set(questions.map((question) => question.type))].join(", ");
|
|
42
|
+
if (types) {
|
|
43
|
+
text += theme.fg("dim", ` (${types})`);
|
|
44
|
+
}
|
|
45
|
+
return text;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function buildRenderResultText(
|
|
49
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: FormResult },
|
|
50
|
+
theme: RenderTheme,
|
|
51
|
+
): string {
|
|
52
|
+
const details = result.details;
|
|
53
|
+
if (!details) {
|
|
54
|
+
const text = result.content?.[0];
|
|
55
|
+
return text?.type === "text" ? (text.text ?? "") : "";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (details.cancelled) {
|
|
59
|
+
return theme.fg("warning", "Cancelled");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return details.answers
|
|
63
|
+
.map((answer) => {
|
|
64
|
+
const question = details.questions.find((candidate) => candidate.id === answer.id);
|
|
65
|
+
const label = question?.label || answer.id;
|
|
66
|
+
if (answer.type === "radio") {
|
|
67
|
+
const prefix = answer.wasCustom ? theme.fg("dim", "(wrote) ") : "";
|
|
68
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${prefix}${answer.value}`;
|
|
69
|
+
}
|
|
70
|
+
if (answer.type === "checkbox") {
|
|
71
|
+
const values = Array.isArray(answer.value) ? answer.value : [answer.value];
|
|
72
|
+
const display = values.length ? values.join(", ") : theme.fg("dim", "(none)");
|
|
73
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${display}`;
|
|
74
|
+
}
|
|
75
|
+
return `${theme.fg("success", SYM.check)} ${theme.fg("accent", label)}: ${answer.value || theme.fg("dim", "(empty)")}`;
|
|
76
|
+
})
|
|
77
|
+
.join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function renderCall(args: { questions?: Question[]; title?: string }, theme: RenderTheme): Text {
|
|
81
|
+
return new Text(buildRenderCallText(args, theme), 0, 0);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderResult(
|
|
85
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: FormResult },
|
|
86
|
+
theme: RenderTheme,
|
|
87
|
+
): Text {
|
|
88
|
+
return new Text(buildRenderResultText(result, theme), 0, 0);
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ryan_nookpi/pi-extension-ask-user-question",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "AskUserQuestion tool extension for pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package"
|
|
8
8
|
],
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
10
|
+
"*.ts",
|
|
11
11
|
"README.md"
|
|
12
12
|
],
|
|
13
13
|
"pi": {
|
package/schema.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
|
|
4
|
+
export const OptionSchema = Type.Object({
|
|
5
|
+
value: Type.String({ description: "Value returned when selected" }),
|
|
6
|
+
label: Type.String({ description: "Display label" }),
|
|
7
|
+
description: Type.Optional(Type.String({ description: "Help text shown below the label" })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const QuestionSchema = Type.Object({
|
|
11
|
+
id: Type.String({ description: "Unique identifier for this question" }),
|
|
12
|
+
type: StringEnum(["radio", "checkbox", "text"] as const, {
|
|
13
|
+
description: "Question type: radio (single-select), checkbox (multi-select), or text (free input)",
|
|
14
|
+
}),
|
|
15
|
+
prompt: Type.String({ description: "The question text to display" }),
|
|
16
|
+
label: Type.Optional(Type.String({ description: "Short label for tab bar (defaults to Q1, Q2...)" })),
|
|
17
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: "Options for radio/checkbox types" })),
|
|
18
|
+
allowOther: Type.Optional(
|
|
19
|
+
Type.Boolean({ description: "Add an 'Other...' option with text input (default: true for radio/checkbox)" }),
|
|
20
|
+
),
|
|
21
|
+
required: Type.Optional(Type.Boolean({ description: "Whether an answer is required (default: true)" })),
|
|
22
|
+
placeholder: Type.Optional(Type.String({ description: "Placeholder for text inputs" })),
|
|
23
|
+
default: Type.Optional(
|
|
24
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
25
|
+
description: "Default value(s). String for radio/text, string[] for checkbox",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const AskUserQuestionParams = Type.Object({
|
|
31
|
+
title: Type.Optional(Type.String({ description: "Form title displayed at the top" })),
|
|
32
|
+
description: Type.Optional(Type.String({ description: "Brief context or instructions shown under the title" })),
|
|
33
|
+
questions: Type.Array(QuestionSchema, {
|
|
34
|
+
description:
|
|
35
|
+
"One or more questions to ask. Use radio for single-select, checkbox for multi-select, text for free input",
|
|
36
|
+
}),
|
|
37
|
+
});
|
package/state.test.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
allRequiredAnswered,
|
|
5
|
+
buildAnswers,
|
|
6
|
+
createAnswerState,
|
|
7
|
+
isAnswered,
|
|
8
|
+
normalizeQuestions,
|
|
9
|
+
optionCount,
|
|
10
|
+
saveOtherAnswer,
|
|
11
|
+
saveTextAnswer,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
import type { Question } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const baseQuestions: Question[] = [
|
|
16
|
+
{
|
|
17
|
+
id: "radio",
|
|
18
|
+
type: "radio",
|
|
19
|
+
prompt: "Pick one",
|
|
20
|
+
options: [
|
|
21
|
+
{ value: "a", label: "Alpha" },
|
|
22
|
+
{ value: "b", label: "Beta" },
|
|
23
|
+
],
|
|
24
|
+
default: "b",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "check",
|
|
28
|
+
type: "checkbox",
|
|
29
|
+
prompt: "Pick many",
|
|
30
|
+
options: [{ value: "a", label: "Alpha" }],
|
|
31
|
+
default: ["a", "a"],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "text",
|
|
35
|
+
type: "text",
|
|
36
|
+
prompt: "Tell me",
|
|
37
|
+
default: " hello ",
|
|
38
|
+
required: false,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "optional-radio",
|
|
42
|
+
type: "radio",
|
|
43
|
+
prompt: "Optional",
|
|
44
|
+
options: [{ value: "x", label: "X" }],
|
|
45
|
+
required: false,
|
|
46
|
+
allowOther: false,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
describe("ask-user-question/state", () => {
|
|
51
|
+
it("normalizes questions and applies defaults", () => {
|
|
52
|
+
const questions = normalizeQuestions(baseQuestions);
|
|
53
|
+
const answerState = createAnswerState(questions);
|
|
54
|
+
|
|
55
|
+
expect(questions[0]).toMatchObject({ label: "Q1", allowOther: true, required: true });
|
|
56
|
+
expect(questions[2]).toMatchObject({ allowOther: false, required: false });
|
|
57
|
+
expect(questions[3]).toMatchObject({ label: "Q4", allowOther: false, required: false });
|
|
58
|
+
expect(answerState.radioAnswers.get("radio")).toEqual({ value: "b", label: "Beta", wasCustom: false });
|
|
59
|
+
expect(answerState.checkAnswers.get("check")).toEqual(new Set(["a"]));
|
|
60
|
+
expect(answerState.textAnswers.get("text")).toBe(" hello ");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("ignores unmatched radio defaults and calculates option counts", () => {
|
|
64
|
+
const questions = normalizeQuestions([
|
|
65
|
+
{
|
|
66
|
+
id: "x",
|
|
67
|
+
type: "radio",
|
|
68
|
+
prompt: "Q",
|
|
69
|
+
options: [{ value: "a", label: "A" }],
|
|
70
|
+
default: "missing",
|
|
71
|
+
allowOther: false,
|
|
72
|
+
},
|
|
73
|
+
{ id: "y", type: "text", prompt: "Text" },
|
|
74
|
+
]);
|
|
75
|
+
const answerState = createAnswerState(questions);
|
|
76
|
+
|
|
77
|
+
expect(answerState.radioAnswers.has("x")).toBe(false);
|
|
78
|
+
expect(optionCount(questions[0])).toBe(1);
|
|
79
|
+
expect(optionCount(questions[1])).toBe(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("tracks answered state and required completion", () => {
|
|
83
|
+
const questions = normalizeQuestions(baseQuestions);
|
|
84
|
+
const answerState = createAnswerState(questions);
|
|
85
|
+
|
|
86
|
+
expect(isAnswered(answerState, questions[0])).toBe(true);
|
|
87
|
+
expect(isAnswered(answerState, questions[1])).toBe(true);
|
|
88
|
+
expect(isAnswered(answerState, questions[2])).toBe(true);
|
|
89
|
+
expect(isAnswered(answerState, questions[3])).toBe(false);
|
|
90
|
+
expect(allRequiredAnswered(answerState, questions)).toBe(true);
|
|
91
|
+
|
|
92
|
+
saveTextAnswer(answerState, "text", " ");
|
|
93
|
+
expect(isAnswered(answerState, questions[2])).toBe(false);
|
|
94
|
+
expect(allRequiredAnswered(answerState, questions)).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("saves text answers and custom answers only when valid", () => {
|
|
98
|
+
const questions = normalizeQuestions(baseQuestions);
|
|
99
|
+
const answerState = createAnswerState(questions);
|
|
100
|
+
|
|
101
|
+
saveTextAnswer(answerState, "text", " updated ");
|
|
102
|
+
saveOtherAnswer(answerState, questions, "radio", " custom radio ");
|
|
103
|
+
saveOtherAnswer(answerState, questions, "check", " custom check ");
|
|
104
|
+
saveOtherAnswer(answerState, questions, "missing", "ignored");
|
|
105
|
+
saveOtherAnswer(answerState, questions, "text", "ignored");
|
|
106
|
+
saveOtherAnswer(answerState, questions, "check", " ");
|
|
107
|
+
|
|
108
|
+
expect(answerState.textAnswers.get("text")).toBe("updated");
|
|
109
|
+
expect(answerState.radioAnswers.get("radio")).toEqual({
|
|
110
|
+
value: "custom radio",
|
|
111
|
+
label: "custom radio",
|
|
112
|
+
wasCustom: true,
|
|
113
|
+
});
|
|
114
|
+
expect(answerState.checkCustom.get("check")).toBe("custom check");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("builds result answers for all question types", () => {
|
|
118
|
+
const questions = normalizeQuestions(baseQuestions);
|
|
119
|
+
const answerState = createAnswerState(questions);
|
|
120
|
+
saveOtherAnswer(answerState, questions, "radio", "custom radio");
|
|
121
|
+
saveOtherAnswer(answerState, questions, "check", "custom check");
|
|
122
|
+
saveTextAnswer(answerState, "text", "updated");
|
|
123
|
+
|
|
124
|
+
expect(buildAnswers(answerState, questions)).toEqual([
|
|
125
|
+
{ id: "radio", type: "radio", value: "custom radio", wasCustom: true },
|
|
126
|
+
{ id: "check", type: "checkbox", value: ["a", "custom check"], wasCustom: true },
|
|
127
|
+
{ id: "text", type: "text", value: "updated", wasCustom: true },
|
|
128
|
+
{ id: "optional-radio", type: "radio", value: "", wasCustom: false },
|
|
129
|
+
]);
|
|
130
|
+
const defaultState = createAnswerState(questions);
|
|
131
|
+
defaultState.checkAnswers.delete("check");
|
|
132
|
+
const defaultAnswers = buildAnswers(defaultState, questions);
|
|
133
|
+
expect(defaultAnswers[1]).toEqual({ id: "check", type: "checkbox", value: [], wasCustom: false });
|
|
134
|
+
expect(defaultAnswers[3]).toEqual({ id: "optional-radio", type: "radio", value: "", wasCustom: false });
|
|
135
|
+
});
|
|
136
|
+
});
|
package/state.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Answer, AnswerState, NormalizedQuestion, Question } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function normalizeQuestions(questions: Question[]): NormalizedQuestion[] {
|
|
4
|
+
return questions.map((question, index) => ({
|
|
5
|
+
...question,
|
|
6
|
+
label: question.label || `Q${index + 1}`,
|
|
7
|
+
options: question.options || [],
|
|
8
|
+
allowOther: question.type === "text" ? false : question.allowOther !== false,
|
|
9
|
+
required: question.required !== false,
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createAnswerState(questions: NormalizedQuestion[]): AnswerState {
|
|
14
|
+
const answerState: AnswerState = {
|
|
15
|
+
radioAnswers: new Map(),
|
|
16
|
+
checkAnswers: new Map(),
|
|
17
|
+
checkCustom: new Map(),
|
|
18
|
+
textAnswers: new Map(),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
for (const question of questions) {
|
|
22
|
+
if (question.type === "checkbox") {
|
|
23
|
+
const defaults = new Set<string>();
|
|
24
|
+
if (Array.isArray(question.default)) {
|
|
25
|
+
for (const value of question.default) defaults.add(value);
|
|
26
|
+
}
|
|
27
|
+
answerState.checkAnswers.set(question.id, defaults);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (question.type === "text" && typeof question.default === "string") {
|
|
32
|
+
answerState.textAnswers.set(question.id, question.default);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (question.type === "radio" && typeof question.default === "string") {
|
|
37
|
+
const option = question.options.find((candidate) => candidate.value === question.default);
|
|
38
|
+
if (option) {
|
|
39
|
+
answerState.radioAnswers.set(question.id, {
|
|
40
|
+
value: option.value,
|
|
41
|
+
label: option.label,
|
|
42
|
+
wasCustom: false,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return answerState;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function optionCount(question: NormalizedQuestion): number {
|
|
52
|
+
if (question.type === "text") return 0;
|
|
53
|
+
return question.options.length + (question.allowOther ? 1 : 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function saveTextAnswer(answerState: AnswerState, questionId: string, text: string): void {
|
|
57
|
+
const trimmed = text.trim();
|
|
58
|
+
if (trimmed) {
|
|
59
|
+
answerState.textAnswers.set(questionId, trimmed);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
answerState.textAnswers.delete(questionId);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveOtherAnswer(
|
|
66
|
+
answerState: AnswerState,
|
|
67
|
+
questions: NormalizedQuestion[],
|
|
68
|
+
questionId: string,
|
|
69
|
+
text: string,
|
|
70
|
+
): void {
|
|
71
|
+
const question = questions.find((candidate) => candidate.id === questionId);
|
|
72
|
+
const trimmed = text.trim();
|
|
73
|
+
if (!question || !trimmed) return;
|
|
74
|
+
|
|
75
|
+
if (question.type === "radio") {
|
|
76
|
+
answerState.radioAnswers.set(question.id, { value: trimmed, label: trimmed, wasCustom: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (question.type === "checkbox") {
|
|
81
|
+
answerState.checkCustom.set(question.id, trimmed);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isAnswered(answerState: AnswerState, question: NormalizedQuestion): boolean {
|
|
86
|
+
if (question.type === "radio") return answerState.radioAnswers.has(question.id);
|
|
87
|
+
if (question.type === "checkbox") {
|
|
88
|
+
const selected = answerState.checkAnswers.get(question.id);
|
|
89
|
+
const custom = answerState.checkCustom.get(question.id);
|
|
90
|
+
return (selected != null && selected.size > 0) || (custom != null && custom.trim().length > 0);
|
|
91
|
+
}
|
|
92
|
+
return (answerState.textAnswers.get(question.id)?.trim() ?? "").length > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function allRequiredAnswered(answerState: AnswerState, questions: NormalizedQuestion[]): boolean {
|
|
96
|
+
return questions.every((question) => !question.required || isAnswered(answerState, question));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildAnswers(answerState: AnswerState, questions: NormalizedQuestion[]): Answer[] {
|
|
100
|
+
return questions.map((question) => {
|
|
101
|
+
if (question.type === "radio") {
|
|
102
|
+
const answer = answerState.radioAnswers.get(question.id);
|
|
103
|
+
return {
|
|
104
|
+
id: question.id,
|
|
105
|
+
type: "radio",
|
|
106
|
+
value: answer?.value ?? "",
|
|
107
|
+
wasCustom: answer?.wasCustom ?? false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (question.type === "checkbox") {
|
|
112
|
+
const selected = answerState.checkAnswers.get(question.id) ?? new Set<string>();
|
|
113
|
+
const custom = answerState.checkCustom.get(question.id)?.trim();
|
|
114
|
+
const values = [...selected];
|
|
115
|
+
if (custom) values.push(custom);
|
|
116
|
+
return {
|
|
117
|
+
id: question.id,
|
|
118
|
+
type: "checkbox",
|
|
119
|
+
value: values,
|
|
120
|
+
wasCustom: Boolean(custom),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: question.id,
|
|
126
|
+
type: "text",
|
|
127
|
+
value: answerState.textAnswers.get(question.id) ?? "",
|
|
128
|
+
wasCustom: true,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export interface QuestionOption {
|
|
2
|
+
value: string;
|
|
3
|
+
label: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Question {
|
|
8
|
+
id: string;
|
|
9
|
+
type: "radio" | "checkbox" | "text";
|
|
10
|
+
prompt: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
options?: QuestionOption[];
|
|
13
|
+
allowOther?: boolean;
|
|
14
|
+
required?: boolean;
|
|
15
|
+
placeholder?: string;
|
|
16
|
+
default?: string | string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AskUserQuestionParamsInput {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
questions: Question[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface NormalizedQuestion extends Question {
|
|
26
|
+
label: string;
|
|
27
|
+
options: QuestionOption[];
|
|
28
|
+
allowOther: boolean;
|
|
29
|
+
required: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Answer {
|
|
33
|
+
id: string;
|
|
34
|
+
type: "radio" | "checkbox" | "text";
|
|
35
|
+
value: string | string[];
|
|
36
|
+
wasCustom: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FormResult {
|
|
40
|
+
title?: string;
|
|
41
|
+
questions: NormalizedQuestion[];
|
|
42
|
+
answers: Answer[];
|
|
43
|
+
cancelled: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RadioAnswer {
|
|
47
|
+
value: string;
|
|
48
|
+
label: string;
|
|
49
|
+
wasCustom: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AnswerState {
|
|
53
|
+
radioAnswers: Map<string, RadioAnswer>;
|
|
54
|
+
checkAnswers: Map<string, Set<string>>;
|
|
55
|
+
checkCustom: Map<string, string>;
|
|
56
|
+
textAnswers: Map<string, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RenderTheme {
|
|
60
|
+
fg(color: string, text: string): string;
|
|
61
|
+
bg(color: string, text: string): string;
|
|
62
|
+
bold(text: string): string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EditorAdapter {
|
|
66
|
+
onSubmit?: (value: string) => void;
|
|
67
|
+
getText(): string;
|
|
68
|
+
setText(text: string): void;
|
|
69
|
+
handleInput(data: string): void;
|
|
70
|
+
render(width: number): string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RenderFormInput {
|
|
74
|
+
title?: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
questions: NormalizedQuestion[];
|
|
77
|
+
answerState: AnswerState;
|
|
78
|
+
currentTab: number;
|
|
79
|
+
cursorIdx: number;
|
|
80
|
+
otherMode: boolean;
|
|
81
|
+
width: number;
|
|
82
|
+
theme: RenderTheme;
|
|
83
|
+
editorLines: string[];
|
|
84
|
+
editorText: string;
|
|
85
|
+
}
|