Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
import {
Alert,
Autocomplete,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
Typography,
} from "@mui/material";
import { useEffect, useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { ProcessGroup, ProcessGroupLite } from "@spiffworkflow-frontend/interfaces";
import TemplateService from "../services/TemplateService";
import useProcessGroups from "../hooks/useProcessGroups";
import type { Template } from "../types/template";

/**
* Convert a display name to a valid process model ID.
* - Converts to lowercase
* - Replaces spaces and special characters with hyphens
* - Removes consecutive hyphens
* - Removes leading/trailing hyphens
*/
function displayNameToProcessModelId(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}

/**
* Flatten process groups into a list of { id, displayName } for the autocomplete.
*/
function flattenProcessGroups(
groups: (ProcessGroup | ProcessGroupLite)[] | null,
parentPath = ""
): { id: string; displayName: string }[] {
if (!groups) return [];
const result: { id: string; displayName: string }[] = [];
for (const group of groups) {
const id = group.id;
const displayName = group.display_name || id;
result.push({ id, displayName: parentPath ? `${parentPath} / ${displayName}` : displayName });
// Recursively add child groups if they exist
if ("process_groups" in group && Array.isArray(group.process_groups)) {
result.push(
...flattenProcessGroups(
group.process_groups as ProcessGroup[],
parentPath ? `${parentPath} / ${displayName}` : displayName
)
);
}
}
return result;
}

export interface CreateProcessModelFromTemplateModalProps {
open: boolean;
onClose: () => void;
template: Template | null;
onSuccess?: (processModelId: string) => void;
}

export default function CreateProcessModelFromTemplateModal({
open,
onClose,
template,
onSuccess,
}: CreateProcessModelFromTemplateModalProps) {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

// Form fields
const [selectedGroup, setSelectedGroup] = useState<{ id: string; displayName: string } | null>(null);
const [processModelId, setProcessModelId] = useState("");
const [displayName, setDisplayName] = useState("");
const [description, setDescription] = useState("");
const [idManuallyEdited, setIdManuallyEdited] = useState(false);

// Fetch process groups
const { processGroups, loading: groupsLoading } = useProcessGroups({ processInfo: {} });
const flattenedGroups = useMemo(() => flattenProcessGroups(processGroups), [processGroups]);

// Reset form when modal opens/closes
useEffect(() => {
if (!open) {
setSelectedGroup(null);
setProcessModelId("");
setDisplayName("");
setDescription("");
setIdManuallyEdited(false);
setError(null);
} else if (template) {
// Pre-fill display name from template name
setDisplayName(template.name);
setProcessModelId(displayNameToProcessModelId(template.name));
setDescription(template.description || "");
}
}, [open, template]);

// Auto-generate process model ID from display name (unless manually edited)
const handleDisplayNameChange = (value: string) => {
setDisplayName(value);
if (!idManuallyEdited) {
setProcessModelId(displayNameToProcessModelId(value));
}
};

const handleProcessModelIdChange = (value: string) => {
setProcessModelId(value);
setIdManuallyEdited(true);
};

const handleSubmit = async () => {
// Validation
if (!selectedGroup) {
setError("Please select a process group.");
return;
}
const trimmedId = processModelId.trim();
if (!trimmedId) {
setError("Process model ID is required.");
return;
}
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(trimmedId)) {
setError("Process model ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.");
return;
}
const trimmedName = displayName.trim();
if (!trimmedName) {
setError("Display name is required.");
return;
}
if (!template) {
setError("No template selected.");
return;
}

setLoading(true);
setError(null);

try {
const result = await TemplateService.createProcessModelFromTemplate(template.id, {
process_group_id: selectedGroup.id,
process_model_id: trimmedId,
display_name: trimmedName,
description: description.trim() || undefined,
});

const fullProcessModelId = `${selectedGroup.id}/${trimmedId}`;
onClose();

if (onSuccess) {
onSuccess(fullProcessModelId);
} else {
// Navigate to the new process model
const encodedId = fullProcessModelId.replace(/\//g, ":");
navigate(`/process-models/${encodedId}`);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to create process model. Please try again.";
setError(message);
} finally {
setLoading(false);
}
};

return (
<Dialog
open={open}
onClose={onClose}
maxWidth="sm"
fullWidth
>
<DialogTitle sx={{ fontSize: "1.25rem", fontWeight: 600 }}>
Create Process Model from Template
</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ pt: 1 }}>
{template && (
<Alert severity="info" sx={{ mb: 1 }}>
Creating from template: <strong>{template.name}</strong> (v{template.version})
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>
)}

<Autocomplete
options={flattenedGroups}
getOptionLabel={(option) => option.displayName}
value={selectedGroup}
onChange={(_, newValue) => setSelectedGroup(newValue)}
loading={groupsLoading}
disabled={loading}
renderInput={(params) => (
<TextField
{...params}
label="Process Group"
required
placeholder="Select a process group"
InputProps={{
...params.InputProps,
endAdornment: (
<>
{groupsLoading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
/>

<TextField
label="Display Name"
fullWidth
required
placeholder="e.g. My Approval Workflow"
value={displayName}
onChange={(e) => handleDisplayNameChange(e.target.value)}
disabled={loading}
helperText="Human-readable name for the process model"
/>

<TextField
label="Process Model ID"
fullWidth
required
placeholder="e.g. my-approval-workflow"
value={processModelId}
onChange={(e) => handleProcessModelIdChange(e.target.value)}
disabled={loading}
helperText="Unique identifier (lowercase letters, numbers, and hyphens only)"
/>

<TextField
label="Description"
fullWidth
multiline
minRows={2}
placeholder="Optional description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={loading}
/>

{selectedGroup && processModelId && (
<Typography variant="body2" color="text.secondary">
Full path: <code>{selectedGroup.id}/{processModelId}</code>
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
<Button onClick={onClose} disabled={loading} variant="outlined">
Cancel
</Button>
<Button
onClick={handleSubmit}
variant="contained"
color="primary"
disabled={loading || groupsLoading}
>
{loading ? "Creating..." : "Create Process Model"}
</Button>
</DialogActions>
</Dialog>
);
}
Loading