From 7b5a582388637ffe73980fca63a0e6f9eeb8f49d Mon Sep 17 00:00:00 2001 From: qfai Date: Tue, 7 Apr 2026 16:01:22 +0800 Subject: [PATCH] feat: add customInputs node for declarative wizard question sequences Add a generic 'customInputs' node type to constructNode.ts that allows wizard JSON to declare arbitrary question sequences without TypeScript code changes. Supports: - text inputs (with optional password masking) - singleSelect dropdowns (with static options) - singleFile browser - folder picker - conditional inputs (multi-level menus via condition on inputs) User answers are stored in inputs._customInputs and automatically injected into the template replace map, so template .tpl files can reference them as {{inputName}}. This enables dynamic template publish to onboard new templates with custom wizard flows without requiring a new extension release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../generator/templates/templateReplaceMap.ts | 13 +- .../src/question/scaffold/constructNode.ts | 167 +++++++++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/fx-core/src/component/generator/templates/templateReplaceMap.ts b/packages/fx-core/src/component/generator/templates/templateReplaceMap.ts index 804ffcc0fa9..91e9d22c421 100644 --- a/packages/fx-core/src/component/generator/templates/templateReplaceMap.ts +++ b/packages/fx-core/src/component/generator/templates/templateReplaceMap.ts @@ -46,7 +46,7 @@ export function getTemplateReplaceMap(inputs: Inputs): { [key: string]: string } } } - return { + const replaceMap: { [key: string]: string } = { appName: appName, ProjectName: appName, SolutionName: solutionName, @@ -82,4 +82,15 @@ export function getTemplateReplaceMap(inputs: Inputs): { [key: string]: string } SandBoxedTeam: featureFlagManager.getBooleanValue(FeatureFlags.SandBoxedTeam) ? "true" : "", pathDelimiter: os.platform() === "win32" ? ";" : ":", }; + + // Auto-inject custom inputs from wizard JSON "customInputs" node. + // These are stored by constructNode's customInputs handler in inputs._customInputs. + // Template files can reference them as {{customInputName}}. + if (inputs._customInputs && typeof inputs._customInputs === "object") { + for (const [key, value] of Object.entries(inputs._customInputs)) { + replaceMap[key] = (value as string) ?? ""; + } + } + + return replaceMap; } diff --git a/packages/fx-core/src/question/scaffold/constructNode.ts b/packages/fx-core/src/question/scaffold/constructNode.ts index 0fdc14e3936..130c8074c41 100644 --- a/packages/fx-core/src/question/scaffold/constructNode.ts +++ b/packages/fx-core/src/question/scaffold/constructNode.ts @@ -1,7 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. -import { IQTreeNode, OptionItem, Platform, SingleSelectQuestion } from "@microsoft/teamsfx-api"; +import { + Inputs, + IQTreeNode, + OptionItem, + Platform, + Question, + SingleSelectQuestion, +} from "@microsoft/teamsfx-api"; import { featureFlagManager } from "../../common/featureFlags"; import { getLocalizedString } from "../../common/localizeUtils"; import { @@ -166,6 +173,13 @@ function resolveNodeReference( }; break; + // Generic custom inputs node — allows wizard JSON to declare arbitrary + // question sequences (text, singleSelect, singleFile, folder) without + // needing new TypeScript code. Inputs are stored in inputs._customInputs + // and automatically injected into the template replace map. + case "customInputs": + return buildCustomInputsNode(jsonObject as any, platform); + default: throw new Error(`Unknown node reference: ${jsonObject.node}`); } @@ -175,3 +189,154 @@ function resolveNodeReference( } return node; } + +interface CustomInputDef { + type: "text" | "singleSelect" | "singleFile" | "folder"; + name: string; + title: string; + placeholder?: string; + password?: boolean; + options?: { id: string; label: string; detail?: string; data?: string }[]; + filters?: { [name: string]: string[] }; + condition?: Record; +} + +interface CustomInputsNodeDef { + node: "customInputs"; + inputs: CustomInputDef[]; + condition?: Record; +} + +/** + * Build a question tree from a declarative list of custom inputs. + * + * JSON example: + * ```json + * { + * "node": "customInputs", + * "inputs": [ + * { "type": "text", "name": "endpoint", "title": "template.endpoint.title" }, + * { "type": "text", "name": "api-key", "title": "template.apiKey.title", "password": true }, + * { "type": "singleSelect", "name": "auth-type", "title": "template.auth.title", + * "options": [{ "id": "none", "label": "None" }, { "id": "key", "label": "API Key" }] }, + * { "type": "text", "name": "key-value", "title": "template.keyValue.title", + * "condition": { "equals": "key" } } + * ] + * } + * ``` + * + * Each input becomes a question node. Conditional inputs become children of the + * previous singleSelect node. User answers are stored in `inputs._customInputs` + * for automatic injection into the template replace map. + */ +function buildCustomInputsNode(def: CustomInputsNodeDef, _platform: Platform): IQTreeNode { + const customInputPrefix = "_custom_"; + + function buildQuestionData(input: CustomInputDef): Question { + const title = getLocalizedString(input.title) || input.title; + const placeholder = input.placeholder + ? getLocalizedString(input.placeholder) || input.placeholder + : undefined; + + switch (input.type) { + case "text": + return { + type: "text", + name: customInputPrefix + input.name, + title, + placeholder, + password: input.password, + }; + case "singleSelect": { + const staticOptions: OptionItem[] = (input.options ?? []).map((opt) => ({ + id: opt.id, + label: getLocalizedString(opt.label) || opt.label, + detail: opt.detail ? getLocalizedString(opt.detail) || opt.detail : undefined, + data: opt.data, + })); + return { + type: "singleSelect", + name: customInputPrefix + input.name, + title, + placeholder, + staticOptions, + } as SingleSelectQuestion; + } + case "singleFile": + return { + type: "singleFile", + name: customInputPrefix + input.name, + title, + filters: input.filters, + }; + case "folder": + return { + type: "folder", + name: customInputPrefix + input.name, + title, + placeholder, + }; + default: + return { type: "text", name: customInputPrefix + input.name, title }; + } + } + + // Separate top-level inputs from conditional children + // Conditional inputs (with condition) attach as children of the most recent singleSelect + const rootNodes: IQTreeNode[] = []; + let lastSelectNode: IQTreeNode | undefined; + + for (const input of def.inputs) { + const questionNode: IQTreeNode = { data: buildQuestionData(input) }; + + if (input.condition && lastSelectNode) { + questionNode.condition = input.condition; + if (!lastSelectNode.children) lastSelectNode.children = []; + lastSelectNode.children.push(questionNode); + } else { + rootNodes.push(questionNode); + if (input.type === "singleSelect") { + lastSelectNode = questionNode; + } + } + } + + // Wrap in a group node, chain as nested children so they appear sequentially + function chainNodes(nodes: IQTreeNode[]): IQTreeNode { + if (nodes.length === 0) { + return { data: { type: "group", name: "custom-inputs" } }; + } + if (nodes.length === 1) { + return nodes[0]; + } + // Nest: first node's children include the rest chained + const first = nodes[0]; + const rest = chainNodes(nodes.slice(1)); + if (!first.children) first.children = []; + first.children.push(rest); + return first; + } + + // Add an onDidSelection callback to store custom input values + for (const node of rootNodes) { + if (node.data && "type" in node.data) { + const originalName = (node.data as any).name as string; + if (originalName.startsWith(customInputPrefix)) { + const cleanName = originalName.slice(customInputPrefix.length); + const origOnDidSelection = (node.data as any).onDidSelection; + (node.data as any).onDidSelection = (selected: string | OptionItem, inputs: Inputs) => { + if (!inputs._customInputs) inputs._customInputs = {}; + const value = typeof selected === "string" ? selected : selected.id; + inputs._customInputs[cleanName] = value; + if (origOnDidSelection) origOnDidSelection(selected, inputs); + }; + } + } + } + + const result = chainNodes(rootNodes); + if (def.condition) { + result.condition = def.condition; + } + return result; +}