diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index d5110c2b7..e44344ae1 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -91,8 +91,6 @@ const GeneralSettings = () => { return (
-

General Settings

-
Show IDs in frontmatter
diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index d875716de..4ea25756a 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,28 +1,152 @@ import { useState, useEffect } from "react"; -import { - validateAllNodes, - validateNodeFormat, - validateNodeName, -} from "~/utils/validateNodeType"; +import { validateNodeFormat, validateNodeName } from "~/utils/validateNodeType"; import { usePlugin } from "./PluginContext"; -import { Notice } from "obsidian"; +import { Notice, setIcon } from "obsidian"; import generateUid from "~/utils/generateUid"; import { DiscourseNode } from "~/types"; import { ConfirmationModal } from "./ConfirmationModal"; import { getTemplateFiles, getTemplatePluginInfo } from "~/utils/templates"; +type EditableFieldKey = keyof Omit; + +type BaseFieldConfig = { + key: EditableFieldKey; + label: string; + description: string; + required?: boolean; + type: "text" | "select"; + placeholder?: string; + validate?: ( + value: string, + nodeType: DiscourseNode, + existingNodes: DiscourseNode[], + ) => { isValid: boolean; error?: string }; +}; + +const FIELD_CONFIGS: Record = { + name: { + key: "name", + label: "Name", + description: "The name of this node type", + required: true, + type: "text", + validate: (value, nodeType, existingNodes) => + validateNodeName(value, [nodeType, ...existingNodes]), + placeholder: "Name", + }, + format: { + key: "format", + label: "Format", + description: + "The format pattern for this node type (e.g., CLM - {content})", + required: true, + type: "text", + validate: (value, nodeType, existingNodes) => + validateNodeFormat(value, [nodeType, ...existingNodes]), + placeholder: "Format (e.g., CLM - {content})", + }, + template: { + key: "template", + label: "Template", + description: "The template to use for this node type", + type: "select", + required: false, + }, +}; + +const FIELD_CONFIG_ARRAY = Object.values(FIELD_CONFIGS); + +const TextField = ({ + fieldConfig, + value, + error, + onChange, +}: { + fieldConfig: BaseFieldConfig; + value: string; + error?: string; + onChange: (value: string) => void; +}) => ( + onChange(e.target.value)} + placeholder={fieldConfig.placeholder} + className={`w-full ${error ? "input-error" : ""}`} + /> +); + +const TemplateField = ({ + value, + error, + onChange, + templateConfig, + templateFiles, +}: { + value: string; + error?: string; + onChange: (value: string) => void; + templateConfig: { isEnabled: boolean; folderPath: string }; + templateFiles: string[]; +}) => ( + +); + +const FieldWrapper = ({ + fieldConfig, + children, + error, +}: { + fieldConfig: BaseFieldConfig; + children: React.ReactNode; + error?: string; +}) => ( +
+
+
{fieldConfig.label}
+
{fieldConfig.description}
+
+
+ {children} + {error &&
{error}
} +
+
+); + const NodeTypeSettings = () => { const plugin = usePlugin(); - const [nodeTypes, setNodeTypes] = useState( - () => plugin.settings.nodeTypes ?? [], + const [nodeTypes, setNodeTypes] = useState([]); + const [editingNodeType, setEditingNodeType] = useState( + null, ); - const [formatErrors, setFormatErrors] = useState>({}); + const [errors, setErrors] = useState< + Partial> + >({}); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [templateFiles, setTemplateFiles] = useState([]); const [templateConfig, setTemplateConfig] = useState({ isEnabled: false, folderPath: "", }); + const [selectedNodeIndex, setSelectedNodeIndex] = useState( + null, + ); useEffect(() => { const config = getTemplatePluginInfo(plugin.app); @@ -32,67 +156,81 @@ const NodeTypeSettings = () => { setTemplateFiles(files); }, [plugin.app]); - const updateErrors = ( - index: number, - validation: { isValid: boolean; error?: string }, - ) => { - if (!validation.isValid) { - setFormatErrors((prev) => ({ + useEffect(() => { + setNodeTypes(plugin.settings.nodeTypes ?? []); + }, [plugin.settings.nodeTypes]); + + const validateField = ( + field: EditableFieldKey, + value: string, + nodeType: DiscourseNode, + ): boolean => { + const fieldConfig = FIELD_CONFIGS[field]; + if (!fieldConfig) return true; + + if (fieldConfig.required && !value.trim()) { + setErrors((prev) => ({ ...prev, - [index]: validation.error || "Invalid input", + [field]: `${fieldConfig.label} is required`, })); - } else { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); + return false; } - }; - const handleNodeTypeChange = async ( - index: number, - field: keyof DiscourseNode, - value: string, - ): Promise => { - const updatedNodeTypes = [...nodeTypes]; - if (!updatedNodeTypes[index]) { - const newId = generateUid("node"); - updatedNodeTypes[index] = { - id: newId, - name: "", - format: "", - template: "", - }; + if (fieldConfig.validate) { + const { isValid, error } = fieldConfig.validate( + value, + nodeType, + nodeTypes, + ); + if (!isValid) { + setErrors((prev) => ({ + ...prev, + [field]: error || `Invalid ${fieldConfig.label.toLowerCase()}`, + })); + return false; + } } - updatedNodeTypes[index][field] = value; + setErrors((prev) => { + const { [field]: _, ...rest } = prev; + return rest; + }); + return true; + }; - if (field === "format") { - const { isValid, error } = validateNodeFormat(value, updatedNodeTypes); - updateErrors(index, { isValid, error }); - } else if (field === "name") { - const nameValidation = validateNodeName(value, updatedNodeTypes); - updateErrors(index, nameValidation); - } + const handleNodeTypeChange = ( + field: EditableFieldKey, + value: string, + ): void => { + if (!editingNodeType) return; - setNodeTypes(updatedNodeTypes); + const updatedNodeType = { ...editingNodeType, [field]: value }; + validateField(field, value, updatedNodeType); + setEditingNodeType(updatedNodeType); setHasUnsavedChanges(true); }; const handleAddNodeType = (): void => { - const newId = generateUid("node"); - const updatedNodeTypes = [ - ...nodeTypes, - { - id: newId, - name: "", - format: "", - template: "", - }, - ]; - setNodeTypes(updatedNodeTypes); + const newNodeType: DiscourseNode = { + id: generateUid("node"), + name: "", + format: "", + template: "", + }; + setEditingNodeType(newNodeType); + setSelectedNodeIndex(nodeTypes.length); setHasUnsavedChanges(true); + setErrors({}); + }; + + const startEditing = (index: number) => { + const nodeType = nodeTypes[index]; + if (nodeType) { + setEditingNodeType({ ...nodeType }); + setSelectedNodeIndex(index); + setHasUnsavedChanges(false); + setErrors({}); + } }; const confirmDeleteNodeType = (index: number): void => { @@ -106,9 +244,12 @@ const NodeTypeSettings = () => { }; const handleDeleteNodeType = async (index: number): Promise => { - const nodeId = nodeTypes[index]?.id; + const nodeType = nodeTypes[index]; + if (!nodeType) return; + const isUsed = plugin.settings.discourseRelations?.some( - (rel) => rel.sourceId === nodeId || rel.destinationId === nodeId, + (rel) => + rel.sourceId === nodeType.id || rel.destinationId === nodeType.id, ); if (isUsed) { @@ -119,115 +260,192 @@ const NodeTypeSettings = () => { } const updatedNodeTypes = nodeTypes.filter((_, i) => i !== index); - setNodeTypes(updatedNodeTypes); plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); - if (formatErrors[index]) { - setFormatErrors((prev) => { - const newErrors = { ...prev }; - delete newErrors[index]; - return newErrors; - }); - } + setNodeTypes(updatedNodeTypes); + setSelectedNodeIndex(null); + setEditingNodeType(null); new Notice("Node type deleted successfully"); }; const handleSave = async (): Promise => { - const { hasErrors, errorMap } = validateAllNodes(nodeTypes); + if (!editingNodeType) return; - if (hasErrors) { - setFormatErrors(errorMap); - new Notice("Please fix the errors before saving"); + if (!validateNodeType(editingNodeType)) { return; } - plugin.settings.nodeTypes = nodeTypes; + + const updatedNodeTypes = [...nodeTypes]; + if ( + selectedNodeIndex !== null && + selectedNodeIndex < updatedNodeTypes.length + ) { + updatedNodeTypes[selectedNodeIndex] = editingNodeType; + } else { + updatedNodeTypes.push(editingNodeType); + } + + plugin.settings.nodeTypes = updatedNodeTypes; await plugin.saveSettings(); - new Notice("Node types saved"); + setNodeTypes(updatedNodeTypes); + new Notice("Node type saved"); setHasUnsavedChanges(false); + setSelectedNodeIndex(null); + setEditingNodeType(null); + setErrors({}); }; - return ( -
-
-

Node Types

-
+ const handleCancel = (): void => { + setEditingNodeType(null); + setSelectedNodeIndex(null); + setHasUnsavedChanges(false); + setErrors({}); + }; + + const validateNodeType = (nodeType: DiscourseNode): boolean => { + let isValid = true; + const newErrors: Partial> = {}; + + Object.entries(FIELD_CONFIGS).forEach(([key, config]) => { + const field = key as EditableFieldKey; + const value = nodeType[field] as string; + + if (config.required && !value?.trim()) { + newErrors[field] = `${config.label} is required`; + isValid = false; + return; + } + + if (config.validate && value) { + const { isValid: fieldValid, error } = config.validate( + value, + nodeType, + nodeTypes, + ); + if (!fieldValid) { + newErrors[field] = error || `Invalid ${config.label.toLowerCase()}`; + isValid = false; + } + } + }); + + setErrors(newErrors); + return isValid; + }; + + const renderField = (fieldConfig: BaseFieldConfig) => { + if (!editingNodeType) return null; + + const value = editingNodeType[fieldConfig.key] as string; + const error = errors[fieldConfig.key]; + const handleChange = (newValue: string) => + handleNodeTypeChange(fieldConfig.key, newValue); + + return ( + + {fieldConfig.key === "template" ? ( + + ) : ( + + )} + + ); + }; + + const renderNodeList = () => ( +
+ {nodeTypes.map((nodeType, index) => ( -
-
-
- - handleNodeTypeChange(index, "name", e.target.value) - } - className="flex-2" - /> - - handleNodeTypeChange(index, "format", e.target.value) - } - className="flex-1" - /> - - -
- {formatErrors[index] && ( -
- {formatErrors[index]} -
- )} +
startEditing(index)} + > + {nodeType.name} +
+ +
))} -
-
- +
+ ); + + const renderEditForm = () => { + if (!editingNodeType) return null; + + return ( +
+
+

Edit Node Type

+ {FIELD_CONFIG_ARRAY.map(renderField)} + {hasUnsavedChanges && ( +
+ + +
+ )}
- {hasUnsavedChanges && ( -
You have unsaved changes
- )} + ); + }; + + return ( +
+ {selectedNodeIndex === null ? renderNodeList() : renderEditForm()}
); }; diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index faadf6555..7dc1bca7b 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -125,10 +125,6 @@ const RelationshipSettings = () => { return (
-
-

Node Type Relations

-
- {plugin.settings.nodeTypes.length === 0 ? (
You need to create some node types first.
) : plugin.settings.relationTypes.length === 0 ? ( diff --git a/apps/obsidian/src/components/RelationshipTypeSettings.tsx b/apps/obsidian/src/components/RelationshipTypeSettings.tsx index b0d0008ed..e8e473795 100644 --- a/apps/obsidian/src/components/RelationshipTypeSettings.tsx +++ b/apps/obsidian/src/components/RelationshipTypeSettings.tsx @@ -103,7 +103,6 @@ const RelationshipTypeSettings = () => { return (
-

Relation Types

{relationTypes.map((relationType, index) => (
diff --git a/apps/obsidian/src/components/Settings.tsx b/apps/obsidian/src/components/Settings.tsx index e87d527b9..686d77ba8 100644 --- a/apps/obsidian/src/components/Settings.tsx +++ b/apps/obsidian/src/components/Settings.tsx @@ -13,10 +13,10 @@ const Settings = () => { const [activeTab, setActiveTab] = useState("general"); return ( -
+

Discourse Graph Settings

-
+