Pane 2 mit Edit, Delete, sort, etc.
This commit is contained in:
parent
8ba098c780
commit
684217bdf4
233
src/interview/loopState.ts
Normal file
233
src/interview/loopState.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
383
src/tests/interview/loopState.test.ts
Normal file
383
src/tests/interview/loopState.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user