Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions lib/mendix_bridge/backend_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def mount_routes
end
@server.mount_proc("/api/git") { |request, response| git_route(request, response) }
@server.mount_proc("/api/page") { |request, response| page_route(request, response) }
@server.mount_proc("/api/flow") { |request, response| flow_route(request, response) }
@server.mount_proc("/api/drafts") { |_request, response| drafts(response) }
@server.mount_proc("/api/marketplace/install") do |request, response|
marketplace_install(request, response)
Expand Down Expand Up @@ -125,7 +126,8 @@ def health(response)
visual_entity_plans: true,
marketplace_install: source_project ? true : false,
git: git_workflow ? true : false,
page_drafts: true
page_drafts: true,
flow_drafts: true
}
}
)
Expand Down Expand Up @@ -371,16 +373,58 @@ def page_route(request, response)
json(response, { error: "invalid JSON payload" }, status: 400)
end

# Accepts a rebuilt microflow/nanoflow body from the flow editor, wraps it in
# CREATE OR MODIFY MICROFLOW/NANOFLOW using the imported signature, validates
# with `mxcli check`, and saves a reviewable draft — same contract as pages.
def flow_route(request, response)
return json(response, { error: "method not allowed" }, status: 405) unless
request.request_method == "POST"

payload = JSON.parse(request.body.to_s)
qn = payload.fetch("qn")
body = payload.fetch("body").to_s
detail = page_detail(qn)
return json(response, { error: "unknown flow" }, status: 404) unless detail

mdl = build_flow_mdl(qn, detail, body)
ok, message = check_mdl(mdl)
persist_draft("flow-plans.json", qn, "body" => body, "mdl" => mdl, "valid" => ok, "message" => message)
json(
response,
{ ok:, mdl:, message: ok ? "Flow MDL validated and draft saved." : message },
status: ok ? 200 : 422
)
rescue KeyError => error
json(response, { error: "missing parameter: #{error.key}" }, status: 400)
rescue JSON::ParserError
json(response, { error: "invalid JSON payload" }, status: 400)
end

def build_flow_mdl(qn, detail, body)
keyword = detail["mdl"].to_s.match?(/\bnanoflow\b/i) ? "NANOFLOW" : "MICROFLOW"
params = Array(detail["parameters"]).map do |parameter|
"$#{parameter['name']}: #{parameter['type']}"
end
header = +"CREATE OR MODIFY #{keyword} #{qn} (#{params.join(', ')})"
header << "\nRETURNS #{detail['return_type']}" if detail["return_type"]
header << "\nFOLDER '#{escape_mdl(detail['folder'])}'" if detail["folder"]
indented = body.strip.empty? ? "" : body.lines.map { |line| line.rstrip }.join("\n")
"#{header}\nBEGIN\n#{indented}\nEND;\n"
end

# Lists the reviewable drafts saved by the visual builders so the viewer can
# surface them (they only exist as inventory sidecars otherwise).
def drafts(response)
entity_path = File.join(@inventory_dir, "inventory", "visual-plans.json")
page_path = File.join(@inventory_dir, "inventory", "page-plans.json")
read = lambda do |file|
path = File.join(@inventory_dir, "inventory", file)
File.file?(path) ? JSON.parse(File.read(path)) : {}
end
json(
response,
{
entities: File.file?(entity_path) ? JSON.parse(File.read(entity_path)) : {},
pages: File.file?(page_path) ? JSON.parse(File.read(page_path)) : {}
entities: read.call("visual-plans.json"),
pages: read.call("page-plans.json"),
flows: read.call("flow-plans.json")
}
)
end
Expand Down Expand Up @@ -482,16 +526,18 @@ def check_mdl(mdl)
end

def persist_page_draft(qn, content, mdl, ok, message)
path = File.join(@inventory_dir, "inventory", "page-plans.json")
persist_draft(
"page-plans.json", qn,
"content" => content, "mdl" => mdl, "valid" => ok, "message" => message
)
end

# Atomic upsert into a draft sidecar under inventory/ (temp + rename, mutex).
def persist_draft(file, qn, entry)
path = File.join(@inventory_dir, "inventory", file)
@layout_mutex.synchronize do
plans = File.file?(path) ? JSON.parse(File.read(path)) : {}
plans[qn] = {
"saved_at" => Time.now.iso8601,
"content" => content,
"mdl" => mdl,
"valid" => ok,
"message" => message
}.compact
plans[qn] = { "saved_at" => Time.now.iso8601 }.merge(entry.compact)
temporary = "#{path}.tmp"
File.write(temporary, "#{JSON.pretty_generate(plans)}\n")
File.rename(temporary, path)
Expand Down
29 changes: 29 additions & 0 deletions test/backend_server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ def setup
"layout" => "Atlas_Core.Default",
"parameters" => [],
"mdl" => "create or modify page Module.Home (Title: 'Home') {}"
},
"Module.ACT_Save" => {
"parse_status" => "parsed",
"parameters" => [{ "name" => "Customer", "type" => "Module.Customer" }],
"return_type" => "Boolean",
"folder" => "Actions",
"mdl" => "create or modify microflow Module.ACT_Save ($Customer: Module.Customer) returns Boolean begin return true; end;"
}
)
write_json("inventory/dependencies.json", "schema_version" => 1, "nodes" => 1, "edges" => [])
Expand Down Expand Up @@ -168,6 +175,28 @@ def test_lists_saved_drafts
assert_includes drafts["pages"].keys, "Module.Home"
end

def test_saves_a_rebuilt_flow_draft
response = post_json(
"/api/flow",
qn: "Module.ACT_Save",
body: " @position(10, 20)\n return true;"
)

assert_equal "200", response.code
body = JSON.parse(response.body)
assert body["ok"]
assert_includes body["mdl"], "CREATE OR MODIFY MICROFLOW Module.ACT_Save ($Customer: Module.Customer)"
assert_includes body["mdl"], "RETURNS Boolean"
assert_includes body["mdl"], "FOLDER 'Actions'"
assert_includes body["mdl"], "return true;"

drafts = get_json("/api/drafts")
assert_equal true, drafts.dig("flows", "Module.ACT_Save", "valid")

unknown = post_json("/api/flow", qn: "Module.Missing", body: "")
assert_equal "404", unknown.code
end

def test_health_reports_page_drafts_capability
assert_equal true, get_json("/api/health").dig("capabilities", "page_drafts")
end
Expand Down
24 changes: 24 additions & 0 deletions web/dist/assets/index-B2Ft6dZR.js

Large diffs are not rendered by default.

23 changes: 0 additions & 23 deletions web/dist/assets/index-xDeQxMQR.js

This file was deleted.

4 changes: 2 additions & 2 deletions web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<meta name="theme-color" content="#b9002f" />
<meta name="application-name" content="Mendix Ruby Bridge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mendix Ruby Bridge</title>
<script type="module" crossorigin src="/assets/index-xDeQxMQR.js"></script>
<title>Mendix Ruby</title>
<script type="module" crossorigin src="/assets/index-B2Ft6dZR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DuDZv5uN.css">
</head>
<body>
Expand Down
2 changes: 1 addition & 1 deletion web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<meta name="theme-color" content="#b9002f" />
<meta name="application-name" content="Mendix Ruby Bridge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mendix Ruby Bridge</title>
<title>Mendix Ruby</title>
</head>
<body>
<div id="root"></div>
Expand Down
9 changes: 9 additions & 0 deletions web/src/components/Drafts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface PageDraft {
interface DraftsPayload {
entities: Record<string, EntityDraft>;
pages: Record<string, PageDraft>;
flows?: Record<string, PageDraft>;
}

export default function Drafts({ onOpen }: { onOpen: (qn: string) => void }) {
Expand Down Expand Up @@ -74,6 +75,14 @@ export default function Drafts({ onOpen }: { onOpen: (qn: string) => void }) {
{pages.length === 0 && <p className="empty">No page drafts yet — use "Save page" in the page builder.</p>}
{pages.map(([qn, d]) => card(qn, d.saved_at, d.valid !== false, d.message, d.mdl))}

<h3>Flow drafts</h3>
{Object.keys(data.flows ?? {}).length === 0 && (
<p className="empty">No flow drafts yet — use "Save flow" in the microflow editor.</p>
)}
{Object.entries(data.flows ?? {}).map(([qn, d]) =>
card(qn, d.saved_at, d.valid !== false, d.message, d.mdl),
)}

<h3>Entity drafts</h3>
{entities.length === 0 && <p className="empty">No entity drafts yet — use "Edit entity" on a parsed entity.</p>}
{entities.map(([qn, d]) =>
Expand Down
13 changes: 12 additions & 1 deletion web/src/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { flowGraph } from "../model/flow";
import { saveLayout, type NodePosition } from "../model/api";
import { flowBodyMdl } from "../model/flowMdl";
import { saveFlow, saveLayout, type NodePosition } from "../model/api";
import { nodeTypes } from "./nodes";

// Studio Pro-like microflow editor: horizontal flow, a shortcut toolbar on top,
Expand Down Expand Up @@ -189,6 +190,16 @@ function PersistedFlowInner({ qn, mdl, savedPositions, parameters = [] }: Props)
<button className="w-btn" disabled={!dirty} onClick={onSave}>
Save layout
</button>
<button
className="w-btn"
title="Serialize the canvas to microflow MDL, validate it, and save a draft"
onClick={async () => {
const result = await saveFlow(qn, flowBodyMdl(nodes, edges));
setStatus(result.message);
}}
>
Save flow
</button>
</div>
<div className="canvas">
<ReactFlow
Expand Down
16 changes: 16 additions & 0 deletions web/src/model/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,22 @@ export async function savePage(qn: string, content: string): Promise<SavePageRes
}
}

/** Validate + persist a rebuilt flow body as a draft (same contract as savePage). */
export async function saveFlow(qn: string, body: string): Promise<SavePageResult> {
try {
const r = await fetch(`${BASE}/flow`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ qn, body }),
});
const parsed = (await r.json().catch(() => ({}))) as Partial<SavePageResult> & { error?: string };
if (!r.ok && parsed.ok === undefined) throw new Error(parsed.error ?? `Save failed (${r.status}).`);
return parsed as SavePageResult;
} catch (e) {
return { ok: false, message: String(e instanceof Error ? e.message : e) };
}
}

// ---- guarded Git workflow --------------------------------------------------
// Mutations require confirming Studio Pro is closed (it locks the .mpr). Unlike
// the marketplace/layout helpers these never fake success: a failed guard or a
Expand Down
11 changes: 6 additions & 5 deletions web/src/model/flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface RawNode {
id: number;
kind: FlowKind;
label: string;
stmt: string | null; // original MDL statement (or decision expression), for round-trip
x: number | null;
y: number | null;
}
Expand All @@ -30,9 +31,9 @@ function parseFlow(mdl: string): { nodes: RawNode[]; edges: RawEdge[] } | null {
const nodes: RawNode[] = [];
const edges: RawEdge[] = [];
let counter = 0;
const add = (kind: FlowKind, label: string, pos: { x: number; y: number } | null) => {
const add = (kind: FlowKind, label: string, pos: { x: number; y: number } | null, stmt: string | null = null) => {
const id = counter++;
nodes.push({ id, kind, label: clip(label, 46), x: pos ? pos.x : null, y: pos ? pos.y : null });
nodes.push({ id, kind, label: clip(label, 46), stmt, x: pos ? pos.x : null, y: pos ? pos.y : null });
return id;
};
const startId = add("terminal", "Start", null);
Expand All @@ -55,7 +56,7 @@ function parseFlow(mdl: string): { nodes: RawNode[]; edges: RawEdge[] } | null {
if (!t) return;
if (/^if\b/.test(t)) {
const expr = t.replace(/^if\s+/, "").replace(/\s+then$/, "");
const id = add("decision", caption || expr, pos);
const id = add("decision", caption || expr, pos, expr);
connect(id);
pos = null;
caption = null;
Expand All @@ -74,7 +75,7 @@ function parseFlow(mdl: string): { nodes: RawNode[]; edges: RawEdge[] } | null {
} else if (/^(begin|end)\b/.test(t) || t.startsWith("@")) {
// annotations handled elsewhere
} else {
const id = add(kindOf(t), caption || t, pos);
const id = add(kindOf(t), caption || t, pos, t);
connect(id);
pos = null;
caption = null;
Expand Down Expand Up @@ -158,7 +159,7 @@ export function flowGraph(mdl: string): { nodes: Node[]; edges: Edge[] } | null
id: String(n.id),
type: nodeType(n),
position: { x: ((n.x as number) - minX) * sx, y: ((n.y as number) - minY) * sy },
data: { label: n.label, kind: n.kind },
data: { label: n.label, kind: n.kind, stmt: n.stmt },
}));
const edges: Edge[] = g.edges.map((e, i) => ({
id: `e${i}`,
Expand Down
Loading
Loading