Skip to content
Open
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
36 changes: 33 additions & 3 deletions extensions/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,46 @@ def _m8flow_migration(app: Flask) -> None:
_register_request_active_hooks(flask_app)
_register_request_tenant_context_hooks(flask_app)


def _register_template_file_fallback_routes(app: Flask) -> None:
"""
Register explicit Flask routes for PUT/DELETE template file so they work even
if Connexion does not register them (e.g. when spec is passed as dict and
operationId resolution fails). Fixes 405 Method Not Allowed on Save.
"""
from m8flow_backend.routes.templates_controller import (
template_put_file,
template_delete_file,
)

base_path = app.config.get("SPIFFWORKFLOW_BACKEND_API_PATH_PREFIX", "/v1.0")
rule = f"{base_path}/m8flow/templates/<int:id>/files/<path:file_name>"

def put_view(id: int, file_name: str):
return template_put_file(id, file_name)

def delete_view(id: int, file_name: str):
return template_delete_file(id, file_name)

app.add_url_rule(rule, "m8flow_template_put_file", put_view, methods=["PUT"])
app.add_url_rule(rule, "m8flow_template_delete_file", delete_view, methods=["DELETE"])


if flask_app is None:
raise RuntimeError("Could not access underlying Flask app from Connexion app")

try:
_register_template_file_fallback_routes(flask_app)
except Exception:
logger.warning("Failed to register template file fallback routes – may already exist", exc_info=True)

# Assert again AFTER create_app (catches re-init/duplicate db/base during app creation)
_assert_model_identity()
_assert_db_engine_bound(flask_app)

# Run migrations at startup
_m8flow_migration(flask_app)

if flask_app is None:
raise RuntimeError("Could not access underlying Flask app from Connexion app")

# Configure SQL echo if enabled
_configure_sql_echo(flask_app)

Expand Down
8 changes: 7 additions & 1 deletion extensions/frontend/src/ContainerForExtensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ import TenantPage from "./views/TenantPage";
// m8 Extension: Import Template Gallery and Template Modeler pages
import TemplateGalleryPage from './views/TemplateGalleryPage';
import TemplateModelerPage from './views/TemplateModelerPage';
import TemplateFileDiagramPage from './views/TemplateFileDiagramPage';
import TemplateFileFormPage from './views/TemplateFileFormPage';
import ProcessModelShowWithSaveAsTemplate from './views/ProcessModelShowWithSaveAsTemplate';

const fadeIn = 'fadeIn';
const fadeOutImmediate = 'fadeOutImmediate';
Expand Down Expand Up @@ -253,9 +256,12 @@ export default function ContainerForExtensions() {
<Route path="reports" element={<ReportsPage />} />
{/* M8Flow Extension: Tenant route */}
<Route path="/tenants" element={<TenantPage />} />
{/* m8 Extension: Template Gallery and Template Modeler routes */}
{/* m8 Extension: Template Gallery and Template Modeler routes (more specific first) */}
<Route path="templates/:templateId/files/:fileName" element={<TemplateFileDiagramPage />} />
<Route path="templates/:templateId/form/:fileName" element={<TemplateFileFormPage />} />
<Route path="templates/:templateId" element={<TemplateModelerPage />} />
<Route path="templates" element={<TemplateGalleryPage />} />
<Route path="process-models/:process_model_id" element={<ProcessModelShowWithSaveAsTemplate />} />
<Route path="extensions/:page_identifier" element={<Extension />} />
<Route path="login" element={<Login />} />
{/* Catch-all route must be last */}
Expand Down
195 changes: 195 additions & 0 deletions extensions/frontend/src/components/CreateTemplateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import {
Alert,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
} from "@mui/material";
import { useEffect, useState } from "react";
import TemplateService from "../services/TemplateService";
import type { CreateTemplateMetadata, Template, TemplateVisibility } from "../types/template";
import { nameToTemplateKey } from "../utils/templateKey";

const VISIBILITY_OPTIONS: { value: TemplateVisibility; label: string }[] = [
{ value: "PRIVATE", label: "Private (only you)" },
{ value: "TENANT", label: "Tenant-wide (all users in your tenant)" },
];

export interface CreateTemplateModalProps {
open: boolean;
onClose: () => void;
onSuccess?: (template: Template) => void;
}

export default function CreateTemplateModal({
open,
onClose,
onSuccess,
}: CreateTemplateModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [category, setCategory] = useState("");
const [tags, setTags] = useState("");
const [visibility, setVisibility] = useState<TemplateVisibility>("PRIVATE");
const [files, setFiles] = useState<File[]>([]);

useEffect(() => {
if (!open) {
setName("");
setDescription("");
setCategory("");
setTags("");
setVisibility("PRIVATE");
setFiles([]);
setError(null);
}
}, [open]);

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files;
if (selected) {
setFiles(Array.from(selected));
}
};

const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
setError("Name is required.");
return;
}
const template_key = nameToTemplateKey(trimmedName);
if (!template_key) {
setError("Name must contain at least one letter or number.");
return;
}
const hasBpmn = files.some(
(f) => f.name.toLowerCase().endsWith(".bpmn")
);
if (!hasBpmn) {
setError("At least one BPMN file is required.");
return;
}
setLoading(true);
setError(null);
try {
const metadata: CreateTemplateMetadata = {
template_key,
name: trimmedName,
visibility,
};
if (description.trim()) metadata.description = description.trim();
if (category.trim()) metadata.category = category.trim();
if (tags.trim()) {
metadata.tags = tags.split(",").map((s) => s.trim()).filter(Boolean);
}
const filesWithContent = files.map((f) => ({
name: f.name,
content: f,
}));
const template = await TemplateService.createTemplateWithFiles(
metadata,
filesWithContent
);
onClose();
onSuccess?.(template);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Failed to create template."
);
} finally {
setLoading(false);
}
};

return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Create template</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
{error && (
<Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>
)}
<TextField
label="Name"
required
fullWidth
value={name}
onChange={(e) => setName(e.target.value)}
disabled={loading}
placeholder="e.g. Approval Workflow"
/>
<TextField
label="Description"
fullWidth
multiline
minRows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={loading}
/>
<TextField
label="Category"
fullWidth
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={loading}
/>
<TextField
label="Tags"
fullWidth
placeholder="Comma-separated"
value={tags}
onChange={(e) => setTags(e.target.value)}
disabled={loading}
/>
<FormControl fullWidth disabled={loading}>
<InputLabel>Visibility</InputLabel>
<Select
value={visibility}
label="Visibility"
onChange={(e) =>
setVisibility(e.target.value as TemplateVisibility)
}
>
{VISIBILITY_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<Button variant="outlined" component="label" disabled={loading}>
{files.length > 0
? `${files.length} file(s) selected (include .bpmn)`
: "Choose files (BPMN required)"}
<input
type="file"
hidden
multiple
accept=".bpmn,.json,.dmn,.md"
onChange={handleFileChange}
/>
</Button>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button variant="contained" onClick={handleSubmit} disabled={loading}>
{loading ? "Creating..." : "Create"}
</Button>
</DialogActions>
</Dialog>
);
}
12 changes: 2 additions & 10 deletions extensions/frontend/src/components/DiagramEditorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export type DiagramEditorToolbarProps = {
onSetPrimaryFile: () => void;
onDownload: () => void;
onViewXml: () => void;
onSaveAsTemplate: () => void;
onSaveAsTemplate?: () => void;
referencesButton: React.ReactNode;
activeUserElement?: React.ReactElement;
onSetPrimaryFileAvailable?: boolean;
Expand Down Expand Up @@ -111,15 +111,7 @@ export default function DiagramEditorToolbar({
</Button>
)}
</Can>
{diagramType === 'bpmn' && (
<Button
variant="contained"
onClick={onSaveAsTemplate}
data-testid="save-as-template-button"
>
Save as Template
</Button>
)}
{/* Save as Template moved to Process Model page (not in diagram editor) */}
{referencesButton}
<Can
I="PUT"
Expand Down
Loading