Pane 2 mit Edit, Delete, sort, etc.
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

This commit is contained in:
Lars 2026-01-16 19:25:55 +01:00
parent 8ba098c780
commit 684217bdf4
6 changed files with 1070 additions and 274 deletions

233
src/interview/loopState.ts Normal file
View File

@ -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<string, unknown>; // 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<string, unknown>): 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<string, unknown>) },
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,
};
}

View File

@ -184,6 +184,14 @@ function parseStep(raw: Record<string, unknown>): InterviewStep | null {
} }
} }
// Parse ui.commit
if (raw.ui && typeof raw.ui === "object") {
const ui = raw.ui as Record<string, unknown>;
if (ui.commit === "explicit_add" || ui.commit === "on_next") {
step.ui = { commit: ui.commit };
}
}
for (const itemRaw of nestedSteps) { for (const itemRaw of nestedSteps) {
if (!itemRaw || typeof itemRaw !== "object") { if (!itemRaw || typeof itemRaw !== "object") {
continue; continue;

View File

@ -34,6 +34,9 @@ export interface LoopStep {
output?: { output?: {
join?: string; // String to join items (default: "\n\n") join?: string; // String to join items (default: "\n\n")
}; };
ui?: {
commit?: "explicit_add" | "on_next"; // Commit mode (default: "explicit_add")
};
} }
export interface LLMDialogStep { export interface LLMDialogStep {

View File

@ -1,11 +1,13 @@
import type { InterviewProfile, InterviewStep } from "./types"; import type { InterviewProfile, InterviewStep } from "./types";
import type { LoopRuntimeState } from "./loopState";
export interface WizardState { export interface WizardState {
profile: InterviewProfile; profile: InterviewProfile;
currentStepIndex: number; currentStepIndex: number;
stepHistory: number[]; // Stack for back navigation stepHistory: number[]; // Stack for back navigation
collectedData: Map<string, unknown>; // key -> value collectedData: Map<string, unknown>; // key -> value
loopContexts: Map<string, unknown[]>; // loop key -> array of collected items loopContexts: Map<string, unknown[]>; // loop key -> array of collected items (deprecated, use loopRuntimeStates)
loopRuntimeStates: Map<string, LoopRuntimeState>; // loop step key -> runtime state
patches: Patch[]; // Collected patches to apply patches: Patch[]; // Collected patches to apply
} }
@ -31,7 +33,8 @@ export function createWizardState(profile: InterviewProfile): WizardState {
currentStepIndex: 0, currentStepIndex: 0,
stepHistory: [], stepHistory: [],
collectedData: new Map(), collectedData: new Map(),
loopContexts: new Map(), loopContexts: new Map(), // Keep for backwards compatibility
loopRuntimeStates: new Map(),
patches: [], patches: [],
}; };
} }

View File

@ -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);
});
});
});

View File

@ -26,6 +26,18 @@ import {
createMarkdownToolbar, createMarkdownToolbar,
} from "./markdownToolbar"; } from "./markdownToolbar";
import { renderProfileToMarkdown, type RenderAnswers } from "../interview/renderer"; 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 { export interface WizardResult {
applied: boolean; applied: boolean;
@ -580,308 +592,428 @@ export class InterviewWizardModal extends Modal {
renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void { renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "loop") return; if (step.type !== "loop") return;
const items = this.state.loopContexts.get(step.key) || []; // Initialize or get loop runtime state
const currentItemIndex = items.length; // Next item index 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", { console.log("Render loop step", {
stepKey: step.key, stepKey: step.key,
stepLabel: step.label, stepLabel: step.label,
itemsCount: items.length, itemsCount: loopState.items.length,
nestedStepsCount: step.items.length, nestedStepsCount: step.items.length,
nestedStepTypes: step.items.map(s => s.type), editIndex: loopState.editIndex,
currentItemIndex: currentItemIndex, commitMode: commitMode,
}); });
// Title
containerEl.createEl("h2", { containerEl.createEl("h2", {
text: step.label || "Loop", text: step.label || "Loop",
}); });
// Show existing items // Show editing indicator
if (items.length > 0) { if (loopState.editIndex !== null) {
const itemsList = containerEl.createEl("div", { cls: "loop-items-list" }); const indicator = containerEl.createEl("div", {
itemsList.style.width = "100%"; cls: "loop-editing-indicator",
itemsList.createEl("h3", { text: `Gesammelte Items (${items.length}):` }); text: `✏️ Editing item ${loopState.editIndex + 1}`,
const ul = itemsList.createEl("ul"); });
for (let i = 0; i < items.length; i++) { indicator.style.padding = "0.5em";
const item = items[i]; indicator.style.background = "var(--background-modifier-border-hover)";
const li = ul.createEl("li"); indicator.style.borderRadius = "4px";
li.createSpan({ text: `Item ${i + 1}: ` }); 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) { if (typeof item === "object" && item !== null) {
// Show readable format instead of JSON const itemEntries = Object.entries(item as Record<string, unknown>);
const itemEntries = Object.entries(item);
if (itemEntries.length > 0) { if (itemEntries.length > 0) {
const itemText = itemEntries const previewText = itemEntries
.map(([key, value]) => { .map(([key, value]) => {
const nestedStep = step.items.find(s => s.key === key); const nestedStep = step.items.find(s => s.key === key);
const label = nestedStep?.label || 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(", "); .join(", ");
li.createSpan({ text: itemText }); preview.createSpan({ text: previewText });
} else { } else {
li.createSpan({ text: "Empty" }); preview.createSpan({ text: "Empty item" });
} }
} else { } 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) { if (step.items.length > 0) {
const itemFormContainer = containerEl.createEl("div", { const editorContainer = rightPane.createEl("div", {
cls: "loop-item-form", cls: "loop-item-editor",
});
itemFormContainer.style.width = "100%";
itemFormContainer.createEl("h3", {
text: items.length === 0 ? "First Item" : `Item ${items.length + 1}`,
}); });
// Create a temporary state for the current loop item
const itemDataKey = `${step.key}_item_${currentItemIndex}`;
const itemData = new Map<string, unknown>();
// Render each nested step
for (const nestedStep of step.items) { for (const nestedStep of step.items) {
if (nestedStep.type === "capture_text") { const draftValue = loopState.draft[nestedStep.key];
const existingValue = (itemData.get(nestedStep.key) as string) || ""; this.renderLoopNestedStep(nestedStep, editorContainer, step.key, draftValue, (fieldId, value) => {
const inputKey = `${itemDataKey}_${nestedStep.key}`; const currentState = this.state.loopRuntimeStates.get(step.key);
if (currentState) {
// Field container with vertical layout const newState = setDraftField(currentState, fieldId, value);
const fieldContainer = itemFormContainer.createEl("div", { this.state.loopRuntimeStates.set(step.key, newState);
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 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<string, unknown> = {};
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) }); 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 // Show patches
const patchesList = containerEl.createEl("ul"); const patchesList = containerEl.createEl("ul");
for (const patch of this.state.patches) { for (const patch of this.state.patches) {
@ -1016,8 +1155,12 @@ export class InterviewWizardModal extends Modal {
const isReview = step?.type === "review"; const isReview = step?.type === "review";
const isLoop = step?.type === "loop"; const isLoop = step?.type === "loop";
// For loop steps, disable Next until at least one item is added // For loop steps, check if we have items (from runtime state or legacy context)
const loopItems = isLoop ? (this.state.loopContexts.get(step.key) || []) : []; 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; const canProceedLoop = !isLoop || loopItems.length > 0;
button button
@ -1061,6 +1204,24 @@ export class InterviewWizardModal extends Modal {
goNext(): void { goNext(): void {
const currentStep = getCurrentStep(this.state); 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 // Save current step data before navigating
if (currentStep) { if (currentStep) {
this.saveCurrentStepData(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 // Use renderer to generate markdown from collected data
const answers: RenderAnswers = { const answers: RenderAnswers = {
collectedData: this.state.collectedData, collectedData: this.state.collectedData,