Merge branch 'dev'
CI/CD / deploy (push) Has been cancelled
Details
CI/CD / deploy (push) Has been cancelled
Details
This commit is contained in:
commit
35cd64f3fe
|
|
@ -1428,3 +1428,61 @@
|
|||
.flows-panel-tab--left { left: 0; }
|
||||
.flows-panel-tab--left.panel-open { left: 380px; transition: left 0.25s ease; }
|
||||
.flows-panel-tab--right { right: 0; border-radius: 8px 0 0 8px; border: 1px solid var(--rs-border-strong); border-right: none; }
|
||||
|
||||
/* ── Overflow pipe animations ─────────────────────────── */
|
||||
@keyframes pipe-flow {
|
||||
0% { stroke-dashoffset: 16; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
.funnel-pipe { cursor: pointer; transition: opacity 0.2s, fill 0.2s; }
|
||||
.funnel-pipe:hover { opacity: 1 !important; filter: brightness(1.2); }
|
||||
.funnel-pipe--active {
|
||||
animation: pipe-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pipe-pulse {
|
||||
0%, 100% { opacity: 0.85; }
|
||||
50% { opacity: 1; filter: brightness(1.3); }
|
||||
}
|
||||
.pipe-flow-stripe {
|
||||
animation: pipe-flow 0.6s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ── Outcome basin hover + cursor ─────────────────────── */
|
||||
.flow-node[data-node-id] .basin-outline { cursor: pointer; }
|
||||
.flow-node:hover .basin-outline { filter: drop-shadow(0 0 6px rgba(99,102,241,0.4)); }
|
||||
.flow-node:hover .node-bg { filter: brightness(1.05); }
|
||||
|
||||
/* ── Linked tasks in outcome modal ────────────────────── */
|
||||
.linked-task-row {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border: 1px solid var(--rs-border-strong); border-radius: 8px; margin-bottom: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.linked-task-row:hover { background: var(--rs-bg-surface-sunken); }
|
||||
.linked-task-title {
|
||||
font-size: 12px; color: var(--rs-text-primary); flex: 1;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.linked-task-title:hover { color: var(--rflows-status-inprogress); text-decoration: underline; }
|
||||
.linked-task-unlink {
|
||||
background: none; border: none; color: var(--rs-text-muted); font-size: 16px;
|
||||
cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.15s;
|
||||
}
|
||||
.linked-task-unlink:hover { color: var(--rflows-status-critical); }
|
||||
|
||||
/* ── Task picker overlay ──────────────────────────────── */
|
||||
.task-picker-overlay {
|
||||
margin-top: 12px; border-top: 1px solid var(--rs-border-strong); padding-top: 12px;
|
||||
}
|
||||
.task-picker-panel { /* inline in modal */ }
|
||||
.task-picker-item {
|
||||
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
|
||||
border-radius: 6px; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.task-picker-item:hover { background: var(--rs-bg-surface-raised); }
|
||||
|
||||
/* ── rTasks badge on outcome basin ────────────────────── */
|
||||
.outcome-tasks-badge {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1478,12 +1478,18 @@ class FolkFlowsApp extends HTMLElement {
|
|||
// Cancel any pending rAF
|
||||
if (this._dragRafId) { cancelAnimationFrame(this._dragRafId); this._dragRafId = null; }
|
||||
|
||||
// Single click = select + open inline editor
|
||||
// Single click = select + open editor/modal
|
||||
if (!wasDragged) {
|
||||
this.selectedNodeId = clickedNodeId;
|
||||
this.selectedEdgeKey = null;
|
||||
this.updateSelectionHighlight();
|
||||
this.enterInlineEdit(clickedNodeId);
|
||||
// Outcome nodes: single click → modal; dblclick → inline edit
|
||||
const clickedNode = this.nodes.find((n) => n.id === clickedNodeId);
|
||||
if (clickedNode?.type === "outcome") {
|
||||
this.openOutcomeModal(clickedNodeId);
|
||||
} else {
|
||||
this.enterInlineEdit(clickedNodeId);
|
||||
}
|
||||
} else {
|
||||
// Full edge redraw for final accuracy
|
||||
this.redrawEdges();
|
||||
|
|
@ -1498,6 +1504,16 @@ class FolkFlowsApp extends HTMLElement {
|
|||
const nodeLayer = this.shadow.getElementById("node-layer");
|
||||
if (nodeLayer) {
|
||||
nodeLayer.addEventListener("pointerdown", (e: PointerEvent) => {
|
||||
// Check pipe click FIRST — overflow pipe allocation editor
|
||||
const pipeEl = (e.target as Element).closest(".funnel-pipe") as SVGRectElement | null;
|
||||
if (pipeEl) {
|
||||
e.stopPropagation();
|
||||
const pipeNodeId = pipeEl.dataset.nodeId;
|
||||
const pipeSide = pipeEl.dataset.pipe as "left" | "right";
|
||||
if (pipeNodeId) this.openPipeEditor(pipeNodeId, pipeSide, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check port interaction FIRST
|
||||
const portGroup = (e.target as Element).closest(".port-group") as SVGGElement | null;
|
||||
if (portGroup) {
|
||||
|
|
@ -1548,10 +1564,14 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.selectedEdgeKey = null;
|
||||
this.updateSelectionHighlight();
|
||||
|
||||
// If click originated from HTML inside foreignObject, open inline edit but skip drag
|
||||
// If click originated from HTML inside foreignObject, open editor/modal but skip drag
|
||||
const target = e.target as Element;
|
||||
if (target instanceof HTMLElement && target.closest("foreignObject")) {
|
||||
this.enterInlineEdit(nodeId);
|
||||
if (node.type === "outcome") {
|
||||
this.openOutcomeModal(nodeId);
|
||||
} else {
|
||||
this.enterInlineEdit(nodeId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -2182,7 +2202,9 @@ class FolkFlowsApp extends HTMLElement {
|
|||
${inflowPipeIndicator}
|
||||
<!-- Overflow pipes at max threshold -->
|
||||
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="left" data-node-id="${n.id}" x="${-pipeW}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
|
||||
${isOverflow ? `<line class="pipe-flow-stripe" x1="${-pipeW + 2}" y1="${pipeY + pipeH / 2}" x2="${-2}" y2="${pipeY + pipeH / 2}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
|
||||
<rect class="funnel-pipe ${isOverflow ? "funnel-pipe--active" : ""}" data-pipe="right" data-node-id="${n.id}" x="${w}" y="${pipeY}" width="${pipeW}" height="${pipeH}" rx="4" style="fill:${isOverflow ? "var(--rflows-status-overflow)" : "var(--rs-bg-surface-raised)"}" opacity="${isOverflow ? 0.85 : 0.3}"/>
|
||||
${isOverflow ? `<line class="pipe-flow-stripe" x1="${w + 2}" y1="${pipeY + pipeH / 2}" x2="${w + pipeW - 2}" y2="${pipeY + pipeH / 2}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
|
||||
${overflowSpill}
|
||||
<rect x="24" y="${satBarY}" width="${satBarW}" height="8" rx="4" style="fill:var(--rs-bg-surface-raised)" opacity="0.3" class="satisfaction-bar-bg"/>
|
||||
<rect x="24" y="${satBarY}" width="${satFillW}" height="8" rx="4" style="fill:var(--rflows-sat-bar)" class="satisfaction-bar-fill" ${satBarBorder}/>
|
||||
|
|
@ -2293,7 +2315,8 @@ class FolkFlowsApp extends HTMLElement {
|
|||
</g>
|
||||
${overflowSplash}
|
||||
<!-- Overflow pipe stub below basin (when overfunded) -->
|
||||
${oOverflowPipeW > 0 ? `<rect x="${(w - oOverflowPipeW) / 2}" y="${h}" width="${oOverflowPipeW}" height="18" rx="${Math.min(4, oOverflowPipeW / 2)}" fill="var(--rflows-status-overflow)" opacity="0.6"/>` : ""}
|
||||
${oOverflowPipeW > 0 ? `<rect class="funnel-pipe funnel-pipe--active" data-pipe="bottom" data-node-id="${n.id}" x="${(w - oOverflowPipeW) / 2}" y="${h}" width="${oOverflowPipeW}" height="18" rx="${Math.min(4, oOverflowPipeW / 2)}" fill="var(--rflows-status-overflow)" opacity="0.6"/>
|
||||
<line class="pipe-flow-stripe" x1="${w / 2}" y1="${h + 2}" x2="${w / 2}" y2="${h + 16}" stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="4 4"/>` : ""}
|
||||
<!-- Header above basin -->
|
||||
<foreignObject x="-10" y="-32" width="${w + 20}" height="34">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:center;justify-content:center;gap:6px;font-family:system-ui,-apple-system,sans-serif;pointer-events:none">
|
||||
|
|
@ -2305,6 +2328,11 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<!-- Funding text centered in basin -->
|
||||
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 4, h / 2 + 4)}" text-anchor="middle" fill="var(--rs-text-primary)" font-size="12" font-weight="600" font-family="ui-monospace,monospace" pointer-events="none" opacity="0.9">${Math.round(fillPct * 100)}%</text>
|
||||
<text x="${w / 2}" y="${Math.max(waterTop + (h - waterTop) / 2 + 18, h / 2 + 18)}" text-anchor="middle" fill="var(--rs-text-muted)" font-size="10" pointer-events="none">${dollarLabel}</text>
|
||||
${(d.linkedTaskIds?.length || 0) > 0 ? `<!-- rTasks linked badge -->
|
||||
<g class="outcome-tasks-badge" transform="translate(${w - 8}, -8)">
|
||||
<circle cx="0" cy="0" r="10" fill="var(--rflows-status-inprogress)" opacity="0.9"/>
|
||||
<text x="0" y="4" text-anchor="middle" fill="white" font-size="9" font-weight="700">${d.linkedTaskIds!.length}</text>
|
||||
</g>` : ""}
|
||||
${this.renderPortsSvg(n)}
|
||||
</g>`;
|
||||
}
|
||||
|
|
@ -4697,14 +4725,16 @@ class FolkFlowsApp extends HTMLElement {
|
|||
phasesHtml += `<button class="phase-add-btn" data-action="add-phase" style="width:100%;justify-content:center;padding:8px">+ Add Phase</button>`;
|
||||
}
|
||||
|
||||
const linkedCount = d.linkedTaskIds?.length || 0;
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "flows-modal-backdrop";
|
||||
backdrop.id = "flows-modal";
|
||||
backdrop.innerHTML = `<div class="flows-modal">
|
||||
backdrop.innerHTML = `<div class="flows-modal" style="width:500px">
|
||||
<div class="flows-modal__header">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor}">${statusLabel}</span>
|
||||
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary)">${this.esc(d.label)}</span>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex:1;min-width:0">
|
||||
<span style="display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;background:${statusColor}22;color:${statusColor};flex-shrink:0">${statusLabel}</span>
|
||||
<span style="font-size:16px;font-weight:700;color:var(--rs-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${this.esc(d.label)}</span>
|
||||
</div>
|
||||
<button class="flows-modal__close" data-modal-action="close">×</button>
|
||||
</div>
|
||||
|
|
@ -4720,10 +4750,195 @@ class FolkFlowsApp extends HTMLElement {
|
|||
<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:10px">Phases</div>
|
||||
${phasesHtml}
|
||||
</div>` : ""}
|
||||
<!-- Linked Tasks section -->
|
||||
<div style="margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<div style="font-size:13px;font-weight:600;color:var(--rs-text-secondary);text-transform:uppercase;letter-spacing:0.05em">
|
||||
Linked Tasks ${linkedCount > 0 ? `<span style="font-size:11px;color:var(--rs-text-muted);font-weight:400">(${linkedCount})</span>` : ""}
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="phase-add-btn" data-action="create-task" style="margin:0">+ Create Task</button>
|
||||
<button class="phase-add-btn" data-action="link-task" style="margin:0">+ Link Existing</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="outcome-linked-tasks" data-loading="true">
|
||||
<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Loading tasks...</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
${linkedCount > 0 ? `<div style="border-top:1px solid var(--rs-border-strong);padding-top:12px;text-align:center">
|
||||
<a data-action="view-in-rtasks" style="font-size:13px;color:var(--rflows-status-inprogress);cursor:pointer;text-decoration:none;font-weight:500">View in rTasks →</a>
|
||||
</div>` : ""}
|
||||
</div>`;
|
||||
|
||||
this.shadow.appendChild(backdrop);
|
||||
this.attachOutcomeModalListeners(backdrop, nodeId);
|
||||
|
||||
// Load linked tasks async
|
||||
this.loadLinkedTasks(backdrop, nodeId);
|
||||
}
|
||||
|
||||
private async loadLinkedTasks(backdrop: HTMLElement, nodeId: string) {
|
||||
const container = backdrop.querySelector(".outcome-linked-tasks") as HTMLElement;
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/flows/outcome-tasks?space=${encodeURIComponent(this.space)}&outcomeId=${encodeURIComponent(nodeId)}`);
|
||||
if (!res.ok) throw new Error("Failed to load");
|
||||
const data = await res.json();
|
||||
|
||||
// Store boards data for picker
|
||||
(container as any).__boards = data.boards || [];
|
||||
|
||||
if (!data.tasks || data.tasks.length === 0) {
|
||||
container.innerHTML = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px;border:1px dashed var(--rs-border-strong);border-radius:8px">No linked tasks yet. Link existing tasks or create new ones.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, { bg: string; fg: string }> = {
|
||||
TODO: { bg: "rgba(100,116,139,0.15)", fg: "#94a3b8" },
|
||||
IN_PROGRESS: { bg: "rgba(59,130,246,0.15)", fg: "#3b82f6" },
|
||||
DONE: { bg: "rgba(16,185,129,0.15)", fg: "#10b981" },
|
||||
};
|
||||
|
||||
container.innerHTML = data.tasks.map((t: any) => {
|
||||
const sc = statusColors[t.status] || statusColors.TODO;
|
||||
const priorityIcon = t.priority === 'HIGH' ? '<span style="color:#ef4444">!</span>' : t.priority === 'MEDIUM' ? '<span style="color:#f59e0b">!</span>' : '';
|
||||
return `<div class="linked-task-row" data-ref="${t.ref}">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
${priorityIcon}
|
||||
<span class="linked-task-title" data-board="${t.boardId}" data-task="${t.taskId}">${this.esc(t.title)}</span>
|
||||
<span style="font-size:10px;font-weight:600;padding:2px 8px;border-radius:9999px;background:${sc.bg};color:${sc.fg};flex-shrink:0">${t.status.replace("_", " ")}</span>
|
||||
</div>
|
||||
<button class="linked-task-unlink" data-unlink-ref="${t.ref}" title="Unlink">×</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// Attach click handlers for task navigation
|
||||
container.querySelectorAll(".linked-task-title").forEach((el) => {
|
||||
(el as HTMLElement).style.cursor = "pointer";
|
||||
el.addEventListener("click", () => {
|
||||
const boardId = (el as HTMLElement).dataset.board;
|
||||
const taskId = (el as HTMLElement).dataset.task;
|
||||
window.location.href = `/${this.space}/rtasks?board=${boardId}&task=${taskId}`;
|
||||
});
|
||||
});
|
||||
|
||||
// Unlink buttons
|
||||
container.querySelectorAll(".linked-task-unlink").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const ref = (btn as HTMLElement).dataset.unlinkRef;
|
||||
if (!ref) return;
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
await fetch("/api/flows/outcome-tasks/unlink", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ space: this.space, outcomeId: nodeId, ref }),
|
||||
});
|
||||
// Update local data
|
||||
const node = this.nodes.find((n) => n.id === nodeId);
|
||||
if (node) {
|
||||
const od = node.data as OutcomeNodeData;
|
||||
if (od.linkedTaskIds) {
|
||||
const idx = od.linkedTaskIds.indexOf(ref);
|
||||
if (idx >= 0) od.linkedTaskIds.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
this.loadLinkedTasks(backdrop, nodeId);
|
||||
this.drawCanvasContent();
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
container.innerHTML = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Could not load linked tasks</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
private async openTaskPicker(backdrop: HTMLElement, nodeId: string) {
|
||||
const container = backdrop.querySelector(".outcome-linked-tasks") as HTMLElement;
|
||||
const boards: { id: string; name: string }[] = (container as any)?.__boards || [];
|
||||
if (boards.length === 0) {
|
||||
alert("No rTasks boards found in this space.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first board by default or the linked board
|
||||
const node = this.nodes.find((n) => n.id === nodeId);
|
||||
const od = node?.data as OutcomeNodeData | undefined;
|
||||
const defaultBoard = od?.linkedBoardId || boards[0]?.id || "";
|
||||
|
||||
// Create picker dropdown
|
||||
const picker = document.createElement("div");
|
||||
picker.className = "task-picker-overlay";
|
||||
picker.innerHTML = `<div class="task-picker-panel">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">Link a Task</span>
|
||||
<button class="flows-modal__close" data-picker-close>×</button>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<select class="editor-select" data-picker-board style="width:100%;padding:6px 10px;font-size:12px">
|
||||
${boards.map((b) => `<option value="${b.id}" ${b.id === defaultBoard ? "selected" : ""}>${this.esc(b.name)}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="task-picker-list" style="max-height:240px;overflow-y:auto">
|
||||
<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Loading...</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
backdrop.querySelector(".flows-modal")?.appendChild(picker);
|
||||
|
||||
const loadBoardTasks = async (boardId: string) => {
|
||||
const listEl = picker.querySelector(".task-picker-list") as HTMLElement;
|
||||
try {
|
||||
const res = await fetch(`/api/flows/board-tasks?space=${encodeURIComponent(this.space)}&boardId=${encodeURIComponent(boardId)}&outcomeId=${encodeURIComponent(nodeId)}`);
|
||||
const data = await res.json();
|
||||
if (!data.tasks || data.tasks.length === 0) {
|
||||
listEl.innerHTML = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">No unlinked tasks in this board</div>`;
|
||||
return;
|
||||
}
|
||||
const statusColors: Record<string, string> = { TODO: "#94a3b8", IN_PROGRESS: "#3b82f6", DONE: "#10b981" };
|
||||
listEl.innerHTML = data.tasks.map((t: any) => `
|
||||
<div class="task-picker-item" data-task-id="${t.id}" data-board-id="${boardId}">
|
||||
<span style="flex:1;font-size:12px;color:var(--rs-text-primary)">${this.esc(t.title)}</span>
|
||||
<span style="font-size:10px;color:${statusColors[t.status] || "#94a3b8"}">${t.status.replace("_", " ")}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
|
||||
listEl.querySelectorAll(".task-picker-item").forEach((item) => {
|
||||
item.addEventListener("click", async () => {
|
||||
const taskId = (item as HTMLElement).dataset.taskId;
|
||||
const bid = (item as HTMLElement).dataset.boardId;
|
||||
if (!taskId || !bid) return;
|
||||
const token = getAccessToken();
|
||||
if (!token) { alert("Please sign in."); return; }
|
||||
await fetch("/api/flows/outcome-tasks/link", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ space: this.space, outcomeId: nodeId, boardId: bid, taskId }),
|
||||
});
|
||||
// Update local data
|
||||
const n = this.nodes.find((nd) => nd.id === nodeId);
|
||||
if (n) {
|
||||
const od2 = n.data as OutcomeNodeData;
|
||||
if (!od2.linkedTaskIds) od2.linkedTaskIds = [];
|
||||
od2.linkedTaskIds.push(`${bid}:${taskId}`);
|
||||
}
|
||||
picker.remove();
|
||||
this.loadLinkedTasks(backdrop, nodeId);
|
||||
this.drawCanvasContent();
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
listEl.innerHTML = `<div style="text-align:center;padding:16px;color:var(--rs-text-muted);font-size:12px">Failed to load tasks</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
picker.querySelector("[data-picker-close]")?.addEventListener("click", () => picker.remove());
|
||||
picker.querySelector("[data-picker-board]")?.addEventListener("change", (e) => {
|
||||
loadBoardTasks((e.target as HTMLSelectElement).value);
|
||||
});
|
||||
|
||||
loadBoardTasks(defaultBoard);
|
||||
}
|
||||
|
||||
private attachOutcomeModalListeners(backdrop: HTMLElement, nodeId: string) {
|
||||
|
|
@ -4764,7 +4979,7 @@ class FolkFlowsApp extends HTMLElement {
|
|||
});
|
||||
});
|
||||
|
||||
// Add task
|
||||
// Add phase task
|
||||
backdrop.querySelectorAll("[data-add-task]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const phaseIdx = parseInt((btn as HTMLElement).dataset.addTask!, 10);
|
||||
|
|
@ -4790,6 +5005,123 @@ class FolkFlowsApp extends HTMLElement {
|
|||
this.drawCanvasContent();
|
||||
}
|
||||
});
|
||||
|
||||
// Create task from outcome
|
||||
backdrop.querySelector('[data-action="create-task"]')?.addEventListener("click", async () => {
|
||||
const token = getAccessToken();
|
||||
if (!token) { alert("Please sign in to create tasks."); return; }
|
||||
const title = prompt("Task title:", d.label);
|
||||
if (!title) return;
|
||||
try {
|
||||
const res = await fetch("/api/flows/outcome-tasks/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ space: this.space, outcomeId: nodeId, title }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.ok && result.ref) {
|
||||
if (!d.linkedTaskIds) d.linkedTaskIds = [];
|
||||
d.linkedTaskIds.push(result.ref);
|
||||
this.loadLinkedTasks(backdrop, nodeId);
|
||||
this.drawCanvasContent();
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Link existing task
|
||||
backdrop.querySelector('[data-action="link-task"]')?.addEventListener("click", () => {
|
||||
this.openTaskPicker(backdrop, nodeId);
|
||||
});
|
||||
|
||||
// View in rTasks
|
||||
backdrop.querySelector('[data-action="view-in-rtasks"]')?.addEventListener("click", () => {
|
||||
const firstRef = d.linkedTaskIds?.[0];
|
||||
if (firstRef) {
|
||||
const [boardId] = firstRef.split(":");
|
||||
window.location.href = `/${this.space}/rtasks?board=${boardId}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private openPipeEditor(nodeId: string, side: "left" | "right", e: PointerEvent) {
|
||||
// Close any existing pipe editor
|
||||
this.shadow.querySelector(".pipe-editor-popover")?.remove();
|
||||
|
||||
const node = this.nodes.find((n) => n.id === nodeId);
|
||||
if (!node || node.type !== "funnel") return;
|
||||
const d = node.data as FunnelNodeData;
|
||||
|
||||
const allocs = d.overflowAllocations || [];
|
||||
const connectedTargets = allocs.map((a) => {
|
||||
const target = this.nodes.find((n) => n.id === a.targetId);
|
||||
return { ...a, targetLabel: target ? (target.data as any).label || a.targetId : a.targetId };
|
||||
});
|
||||
|
||||
// Position popover near click
|
||||
const svg = this.shadow.querySelector("svg") as SVGSVGElement;
|
||||
if (!svg) return;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const popX = e.clientX - svgRect.left + 10;
|
||||
const popY = e.clientY - svgRect.top;
|
||||
|
||||
const popover = document.createElement("div");
|
||||
popover.className = "pipe-editor-popover";
|
||||
popover.style.cssText = `position:absolute;left:${popX}px;top:${popY}px;z-index:40;background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:12px;padding:16px;min-width:220px;box-shadow:var(--rs-shadow-lg)`;
|
||||
|
||||
let allocHtml = "";
|
||||
if (connectedTargets.length === 0) {
|
||||
allocHtml = `<div style="text-align:center;padding:12px;color:var(--rs-text-muted);font-size:12px">No overflow targets wired. Wire an overflow port to a funnel to configure.</div>`;
|
||||
} else {
|
||||
allocHtml = connectedTargets.map((a, i) => `
|
||||
<div class="pipe-alloc-row" style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<div style="width:8px;height:8px;border-radius:50%;background:${a.color};flex-shrink:0"></div>
|
||||
<span style="flex:1;font-size:12px;color:var(--rs-text-primary)">${this.esc(a.targetLabel)}</span>
|
||||
<input type="range" min="0" max="100" value="${a.percentage}" data-alloc-idx="${i}" style="width:80px;accent-color:${a.color}"/>
|
||||
<span class="pipe-alloc-pct" data-alloc-pct="${i}" style="font-size:11px;color:var(--rs-text-muted);width:32px;text-align:right">${a.percentage}%</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
popover.innerHTML = `
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--rs-text-primary)">Overflow Allocations</span>
|
||||
<button class="flows-modal__close" data-pipe-close style="font-size:16px;padding:0 4px">×</button>
|
||||
</div>
|
||||
${allocHtml}
|
||||
`;
|
||||
|
||||
// Wrap in relative container to position correctly
|
||||
const canvasContainer = svg.parentElement;
|
||||
if (canvasContainer) {
|
||||
canvasContainer.style.position = "relative";
|
||||
canvasContainer.appendChild(popover);
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
popover.querySelector("[data-pipe-close]")?.addEventListener("click", () => popover.remove());
|
||||
|
||||
popover.querySelectorAll('input[type="range"]').forEach((slider) => {
|
||||
slider.addEventListener("input", (ev) => {
|
||||
const idx = parseInt((slider as HTMLElement).dataset.allocIdx!, 10);
|
||||
const val = parseInt((ev.target as HTMLInputElement).value, 10);
|
||||
if (d.overflowAllocations && d.overflowAllocations[idx]) {
|
||||
d.overflowAllocations[idx].percentage = val;
|
||||
const pctLabel = popover.querySelector(`[data-alloc-pct="${idx}"]`);
|
||||
if (pctLabel) pctLabel.textContent = `${val}%`;
|
||||
this.drawCanvasContent();
|
||||
this.scheduleSave();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Click outside to close
|
||||
const outsideHandler = (ev: MouseEvent) => {
|
||||
if (!popover.contains(ev.target as Node)) {
|
||||
popover.remove();
|
||||
document.removeEventListener("pointerdown", outsideHandler, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener("pointerdown", outsideHandler, true), 10);
|
||||
}
|
||||
|
||||
private openSourceModal(nodeId: string) {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export function migrateOutcomeNodeData(d: any): OutcomeNodeData {
|
|||
phases: d.phases,
|
||||
overflowAllocations: d.overflowAllocations?.map((a: any) => ({ ...a, percentage: safeNum(a.percentage) })),
|
||||
source: d.source,
|
||||
linkedTaskIds: Array.isArray(d.linkedTaskIds) ? d.linkedTaskIds : [],
|
||||
linkedBoardId: d.linkedBoardId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +144,10 @@ export interface OutcomeNodeData {
|
|||
phases?: OutcomePhase[];
|
||||
overflowAllocations?: OverflowAllocation[];
|
||||
source?: IntegrationSource;
|
||||
/** Array of "{boardId}:{taskId}" strings linking to rTasks items */
|
||||
linkedTaskIds?: string[];
|
||||
/** Default rTasks board to link tasks from */
|
||||
linkedBoardId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,23 @@ function ensureDoc(space: string): FlowsDoc {
|
|||
});
|
||||
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
|
||||
}
|
||||
// Migrate v4 → v5: add linkedTaskIds to outcome nodes
|
||||
if (doc.meta.version < 5) {
|
||||
_syncServer!.changeDoc<FlowsDoc>(docId, 'migrate to v5', (d) => {
|
||||
if (d.canvasFlows) {
|
||||
for (const flow of Object.values(d.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes as any[]) {
|
||||
if (node.type === 'outcome' && !node.data.linkedTaskIds) {
|
||||
node.data.linkedTaskIds = [] as any;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d.meta.version = 5 as any;
|
||||
});
|
||||
doc = _syncServer!.getDoc<FlowsDoc>(docId)!;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
|
@ -781,6 +798,219 @@ routes.post("/api/budgets/segments", async (c) => {
|
|||
return c.json({ error: "action must be 'add' or 'remove'" }, 400);
|
||||
});
|
||||
|
||||
// ─── Outcome-Tasks integration API ───────────────────────
|
||||
|
||||
/** Helper: find outcome node in FlowsDoc */
|
||||
function findOutcomeNode(doc: FlowsDoc, outcomeId: string): { flow: CanvasFlow; nodeIdx: number; data: OutcomeNodeData } | null {
|
||||
for (const flow of Object.values(doc.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (let i = 0; i < flow.nodes.length; i++) {
|
||||
const node = flow.nodes[i];
|
||||
if (node.id === outcomeId && node.type === 'outcome') {
|
||||
return { flow, nodeIdx: i, data: node.data as OutcomeNodeData };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
routes.get("/api/flows/outcome-tasks", async (c) => {
|
||||
const space = c.req.query("space") || "";
|
||||
const outcomeId = c.req.query("outcomeId") || "";
|
||||
if (!space || !outcomeId) return c.json({ error: "space and outcomeId required" }, 400);
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
|
||||
const doc = ensureDoc(space);
|
||||
const found = findOutcomeNode(doc, outcomeId);
|
||||
if (!found) return c.json({ error: "Outcome not found" }, 404);
|
||||
|
||||
const linkedTaskIds = found.data.linkedTaskIds || [];
|
||||
const tasks: any[] = [];
|
||||
|
||||
for (const ref of linkedTaskIds) {
|
||||
const [boardId, taskId] = ref.split(":");
|
||||
if (!boardId || !taskId) continue;
|
||||
const boardDoc = _syncServer.getDoc<BoardDoc>(boardDocId(space, boardId));
|
||||
if (!boardDoc?.tasks?.[taskId]) continue;
|
||||
const t = boardDoc.tasks[taskId];
|
||||
tasks.push({
|
||||
ref,
|
||||
boardId,
|
||||
taskId,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
assigneeId: t.assigneeId,
|
||||
labels: t.labels,
|
||||
});
|
||||
}
|
||||
|
||||
// Also return available boards for the task picker
|
||||
const boards: { id: string; name: string; taskCount: number }[] = [];
|
||||
for (const id of _syncServer.getDocIds()) {
|
||||
if (!id.startsWith(`${space}:tasks:boards:`)) continue;
|
||||
const bd = _syncServer.getDoc<BoardDoc>(id);
|
||||
if (!bd?.board) continue;
|
||||
boards.push({ id: bd.board.id, name: bd.board.name, taskCount: Object.keys(bd.tasks || {}).length });
|
||||
}
|
||||
|
||||
return c.json({ tasks, boards, linkedBoardId: found.data.linkedBoardId || null });
|
||||
});
|
||||
|
||||
routes.post("/api/flows/outcome-tasks/link", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
|
||||
const { space, outcomeId, boardId, taskId } = await c.req.json();
|
||||
if (!space || !outcomeId || !boardId || !taskId) return c.json({ error: "space, outcomeId, boardId, taskId required" }, 400);
|
||||
|
||||
const doc = ensureDoc(space);
|
||||
const found = findOutcomeNode(doc, outcomeId);
|
||||
if (!found) return c.json({ error: "Outcome not found" }, 404);
|
||||
|
||||
// Verify task exists
|
||||
const boardDoc = _syncServer.getDoc<BoardDoc>(boardDocId(space, boardId));
|
||||
if (!boardDoc?.tasks?.[taskId]) return c.json({ error: "Task not found" }, 404);
|
||||
|
||||
const ref = `${boardId}:${taskId}`;
|
||||
const docId = flowsDocId(space);
|
||||
|
||||
_syncServer.changeDoc<FlowsDoc>(docId, `link task ${ref} to outcome ${outcomeId}`, (d) => {
|
||||
for (const flow of Object.values(d.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes) {
|
||||
if (node.id === outcomeId && node.type === 'outcome') {
|
||||
const data = node.data as any;
|
||||
if (!data.linkedTaskIds) data.linkedTaskIds = [];
|
||||
if (!data.linkedTaskIds.includes(ref)) data.linkedTaskIds.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return c.json({ ok: true, ref });
|
||||
});
|
||||
|
||||
routes.post("/api/flows/outcome-tasks/unlink", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
|
||||
const { space, outcomeId, ref } = await c.req.json();
|
||||
if (!space || !outcomeId || !ref) return c.json({ error: "space, outcomeId, ref required" }, 400);
|
||||
|
||||
const docId = flowsDocId(space);
|
||||
_syncServer.changeDoc<FlowsDoc>(docId, `unlink task ${ref} from outcome ${outcomeId}`, (d) => {
|
||||
for (const flow of Object.values(d.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes) {
|
||||
if (node.id === outcomeId && node.type === 'outcome') {
|
||||
const data = node.data as any;
|
||||
if (data.linkedTaskIds) {
|
||||
const idx = data.linkedTaskIds.indexOf(ref);
|
||||
if (idx >= 0) data.linkedTaskIds.splice(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
routes.post("/api/flows/outcome-tasks/create", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
|
||||
let claims;
|
||||
try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
|
||||
const { space, outcomeId, title, boardId: reqBoardId } = await c.req.json();
|
||||
if (!space || !outcomeId) return c.json({ error: "space, outcomeId required" }, 400);
|
||||
|
||||
const doc = ensureDoc(space);
|
||||
const found = findOutcomeNode(doc, outcomeId);
|
||||
if (!found) return c.json({ error: "Outcome not found" }, 404);
|
||||
|
||||
// Use requested board or linked board or default BCRG board
|
||||
const boardId = reqBoardId || found.data.linkedBoardId || `${space}-bcrg`;
|
||||
const bDocId = boardDocId(space, boardId);
|
||||
const boardDoc = _syncServer.getDoc<BoardDoc>(bDocId);
|
||||
if (!boardDoc) return c.json({ error: "Board not found" }, 404);
|
||||
|
||||
const taskId = crypto.randomUUID();
|
||||
const taskTitle = title || `Task: ${found.data.label}`;
|
||||
const refTag = `ref:rflows:outcome:${outcomeId}`;
|
||||
const did = (claims as any).did || claims.sub;
|
||||
|
||||
_syncServer.changeDoc<BoardDoc>(bDocId, `Create task for outcome ${outcomeId}`, (d) => {
|
||||
d.tasks[taskId] = createTaskItem(taskId, space, taskTitle, {
|
||||
status: 'TODO',
|
||||
priority: 'MEDIUM',
|
||||
description: `${refTag} — Created from rFlows outcome "${found!.data.label}"`,
|
||||
labels: ['rflows'],
|
||||
createdBy: did,
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-link the new task to the outcome
|
||||
const ref = `${boardId}:${taskId}`;
|
||||
const fDocId = flowsDocId(space);
|
||||
_syncServer.changeDoc<FlowsDoc>(fDocId, `auto-link created task to outcome ${outcomeId}`, (d) => {
|
||||
for (const flow of Object.values(d.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes) {
|
||||
if (node.id === outcomeId && node.type === 'outcome') {
|
||||
const data = node.data as any;
|
||||
if (!data.linkedTaskIds) data.linkedTaskIds = [];
|
||||
if (!data.linkedTaskIds.includes(ref)) data.linkedTaskIds.push(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[rflows] Created task "${taskTitle}" in board ${boardId}, linked to outcome ${outcomeId}`);
|
||||
return c.json({ ok: true, ref, taskId, boardId });
|
||||
});
|
||||
|
||||
// List unlinked tasks from a board (for picker)
|
||||
routes.get("/api/flows/board-tasks", async (c) => {
|
||||
const space = c.req.query("space") || "";
|
||||
const boardId = c.req.query("boardId") || "";
|
||||
const outcomeId = c.req.query("outcomeId") || "";
|
||||
if (!space || !boardId) return c.json({ error: "space and boardId required" }, 400);
|
||||
if (!_syncServer) return c.json({ error: "Not initialized" }, 503);
|
||||
|
||||
const boardDoc = _syncServer.getDoc<BoardDoc>(boardDocId(space, boardId));
|
||||
if (!boardDoc) return c.json({ tasks: [] });
|
||||
|
||||
// Get already-linked task IDs for this outcome
|
||||
const linkedRefs = new Set<string>();
|
||||
if (outcomeId) {
|
||||
const doc = ensureDoc(space);
|
||||
const found = findOutcomeNode(doc, outcomeId);
|
||||
if (found?.data.linkedTaskIds) {
|
||||
for (const ref of found.data.linkedTaskIds) linkedRefs.add(ref);
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = Object.values(boardDoc.tasks)
|
||||
.filter((t) => !linkedRefs.has(`${boardId}:${t.id}`))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
priority: t.priority,
|
||||
labels: t.labels,
|
||||
}));
|
||||
|
||||
return c.json({ tasks });
|
||||
});
|
||||
|
||||
// ─── Page routes ────────────────────────────────────────
|
||||
|
||||
const flowsScripts = `
|
||||
|
|
@ -1039,6 +1269,77 @@ export const flowsModule: RSpaceModule = {
|
|||
} catch {}
|
||||
});
|
||||
|
||||
// Reverse sync: rTasks status changes → update linked outcome status
|
||||
let _syncInProgress = false;
|
||||
_syncServer.registerWatcher(':tasks:boards:', (docId, doc) => {
|
||||
if (_syncInProgress) return;
|
||||
try {
|
||||
const boardDoc = doc as BoardDoc;
|
||||
if (!boardDoc?.tasks || !boardDoc?.board) return;
|
||||
|
||||
// Extract space from docId: {space}:tasks:boards:{boardId}
|
||||
const parts = docId.split(':tasks:boards:');
|
||||
const space = parts[0];
|
||||
const boardId = parts[1];
|
||||
if (!space || !boardId) return;
|
||||
|
||||
// Find flow docs for this space
|
||||
const fDocId = flowsDocId(space);
|
||||
const flowsDoc = _syncServer!.getDoc<FlowsDoc>(fDocId);
|
||||
if (!flowsDoc?.canvasFlows) return;
|
||||
|
||||
// Check each outcome's linked tasks
|
||||
for (const flow of Object.values(flowsDoc.canvasFlows)) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes) {
|
||||
if (node.type !== 'outcome') continue;
|
||||
const data = node.data as OutcomeNodeData;
|
||||
if (!data.linkedTaskIds || data.linkedTaskIds.length === 0) continue;
|
||||
|
||||
// Filter linked tasks that belong to this board
|
||||
const boardRefs = data.linkedTaskIds.filter((r) => r.startsWith(`${boardId}:`));
|
||||
if (boardRefs.length === 0) continue;
|
||||
|
||||
// Check statuses of ALL linked tasks (across all boards)
|
||||
const allStatuses: string[] = [];
|
||||
for (const ref of data.linkedTaskIds) {
|
||||
const [bid, tid] = ref.split(':');
|
||||
const bd = bid === boardId ? boardDoc : _syncServer!.getDoc<BoardDoc>(boardDocId(space, bid));
|
||||
if (bd?.tasks?.[tid]) allStatuses.push(bd.tasks[tid].status);
|
||||
}
|
||||
|
||||
if (allStatuses.length === 0) continue;
|
||||
|
||||
const allDone = allStatuses.every((s) => s === 'DONE');
|
||||
const anyInProgress = allStatuses.some((s) => s === 'IN_PROGRESS');
|
||||
|
||||
let newStatus: OutcomeNodeData['status'] | null = null;
|
||||
if (allDone && data.status !== 'completed') {
|
||||
newStatus = 'completed';
|
||||
} else if (anyInProgress && data.status === 'not-started') {
|
||||
newStatus = 'in-progress';
|
||||
}
|
||||
|
||||
if (newStatus) {
|
||||
_syncInProgress = true;
|
||||
_syncServer!.changeDoc<FlowsDoc>(fDocId, `sync outcome ${node.id} → ${newStatus}`, (d) => {
|
||||
for (const f of Object.values(d.canvasFlows)) {
|
||||
if (!f.nodes) continue;
|
||||
for (const n of f.nodes) {
|
||||
if (n.id === node.id && n.type === 'outcome') {
|
||||
(n.data as any).status = newStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
_syncInProgress = false;
|
||||
console.log(`[rflows] Reverse sync: outcome "${node.id}" → ${newStatus}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Pre-populate _completedOutcomes from existing docs to avoid duplicates on restart
|
||||
for (const id of _syncServer.getDocIds()) {
|
||||
if (!id.includes(':flows:data')) continue;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
* v2: adds canvasFlows (full node data) and activeFlowId
|
||||
* v3: adds mortgagePositions and reinvestmentPositions
|
||||
* v4: adds budgetSegments, budgetAllocations, budgetTotalAmount
|
||||
* v5: adds linkedTaskIds and linkedBoardId to OutcomeNodeData
|
||||
*/
|
||||
|
||||
import type { DocSchema } from '../../shared/local-first/document';
|
||||
|
|
@ -55,12 +56,12 @@ export interface FlowsDoc {
|
|||
export const flowsSchema: DocSchema<FlowsDoc> = {
|
||||
module: 'flows',
|
||||
collection: 'data',
|
||||
version: 4,
|
||||
version: 5,
|
||||
init: (): FlowsDoc => ({
|
||||
meta: {
|
||||
module: 'flows',
|
||||
collection: 'data',
|
||||
version: 4,
|
||||
version: 5,
|
||||
spaceSlug: '',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
|
|
@ -81,7 +82,18 @@ export const flowsSchema: DocSchema<FlowsDoc> = {
|
|||
if (!doc.budgetSegments) doc.budgetSegments = {};
|
||||
if (!doc.budgetAllocations) doc.budgetAllocations = {};
|
||||
if (doc.budgetTotalAmount === undefined) doc.budgetTotalAmount = 0;
|
||||
doc.meta.version = 4;
|
||||
// v5: migrate outcome nodes — add linkedTaskIds/linkedBoardId
|
||||
if (doc.canvasFlows) {
|
||||
for (const flow of Object.values(doc.canvasFlows) as any[]) {
|
||||
if (!flow.nodes) continue;
|
||||
for (const node of flow.nodes as any[]) {
|
||||
if (node.type === 'outcome' && !node.data.linkedTaskIds) {
|
||||
node.data.linkedTaskIds = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.meta.version = 5;
|
||||
return doc;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue