@ryan_nookpi/pi-extension-ask-user-question 0.1.1 → 0.2.1
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 +78 -14
- 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 +36 -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: (직접 입력) custom\nTwo: (선택 없음)\nThree: (비어 있음)");
|
|
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개 문항 (radio)");
|
|
66
|
+
expect(buildRenderCallText({}, theme)).toContain("0개 문항");
|
|
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("취소됨");
|
|
76
|
+
expect(buildRenderResultText({ details: formResult }, theme)).toBe(
|
|
77
|
+
"✓ One: (직접 입력) custom\n✓ Two: (선택 없음)\n✓ Three: (비어 있음)",
|
|
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 ? "(직접 입력) " : "";
|
|
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}: (선택 없음)` : `${label}: ${values.join(", ")}`;
|
|
28
|
+
}
|
|
29
|
+
return `${label}: ${answer.value || "(비어 있음)"}`;
|
|
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}개 문항`);
|
|
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", "취소됨");
|
|
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", "(직접 입력) ") : "";
|
|
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", "(선택 없음)");
|
|
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", "(비어 있음)")}`;
|
|
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.1",
|
|
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,36 @@
|
|
|
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: "선택 시 반환할 값" }),
|
|
6
|
+
label: Type.String({ description: "화면에 표시할 라벨" }),
|
|
7
|
+
description: Type.Optional(Type.String({ description: "옵션 아래에 표시할 보조 설명" })),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const QuestionSchema = Type.Object({
|
|
11
|
+
id: Type.String({ description: "질문 고유 식별자" }),
|
|
12
|
+
type: StringEnum(["radio", "checkbox", "text"] as const, {
|
|
13
|
+
description: "질문 유형: radio(단일 선택), checkbox(복수 선택), text(자유 입력)",
|
|
14
|
+
}),
|
|
15
|
+
prompt: Type.String({ description: "사용자에게 표시할 질문 문구" }),
|
|
16
|
+
label: Type.Optional(Type.String({ description: "탭 바에 표시할 짧은 라벨(기본값: Q1, Q2...)" })),
|
|
17
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: "radio/checkbox용 선택지 목록" })),
|
|
18
|
+
allowOther: Type.Optional(
|
|
19
|
+
Type.Boolean({ description: "'기타...' 직접 입력 옵션 추가 여부 (radio/checkbox 기본값: true)" }),
|
|
20
|
+
),
|
|
21
|
+
required: Type.Optional(Type.Boolean({ description: "응답 필수 여부 (기본값: true)" })),
|
|
22
|
+
placeholder: Type.Optional(Type.String({ description: "text 입력 시 표시할 플레이스홀더" })),
|
|
23
|
+
default: Type.Optional(
|
|
24
|
+
Type.Union([Type.String(), Type.Array(Type.String())], {
|
|
25
|
+
description: "기본값. radio/text는 문자열, checkbox는 문자열 배열",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const AskUserQuestionParams = Type.Object({
|
|
31
|
+
title: Type.Optional(Type.String({ description: "폼 상단에 표시할 제목" })),
|
|
32
|
+
description: Type.Optional(Type.String({ description: "폼 상단에 표시할 안내 문구" })),
|
|
33
|
+
questions: Type.Array(QuestionSchema, {
|
|
34
|
+
description: "질문 목록. radio는 단일 선택, checkbox는 복수 선택, text는 자유 입력",
|
|
35
|
+
}),
|
|
36
|
+
});
|
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
|
+
}
|