diff --git a/experience_cluster_config.yaml b/experience_cluster_config.yaml new file mode 100644 index 0000000..c346ede --- /dev/null +++ b/experience_cluster_config.yaml @@ -0,0 +1,46 @@ +- key: experience_cluster + group: experience + label: "Experience – Cluster" + note_type: experience + defaults: + status: active + steps: + - id: title + kind: capture_frontmatter + field: title + label: "Hub Titel" + required: true + # Einzeiliges Feld + - id: subtitle + kind: capture_text_line + label: "Untertitel" + prompt: "Kurzer Untertitel" + - id: intro + kind: capture_text + section: "## Einleitung" + label: "Einleitung" + prompt: "Beschreibe die Einleitung" + - id: items + kind: loop + label: "Erlebnisse" + item_label: "Erlebnis" + min_items: 1 + steps: + - id: item_Headline + kind: capture_text_line + label: "Überschrift" + required: false + prompt: "Nennen kurz die Gruppenüberschrift" + - id: item_list + kind: loop + label: "Listeneinträge" + steps: + - id: list_item + kind: capture_text_line + label: "Eintrag" + required: true + prompt: "Einzelner Listeneintrag" + - id: review + kind: review + label: "Review & Apply" + checks: [lint_current_note] diff --git a/src/interview/loopState.ts b/src/interview/loopState.ts index 45e52b7..b646cef 100644 --- a/src/interview/loopState.ts +++ b/src/interview/loopState.ts @@ -7,6 +7,7 @@ export interface LoopRuntimeState { items: unknown[]; // Committed items draft: Record; // Current draft being edited editIndex: number | null; // Index of item being edited, or null for new item + activeItemStepIndex: number; // Current step index in subwizard (0-based) } /** @@ -17,6 +18,7 @@ export function createLoopState(): LoopRuntimeState { items: [], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; } @@ -94,6 +96,7 @@ export function commitDraft(state: LoopRuntimeState): LoopRuntimeState { items: newItems, draft: {}, editIndex: null, + activeItemStepIndex: 0, }; } @@ -117,6 +120,7 @@ export function startEdit( ...state, draft: { ...(item as Record) }, editIndex: index, + activeItemStepIndex: 0, // Reset to first step when starting edit }; } @@ -152,6 +156,7 @@ export function deleteItem( editIndex: newEditIndex, // Clear draft if we were editing the deleted item draft: newEditIndex === null ? {} : state.draft, + activeItemStepIndex: newEditIndex === null ? 0 : state.activeItemStepIndex, }; } @@ -229,5 +234,64 @@ export function clearDraft(state: LoopRuntimeState): LoopRuntimeState { ...state, draft: {}, editIndex: null, + activeItemStepIndex: 0, + }; +} + +/** + * Set active item step index. + */ +export function setActiveItemStepIndex( + state: LoopRuntimeState, + index: number +): LoopRuntimeState { + if (index < 0) { + return state; // Invalid index + } + + return { + ...state, + activeItemStepIndex: index, + }; +} + +/** + * Move to next step in item subwizard. + */ +export function itemNextStep( + state: LoopRuntimeState, + childStepCount: number +): LoopRuntimeState { + if (state.activeItemStepIndex >= childStepCount - 1) { + return state; // Already at last step + } + + return { + ...state, + activeItemStepIndex: state.activeItemStepIndex + 1, + }; +} + +/** + * Move to previous step in item subwizard. + */ +export function itemPrevStep(state: LoopRuntimeState): LoopRuntimeState { + if (state.activeItemStepIndex <= 0) { + return state; // Already at first step + } + + return { + ...state, + activeItemStepIndex: state.activeItemStepIndex - 1, + }; +} + +/** + * Reset item wizard to first step. + */ +export function resetItemWizard(state: LoopRuntimeState): LoopRuntimeState { + return { + ...state, + activeItemStepIndex: 0, }; } diff --git a/src/interview/renderer.ts b/src/interview/renderer.ts index ab427ad..e24a2f4 100644 --- a/src/interview/renderer.ts +++ b/src/interview/renderer.ts @@ -130,9 +130,23 @@ function renderCaptureFrontmatter(step: CaptureFrontmatterStep, answers: RenderA } /** - * Render loop step. + * Render loop step (recursive, supports arbitrary nesting depth). */ function renderLoop(step: LoopStep, answers: RenderAnswers): string | null { + return renderLoopRecursive(step, answers, 0); +} + +/** + * Recursive helper to render loop items with nested steps. + * Supports arbitrary nesting depth (no hard limit, but practical limits apply due to stack size). + */ +function renderLoopRecursive(step: LoopStep, answers: RenderAnswers, depth: number): string | null { + // Safety check: prevent infinite recursion (practical limit: 100 levels) + if (depth > 100) { + console.warn(`Loop nesting depth ${depth} exceeds practical limit, stopping recursion`); + return null; + } + const items = answers.loopContexts.get(step.key); if (!items || items.length === 0) { return null; @@ -147,7 +161,7 @@ function renderLoop(step: LoopStep, answers: RenderAnswers): string | null { const itemParts: string[] = []; - // Render each nested step for this item + // Render each nested step for this item (recursively) for (const nestedStep of step.items) { const fieldValue = (item as Record)[nestedStep.key]; @@ -198,6 +212,26 @@ function renderLoop(step: LoopStep, answers: RenderAnswers): string | null { })); } } + } else if (nestedStep.type === "loop") { + // Recursively handle nested loop: fieldValue should be an array of items + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + const nestedLoopStep = nestedStep as LoopStep; + + // Create a temporary answers object for the nested loop + // The nested loop items are stored in fieldValue, so we need to create + // a temporary loopContexts map for the nested loop + const nestedAnswers: RenderAnswers = { + collectedData: answers.collectedData, + loopContexts: new Map(answers.loopContexts), + }; + nestedAnswers.loopContexts.set(nestedLoopStep.key, fieldValue); + + // Recursively render the nested loop + const nestedOutput = renderLoopRecursive(nestedLoopStep, nestedAnswers, depth + 1); + if (nestedOutput) { + itemParts.push(nestedOutput); + } + } } } diff --git a/src/tests/interview/loopState.test.ts b/src/tests/interview/loopState.test.ts index 89fb2b3..adced7f 100644 --- a/src/tests/interview/loopState.test.ts +++ b/src/tests/interview/loopState.test.ts @@ -9,6 +9,10 @@ import { moveItemUp, moveItemDown, clearDraft, + setActiveItemStepIndex, + itemNextStep, + itemPrevStep, + resetItemWizard, type LoopRuntimeState, } from "../../interview/loopState"; @@ -114,6 +118,7 @@ describe("loopState", () => { items: [{ field1: "old" }], draft: { field1: "new" }, editIndex: 0, + activeItemStepIndex: 0, }; const newState = commitDraft(state); @@ -146,6 +151,7 @@ describe("loopState", () => { items: [{ field1: "value1", field2: "value2" }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = startEdit(state, 0); @@ -160,6 +166,7 @@ describe("loopState", () => { items: [{ field1: "value1" }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = startEdit(state, 5); @@ -172,6 +179,7 @@ describe("loopState", () => { items: [{ field1: "value1" }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; startEdit(state, 0); @@ -186,6 +194,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }, { id: 3 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = deleteItem(state, 1); @@ -200,6 +209,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }], draft: { id: 2 }, editIndex: 1, + activeItemStepIndex: 1, }; const newState = deleteItem(state, 1); @@ -214,6 +224,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }, { id: 3 }], draft: { id: 3 }, editIndex: 2, + activeItemStepIndex: 0, }; const newState = deleteItem(state, 1); @@ -227,6 +238,7 @@ describe("loopState", () => { items: [{ id: 1 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = deleteItem(state, 5); @@ -240,6 +252,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }, { id: 3 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemUp(state, 1); @@ -254,6 +267,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }], draft: { id: 2 }, editIndex: 1, + activeItemStepIndex: 0, }; const newState = moveItemUp(state, 1); @@ -267,6 +281,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }, { id: 3 }], draft: { id: 3 }, editIndex: 2, + activeItemStepIndex: 0, }; const newState = moveItemUp(state, 1); @@ -283,6 +298,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemUp(state, 0); @@ -294,6 +310,7 @@ describe("loopState", () => { items: [{ id: 1 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemUp(state, 5); @@ -307,6 +324,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }, { id: 3 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemDown(state, 1); @@ -321,6 +339,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }], draft: { id: 1 }, editIndex: 0, + activeItemStepIndex: 0, }; const newState = moveItemDown(state, 0); @@ -335,6 +354,7 @@ describe("loopState", () => { items: [{ id: 1 }, { id: 2 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemDown(state, 1); @@ -346,6 +366,7 @@ describe("loopState", () => { items: [{ id: 1 }], draft: {}, editIndex: null, + activeItemStepIndex: 0, }; const newState = moveItemDown(state, 5); @@ -359,6 +380,7 @@ describe("loopState", () => { items: [{ id: 1 }], draft: { field1: "value1" }, editIndex: 0, + activeItemStepIndex: 2, }; const newState = clearDraft(state); @@ -373,11 +395,126 @@ describe("loopState", () => { items: [], draft: { field1: "value1" }, editIndex: 0, + activeItemStepIndex: 1, }; clearDraft(state); expect(state.draft).toEqual({ field1: "value1" }); expect(state.editIndex).toBe(0); + expect(state.activeItemStepIndex).toBe(1); + }); + }); + + describe("subwizard navigation", () => { + it("should set active item step index", () => { + const state = createLoopState(); + const newState = setActiveItemStepIndex(state, 2); + expect(newState.activeItemStepIndex).toBe(2); + expect(newState.items).toEqual([]); + }); + + it("should not set negative index", () => { + const state = createLoopState(); + const newState = setActiveItemStepIndex(state, -1); + expect(newState).toBe(state); // Should return unchanged + }); + + it("should move to next step", () => { + const state = createLoopState(); + let newState = itemNextStep(state, 3); + expect(newState.activeItemStepIndex).toBe(1); + + newState = itemNextStep(newState, 3); + expect(newState.activeItemStepIndex).toBe(2); + }); + + it("should not move beyond last step", () => { + const state: LoopRuntimeState = { + items: [], + draft: {}, + editIndex: null, + activeItemStepIndex: 2, + }; + + const newState = itemNextStep(state, 3); + expect(newState).toBe(state); // Should return unchanged + }); + + it("should move to previous step", () => { + const state: LoopRuntimeState = { + items: [], + draft: {}, + editIndex: null, + activeItemStepIndex: 2, + }; + + let newState = itemPrevStep(state); + expect(newState.activeItemStepIndex).toBe(1); + + newState = itemPrevStep(newState); + expect(newState.activeItemStepIndex).toBe(0); + }); + + it("should not move before first step", () => { + const state = createLoopState(); + const newState = itemPrevStep(state); + expect(newState).toBe(state); // Should return unchanged + }); + + it("should reset item wizard to first step", () => { + const state: LoopRuntimeState = { + items: [], + draft: {}, + editIndex: null, + activeItemStepIndex: 3, + }; + + const newState = resetItemWizard(state); + expect(newState.activeItemStepIndex).toBe(0); + }); + + it("should reset to first step when starting edit", () => { + const state: LoopRuntimeState = { + items: [{ field1: "value1" }], + draft: {}, + editIndex: null, + activeItemStepIndex: 2, + }; + + const newState = startEdit(state, 0); + expect(newState.activeItemStepIndex).toBe(0); + expect(newState.editIndex).toBe(0); + }); + + it("should reset to first step when committing draft", () => { + const state: LoopRuntimeState = { + items: [], + draft: { field1: "value1" }, + editIndex: null, + activeItemStepIndex: 2, + }; + + const newState = commitDraft(state); + expect(newState.activeItemStepIndex).toBe(0); + expect(newState.items.length).toBe(1); + }); + + it("should allow commit:on_next even when not on last step", () => { + // Test that commit logic works regardless of activeItemStepIndex + const state: LoopRuntimeState = { + items: [], + draft: { field1: "value1", field2: "value2" }, + editIndex: null, + activeItemStepIndex: 1, // Not on last step + }; + + // Draft is dirty, should commit + expect(isDraftDirty(state.draft)).toBe(true); + + const newState = commitDraft(state); + expect(newState.items.length).toBe(1); + expect(newState.activeItemStepIndex).toBe(0); + expect(newState.draft).toEqual({}); }); }); }); diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index 34a077c..b9c2a77 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -37,6 +37,10 @@ import { moveItemUp, moveItemDown, clearDraft, + setActiveItemStepIndex, + itemNextStep, + itemPrevStep, + resetItemWizard, } from "../interview/loopState"; export interface WizardResult { @@ -601,6 +605,7 @@ export class InterviewWizardModal extends Modal { items: legacyItems, draft: {}, editIndex: null, + activeItemStepIndex: 0, }; this.state.loopRuntimeStates.set(step.key, loopState); } @@ -722,9 +727,13 @@ export class InterviewWizardModal extends Modal { editBtn.style.fontSize = "0.85em"; editBtn.style.padding = "0.25em 0.5em"; editBtn.onclick = () => { - const newState = startEdit(loopState!, i); - this.state.loopRuntimeStates.set(step.key, newState); - this.renderStep(); + const currentState = this.state.loopRuntimeStates.get(step.key); + if (currentState) { + let newState = startEdit(currentState, i); + newState = resetItemWizard(newState); // Reset to first step + this.state.loopRuntimeStates.set(step.key, newState); + this.renderStep(); + } }; // Delete button @@ -780,68 +789,156 @@ export class InterviewWizardModal extends Modal { rightPane.style.width = "60%"; rightPane.style.flex = "1"; - const editorTitle = rightPane.createEl("h3", { - text: loopState.editIndex !== null - ? `Edit Item ${loopState.editIndex + 1}` - : "New Item", - }); + // Subwizard header + const itemTitle = rightPane.createEl("h3"); + const itemTitleText = loopState.editIndex !== null + ? `Item: ${loopState.editIndex + 1} (editing)` + : "Item: New"; + const stepCounter = step.items.length > 0 + ? ` - Step ${loopState.activeItemStepIndex + 1}/${step.items.length}` + : ""; + itemTitle.textContent = itemTitleText + stepCounter; - // Render nested steps bound to draft + // Render only the active nested step (subwizard) if (step.items.length > 0) { - const editorContainer = rightPane.createEl("div", { - cls: "loop-item-editor", - }); + const activeStepIndex = Math.min(loopState.activeItemStepIndex, step.items.length - 1); + const activeNestedStep = step.items[activeStepIndex]; + + if (activeNestedStep) { + const editorContainer = rightPane.createEl("div", { + cls: "loop-item-editor", + }); - for (const nestedStep of step.items) { - const draftValue = loopState.draft[nestedStep.key]; - this.renderLoopNestedStep(nestedStep, editorContainer, step.key, draftValue, (fieldId, value) => { + const draftValue = loopState.draft[activeNestedStep.key]; + this.renderLoopNestedStep(activeNestedStep, editorContainer, step.key, draftValue, (fieldId, value) => { const currentState = this.state.loopRuntimeStates.get(step.key); if (currentState) { const newState = setDraftField(currentState, fieldId, value); this.state.loopRuntimeStates.set(step.key, newState); } }); - } - // Action buttons - const buttonContainer = rightPane.createEl("div", { - cls: "loop-editor-actions", - }); - buttonContainer.style.display = "flex"; - buttonContainer.style.gap = "0.5em"; - buttonContainer.style.marginTop = "1em"; - - // Add/Save Item button - const commitBtn = buttonContainer.createEl("button", { - text: loopState.editIndex !== null ? "Save Item" : "Add Item", - cls: "mod-cta", - }); - commitBtn.onclick = () => { - const currentState = this.state.loopRuntimeStates.get(step.key); - if (currentState && isDraftDirty(currentState.draft)) { - const newState = commitDraft(currentState); - this.state.loopRuntimeStates.set(step.key, newState); - // Update answers - this.state.loopContexts.set(step.key, newState.items); - this.renderStep(); - } else { - new Notice("Please enter at least one field"); - } - }; - - // Clear button - if (loopState.editIndex !== null || isDraftDirty(loopState.draft)) { - const clearBtn = buttonContainer.createEl("button", { - text: "Clear", + // Subwizard navigation buttons + const subwizardNav = rightPane.createEl("div", { + cls: "loop-subwizard-navigation", }); - clearBtn.onclick = () => { + subwizardNav.style.display = "flex"; + subwizardNav.style.gap = "0.5em"; + subwizardNav.style.marginTop = "1em"; + subwizardNav.style.justifyContent = "space-between"; + + // Left side: Item Back/Next + const itemNavLeft = subwizardNav.createEl("div", { + cls: "loop-item-nav-left", + }); + itemNavLeft.style.display = "flex"; + itemNavLeft.style.gap = "0.5em"; + + // Item Back button + const itemBackBtn = itemNavLeft.createEl("button", { + text: "← Item Back", + }); + itemBackBtn.disabled = activeStepIndex === 0; + itemBackBtn.onclick = () => { const currentState = this.state.loopRuntimeStates.get(step.key); if (currentState) { - const newState = clearDraft(currentState); + const newState = itemPrevStep(currentState); this.state.loopRuntimeStates.set(step.key, newState); this.renderStep(); } }; + + // Item Next button + const itemNextBtn = itemNavLeft.createEl("button", { + text: "Item Next →", + }); + itemNextBtn.disabled = activeStepIndex >= step.items.length - 1; + itemNextBtn.onclick = () => { + const currentState = this.state.loopRuntimeStates.get(step.key); + if (currentState) { + const newState = itemNextStep(currentState, step.items.length); + this.state.loopRuntimeStates.set(step.key, newState); + this.renderStep(); + } + }; + + // Right side: Done/Save Item + const itemNavRight = subwizardNav.createEl("div", { + cls: "loop-item-nav-right", + }); + itemNavRight.style.display = "flex"; + itemNavRight.style.gap = "0.5em"; + + // Check for required fields + const missingRequired: string[] = []; + for (const nestedStep of step.items) { + if ( + (nestedStep.type === "capture_text" || nestedStep.type === "capture_text_line" || nestedStep.type === "capture_frontmatter") && + nestedStep.required + ) { + const value = loopState.draft[nestedStep.key]; + if (!value || (typeof value === "string" && value.trim() === "")) { + missingRequired.push(nestedStep.label || nestedStep.key); + } + } + } + + // Done/Save Item button + const doneBtn = itemNavRight.createEl("button", { + text: loopState.editIndex !== null ? "Save Item" : "Done", + cls: "mod-cta", + }); + doneBtn.onclick = () => { + const currentState = this.state.loopRuntimeStates.get(step.key); + if (!currentState) return; + + // Check required fields + if (missingRequired.length > 0) { + const msg = `Required fields missing: ${missingRequired.join(", ")}. Save anyway?`; + if (!confirm(msg)) { + return; + } + } + + if (isDraftDirty(currentState.draft)) { + const wasNewItem = currentState.editIndex === null; + const newState = commitDraft(currentState); + this.state.loopRuntimeStates.set(step.key, newState); + // Update answers + this.state.loopContexts.set(step.key, newState.items); + + // If we just created a new item, reset all nested loop states + if (wasNewItem) { + for (const nestedStep of step.items) { + if (nestedStep.type === "loop") { + const nestedLoopKey = `${step.key}.${nestedStep.key}`; + const nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (nestedLoopState) { + const resetState = clearDraft(nestedLoopState); + this.state.loopRuntimeStates.set(nestedLoopKey, resetState); + } + } + } + } + + this.renderStep(); + } else { + new Notice("Please enter at least one field"); + } + }; + + // Show warning if required fields missing + if (missingRequired.length > 0) { + const warning = rightPane.createEl("div", { + cls: "loop-required-warning", + text: `⚠️ Required fields missing: ${missingRequired.join(", ")}`, + }); + warning.style.padding = "0.5em"; + warning.style.background = "var(--background-modifier-error)"; + warning.style.borderRadius = "4px"; + warning.style.marginTop = "0.5em"; + warning.style.color = "var(--text-error)"; + } } } } @@ -1014,6 +1111,300 @@ export class InterviewWizardModal extends Modal { text.inputEl.style.width = "100%"; text.inputEl.style.boxSizing = "border-box"; }); + } else if (nestedStep.type === "loop") { + // Nested loop: render as a nested loop editor + // The draft value should be an array of items + const nestedLoopItems = Array.isArray(draftValue) ? draftValue : []; + + // Get or create nested loop state + const nestedLoopKey = `${loopKey}.${nestedStep.key}`; + let nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (!nestedLoopState) { + nestedLoopState = { + items: nestedLoopItems, + draft: {}, + editIndex: null, + activeItemStepIndex: 0, + }; + this.state.loopRuntimeStates.set(nestedLoopKey, nestedLoopState); + } + + // Render nested loop UI - use a simplified 2-pane layout + const nestedLoopContainer = containerEl.createEl("div", { + cls: "nested-loop-container", + }); + nestedLoopContainer.style.border = "1px solid var(--background-modifier-border)"; + nestedLoopContainer.style.borderRadius = "4px"; + nestedLoopContainer.style.padding = "1em"; + nestedLoopContainer.style.marginTop = "0.5em"; + + if (nestedStep.label) { + const labelEl = nestedLoopContainer.createEl("div", { + cls: "mindnet-field__label", + text: nestedStep.label, + }); + labelEl.style.marginBottom = "0.5em"; + labelEl.style.fontWeight = "bold"; + } + + // Create 2-pane layout for nested loop + const nestedPaneContainer = nestedLoopContainer.createEl("div", { + cls: "nested-loop-panes", + }); + nestedPaneContainer.style.display = "flex"; + nestedPaneContainer.style.gap = "1em"; + nestedPaneContainer.style.minHeight = "200px"; + + // Left pane: Items list (narrower for nested loops) + const nestedLeftPane = nestedPaneContainer.createEl("div", { + cls: "nested-loop-items-pane", + }); + nestedLeftPane.style.width = "25%"; + nestedLeftPane.style.borderRight = "1px solid var(--background-modifier-border)"; + nestedLeftPane.style.paddingRight = "1em"; + nestedLeftPane.style.maxHeight = "300px"; + nestedLeftPane.style.overflowY = "auto"; + + const nestedItemsTitle = nestedLeftPane.createEl("div", { + text: "Einträge", + cls: "mindnet-field__label", + }); + nestedItemsTitle.style.marginBottom = "0.5em"; + + // Render items list + nestedLoopState.items.forEach((item, i) => { + const itemEl = nestedLeftPane.createEl("div", { + cls: "nested-loop-item", + }); + itemEl.style.padding = "0.5em"; + itemEl.style.marginBottom = "0.25em"; + itemEl.style.background = "var(--background-secondary)"; + itemEl.style.borderRadius = "4px"; + itemEl.style.cursor = "pointer"; + + // Extract first non-empty field value for display + let itemText = `Item ${i + 1}`; + if (typeof item === "object" && item !== null) { + const itemObj = item as Record; + // Try to find first non-empty string value + for (const [key, value] of Object.entries(itemObj)) { + if (value && typeof value === "string" && value.trim() !== "") { + itemText = value.trim(); + break; + } + } + } else if (item) { + itemText = String(item); + } + itemEl.textContent = itemText.length > 40 ? itemText.substring(0, 40) + "..." : itemText; + + // Edit button + const editBtn = itemEl.createEl("button", { + text: "✏️", + cls: "nested-edit-btn", + }); + editBtn.style.float = "right"; + editBtn.style.marginLeft = "0.5em"; + editBtn.onclick = (e) => { + e.stopPropagation(); + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + let newState = startEdit(currentNestedState, i); + newState = resetItemWizard(newState); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + this.renderStep(); + } + }; + + // Move Up button + const moveUpBtn = itemEl.createEl("button", { + text: "↑", + cls: "nested-move-up-btn", + }); + moveUpBtn.style.float = "right"; + moveUpBtn.style.marginLeft = "0.25em"; + moveUpBtn.disabled = i === 0; + moveUpBtn.onclick = (e) => { + e.stopPropagation(); + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = moveItemUp(currentNestedState, i); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + // Update parent draft + onFieldChange(nestedStep.key, newState.items); + this.renderStep(); + } + }; + + // Move Down button + const moveDownBtn = itemEl.createEl("button", { + text: "↓", + cls: "nested-move-down-btn", + }); + moveDownBtn.style.float = "right"; + moveDownBtn.style.marginLeft = "0.25em"; + moveDownBtn.disabled = i >= nestedLoopState.items.length - 1; + moveDownBtn.onclick = (e) => { + e.stopPropagation(); + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = moveItemDown(currentNestedState, i); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + // Update parent draft + onFieldChange(nestedStep.key, newState.items); + this.renderStep(); + } + }; + + // Delete button + const deleteBtn = itemEl.createEl("button", { + text: "🗑️", + cls: "nested-delete-btn", + }); + deleteBtn.style.float = "right"; + deleteBtn.style.marginLeft = "0.25em"; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = deleteItem(currentNestedState, i); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + // Update parent draft + onFieldChange(nestedStep.key, newState.items); + this.renderStep(); + } + }; + }); + + // Right pane: Editor for nested loop (wider for nested loops) + const nestedRightPane = nestedPaneContainer.createEl("div", { + cls: "nested-loop-editor-pane", + }); + nestedRightPane.style.width = "75%"; + nestedRightPane.style.flex = "1"; + + const nestedEditorTitle = nestedRightPane.createEl("div", { + cls: "mindnet-field__label", + }); + const nestedTitleText = nestedLoopState.editIndex !== null + ? `Eintrag ${nestedLoopState.editIndex + 1} bearbeiten` + : "Neuer Eintrag"; + const nestedStepCounter = nestedStep.items.length > 0 + ? ` - Schritt ${nestedLoopState.activeItemStepIndex + 1}/${nestedStep.items.length}` + : ""; + nestedEditorTitle.textContent = nestedTitleText + nestedStepCounter; + nestedEditorTitle.style.marginBottom = "0.5em"; + + // Render active nested step + if (nestedStep.items.length > 0) { + const activeNestedStepIndex = Math.min(nestedLoopState.activeItemStepIndex, nestedStep.items.length - 1); + const activeNestedNestedStep = nestedStep.items[activeNestedStepIndex]; + + // Recursively render nested step (supports arbitrary nesting depth) + if (activeNestedNestedStep) { + const nestedDraftValue = nestedLoopState.draft[activeNestedNestedStep.key]; + this.renderLoopNestedStep( + activeNestedNestedStep, + nestedRightPane, + nestedLoopKey, + nestedDraftValue, + (fieldId, value) => { + // Update state without re-rendering to preserve focus + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = setDraftField(currentNestedState, fieldId, value); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + // Update parent draft without re-rendering + const parentLoopState = this.state.loopRuntimeStates.get(loopKey); + if (parentLoopState) { + const updatedParentDraft = { + ...parentLoopState.draft, + [nestedStep.key]: newState.items, + }; + const updatedParentState = setDraftField(parentLoopState, nestedStep.key, newState.items); + this.state.loopRuntimeStates.set(loopKey, updatedParentState); + } + // Do NOT call renderStep() here - it causes focus loss + } + } + ); + } + + // Navigation buttons for nested loop + const nestedNav = nestedRightPane.createEl("div", { + cls: "nested-loop-navigation", + }); + nestedNav.style.display = "flex"; + nestedNav.style.gap = "0.5em"; + nestedNav.style.marginTop = "1em"; + nestedNav.style.justifyContent = "space-between"; + + // Left: Back/Next + const nestedNavLeft = nestedNav.createEl("div"); + nestedNavLeft.style.display = "flex"; + nestedNavLeft.style.gap = "0.5em"; + + const nestedBackBtn = nestedNavLeft.createEl("button", { + text: "← Zurück", + }); + nestedBackBtn.disabled = activeNestedStepIndex === 0; + nestedBackBtn.onclick = () => { + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = itemPrevStep(currentNestedState); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + this.renderStep(); + } + }; + + const nestedNextBtn = nestedNavLeft.createEl("button", { + text: "Weiter →", + }); + nestedNextBtn.disabled = activeNestedStepIndex >= nestedStep.items.length - 1; + nestedNextBtn.onclick = () => { + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = itemNextStep(currentNestedState, nestedStep.items.length); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + this.renderStep(); + } + }; + + // Right: Save/Clear + const nestedNavRight = nestedNav.createEl("div"); + nestedNavRight.style.display = "flex"; + nestedNavRight.style.gap = "0.5em"; + + const nestedSaveBtn = nestedNavRight.createEl("button", { + text: nestedLoopState.editIndex !== null ? "Speichern" : "Hinzufügen", + cls: "mod-cta", + }); + nestedSaveBtn.onclick = () => { + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState && isDraftDirty(currentNestedState.draft)) { + const newState = commitDraft(currentNestedState); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + // Update parent draft + onFieldChange(nestedStep.key, newState.items); + // Re-render to show updated item list + this.renderStep(); + } + }; + + if (nestedLoopState.editIndex !== null || isDraftDirty(nestedLoopState.draft)) { + const nestedClearBtn = nestedNavRight.createEl("button", { + text: "Löschen", + }); + nestedClearBtn.onclick = () => { + const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey); + if (currentNestedState) { + const newState = clearDraft(currentNestedState); + this.state.loopRuntimeStates.set(nestedLoopKey, newState); + this.renderStep(); + } + }; + } + } } }