Eingebette Loops
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 20:10:08 +01:00
parent 684217bdf4
commit 5a171987b2
5 changed files with 722 additions and 50 deletions

View File

@ -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]

View File

@ -7,6 +7,7 @@ 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
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<string, unknown>) },
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,
};
}

View File

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

View File

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

View File

@ -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];
for (const nestedStep of step.items) {
const draftValue = loopState.draft[nestedStep.key];
this.renderLoopNestedStep(nestedStep, editorContainer, step.key, draftValue, (fieldId, value) => {
if (activeNestedStep) {
const editorContainer = rightPane.createEl("div", {
cls: "loop-item-editor",
});
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<string, unknown>;
// 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();
}
};
}
}
}
}