Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export function getTemplateReplaceMap(inputs: Inputs): { [key: string]: string }
}
}

return {
const replaceMap: { [key: string]: string } = {
appName: appName,
ProjectName: appName,
SolutionName: solutionName,
Expand Down Expand Up @@ -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;
}
167 changes: 166 additions & 1 deletion packages/fx-core/src/question/scaffold/constructNode.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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<string, unknown>;
}

interface CustomInputsNodeDef {
node: "customInputs";
inputs: CustomInputDef[];
condition?: Record<string, unknown>;
}

/**
* 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;
}
Loading