From 684217bdf4613b96bfa36e37e39818806c82c6e6 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 16 Jan 2026 19:25:55 +0100 Subject: [PATCH] Pane 2 mit Edit, Delete, sort, etc. --- src/interview/loopState.ts | 233 +++++++++ src/interview/parseInterviewConfig.ts | 8 + src/interview/types.ts | 3 + src/interview/wizardState.ts | 7 +- src/tests/interview/loopState.test.ts | 383 ++++++++++++++ src/ui/InterviewWizardModal.ts | 710 ++++++++++++++++---------- 6 files changed, 1070 insertions(+), 274 deletions(-) create mode 100644 src/interview/loopState.ts create mode 100644 src/tests/interview/loopState.test.ts diff --git a/src/interview/loopState.ts b/src/interview/loopState.ts new file mode 100644 index 0000000..45e52b7 --- /dev/null +++ b/src/interview/loopState.ts @@ -0,0 +1,233 @@ +/** + * Loop runtime state management. + * Pure functions for managing loop item state (draft, committed items, editing). + */ + +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 +} + +/** + * Create initial loop state. + */ +export function createLoopState(): LoopRuntimeState { + return { + items: [], + draft: {}, + editIndex: null, + }; +} + +/** + * Set a field value in the draft. + */ +export function setDraftField( + state: LoopRuntimeState, + fieldId: string, + value: unknown +): LoopRuntimeState { + return { + ...state, + draft: { + ...state.draft, + [fieldId]: value, + }, + }; +} + +/** + * Check if draft has any non-empty data (is dirty). + * Returns true if draft contains any non-empty string, true, non-zero number, non-empty array, or non-empty object. + */ +export function isDraftDirty(draft: Record): boolean { + for (const [key, value] of Object.entries(draft)) { + if (value === null || value === undefined) { + continue; + } + + if (typeof value === "string" && value.trim() !== "") { + return true; + } + + if (typeof value === "boolean" && value === true) { + return true; + } + + if (typeof value === "number" && value !== 0) { + return true; + } + + if (Array.isArray(value) && value.length > 0) { + return true; + } + + if (typeof value === "object" && Object.keys(value).length > 0) { + return true; + } + } + + return false; +} + +/** + * Commit draft to items (add new or update existing). + */ +export function commitDraft(state: LoopRuntimeState): LoopRuntimeState { + // If draft is not dirty, return state unchanged + if (!isDraftDirty(state.draft)) { + return state; + } + + const newItems = [...state.items]; + + if (state.editIndex !== null && state.editIndex >= 0 && state.editIndex < newItems.length) { + // Update existing item + newItems[state.editIndex] = { ...state.draft }; + } else { + // Add new item + newItems.push({ ...state.draft }); + } + + return { + items: newItems, + draft: {}, + editIndex: null, + }; +} + +/** + * Start editing an existing item (load into draft). + */ +export function startEdit( + state: LoopRuntimeState, + index: number +): LoopRuntimeState { + if (index < 0 || index >= state.items.length) { + return state; // Invalid index, return unchanged + } + + const item = state.items[index]; + if (!item || typeof item !== "object") { + return state; + } + + return { + ...state, + draft: { ...(item as Record) }, + editIndex: index, + }; +} + +/** + * Delete an item at the given index. + */ +export function deleteItem( + state: LoopRuntimeState, + index: number +): LoopRuntimeState { + if (index < 0 || index >= state.items.length) { + return state; // Invalid index, return unchanged + } + + const newItems = [...state.items]; + newItems.splice(index, 1); + + // If we were editing the deleted item or an item after it, adjust editIndex + let newEditIndex = state.editIndex; + if (state.editIndex !== null) { + if (state.editIndex === index) { + // Deleted the item we were editing + newEditIndex = null; + } else if (state.editIndex > index) { + // Deleted an item before the one we're editing, adjust index + newEditIndex = state.editIndex - 1; + } + } + + return { + ...state, + items: newItems, + editIndex: newEditIndex, + // Clear draft if we were editing the deleted item + draft: newEditIndex === null ? {} : state.draft, + }; +} + +/** + * Move an item up (swap with previous item). + */ +export function moveItemUp( + state: LoopRuntimeState, + index: number +): LoopRuntimeState { + if (index <= 0 || index >= state.items.length) { + return state; // Cannot move first item up or invalid index + } + + const newItems = [...state.items]; + const temp = newItems[index]; + newItems[index] = newItems[index - 1]; + newItems[index - 1] = temp; + + // Adjust editIndex if we're editing one of the moved items + let newEditIndex = state.editIndex; + if (state.editIndex !== null) { + if (state.editIndex === index) { + newEditIndex = index - 1; + } else if (state.editIndex === index - 1) { + newEditIndex = index; + } + } + + return { + ...state, + items: newItems, + editIndex: newEditIndex, + }; +} + +/** + * Move an item down (swap with next item). + */ +export function moveItemDown( + state: LoopRuntimeState, + index: number +): LoopRuntimeState { + if (index < 0 || index >= state.items.length - 1) { + return state; // Cannot move last item down or invalid index + } + + const newItems = [...state.items]; + const temp = newItems[index]; + newItems[index] = newItems[index + 1]; + newItems[index + 1] = temp; + + // Adjust editIndex if we're editing one of the moved items + let newEditIndex = state.editIndex; + if (state.editIndex !== null) { + if (state.editIndex === index) { + newEditIndex = index + 1; + } else if (state.editIndex === index + 1) { + newEditIndex = index; + } + } + + return { + ...state, + items: newItems, + editIndex: newEditIndex, + }; +} + +/** + * Clear draft and exit edit mode. + */ +export function clearDraft(state: LoopRuntimeState): LoopRuntimeState { + return { + ...state, + draft: {}, + editIndex: null, + }; +} diff --git a/src/interview/parseInterviewConfig.ts b/src/interview/parseInterviewConfig.ts index e7b134d..d7585e7 100644 --- a/src/interview/parseInterviewConfig.ts +++ b/src/interview/parseInterviewConfig.ts @@ -183,6 +183,14 @@ function parseStep(raw: Record): InterviewStep | null { step.output = { join: output.join }; } } + + // Parse ui.commit + if (raw.ui && typeof raw.ui === "object") { + const ui = raw.ui as Record; + if (ui.commit === "explicit_add" || ui.commit === "on_next") { + step.ui = { commit: ui.commit }; + } + } for (const itemRaw of nestedSteps) { if (!itemRaw || typeof itemRaw !== "object") { diff --git a/src/interview/types.ts b/src/interview/types.ts index 85bbc8a..bdd9173 100644 --- a/src/interview/types.ts +++ b/src/interview/types.ts @@ -34,6 +34,9 @@ export interface LoopStep { output?: { join?: string; // String to join items (default: "\n\n") }; + ui?: { + commit?: "explicit_add" | "on_next"; // Commit mode (default: "explicit_add") + }; } export interface LLMDialogStep { diff --git a/src/interview/wizardState.ts b/src/interview/wizardState.ts index 1144036..99f27d7 100644 --- a/src/interview/wizardState.ts +++ b/src/interview/wizardState.ts @@ -1,11 +1,13 @@ import type { InterviewProfile, InterviewStep } from "./types"; +import type { LoopRuntimeState } from "./loopState"; export interface WizardState { profile: InterviewProfile; currentStepIndex: number; stepHistory: number[]; // Stack for back navigation collectedData: Map; // key -> value - loopContexts: Map; // loop key -> array of collected items + loopContexts: Map; // loop key -> array of collected items (deprecated, use loopRuntimeStates) + loopRuntimeStates: Map; // loop step key -> runtime state patches: Patch[]; // Collected patches to apply } @@ -31,7 +33,8 @@ export function createWizardState(profile: InterviewProfile): WizardState { currentStepIndex: 0, stepHistory: [], collectedData: new Map(), - loopContexts: new Map(), + loopContexts: new Map(), // Keep for backwards compatibility + loopRuntimeStates: new Map(), patches: [], }; } diff --git a/src/tests/interview/loopState.test.ts b/src/tests/interview/loopState.test.ts new file mode 100644 index 0000000..89fb2b3 --- /dev/null +++ b/src/tests/interview/loopState.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from "vitest"; +import { + createLoopState, + setDraftField, + isDraftDirty, + commitDraft, + startEdit, + deleteItem, + moveItemUp, + moveItemDown, + clearDraft, + type LoopRuntimeState, +} from "../../interview/loopState"; + +describe("loopState", () => { + describe("createLoopState", () => { + it("should create initial empty state", () => { + const state = createLoopState(); + expect(state.items).toEqual([]); + expect(state.draft).toEqual({}); + expect(state.editIndex).toBeNull(); + }); + }); + + describe("setDraftField", () => { + it("should set a field in draft", () => { + const state = createLoopState(); + const newState = setDraftField(state, "field1", "value1"); + expect(newState.draft.field1).toBe("value1"); + expect(newState.items).toEqual([]); + expect(newState.editIndex).toBeNull(); + }); + + it("should update existing field", () => { + const state = createLoopState(); + let newState = setDraftField(state, "field1", "value1"); + newState = setDraftField(newState, "field1", "value2"); + expect(newState.draft.field1).toBe("value2"); + }); + + it("should not mutate original state", () => { + const state = createLoopState(); + setDraftField(state, "field1", "value1"); + expect(state.draft).toEqual({}); + }); + }); + + describe("isDraftDirty", () => { + it("should return false for empty draft", () => { + expect(isDraftDirty({})).toBe(false); + }); + + it("should return true for non-empty string", () => { + expect(isDraftDirty({ text: "content" })).toBe(true); + }); + + it("should return false for empty string", () => { + expect(isDraftDirty({ text: "" })).toBe(false); + expect(isDraftDirty({ text: " " })).toBe(false); + }); + + it("should return true for boolean true", () => { + expect(isDraftDirty({ flag: true })).toBe(true); + }); + + it("should return false for boolean false", () => { + expect(isDraftDirty({ flag: false })).toBe(false); + }); + + it("should return true for non-zero number", () => { + expect(isDraftDirty({ count: 5 })).toBe(true); + expect(isDraftDirty({ count: -1 })).toBe(true); + }); + + it("should return false for zero", () => { + expect(isDraftDirty({ count: 0 })).toBe(false); + }); + + it("should return true for non-empty array", () => { + expect(isDraftDirty({ items: [1, 2] })).toBe(true); + }); + + it("should return false for empty array", () => { + expect(isDraftDirty({ items: [] })).toBe(false); + }); + + it("should return true for non-empty object", () => { + expect(isDraftDirty({ obj: { key: "value" } })).toBe(true); + }); + + it("should return false for empty object", () => { + expect(isDraftDirty({ obj: {} })).toBe(false); + }); + + it("should ignore null and undefined", () => { + expect(isDraftDirty({ nullVal: null, undefinedVal: undefined })).toBe(false); + }); + }); + + describe("commitDraft", () => { + it("should add new item when editIndex is null", () => { + const state = createLoopState(); + let newState = setDraftField(state, "field1", "value1"); + newState = commitDraft(newState); + + expect(newState.items.length).toBe(1); + expect(newState.items[0]).toEqual({ field1: "value1" }); + expect(newState.draft).toEqual({}); + expect(newState.editIndex).toBeNull(); + }); + + it("should update existing item when editIndex is set", () => { + const state: LoopRuntimeState = { + items: [{ field1: "old" }], + draft: { field1: "new" }, + editIndex: 0, + }; + + const newState = commitDraft(state); + + expect(newState.items.length).toBe(1); + expect(newState.items[0]).toEqual({ field1: "new" }); + expect(newState.draft).toEqual({}); + expect(newState.editIndex).toBeNull(); + }); + + it("should not commit if draft is not dirty", () => { + const state = createLoopState(); + const newState = commitDraft(state); + + expect(newState.items.length).toBe(0); + expect(newState.draft).toEqual({}); + }); + + it("should not mutate original state", () => { + const state = createLoopState(); + let newState = setDraftField(state, "field1", "value1"); + commitDraft(newState); + expect(newState.draft).toEqual({ field1: "value1" }); + }); + }); + + describe("startEdit", () => { + it("should load item into draft and set editIndex", () => { + const state: LoopRuntimeState = { + items: [{ field1: "value1", field2: "value2" }], + draft: {}, + editIndex: null, + }; + + const newState = startEdit(state, 0); + + expect(newState.draft).toEqual({ field1: "value1", field2: "value2" }); + expect(newState.editIndex).toBe(0); + expect(newState.items.length).toBe(1); + }); + + it("should handle invalid index", () => { + const state: LoopRuntimeState = { + items: [{ field1: "value1" }], + draft: {}, + editIndex: null, + }; + + const newState = startEdit(state, 5); + + expect(newState).toBe(state); // Should return unchanged + }); + + it("should not mutate original state", () => { + const state: LoopRuntimeState = { + items: [{ field1: "value1" }], + draft: {}, + editIndex: null, + }; + + startEdit(state, 0); + expect(state.draft).toEqual({}); + expect(state.editIndex).toBeNull(); + }); + }); + + describe("deleteItem", () => { + it("should delete item at index", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + draft: {}, + editIndex: null, + }; + + const newState = deleteItem(state, 1); + + expect(newState.items.length).toBe(2); + expect(newState.items[0]).toEqual({ id: 1 }); + expect(newState.items[1]).toEqual({ id: 3 }); + }); + + it("should clear draft if editing deleted item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }], + draft: { id: 2 }, + editIndex: 1, + }; + + const newState = deleteItem(state, 1); + + expect(newState.items.length).toBe(1); + expect(newState.editIndex).toBeNull(); + expect(newState.draft).toEqual({}); + }); + + it("should adjust editIndex when deleting before edited item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + draft: { id: 3 }, + editIndex: 2, + }; + + const newState = deleteItem(state, 1); + + expect(newState.items.length).toBe(2); + expect(newState.editIndex).toBe(1); // Adjusted from 2 to 1 + }); + + it("should handle invalid index", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }], + draft: {}, + editIndex: null, + }; + + const newState = deleteItem(state, 5); + expect(newState).toBe(state); // Should return unchanged + }); + }); + + describe("moveItemUp", () => { + it("should move item up", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemUp(state, 1); + + expect(newState.items[0]).toEqual({ id: 2 }); + expect(newState.items[1]).toEqual({ id: 1 }); + expect(newState.items[2]).toEqual({ id: 3 }); + }); + + it("should adjust editIndex when moving edited item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }], + draft: { id: 2 }, + editIndex: 1, + }; + + const newState = moveItemUp(state, 1); + + expect(newState.items[0]).toEqual({ id: 2 }); + expect(newState.editIndex).toBe(0); + }); + + it("should adjust editIndex when moving item before edited item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + draft: { id: 3 }, + editIndex: 2, + }; + + const newState = moveItemUp(state, 1); + + // After moving item 1 up: [id:2, id:1, id:3] + expect(newState.items[0]).toEqual({ id: 2 }); + expect(newState.items[1]).toEqual({ id: 1 }); + expect(newState.items[2]).toEqual({ id: 3 }); // Item 3 stays at index 2 + expect(newState.editIndex).toBe(2); // Still editing item 3, still at index 2 + }); + + it("should not move first item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemUp(state, 0); + expect(newState).toBe(state); // Should return unchanged + }); + + it("should handle invalid index", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemUp(state, 5); + expect(newState).toBe(state); // Should return unchanged + }); + }); + + describe("moveItemDown", () => { + it("should move item down", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }, { id: 3 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemDown(state, 1); + + expect(newState.items[0]).toEqual({ id: 1 }); + expect(newState.items[1]).toEqual({ id: 3 }); + expect(newState.items[2]).toEqual({ id: 2 }); + }); + + it("should adjust editIndex when moving edited item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }], + draft: { id: 1 }, + editIndex: 0, + }; + + const newState = moveItemDown(state, 0); + + expect(newState.items[0]).toEqual({ id: 2 }); + expect(newState.items[1]).toEqual({ id: 1 }); + expect(newState.editIndex).toBe(1); + }); + + it("should not move last item", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }, { id: 2 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemDown(state, 1); + expect(newState).toBe(state); // Should return unchanged + }); + + it("should handle invalid index", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }], + draft: {}, + editIndex: null, + }; + + const newState = moveItemDown(state, 5); + expect(newState).toBe(state); // Should return unchanged + }); + }); + + describe("clearDraft", () => { + it("should clear draft and reset editIndex", () => { + const state: LoopRuntimeState = { + items: [{ id: 1 }], + draft: { field1: "value1" }, + editIndex: 0, + }; + + const newState = clearDraft(state); + + expect(newState.draft).toEqual({}); + expect(newState.editIndex).toBeNull(); + expect(newState.items.length).toBe(1); + }); + + it("should not mutate original state", () => { + const state: LoopRuntimeState = { + items: [], + draft: { field1: "value1" }, + editIndex: 0, + }; + + clearDraft(state); + expect(state.draft).toEqual({ field1: "value1" }); + expect(state.editIndex).toBe(0); + }); + }); +}); diff --git a/src/ui/InterviewWizardModal.ts b/src/ui/InterviewWizardModal.ts index d465469..34a077c 100644 --- a/src/ui/InterviewWizardModal.ts +++ b/src/ui/InterviewWizardModal.ts @@ -26,6 +26,18 @@ import { createMarkdownToolbar, } from "./markdownToolbar"; import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; +import { + type LoopRuntimeState, + createLoopState, + setDraftField, + isDraftDirty, + commitDraft, + startEdit, + deleteItem, + moveItemUp, + moveItemDown, + clearDraft, +} from "../interview/loopState"; export interface WizardResult { applied: boolean; @@ -580,308 +592,428 @@ export class InterviewWizardModal extends Modal { renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void { if (step.type !== "loop") return; - const items = this.state.loopContexts.get(step.key) || []; - const currentItemIndex = items.length; // Next item index + // Initialize or get loop runtime state + let loopState = this.state.loopRuntimeStates.get(step.key); + if (!loopState) { + // Initialize from legacy loopContexts if available + const legacyItems = this.state.loopContexts.get(step.key) || []; + loopState = { + items: legacyItems, + draft: {}, + editIndex: null, + }; + this.state.loopRuntimeStates.set(step.key, loopState); + } + + const commitMode = step.ui?.commit || "explicit_add"; console.log("Render loop step", { stepKey: step.key, stepLabel: step.label, - itemsCount: items.length, + itemsCount: loopState.items.length, nestedStepsCount: step.items.length, - nestedStepTypes: step.items.map(s => s.type), - currentItemIndex: currentItemIndex, + editIndex: loopState.editIndex, + commitMode: commitMode, }); + // Title containerEl.createEl("h2", { text: step.label || "Loop", }); - // Show existing items - if (items.length > 0) { - const itemsList = containerEl.createEl("div", { cls: "loop-items-list" }); - itemsList.style.width = "100%"; - itemsList.createEl("h3", { text: `Gesammelte Items (${items.length}):` }); - const ul = itemsList.createEl("ul"); - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const li = ul.createEl("li"); - li.createSpan({ text: `Item ${i + 1}: ` }); + // Show editing indicator + if (loopState.editIndex !== null) { + const indicator = containerEl.createEl("div", { + cls: "loop-editing-indicator", + text: `✏️ Editing item ${loopState.editIndex + 1}`, + }); + indicator.style.padding = "0.5em"; + indicator.style.background = "var(--background-modifier-border-hover)"; + indicator.style.borderRadius = "4px"; + indicator.style.marginBottom = "1em"; + } + + // 2-pane container + const paneContainer = containerEl.createEl("div", { + cls: "loop-pane-container", + }); + paneContainer.style.display = "flex"; + paneContainer.style.gap = "1em"; + paneContainer.style.width = "100%"; + paneContainer.style.minHeight = "400px"; + + // Left pane: Items list + const leftPane = paneContainer.createEl("div", { + cls: "loop-items-pane", + }); + leftPane.style.width = "40%"; + leftPane.style.minWidth = "250px"; + leftPane.style.borderRight = "1px solid var(--background-modifier-border)"; + leftPane.style.paddingRight = "1em"; + leftPane.style.overflowY = "auto"; + leftPane.style.maxHeight = "600px"; + + leftPane.createEl("h3", { + text: `Items (${loopState.items.length})`, + }); + + // Items list + const itemsList = leftPane.createEl("div", { + cls: "loop-items-list", + }); + + if (loopState.items.length === 0) { + itemsList.createEl("p", { + text: "No items yet. Use the editor on the right to add items.", + cls: "interview-note", + }); + } else { + for (let i = 0; i < loopState.items.length; i++) { + const item = loopState.items[i]; + const itemEl = itemsList.createEl("div", { + cls: `loop-item-entry ${loopState.editIndex === i ? "is-editing" : ""}`, + }); + itemEl.style.padding = "0.5em"; + itemEl.style.marginBottom = "0.5em"; + itemEl.style.border = "1px solid var(--background-modifier-border)"; + itemEl.style.borderRadius = "4px"; + if (loopState.editIndex === i) { + itemEl.style.background = "var(--background-modifier-border-hover)"; + } + + // Item preview + const preview = itemEl.createEl("div", { + cls: "loop-item-preview", + }); + preview.style.marginBottom = "0.5em"; + if (typeof item === "object" && item !== null) { - // Show readable format instead of JSON - const itemEntries = Object.entries(item); + const itemEntries = Object.entries(item as Record); if (itemEntries.length > 0) { - const itemText = itemEntries + const previewText = itemEntries .map(([key, value]) => { const nestedStep = step.items.find(s => s.key === key); const label = nestedStep?.label || key; - return `${label}: ${String(value).substring(0, 50)}${String(value).length > 50 ? "..." : ""}`; + const strValue = String(value); + return `${label}: ${strValue.substring(0, 40)}${strValue.length > 40 ? "..." : ""}`; }) .join(", "); - li.createSpan({ text: itemText }); + preview.createSpan({ text: previewText }); } else { - li.createSpan({ text: "Empty" }); + preview.createSpan({ text: "Empty item" }); } } else { - li.createSpan({ text: String(item) }); + preview.createSpan({ text: String(item) }); } + + // Actions + const actions = itemEl.createEl("div", { + cls: "loop-item-actions", + }); + actions.style.display = "flex"; + actions.style.gap = "0.25em"; + actions.style.flexWrap = "wrap"; + + // Edit button + const editBtn = actions.createEl("button", { + text: "Edit", + cls: "mod-cta", + }); + 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(); + }; + + // Delete button + const deleteBtn = actions.createEl("button", { + text: "Delete", + }); + deleteBtn.style.fontSize = "0.85em"; + deleteBtn.style.padding = "0.25em 0.5em"; + deleteBtn.onclick = () => { + if (confirm(`Delete item ${i + 1}?`)) { + const newState = deleteItem(loopState!, i); + this.state.loopRuntimeStates.set(step.key, newState); + // Update answers + this.state.loopContexts.set(step.key, newState.items); + this.renderStep(); + } + }; + + // Move Up button + const moveUpBtn = actions.createEl("button", { + text: "↑", + }); + moveUpBtn.style.fontSize = "0.85em"; + moveUpBtn.style.padding = "0.25em 0.5em"; + moveUpBtn.disabled = i === 0; + moveUpBtn.onclick = () => { + const newState = moveItemUp(loopState!, i); + this.state.loopRuntimeStates.set(step.key, newState); + this.state.loopContexts.set(step.key, newState.items); + this.renderStep(); + }; + + // Move Down button + const moveDownBtn = actions.createEl("button", { + text: "↓", + }); + moveDownBtn.style.fontSize = "0.85em"; + moveDownBtn.style.padding = "0.25em 0.5em"; + moveDownBtn.disabled = i === loopState.items.length - 1; + moveDownBtn.onclick = () => { + const newState = moveItemDown(loopState!, i); + this.state.loopRuntimeStates.set(step.key, newState); + this.state.loopContexts.set(step.key, newState.items); + this.renderStep(); + }; } - itemsList.createEl("p", { - text: "Sie können weitere Items hinzufügen oder mit 'Next' fortfahren.", - cls: "interview-note", - }); } - // Render nested steps for current item + // Right pane: Editor + const rightPane = paneContainer.createEl("div", { + cls: "loop-editor-pane", + }); + rightPane.style.width = "60%"; + rightPane.style.flex = "1"; + + const editorTitle = rightPane.createEl("h3", { + text: loopState.editIndex !== null + ? `Edit Item ${loopState.editIndex + 1}` + : "New Item", + }); + + // Render nested steps bound to draft if (step.items.length > 0) { - const itemFormContainer = containerEl.createEl("div", { - cls: "loop-item-form", - }); - itemFormContainer.style.width = "100%"; - - itemFormContainer.createEl("h3", { - text: items.length === 0 ? "First Item" : `Item ${items.length + 1}`, + const editorContainer = rightPane.createEl("div", { + cls: "loop-item-editor", }); - // Create a temporary state for the current loop item - const itemDataKey = `${step.key}_item_${currentItemIndex}`; - const itemData = new Map(); - - // Render each nested step for (const nestedStep of step.items) { - if (nestedStep.type === "capture_text") { - const existingValue = (itemData.get(nestedStep.key) as string) || ""; - const inputKey = `${itemDataKey}_${nestedStep.key}`; - - // Field container with vertical layout - const fieldContainer = itemFormContainer.createEl("div", { - cls: "mindnet-field", - }); - - // Label - if (nestedStep.label) { - const labelEl = fieldContainer.createEl("div", { - cls: "mindnet-field__label", - text: nestedStep.label, - }); + const draftValue = loopState.draft[nestedStep.key]; + this.renderLoopNestedStep(nestedStep, 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); } - - // Description/Prompt - if (nestedStep.prompt) { - const descEl = fieldContainer.createEl("div", { - cls: "mindnet-field__desc", - text: nestedStep.prompt, - }); - } - - // Editor container - const editorContainer = fieldContainer.createEl("div", { - cls: "markdown-editor-container", - }); - editorContainer.style.width = "100%"; - editorContainer.style.position = "relative"; - - const textEditorContainer = editorContainer.createEl("div", { - cls: "markdown-editor-wrapper", - }); - textEditorContainer.style.width = "100%"; - - const textSetting = new Setting(textEditorContainer); - textSetting.settingEl.style.width = "100%"; - textSetting.controlEl.style.width = "100%"; - - // Hide the default label from Setting component - const settingNameEl = textSetting.settingEl.querySelector(".setting-item-name") as HTMLElement; - if (settingNameEl) { - settingNameEl.style.display = "none"; - } - - textSetting.addTextArea((text) => { - text.setValue(existingValue); - this.currentInputValues.set(inputKey, existingValue); - - text.onChange((value) => { - this.currentInputValues.set(inputKey, value); - itemData.set(nestedStep.key, value); - }); - text.inputEl.rows = 10; - text.inputEl.style.width = "100%"; - text.inputEl.style.minHeight = "200px"; - text.inputEl.style.boxSizing = "border-box"; - }); - - // Add toolbar for loop item textarea - setTimeout(() => { - const textarea = textEditorContainer.querySelector("textarea"); - if (textarea) { - const itemToolbar = createMarkdownToolbar(textarea); - textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); - } - }, 10); - } else if (nestedStep.type === "capture_text_line") { - const existingValue = (itemData.get(nestedStep.key) as string) || ""; - const inputKey = `${itemDataKey}_${nestedStep.key}`; - - // Field container with vertical layout - const fieldContainer = itemFormContainer.createEl("div", { - cls: "mindnet-field", - }); - - // Label - if (nestedStep.label) { - const labelEl = fieldContainer.createEl("div", { - cls: "mindnet-field__label", - text: nestedStep.label, - }); - } - - // Description/Prompt - if (nestedStep.prompt) { - const descEl = fieldContainer.createEl("div", { - cls: "mindnet-field__desc", - text: nestedStep.prompt, - }); - } - - // Input container - const inputContainer = fieldContainer.createEl("div", { - cls: "mindnet-field__input", - }); - inputContainer.style.width = "100%"; - - const fieldSetting = new Setting(inputContainer); - fieldSetting.settingEl.style.width = "100%"; - fieldSetting.controlEl.style.width = "100%"; - - // Hide the default label from Setting component - const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; - if (settingNameEl) { - settingNameEl.style.display = "none"; - } - - fieldSetting.addText((text) => { - text.setValue(existingValue); - this.currentInputValues.set(inputKey, existingValue); - - text.onChange((value) => { - this.currentInputValues.set(inputKey, value); - itemData.set(nestedStep.key, value); - }); - text.inputEl.style.width = "100%"; - text.inputEl.style.boxSizing = "border-box"; - }); - } else if (nestedStep.type === "capture_frontmatter") { - const existingValue = (itemData.get(nestedStep.key) as string) || ""; - const inputKey = `${itemDataKey}_${nestedStep.key}`; - - // Field container with vertical layout - const fieldContainer = itemFormContainer.createEl("div", { - cls: "mindnet-field", - }); - - // Label - const labelText = nestedStep.label || nestedStep.field || nestedStep.key; - if (labelText) { - const labelEl = fieldContainer.createEl("div", { - cls: "mindnet-field__label", - text: labelText, - }); - } - - // Description/Prompt - if (nestedStep.prompt) { - const descEl = fieldContainer.createEl("div", { - cls: "mindnet-field__desc", - text: nestedStep.prompt, - }); - } - - // Input container - const inputContainer = fieldContainer.createEl("div", { - cls: "mindnet-field__input", - }); - inputContainer.style.width = "100%"; - - const fieldSetting = new Setting(inputContainer); - fieldSetting.settingEl.style.width = "100%"; - fieldSetting.controlEl.style.width = "100%"; - - // Hide the default label from Setting component - const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; - if (settingNameEl) { - settingNameEl.style.display = "none"; - } - - fieldSetting.addText((text) => { - text.setValue(existingValue); - this.currentInputValues.set(inputKey, existingValue); - - text.onChange((value) => { - this.currentInputValues.set(inputKey, value); - itemData.set(nestedStep.key, value); - }); - text.inputEl.style.width = "100%"; - text.inputEl.style.boxSizing = "border-box"; - }); - } - } - - // Add item button - const addButtonSetting = new Setting(itemFormContainer); - addButtonSetting.settingEl.style.width = "100%"; - addButtonSetting.addButton((button) => { - button - .setButtonText("Add Item") - .setCta() - .onClick(() => { - // Collect all item data - const itemDataObj: Record = {}; - let hasData = false; - - for (const nestedStep of step.items) { - const inputKey = `${itemDataKey}_${nestedStep.key}`; - const value = this.currentInputValues.get(inputKey); - if (value !== undefined && String(value).trim()) { - itemDataObj[nestedStep.key] = value; - hasData = true; - } - } - - if (!hasData) { - new Notice("Please enter at least one field before adding an item"); - return; - } - - // Add to loop context - items.push(itemDataObj); - this.state.loopContexts.set(step.key, items); - - // Clear input values for this item - for (const nestedStep of step.items) { - const inputKey = `${itemDataKey}_${nestedStep.key}`; - this.currentInputValues.delete(inputKey); - } - - console.log("Loop item added", { - stepKey: step.key, - itemIndex: items.length - 1, - itemData: itemDataObj, - totalItems: items.length, - }); - - new Notice(`Item ${items.length} added. You can add more or click Next to continue.`); - - // Re-render to show new item and reset form - this.renderStep(); - }); - }); - - // Show hint if no items yet - if (items.length === 0) { - itemFormContainer.createEl("p", { - text: "⚠️ Please add at least one item before continuing", - cls: "interview-warning", }); } + + // 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", + }); + clearBtn.onclick = () => { + const currentState = this.state.loopRuntimeStates.get(step.key); + if (currentState) { + const newState = clearDraft(currentState); + this.state.loopRuntimeStates.set(step.key, newState); + this.renderStep(); + } + }; + } + } + } + + /** + * Render a nested step within a loop editor. + */ + private renderLoopNestedStep( + nestedStep: InterviewStep, + containerEl: HTMLElement, + loopKey: string, + draftValue: unknown, + onFieldChange: (fieldId: string, value: unknown) => void + ): void { + const existingValue = draftValue !== undefined ? String(draftValue) : ""; + + if (nestedStep.type === "capture_text") { + // Field container + const fieldContainer = containerEl.createEl("div", { + cls: "mindnet-field", + }); + + // Label + if (nestedStep.label) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: nestedStep.label, + }); + } + + // Description/Prompt + if (nestedStep.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: nestedStep.prompt, + }); + } + + // Editor container + const editorContainer = fieldContainer.createEl("div", { + cls: "markdown-editor-container", + }); + editorContainer.style.width = "100%"; + editorContainer.style.position = "relative"; + + const textEditorContainer = editorContainer.createEl("div", { + cls: "markdown-editor-wrapper", + }); + textEditorContainer.style.width = "100%"; + + const textSetting = new Setting(textEditorContainer); + textSetting.settingEl.style.width = "100%"; + textSetting.controlEl.style.width = "100%"; + + // Hide the default label + const settingNameEl = textSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; + if (settingNameEl) { + settingNameEl.style.display = "none"; + } + + textSetting.addTextArea((text) => { + text.setValue(existingValue); + text.onChange((value) => { + onFieldChange(nestedStep.key, value); + }); + text.inputEl.rows = 8; + text.inputEl.style.width = "100%"; + text.inputEl.style.minHeight = "150px"; + text.inputEl.style.boxSizing = "border-box"; + }); + + // Add toolbar + setTimeout(() => { + const textarea = textEditorContainer.querySelector("textarea"); + if (textarea) { + const itemToolbar = createMarkdownToolbar(textarea); + textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild); + } + }, 10); + } else if (nestedStep.type === "capture_text_line") { + // Field container + const fieldContainer = containerEl.createEl("div", { + cls: "mindnet-field", + }); + + // Label + if (nestedStep.label) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: nestedStep.label, + }); + } + + // Description/Prompt + if (nestedStep.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: nestedStep.prompt, + }); + } + + // Input container + const inputContainer = fieldContainer.createEl("div", { + cls: "mindnet-field__input", + }); + inputContainer.style.width = "100%"; + + const fieldSetting = new Setting(inputContainer); + fieldSetting.settingEl.style.width = "100%"; + fieldSetting.controlEl.style.width = "100%"; + + // Hide the default label + const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; + if (settingNameEl) { + settingNameEl.style.display = "none"; + } + + fieldSetting.addText((text) => { + text.setValue(existingValue); + text.onChange((value) => { + onFieldChange(nestedStep.key, value); + }); + text.inputEl.style.width = "100%"; + text.inputEl.style.boxSizing = "border-box"; + }); + } else if (nestedStep.type === "capture_frontmatter") { + // Field container + const fieldContainer = containerEl.createEl("div", { + cls: "mindnet-field", + }); + + // Label + const labelText = nestedStep.label || nestedStep.field || nestedStep.key; + if (labelText) { + const labelEl = fieldContainer.createEl("div", { + cls: "mindnet-field__label", + text: labelText, + }); + } + + // Description/Prompt + if (nestedStep.prompt) { + const descEl = fieldContainer.createEl("div", { + cls: "mindnet-field__desc", + text: nestedStep.prompt, + }); + } + + // Input container + const inputContainer = fieldContainer.createEl("div", { + cls: "mindnet-field__input", + }); + inputContainer.style.width = "100%"; + + const fieldSetting = new Setting(inputContainer); + fieldSetting.settingEl.style.width = "100%"; + fieldSetting.controlEl.style.width = "100%"; + + // Hide the default label + const settingNameEl = fieldSetting.settingEl.querySelector(".setting-item-name") as HTMLElement | null; + if (settingNameEl) { + settingNameEl.style.display = "none"; + } + + fieldSetting.addText((text) => { + text.setValue(existingValue); + text.onChange((value) => { + onFieldChange(nestedStep.key, value); + }); + text.inputEl.style.width = "100%"; + text.inputEl.style.boxSizing = "border-box"; + }); } } @@ -979,6 +1111,13 @@ export class InterviewWizardModal extends Modal { li.createSpan({ text: String(value) }); } + // Show loop items (from runtime states) + for (const [loopKey, loopState] of this.state.loopRuntimeStates.entries()) { + const loopLi = dataList.createEl("li"); + loopLi.createEl("strong", { text: `${loopKey} (${loopState.items.length} items): ` }); + loopLi.createSpan({ text: `${loopState.items.length} committed items` }); + } + // Show patches const patchesList = containerEl.createEl("ul"); for (const patch of this.state.patches) { @@ -1016,8 +1155,12 @@ export class InterviewWizardModal extends Modal { const isReview = step?.type === "review"; const isLoop = step?.type === "loop"; - // For loop steps, disable Next until at least one item is added - const loopItems = isLoop ? (this.state.loopContexts.get(step.key) || []) : []; + // For loop steps, check if we have items (from runtime state or legacy context) + let loopItems: unknown[] = []; + if (isLoop) { + const loopState = this.state.loopRuntimeStates.get(step.key); + loopItems = loopState ? loopState.items : (this.state.loopContexts.get(step.key) || []); + } const canProceedLoop = !isLoop || loopItems.length > 0; button @@ -1061,6 +1204,24 @@ export class InterviewWizardModal extends Modal { goNext(): void { const currentStep = getCurrentStep(this.state); + // Handle loop commit mode + if (currentStep && currentStep.type === "loop") { + const loopState = this.state.loopRuntimeStates.get(currentStep.key); + const commitMode = currentStep.ui?.commit || "explicit_add"; + + // In on_next mode, auto-commit dirty draft + if (commitMode === "on_next" && loopState && isDraftDirty(loopState.draft)) { + const newState = commitDraft(loopState); + this.state.loopRuntimeStates.set(currentStep.key, newState); + // Update answers + this.state.loopContexts.set(currentStep.key, newState.items); + console.log("Auto-committed draft on Next", { + stepKey: currentStep.key, + itemsCount: newState.items.length, + }); + } + } + // Save current step data before navigating if (currentStep) { this.saveCurrentStepData(currentStep); @@ -1164,6 +1325,11 @@ export class InterviewWizardModal extends Modal { } } + // Sync loopRuntimeStates to loopContexts for renderer + for (const [loopKey, loopState] of this.state.loopRuntimeStates.entries()) { + this.state.loopContexts.set(loopKey, loopState.items); + } + // Use renderer to generate markdown from collected data const answers: RenderAnswers = { collectedData: this.state.collectedData,