Files
DataMate/frontend/src/pages/DataAnnotation/components/TemplateConfigurationTreeEditor.tsx
Jerry Yan b36fdd2438 feat(annotation): 添加数据类型过滤功能到标签配置树编辑器
- 引入 DataType 枚举类型定义
- 根据数据类型动态过滤对象标签选项
- 在模板表单中添加数据类型监听
- 改进错误处理逻辑以提高类型安全性
- 集成数据类型参数到配置树编辑器组件
2026-02-02 20:37:38 +08:00

925 lines
28 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Button,
Card,
Input,
Select,
Space,
Tag,
Tree,
Typography,
} from "antd";
import type { DataNode, TreeProps } from "antd/es/tree";
import {
PlusOutlined,
DeleteOutlined,
BranchesOutlined,
} from "@ant-design/icons";
import { useTagConfig } from "@/hooks/useTagConfig";
import {
getControlDisplayName,
getObjectDisplayName,
type LabelStudioTagConfig,
} from "../annotation.tagconfig";
import { DataType } from "../annotation.model";
const { Text, Title } = Typography;
interface XmlNode {
id: string;
tag: string;
attrs: Record<string, string>;
children: XmlNode[];
text?: string;
}
interface ValidationIssue {
errors: string[];
warnings: string[];
}
interface TemplateConfigurationTreeEditorProps {
value?: string;
onChange?: (xml: string) => void;
readOnly?: boolean;
readOnlyStructure?: boolean;
height?: number | string;
dataType?: DataType;
}
const DEFAULT_ROOT_TAG = "View";
const CHILD_TAGS = ["Label", "Choice", "Relation", "Item", "Path", "Channel"];
const OBJECT_TAGS_BY_DATA_TYPE: Record<DataType, string[]> = {
[DataType.TEXT]: ["Text", "Paragraphs", "Markdown"],
[DataType.IMAGE]: ["Image", "Bitmask"],
[DataType.AUDIO]: ["Audio", "AudioPlus"],
[DataType.VIDEO]: ["Video"],
[DataType.PDF]: ["PDF"],
[DataType.TIMESERIES]: ["Timeseries", "TimeSeries", "Vector"],
[DataType.CHAT]: ["Chat"],
[DataType.HTML]: ["HyperText", "Markdown"],
[DataType.TABLE]: ["Table", "Vector"],
};
const createId = () =>
`node_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
const createEmptyTree = (): XmlNode => ({
id: createId(),
tag: DEFAULT_ROOT_TAG,
attrs: {},
children: [],
});
const escapeAttributeValue = (value: string) =>
value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapeTextValue = (value: string) =>
value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const parseXmlToTree = (xml: string): { tree: XmlNode | null; error?: string } => {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, "text/xml");
const parserError = doc.getElementsByTagName("parsererror");
if (parserError.length > 0) {
return { tree: null, error: "XML 格式错误,请检查标签闭合与层级。" };
}
const root = doc.documentElement;
if (!root || root.tagName !== DEFAULT_ROOT_TAG) {
return { tree: null, error: "根节点必须是 <View>。" };
}
const buildNode = (element: Element): XmlNode => {
const attrs: Record<string, string> = {};
Array.from(element.attributes).forEach((attr) => {
attrs[attr.name] = attr.value;
});
const elementChildren = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.ELEMENT_NODE
) as Element[];
const textNodes = Array.from(element.childNodes).filter(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim()
);
const children = elementChildren.map(buildNode);
const text =
children.length === 0
? textNodes.map((node) => node.textContent ?? "").join("").trim()
: undefined;
return {
id: createId(),
tag: element.tagName,
attrs,
children,
text: text || undefined,
};
};
return { tree: buildNode(root) };
} catch (error) {
console.error("解析 XML 失败", error);
return { tree: null, error: "解析 XML 失败,请检查配置内容。" };
}
};
const serializeTreeToXml = (node: XmlNode, indent = 0): string => {
const pad = " ".repeat(indent);
const attrs = Object.entries(node.attrs)
.filter(([key]) => key.trim())
.map(([key, value]) => ` ${key}="${escapeAttributeValue(value)}"`)
.join("");
if (node.children.length === 0 && !node.text) {
return `${pad}<${node.tag}${attrs} />`;
}
const lines: string[] = [`${pad}<${node.tag}${attrs}>`];
if (node.text) {
lines.push(`${pad} ${escapeTextValue(node.text)}`);
}
node.children.forEach((child) => {
lines.push(serializeTreeToXml(child, indent + 1));
});
lines.push(`${pad}</${node.tag}>`);
return lines.join("\n");
};
const updateNode = (
node: XmlNode,
targetId: string,
updater: (current: XmlNode) => XmlNode
): XmlNode => {
if (node.id === targetId) {
return updater(node);
}
let changed = false;
const children = node.children.map((child) => {
const updated = updateNode(child, targetId, updater);
if (updated !== child) changed = true;
return updated;
});
return changed ? { ...node, children } : node;
};
const removeNodeById = (
node: XmlNode,
targetId: string
): { node: XmlNode; removed?: XmlNode } => {
const index = node.children.findIndex((child) => child.id === targetId);
if (index >= 0) {
const removed = node.children[index];
const children = node.children.filter((child) => child.id !== targetId);
return { node: { ...node, children }, removed };
}
let removed: XmlNode | undefined;
const children = node.children.map((child) => {
const result = removeNodeById(child, targetId);
if (result.removed) removed = result.removed;
return result.node;
});
if (removed) {
return { node: { ...node, children }, removed };
}
return { node };
};
const findNodeWithParent = (
node: XmlNode,
targetId: string,
parent?: XmlNode
): { node?: XmlNode; parent?: XmlNode; index?: number } => {
if (node.id === targetId) {
return { node, parent, index: parent?.children.findIndex((c) => c.id === targetId) };
}
for (const child of node.children) {
const result = findNodeWithParent(child, targetId, node);
if (result.node) return result;
}
return {};
};
const isDescendant = (node: XmlNode, targetId: string): boolean => {
if (node.id === targetId) return true;
return node.children.some((child) => isDescendant(child, targetId));
};
const COMMON_TAG_DISPLAY_NAMES: Record<string, string> = {
View: "容器",
Header: "标题",
Style: "样式",
Label: "标签项",
Choice: "选项",
Relation: "关系",
Relations: "关系组",
Item: "列表项",
Path: "路径",
Channel: "通道",
Collapse: "折叠面板",
Filter: "过滤器",
Shortcut: "快捷键",
};
const getDefaultName = (tag: string) => {
const lower = tag.toLowerCase();
if (lower.includes("text")) return "text";
if (lower.includes("image")) return "image";
if (lower.includes("audio")) return "audio";
if (lower.includes("video")) return "video";
if (lower.includes("pdf")) return "pdf";
if (lower.includes("chat")) return "chat";
if (lower.includes("table")) return "table";
if (lower.includes("timeseries") || lower.includes("time")) return "ts";
return lower;
};
const createNode = (
tag: string,
config: LabelStudioTagConfig | null,
objectNames: string[]
): XmlNode => {
const attrs: Record<string, string> = {};
const controlConfig = config?.controls?.[tag];
const objectConfig = config?.objects?.[tag];
const requiredAttrs = controlConfig?.required_attrs || objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
attrs[attr] = "";
});
if (objectConfig) {
const name = getDefaultName(tag);
if (!attrs.name) {
attrs.name = name;
}
if (!attrs.value) {
attrs.value = `$${attrs.name}`;
}
}
if (controlConfig) {
const isLabeling = controlConfig.category === "labeling";
if (isLabeling) {
if (!attrs.name) {
attrs.name = getDefaultName(tag);
}
if (!attrs.toName) {
attrs.toName = objectNames[0] || "";
}
} else {
// For layout controls, only fill if required
if (attrs.name !== undefined && !attrs.name) {
attrs.name = getDefaultName(tag);
}
if (attrs.toName !== undefined && !attrs.toName) {
attrs.toName = objectNames[0] || "";
}
}
}
if (CHILD_TAGS.includes(tag)) {
attrs.value = attrs.value || "";
}
const node: XmlNode = {
id: createId(),
tag,
attrs,
children: [],
};
if (controlConfig?.requires_children && controlConfig.child_tag) {
node.children.push({
id: createId(),
tag: controlConfig.child_tag,
attrs: { value: "" },
children: [],
});
}
if (tag === "Style") {
node.text = "";
}
return node;
};
const collectObjectNames = (node: XmlNode, config: LabelStudioTagConfig | null): string[] => {
const names: string[] = [];
const objectTags = new Set(Object.keys(config?.objects || {}));
const traverse = (current: XmlNode) => {
if (objectTags.has(current.tag) && current.attrs.name) {
names.push(current.attrs.name);
}
current.children.forEach(traverse);
};
traverse(node);
return names;
};
const validateTree = (
node: XmlNode,
config: LabelStudioTagConfig | null
): Record<string, ValidationIssue> => {
const issues: Record<string, ValidationIssue> = {};
const objectTags = new Set(Object.keys(config?.objects || {}));
const controlTags = new Set(Object.keys(config?.controls || {}));
const objectNames = new Set(collectObjectNames(node, config));
const ensureIssue = (id: string) => {
if (!issues[id]) {
issues[id] = { errors: [], warnings: [] };
}
return issues[id];
};
let labelingControlCount = 0;
let objectCount = 0;
const validateNode = (current: XmlNode) => {
const issue = ensureIssue(current.id);
const controlConfig = config?.controls?.[current.tag];
const objectConfig = config?.objects?.[current.tag];
const isObject = objectTags.has(current.tag);
const isControl = controlTags.has(current.tag);
const isLabelingControl = isControl && controlConfig?.category === "labeling";
if (isObject) {
objectCount += 1;
const requiredAttrs = objectConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`对象缺少必填属性: ${attr}`);
}
});
if (current.attrs.value && !current.attrs.value.startsWith("$")) {
issue.errors.push("对象 value 应以 $ 开头");
}
}
if (isControl) {
if (isLabelingControl) {
labelingControlCount += 1;
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.errors.push(`控件缺少必填属性: ${attr}`);
}
});
if (current.attrs.toName) {
const toNames = current.attrs.toName
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const invalid = toNames.filter((name) => !objectNames.has(name));
if (invalid.length > 0) {
issue.errors.push(`toName 未找到对象: ${invalid.join(", ")}`);
}
}
} else {
const requiredAttrs = controlConfig?.required_attrs || [];
requiredAttrs.forEach((attr) => {
if (!current.attrs[attr]) {
issue.warnings.push(`布局标签缺少建议属性: ${attr}`);
}
});
}
if (controlConfig?.requires_children && controlConfig.child_tag) {
const children = current.children.filter(
(child) => child.tag === controlConfig.child_tag
);
if (children.length === 0) {
const message = `${current.tag} 需要至少一个 <${controlConfig.child_tag}> 子节点`;
if (isLabelingControl) {
issue.errors.push(message);
} else {
issue.warnings.push(message);
}
}
children.forEach((child) => {
if (!child.attrs.value) {
const message = `<${child.tag}> 缺少 value 属性`;
const childIssue = ensureIssue(child.id);
if (isLabelingControl) {
childIssue.errors.push(message);
} else {
childIssue.warnings.push(message);
}
}
});
}
}
current.children.forEach(validateNode);
};
validateNode(node);
if (node.tag === DEFAULT_ROOT_TAG) {
const rootIssue = ensureIssue(node.id);
if (objectCount === 0) {
rootIssue.errors.push("至少需要一个数据对象标签");
}
if (labelingControlCount === 0) {
rootIssue.errors.push("至少需要一个标注控件标签");
}
}
return issues;
};
const TemplateConfigurationTreeEditor = ({
value,
onChange,
readOnly = false,
readOnlyStructure = false,
height = 420,
dataType,
}: TemplateConfigurationTreeEditorProps) => {
const { config } = useTagConfig(false);
const [tree, setTree] = useState<XmlNode>(() => createEmptyTree());
const [selectedId, setSelectedId] = useState<string>(tree.id);
const [parseError, setParseError] = useState<string | null>(null);
const lastSerialized = useRef<string>("");
useEffect(() => {
if (!value) {
const empty = createEmptyTree();
setTree(empty);
setSelectedId(empty.id);
setParseError(null);
lastSerialized.current = "";
return;
}
if (value === lastSerialized.current) return;
const result = parseXmlToTree(value);
if (result.tree) {
setTree(result.tree);
setSelectedId(result.tree.id);
setParseError(null);
lastSerialized.current = value;
} else if (result.error) {
setParseError(result.error);
}
}, [value]);
useEffect(() => {
const xml = serializeTreeToXml(tree);
lastSerialized.current = xml;
onChange?.(xml);
}, [tree, onChange]);
const objectNames = useMemo(
() => collectObjectNames(tree, config || null),
[tree, config]
);
const validationIssues = useMemo(
() => validateTree(tree, config || null),
[tree, config]
);
const selectedNode = useMemo(() => {
const result = findNodeWithParent(tree, selectedId);
return result.node || tree;
}, [tree, selectedId]);
const selectedIssue = validationIssues[selectedNode.id] || {
errors: [],
warnings: [],
};
const isStructureLocked = readOnly || readOnlyStructure;
const canEditAttributeValues = !readOnly && selectedNode.id !== tree.id;
const canEditAttributeKeys = canEditAttributeValues && !readOnlyStructure;
const canEditTagName = canEditAttributeKeys;
const canEditText = canEditAttributeValues && !readOnlyStructure;
const controlOptions = useMemo(() => {
if (!config?.controls) return { labeling: [], layout: [] };
const labeling = Object.entries(config.controls)
.filter(([, item]) => item.category === "labeling")
.map(([tag]) => ({ value: tag, label: getControlDisplayName(tag) }));
const layout = Object.entries(config.controls)
.filter(([, item]) => item.category === "layout")
.map(([tag]) => ({
value: tag,
label: COMMON_TAG_DISPLAY_NAMES[tag] || tag,
}));
return { labeling, layout };
}, [config]);
const objectOptions = useMemo(() => {
if (!config?.objects) return [];
const options = Object.keys(config.objects).map((tag) => ({
value: tag,
label: getObjectDisplayName(tag),
}));
if (!dataType) return options;
const allowedTags = OBJECT_TAGS_BY_DATA_TYPE[dataType];
if (!allowedTags) return options;
const allowedSet = new Set(allowedTags);
const filtered = options.filter((option) => allowedSet.has(option.value));
return filtered.length > 0 ? filtered : options;
}, [config, dataType]);
const tagOptions = useMemo(() => {
const options = [] as {
label: string;
options: { value: string; label: string }[];
}[];
options.push({
label: "容器",
options: [{ value: "View", label: COMMON_TAG_DISPLAY_NAMES.View }],
});
if (objectOptions.length > 0) {
options.push({ label: "数据对象", options: objectOptions });
}
if (controlOptions.labeling.length > 0) {
options.push({ label: "标注控件", options: controlOptions.labeling });
}
if (controlOptions.layout.length > 0) {
options.push({ label: "布局标签", options: controlOptions.layout });
}
options.push({
label: "子标签",
options: CHILD_TAGS.map((tag) => ({
value: tag,
label: COMMON_TAG_DISPLAY_NAMES[tag] || tag,
})),
});
return options;
}, [objectOptions, controlOptions]);
const getTagDisplayName = useCallback(
(tag: string) => {
if (config?.objects?.[tag]) return getObjectDisplayName(tag);
if (config?.controls?.[tag]) return getControlDisplayName(tag);
return COMMON_TAG_DISPLAY_NAMES[tag] || tag;
},
[config]
);
const handleAddNode = (tag: string, mode: "child" | "sibling") => {
if (isStructureLocked) return;
const newNode = createNode(tag, config || null, objectNames);
if (mode === "child") {
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
children: [...current.children, newNode],
}))
);
setSelectedId(newNode.id);
return;
}
const { parent } = findNodeWithParent(tree, selectedNode.id);
if (!parent) return;
setTree((prev) =>
updateNode(prev, parent.id, (current) => {
const index = current.children.findIndex(
(child) => child.id === selectedNode.id
);
const children = [...current.children];
children.splice(index + 1, 0, newNode);
return { ...current, children };
})
);
setSelectedId(newNode.id);
};
const handleDeleteNode = () => {
if (isStructureLocked || selectedNode.id === tree.id) return;
const { node: nextTree } = removeNodeById(tree, selectedNode.id);
setTree(nextTree);
setSelectedId(nextTree.id);
};
const handleAttrKeyChange = (oldKey: string, newKey: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
const value = attrs[oldKey] ?? "";
delete attrs[oldKey];
if (newKey) {
attrs[newKey] = value;
}
return { ...current, attrs };
})
);
};
const handleAttrValueChange = (key: string, value: string) => {
if (!canEditAttributeValues) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
attrs: { ...current.attrs, [key]: value },
}))
);
};
const handleAddAttribute = () => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
let index = 1;
let key = "attr";
while (attrs[key]) {
index += 1;
key = `attr${index}`;
}
attrs[key] = "";
return { ...current, attrs };
})
);
};
const handleRemoveAttribute = (key: string) => {
if (!canEditAttributeKeys) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => {
const attrs = { ...current.attrs };
delete attrs[key];
return { ...current, attrs };
})
);
};
const handleTagNameChange = (value: string) => {
if (!canEditTagName) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
tag: value || current.tag,
}))
);
};
const handleTextChange = (value: string) => {
if (!canEditText) return;
setTree((prev) =>
updateNode(prev, selectedNode.id, (current) => ({
...current,
text: value,
}))
);
};
const treeData: DataNode[] = useMemo(() => {
const build = (node: XmlNode): DataNode => {
const issue = validationIssues[node.id];
const hasError = issue?.errors?.length > 0;
const hasWarning = issue?.warnings?.length > 0;
const name = node.attrs.name || node.attrs.value;
const title = name
? `${getTagDisplayName(node.tag)} (${name})`
: getTagDisplayName(node.tag);
return {
key: node.id,
title: (
<Space size={6}>
<span className={hasError ? "text-red-600" : undefined}>
{title}
</span>
{hasError && <Tag color="red"></Tag>}
{!hasError && hasWarning && <Tag color="gold"></Tag>}
</Space>
),
children: node.children.map(build),
draggable: !isStructureLocked && node.id !== tree.id,
};
};
return [build(tree)];
}, [tree, validationIssues, isStructureLocked, getTagDisplayName]);
const onDrop: TreeProps["onDrop"] = (info) => {
if (isStructureLocked) return;
const dragId = String(info.dragNode.key);
const dropId = String(info.node.key);
if (dragId === tree.id) return;
if (dragId === dropId) return;
const dragInfo = findNodeWithParent(tree, dragId);
const dropInfo = findNodeWithParent(tree, dropId);
if (!dragInfo.node || !dropInfo.node) return;
if (isDescendant(dragInfo.node, dropId)) {
return;
}
const removedResult = removeNodeById(tree, dragId);
let nextTree = removedResult.node;
const movingNode = removedResult.removed;
if (!movingNode) return;
if (!info.dropToGap) {
nextTree = updateNode(nextTree, dropId, (current) => ({
...current,
children: [...current.children, movingNode],
}));
} else {
const { parent } = findNodeWithParent(nextTree, dropId);
if (!parent) return;
nextTree = updateNode(nextTree, parent.id, (current) => {
const index = current.children.findIndex((child) => child.id === dropId);
const children = [...current.children];
const insertIndex = info.dropPosition > 0 ? index + 1 : index;
children.splice(insertIndex, 0, movingNode);
return { ...current, children };
});
}
setTree(nextTree);
setSelectedId(movingNode.id);
};
return (
<div className="grid grid-cols-2 gap-4" style={{ minHeight: 360 }}>
<Card
title={
<Space>
<BranchesOutlined />
<span></span>
</Space>
}
size="small"
style={{ height }}
bodyStyle={{ height: "100%", overflow: "auto" }}
>
{parseError && (
<Alert message={parseError} type="error" showIcon style={{ marginBottom: 8 }} />
)}
<Tree
key={tree.id}
treeData={treeData}
selectedKeys={[selectedId]}
onSelect={(keys) => {
if (keys.length > 0) setSelectedId(String(keys[0]));
}}
defaultExpandAll
draggable={!isStructureLocked}
onDrop={onDrop}
blockNode
/>
</Card>
<Card title="节点配置" size="small" style={{ height }} bodyStyle={{ height: "100%", overflow: "auto" }}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div>
<Title level={5} style={{ margin: 0 }}>
{selectedNode.tag}
</Title>
<Text type="secondary">{selectedNode.id}</Text>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
placeholder="添加子节点"
options={tagOptions}
value={null}
onChange={(value) => {
handleAddNode(value, "child");
}}
disabled={isStructureLocked}
/>
<Select
placeholder="添加同级节点"
options={tagOptions}
value={null}
onChange={(value) => {
handleAddNode(value, "sibling");
}}
disabled={isStructureLocked || selectedNode.id === tree.id}
/>
<Button
danger
icon={<DeleteOutlined />}
onClick={handleDeleteNode}
disabled={isStructureLocked || selectedNode.id === tree.id}
>
</Button>
</div>
<div>
<Text></Text>
<Input
value={selectedNode.tag}
onChange={(e) => handleTagNameChange(e.target.value)}
disabled={!canEditTagName}
/>
</div>
{selectedNode.tag === "Style" || selectedNode.text !== undefined ? (
<div>
<Text></Text>
<Input.TextArea
value={selectedNode.text}
onChange={(e) => handleTextChange(e.target.value)}
rows={4}
disabled={!canEditText}
/>
</div>
) : null}
<div>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Space align="center" style={{ width: "100%", justifyContent: "space-between" }}>
<Text></Text>
<Button size="small" onClick={handleAddAttribute} disabled={!canEditAttributeKeys} icon={<PlusOutlined />}>
</Button>
</Space>
{Object.entries(selectedNode.attrs).length === 0 && (
<Text type="secondary"></Text>
)}
{Object.entries(selectedNode.attrs).map(([key, value]) => (
<Space key={key} align="baseline" style={{ width: "100%" }}>
<Input
value={key}
onChange={(e) => handleAttrKeyChange(key, e.target.value)}
placeholder="属性名"
style={{ width: 120 }}
disabled={!canEditAttributeKeys}
/>
{key === "toName" ? (
<Select
mode="multiple"
style={{ flex: 1 }}
placeholder="选择对象"
value={value
.split(",")
.map((item) => item.trim())
.filter(Boolean)}
onChange={(values) => handleAttrValueChange(key, values.join(","))}
options={objectNames.map((name) => ({ value: name, label: name }))}
disabled={!canEditAttributeValues}
/>
) : (
<Input
value={value}
onChange={(e) => handleAttrValueChange(key, e.target.value)}
placeholder="属性值"
style={{ flex: 1 }}
disabled={!canEditAttributeValues}
/>
)}
<Button
size="small"
danger
onClick={() => handleRemoveAttribute(key)}
disabled={!canEditAttributeKeys}
>
</Button>
</Space>
))}
</Space>
</div>
{(selectedIssue.errors.length > 0 || selectedIssue.warnings.length > 0) && (
<div>
<Text strong></Text>
<div className="mt-2 space-y-1">
{selectedIssue.errors.map((err, index) => (
<Tag key={`err-${index}`} color="red">
{err}
</Tag>
))}
{selectedIssue.warnings.map((warn, index) => (
<Tag key={`warn-${index}`} color="gold">
{warn}
</Tag>
))}
</div>
</div>
)}
</Space>
</Card>
</div>
);
};
export default TemplateConfigurationTreeEditor;