diff --git a/apps/obsidian/src/components/AdminPanelSettings.tsx b/apps/obsidian/src/components/AdminPanelSettings.tsx index 66f6ee70b..ac6be9af2 100644 --- a/apps/obsidian/src/components/AdminPanelSettings.tsx +++ b/apps/obsidian/src/components/AdminPanelSettings.tsx @@ -8,31 +8,27 @@ export const AdminPanelSettings = () => { const [syncModeEnabled, setSyncModeEnabled] = useState( plugin.settings.syncModeEnabled ?? false, ); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const handleSyncModeToggle = useCallback((newValue: boolean) => { - setSyncModeEnabled(newValue); - setHasUnsavedChanges(true); - }, []); + const handleSyncModeToggle = useCallback( + async (newValue: boolean) => { + setSyncModeEnabled(newValue); + plugin.settings.syncModeEnabled = newValue; + await plugin.saveSettings(); - const handleSave = async () => { - plugin.settings.syncModeEnabled = syncModeEnabled; - await plugin.saveSettings(); - new Notice("Admin panel settings saved"); - setHasUnsavedChanges(false); - - if (syncModeEnabled) { - try { - await initializeSupabaseSync(plugin); - new Notice("Sync mode initialized successfully"); - } catch (error) { - console.error("Failed to initialize sync mode:", error); - new Notice( - `Failed to initialize sync mode: ${error instanceof Error ? error.message : String(error)}`, - ); + if (newValue) { + try { + await initializeSupabaseSync(plugin); + new Notice("Sync mode initialized successfully"); + } catch (error) { + console.error("Failed to initialize sync mode:", error); + new Notice( + `Failed to initialize sync mode: ${error instanceof Error ? error.message : String(error)}`, + ); + } } - } - }; + }, + [plugin], + ); return (
@@ -46,26 +42,12 @@ export const AdminPanelSettings = () => {
handleSyncModeToggle(!syncModeEnabled)} + onClick={() => void handleSyncModeToggle(!syncModeEnabled)} >
- -
- -
- - {hasUnsavedChanges && ( -
You have unsaved changes
- )} ); }; diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index cc91f4d45..d89453575 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { usePlugin } from "./PluginContext"; -import { Notice, setIcon } from "obsidian"; +import { setIcon } from "obsidian"; import SuggestInput from "./SuggestInput"; import { SLACK_LOGO, WHITE_LOGO_SVG } from "~/icons"; @@ -157,57 +157,51 @@ const GeneralSettings = () => { const [nodeTagHotkey, setNodeTagHotkey] = useState( plugin.settings.nodeTagHotkey, ); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); - setHasUnsavedChanges(true); + plugin.settings.showIdsInFrontmatter = newValue; + void plugin.saveSettings(); }; - const handleFolderPathChange = useCallback((newValue: string) => { - setNodesFolderPath(newValue); - setHasUnsavedChanges(true); - }, []); + const handleFolderPathChange = useCallback( + (newValue: string) => { + setNodesFolderPath(newValue); + plugin.settings.nodesFolderPath = newValue.trim(); + void plugin.saveSettings(); + }, + [plugin], + ); - const handleCanvasFolderPathChange = useCallback((newValue: string) => { - setCanvasFolderPath(newValue); - setHasUnsavedChanges(true); - }, []); + const handleCanvasFolderPathChange = useCallback( + (newValue: string) => { + setCanvasFolderPath(newValue); + plugin.settings.canvasFolderPath = newValue.trim(); + void plugin.saveSettings(); + }, + [plugin], + ); const handleCanvasAttachmentsFolderPathChange = useCallback( (newValue: string) => { setCanvasAttachmentsFolderPath(newValue); - setHasUnsavedChanges(true); + plugin.settings.canvasAttachmentsFolderPath = newValue.trim(); + void plugin.saveSettings(); }, - [], + [plugin], ); - const handleNodeTagHotkeyChange = useCallback((newValue: string) => { - // Only allow single character - if (newValue.length <= 1) { - setNodeTagHotkey(newValue); - setHasUnsavedChanges(true); - } - }, []); - - const handleSave = async () => { - const trimmedNodesFolderPath = nodesFolderPath.trim(); - const trimmedCanvasFolderPath = canvasFolderPath.trim(); - const trimmedCanvasAttachmentsFolderPath = - canvasAttachmentsFolderPath.trim(); - plugin.settings.showIdsInFrontmatter = showIdsInFrontmatter; - plugin.settings.nodesFolderPath = trimmedNodesFolderPath; - plugin.settings.canvasFolderPath = trimmedCanvasFolderPath; - plugin.settings.canvasAttachmentsFolderPath = - trimmedCanvasAttachmentsFolderPath; - plugin.settings.nodeTagHotkey = nodeTagHotkey || ""; - setNodesFolderPath(trimmedNodesFolderPath); - setCanvasFolderPath(trimmedCanvasFolderPath); - setCanvasAttachmentsFolderPath(trimmedCanvasAttachmentsFolderPath); - await plugin.saveSettings(); - new Notice("General settings saved"); - setHasUnsavedChanges(false); - }; + const handleNodeTagHotkeyChange = useCallback( + (newValue: string) => { + // Only allow single character + if (newValue.length <= 1) { + setNodeTagHotkey(newValue); + plugin.settings.nodeTagHotkey = newValue; + void plugin.saveSettings(); + } + }, + [plugin], + ); return (
@@ -313,19 +307,6 @@ const GeneralSettings = () => {
-
- -
- - {hasUnsavedChanges && ( -
You have unsaved changes
- )} ); diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index 4861db485..838a49c5b 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { validateNodeFormat, validateNodeName } from "~/utils/validateNodeType"; import { usePlugin } from "./PluginContext"; import { Notice, setIcon } from "obsidian"; @@ -159,6 +159,7 @@ const TextField = ({ value, error, onChange, + onBlur, nodeType, disabled, }: { @@ -166,6 +167,7 @@ const TextField = ({ value: string; error?: string; onChange: (value: string) => void; + onBlur?: () => void; nodeType?: DiscourseNode; disabled?: boolean; }) => { @@ -182,6 +184,7 @@ const TextField = ({ type="text" value={value || ""} onChange={(e) => onChange(e.target.value)} + onBlur={onBlur} placeholder={getPlaceholder()} className={`w-full ${error ? "input-error" : ""}`} disabled={disabled} @@ -279,7 +282,6 @@ const NodeTypeSettings = () => { const [errors, setErrors] = useState< Partial> >({}); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [templateFiles, setTemplateFiles] = useState([]); const [templateConfig, setTemplateConfig] = useState({ isEnabled: false, @@ -288,6 +290,8 @@ const NodeTypeSettings = () => { const [selectedNodeIndex, setSelectedNodeIndex] = useState( null, ); + // Ref to always have the latest editing state for onBlur handlers + const editingRef = useRef(null); useEffect(() => { const config = getTemplatePluginInfo(plugin.app); @@ -339,11 +343,61 @@ const NodeTypeSettings = () => { return true; }; + 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 saveSettings = (nodeTypeToSave: DiscourseNode) => { + if (!validateNodeType(nodeTypeToSave)) return; + + const updatedNodeTypes = [...nodeTypes]; + if ( + selectedNodeIndex !== null && + selectedNodeIndex < updatedNodeTypes.length + ) { + updatedNodeTypes[selectedNodeIndex] = nodeTypeToSave; + } else { + updatedNodeTypes.push(nodeTypeToSave); + setSelectedNodeIndex(updatedNodeTypes.length - 1); + } + + plugin.settings.nodeTypes = updatedNodeTypes; + setNodeTypes(updatedNodeTypes); + void plugin.saveSettings(); + }; + const handleNodeTypeChange = ( field: EditableFieldKey, value: string | boolean, - ): void => { - if (!editingNodeType) return; + ): DiscourseNode | null => { + if (!editingNodeType) return null; const updatedNodeType = { ...editingNodeType, @@ -354,7 +408,8 @@ const NodeTypeSettings = () => { validateField(field, value, updatedNodeType); } setEditingNodeType(updatedNodeType); - setHasUnsavedChanges(true); + editingRef.current = updatedNodeType; + return updatedNodeType; }; const handleAddNodeType = (): void => { @@ -370,8 +425,8 @@ const NodeTypeSettings = () => { modified: now, }; setEditingNodeType(newNodeType); + editingRef.current = newNodeType; setSelectedNodeIndex(nodeTypes.length); - setHasUnsavedChanges(true); setErrors({}); }; @@ -379,12 +434,19 @@ const NodeTypeSettings = () => { const nodeType = nodeTypes[index]; if (nodeType) { setEditingNodeType({ ...nodeType }); + editingRef.current = { ...nodeType }; setSelectedNodeIndex(index); - setHasUnsavedChanges(false); setErrors({}); } }; + const handleBack = (): void => { + setEditingNodeType(null); + editingRef.current = null; + setSelectedNodeIndex(null); + setErrors({}); + }; + const confirmDeleteNodeType = (index: number): void => { const nodeType = nodeTypes[index] || { name: "Unnamed" }; const modal = new ConfirmationModal(plugin.app, { @@ -417,85 +479,32 @@ const NodeTypeSettings = () => { setNodeTypes(updatedNodeTypes); setSelectedNodeIndex(null); setEditingNodeType(null); + editingRef.current = null; new Notice("Node type deleted successfully"); }; - const handleSave = async (): Promise => { - if (!editingNodeType) return; - - if (!validateNodeType(editingNodeType)) { - return; - } - - const updatedNodeTypes = [...nodeTypes]; - if ( - selectedNodeIndex !== null && - selectedNodeIndex < updatedNodeTypes.length - ) { - updatedNodeTypes[selectedNodeIndex] = editingNodeType; - } else { - updatedNodeTypes.push(editingNodeType); - } - - plugin.settings.nodeTypes = updatedNodeTypes; - await plugin.saveSettings(); - setNodeTypes(updatedNodeTypes); - new Notice("Node type saved"); - setHasUnsavedChanges(false); - setSelectedNodeIndex(null); - setEditingNodeType(null); - setErrors({}); - }; - - 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 isEditingImported = getImportInfo( editingNodeType?.importedFromRid, ).isImported; + const handleBlur = () => { + if (editingRef.current) saveSettings(editingRef.current); + }; + const renderField = (fieldConfig: BaseFieldConfig) => { if (!editingNodeType) return null; const value = editingNodeType[fieldConfig.key] as string | boolean; const error = errors[fieldConfig.key]; - const handleChange = (newValue: string | boolean) => - handleNodeTypeChange(fieldConfig.key, newValue); + + // Text fields: update local state on change, save on blur + // Discrete fields (color, boolean, select): update + save on change + const handleChange = (newValue: string | boolean) => { + const updated = handleNodeTypeChange(fieldConfig.key, newValue); + if (fieldConfig.type !== "text" && updated) { + saveSettings(updated); + } + }; return ( { value={value as string} error={error} onChange={handleChange} + onBlur={handleBlur} nodeType={editingNodeType} disabled={isEditingImported} /> @@ -662,7 +672,7 @@ const NodeTypeSettings = () => {
- {hasUnsavedChanges && !isEditingImported && ( -
- - -
- )}
); }; diff --git a/apps/obsidian/src/components/RelationshipSettings.tsx b/apps/obsidian/src/components/RelationshipSettings.tsx index 8fce869ac..a4fd78571 100644 --- a/apps/obsidian/src/components/RelationshipSettings.tsx +++ b/apps/obsidian/src/components/RelationshipSettings.tsx @@ -15,7 +15,7 @@ const RelationshipSettings = () => { const [discourseRelations, setDiscourseRelations] = useState< DiscourseRelation[] >(() => plugin.settings.discourseRelations ?? []); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [errors, setErrors] = useState>({}); const findRelationTypeById = ( id: string, @@ -28,11 +28,42 @@ const RelationshipSettings = () => { "id" | "modified" | "created" | "importedFromRid" >; - const handleRelationChange = async ( + const saveSettings = (relations: DiscourseRelation[]): void => { + const newErrors: Record = {}; + const completeRelations = relations.filter( + (r) => r.relationshipTypeId && r.sourceId && r.destinationId, + ); + + // Check for duplicates among complete relations + const seenKeys = new Map(); + for (const r of completeRelations) { + const idx = relations.indexOf(r); + const key = `${r.relationshipTypeId}-${r.sourceId}-${r.destinationId}`; + const prev = seenKeys.get(key); + if (prev !== undefined) { + newErrors[idx] = "Duplicate relation"; + if (!newErrors[prev]) newErrors[prev] = "Duplicate relation"; + } + seenKeys.set(key, idx); + } + + setErrors(newErrors); + if (Object.keys(newErrors).length > 0) return; + + // Persist complete local relations + all imported relations + const importedRelations = relations.filter((r) => r.importedFromRid); + plugin.settings.discourseRelations = [ + ...completeRelations.filter((r) => !r.importedFromRid), + ...importedRelations, + ]; + void plugin.saveSettings(); + }; + + const handleRelationChange = ( index: number, field: EditableFieldKey, value: string, - ): Promise => { + ): void => { const updatedRelations = [...discourseRelations]; const now = new Date().getTime(); @@ -48,10 +79,13 @@ const RelationshipSettings = () => { }; } - updatedRelations[index][field] = value; - updatedRelations[index].modified = now; + updatedRelations[index] = { + ...updatedRelations[index], + [field]: value, + modified: now, + }; setDiscourseRelations(updatedRelations); - setHasUnsavedChanges(true); + saveSettings(updatedRelations); }; const handleAddRelation = (): void => { @@ -69,7 +103,6 @@ const RelationshipSettings = () => { }, ]; setDiscourseRelations(updatedRelations); - setHasUnsavedChanges(true); }; const confirmDeleteRelation = (index: number): void => { @@ -111,32 +144,6 @@ const RelationshipSettings = () => { new Notice("Relation deleted"); }; - const handleSave = async (): Promise => { - for (const relation of discourseRelations) { - if ( - !relation.relationshipTypeId || - !relation.sourceId || - !relation.destinationId - ) { - new Notice("All fields are required for relations."); - return; - } - } - - const relationKeys = discourseRelations.map( - (r) => `${r.relationshipTypeId}-${r.sourceId}-${r.destinationId}`, - ); - if (new Set(relationKeys).size !== relationKeys.length) { - new Notice("Duplicate relations are not allowed."); - return; - } - - plugin.settings.discourseRelations = discourseRelations; - await plugin.saveSettings(); - new Notice("Relations saved"); - setHasUnsavedChanges(false); - }; - const localRelations = discourseRelations.filter( (relation) => !relation.importedFromRid, ); @@ -147,6 +154,7 @@ const RelationshipSettings = () => { const renderRelationItem = (relation: DiscourseRelation, index: number) => { const importInfo = getImportInfo(relation.importedFromRid); const isImported = importInfo.isImported; + const error = errors[index]; return (
@@ -155,9 +163,9 @@ const RelationshipSettings = () => { - void handleRelationChange( + handleRelationChange( index, "relationshipTypeId", e.target.value, ) } - className="flex-1 pl-2" + className={`flex-1 pl-2 ${error ? "input-error" : ""}`} disabled={isImported} > @@ -191,13 +199,9 @@ const RelationshipSettings = () => { { onChange={(e) => handleRelationTypeChange(index, "complement", e.target.value) } - className="flex-1" + onBlur={() => saveSettings(relationTypesRef.current)} + className={`flex-1 ${error ? "input-error" : ""}`} disabled={isImported} /> { )}
+ {error &&
{error}
} {isImported && (
{importInfo.spaceUri && ( @@ -318,18 +352,8 @@ const RelationshipTypeSettings = () => { -
- {hasUnsavedChanges && ( -
You have unsaved changes
- )} ); };