diff --git a/extensions/app.py b/extensions/app.py index 0a10a91b77..38a3baa145 100644 --- a/extensions/app.py +++ b/extensions/app.py @@ -224,6 +224,39 @@ 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//files/" + + 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) @@ -231,9 +264,6 @@ def _m8flow_migration(app: Flask) -> None: # 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) diff --git a/extensions/frontend/src/ContainerForExtensions.tsx b/extensions/frontend/src/ContainerForExtensions.tsx index b7524667af..f569bfdb3a 100644 --- a/extensions/frontend/src/ContainerForExtensions.tsx +++ b/extensions/frontend/src/ContainerForExtensions.tsx @@ -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'; @@ -253,9 +256,12 @@ export default function ContainerForExtensions() { } /> {/* M8Flow Extension: Tenant route */} } /> - {/* m8 Extension: Template Gallery and Template Modeler routes */} + {/* m8 Extension: Template Gallery and Template Modeler routes (more specific first) */} + } /> + } /> } /> } /> + } /> } /> } /> {/* Catch-all route must be last */} diff --git a/extensions/frontend/src/components/CreateTemplateModal.tsx b/extensions/frontend/src/components/CreateTemplateModal.tsx new file mode 100644 index 0000000000..26c85f0f27 --- /dev/null +++ b/extensions/frontend/src/components/CreateTemplateModal.tsx @@ -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(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState(""); + const [tags, setTags] = useState(""); + const [visibility, setVisibility] = useState("PRIVATE"); + const [files, setFiles] = useState([]); + + useEffect(() => { + if (!open) { + setName(""); + setDescription(""); + setCategory(""); + setTags(""); + setVisibility("PRIVATE"); + setFiles([]); + setError(null); + } + }, [open]); + + const handleFileChange = (e: React.ChangeEvent) => { + 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 ( + + Create template + + + {error && ( + {error} + )} + setName(e.target.value)} + disabled={loading} + placeholder="e.g. Approval Workflow" + /> + setDescription(e.target.value)} + disabled={loading} + /> + setCategory(e.target.value)} + disabled={loading} + /> + setTags(e.target.value)} + disabled={loading} + /> + + Visibility + + + + + + + + + + + ); +} diff --git a/extensions/frontend/src/components/DiagramEditorToolbar.tsx b/extensions/frontend/src/components/DiagramEditorToolbar.tsx index 9deae39b8c..d1df0cfa6c 100644 --- a/extensions/frontend/src/components/DiagramEditorToolbar.tsx +++ b/extensions/frontend/src/components/DiagramEditorToolbar.tsx @@ -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; @@ -111,15 +111,7 @@ export default function DiagramEditorToolbar({ )} - {diagramType === 'bpmn' && ( - - )} + {/* Save as Template moved to Process Model page (not in diagram editor) */} {referencesButton} void; + onSuccess?: (template: Template) => void; +} + +export default function ImportTemplateModal({ + open, + onClose, + onSuccess, +}: ImportTemplateModalProps) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [name, setName] = useState(""); + const [file, setFile] = useState(null); + const [visibility, setVisibility] = useState("PRIVATE"); + + const handleFileChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + setFile(f ?? null); + }; + + 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; + } + if (!file) { + setError("Please select a zip file."); + return; + } + setLoading(true); + setError(null); + try { + const metadata: CreateTemplateMetadata = { + template_key, + name: trimmedName, + visibility, + }; + const template = await TemplateService.importTemplate(file, metadata); + onClose(); + onSuccess?.(template); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Import failed."); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setName(""); + setFile(null); + setVisibility("PRIVATE"); + setError(null); + onClose(); + }; + + return ( + + Import template from zip + + + {error && ( + {error} + )} + setName(e.target.value)} + disabled={loading} + placeholder="e.g. My Workflow" + /> + + Visibility + + + + + + + + + + + ); +} diff --git a/extensions/frontend/src/components/ReactDiagramEditor.tsx b/extensions/frontend/src/components/ReactDiagramEditor.tsx index f6226c97ba..ae241f5dbe 100644 --- a/extensions/frontend/src/components/ReactDiagramEditor.tsx +++ b/extensions/frontend/src/components/ReactDiagramEditor.tsx @@ -26,7 +26,6 @@ import { useDiagramImport } from './useDiagramImport'; import ReferencesModal from './ReferencesModal'; import DiagramEditorToolbar from './DiagramEditorToolbar'; import DiagramEditorControls from './DiagramEditorControls'; -import SaveAsTemplateModal from './SaveAsTemplateModal'; import type { ReactDiagramEditorProps } from './ReactDiagramEditor.types'; export default function ReactDiagramEditor(props: ReactDiagramEditorProps) { @@ -50,7 +49,6 @@ export default function ReactDiagramEditor(props: ReactDiagramEditorProps) { const [performingXmlUpdates, setPerformingXmlUpdates] = useState(false); const [showingReferences, setShowingReferences] = useState(false); - const [saveAsTemplateModalOpen, setSaveAsTemplateModalOpen] = useState(false); const { targetUris } = useUriListForPermissions(); const permissionRequestData: PermissionsToCheck = {}; @@ -166,7 +164,6 @@ export default function ReactDiagramEditor(props: ReactDiagramEditorProps) { onViewXml={() => navigate(`/process-models/${processModelId}/form/${fileName}`) } - onSaveAsTemplate={() => setSaveAsTemplateModalOpen(true)} referencesButton={referencesButton} activeUserElement={activeUserElement} onSetPrimaryFileAvailable={!!onSetPrimaryFile} @@ -176,18 +173,6 @@ export default function ReactDiagramEditor(props: ReactDiagramEditorProps) { onClose={() => setShowingReferences(false)} callers={callers} /> - setSaveAsTemplateModalOpen(false)} - onSuccess={() => setSaveAsTemplateModalOpen(false)} - getBpmnXml={() => - diagramModelerState - ? (diagramModelerState as any) - .saveXML({ format: true }) - .then((xmlObject: any) => xmlObject.xml) - : Promise.resolve('') - } - /> zoom(1)} onZoomOut={() => zoom(-1)} diff --git a/extensions/frontend/src/components/SaveAsTemplateModal.test.tsx b/extensions/frontend/src/components/SaveAsTemplateModal.test.tsx index 18b2b0b3a6..f8c3aa285d 100644 --- a/extensions/frontend/src/components/SaveAsTemplateModal.test.tsx +++ b/extensions/frontend/src/components/SaveAsTemplateModal.test.tsx @@ -6,7 +6,7 @@ import SaveAsTemplateModal from "./SaveAsTemplateModal"; vi.mock("../services/TemplateService", () => ({ default: { - createTemplate: vi.fn(), + createTemplateWithFiles: vi.fn(), }, })); @@ -14,6 +14,10 @@ import TemplateService from "../services/TemplateService"; const theme = createTheme(); +const defaultFiles = [ + { name: "diagram.bpmn", content: new Blob([""], { type: "application/xml" }) }, +]; + function renderWithTheme(ui: React.ReactElement) { return render({ui}); } @@ -27,14 +31,14 @@ describe("SaveAsTemplateModal", () => { const defaultProps = { open: true, onClose: vi.fn(), - getBpmnXml: vi.fn().mockResolvedValue(""), + getFiles: vi.fn().mockResolvedValue(defaultFiles), }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(defaultProps.onClose).mockClear(); - vi.mocked(defaultProps.getBpmnXml).mockClear().mockResolvedValue(""); - vi.mocked(TemplateService.createTemplate).mockResolvedValue({ + vi.mocked(defaultProps.getFiles).mockClear().mockResolvedValue(defaultFiles); + vi.mocked(TemplateService.createTemplateWithFiles).mockResolvedValue({ id: 1, templateKey: "test-key", name: "Test Template", @@ -68,7 +72,7 @@ describe("SaveAsTemplateModal", () => { await waitFor(() => { expect(screen.getByText("Template key is required.")).toBeInTheDocument(); }); - expect(TemplateService.createTemplate).not.toHaveBeenCalled(); + expect(TemplateService.createTemplateWithFiles).not.toHaveBeenCalled(); }); it("shows validation error when name is empty on submit", async () => { @@ -78,25 +82,25 @@ describe("SaveAsTemplateModal", () => { await waitFor(() => { expect(screen.getByText("Name is required.")).toBeInTheDocument(); }); - expect(TemplateService.createTemplate).not.toHaveBeenCalled(); + expect(TemplateService.createTemplateWithFiles).not.toHaveBeenCalled(); }); - it("calls getBpmnXml and createTemplate with trimmed key and name on submit", async () => { + it("calls getFiles and createTemplateWithFiles with trimmed key and name on submit", async () => { renderWithTheme(); typeInField(/Template key/i, " my-key "); typeInField(/Name/i, " My Template "); fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); await waitFor(() => { - expect(defaultProps.getBpmnXml).toHaveBeenCalledTimes(1); + expect(defaultProps.getFiles).toHaveBeenCalledTimes(1); }); await waitFor(() => { - expect(TemplateService.createTemplate).toHaveBeenCalledWith( - "", + expect(TemplateService.createTemplateWithFiles).toHaveBeenCalledWith( expect.objectContaining({ template_key: "my-key", name: "My Template", visibility: "PRIVATE", - }) + }), + defaultFiles ); }); }); @@ -110,33 +114,47 @@ describe("SaveAsTemplateModal", () => { typeInField(/Tags/i, "tag1, tag2 , tag3"); fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); await waitFor(() => { - expect(TemplateService.createTemplate).toHaveBeenCalledWith( - "", + expect(TemplateService.createTemplateWithFiles).toHaveBeenCalledWith( expect.objectContaining({ template_key: "my-key", name: "My Template", description: "A description", category: "Approval", tags: ["tag1", "tag2", "tag3"], - }) + }), + defaultFiles ); }); }); - it("shows error when getBpmnXml returns empty string", async () => { - vi.mocked(defaultProps.getBpmnXml).mockResolvedValue(""); + it("shows error when getFiles returns no bpmn file", async () => { + vi.mocked(defaultProps.getFiles).mockResolvedValue([ + { name: "data.json", content: new Blob(["{}"], { type: "application/json" }) }, + ]); + renderWithTheme(); + typeInField(/Template key/i, "my-key"); + typeInField(/Name/i, "My Template"); + fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); + await waitFor(() => { + expect(screen.getByText("At least one BPMN file is required.")).toBeInTheDocument(); + }); + expect(TemplateService.createTemplateWithFiles).not.toHaveBeenCalled(); + }); + + it("shows error when getFiles returns empty array", async () => { + vi.mocked(defaultProps.getFiles).mockResolvedValue([]); renderWithTheme(); typeInField(/Template key/i, "my-key"); typeInField(/Name/i, "My Template"); fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); await waitFor(() => { - expect(screen.getByText("Could not get diagram content. Please try again.")).toBeInTheDocument(); + expect(screen.getByText("No files to save. Please try again.")).toBeInTheDocument(); }); - expect(TemplateService.createTemplate).not.toHaveBeenCalled(); + expect(TemplateService.createTemplateWithFiles).not.toHaveBeenCalled(); }); - it("shows error when createTemplate rejects", async () => { - vi.mocked(TemplateService.createTemplate).mockRejectedValue(new Error("Server error")); + it("shows error when createTemplateWithFiles rejects", async () => { + vi.mocked(TemplateService.createTemplateWithFiles).mockRejectedValue(new Error("Server error")); renderWithTheme(); typeInField(/Template key/i, "my-key"); typeInField(/Name/i, "My Template"); @@ -160,6 +178,18 @@ describe("SaveAsTemplateModal", () => { }); }); + it("shows error when getFiles rejects", async () => { + vi.mocked(defaultProps.getFiles).mockRejectedValue(new Error("Could not load files")); + renderWithTheme(); + typeInField(/Template key/i, "my-key"); + typeInField(/Name/i, "My Template"); + fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); + await waitFor(() => { + expect(screen.getByText("Could not load files")).toBeInTheDocument(); + }); + expect(TemplateService.createTemplateWithFiles).not.toHaveBeenCalled(); + }); + it("Cancel button calls onClose", () => { renderWithTheme(); fireEvent.click(screen.getByRole("button", { name: /Cancel/i })); @@ -176,9 +206,9 @@ describe("SaveAsTemplateModal", () => { fireEvent.click(tenantOption); fireEvent.click(screen.getByRole("button", { name: /Create Template/i })); await waitFor(() => { - expect(TemplateService.createTemplate).toHaveBeenCalledWith( - "", - expect.objectContaining({ visibility: "TENANT" }) + expect(TemplateService.createTemplateWithFiles).toHaveBeenCalledWith( + expect.objectContaining({ visibility: "TENANT" }), + defaultFiles ); }); }); diff --git a/extensions/frontend/src/components/SaveAsTemplateModal.tsx b/extensions/frontend/src/components/SaveAsTemplateModal.tsx index 48a28e3778..fc43555432 100644 --- a/extensions/frontend/src/components/SaveAsTemplateModal.tsx +++ b/extensions/frontend/src/components/SaveAsTemplateModal.tsx @@ -1,4 +1,5 @@ import { + Alert, Button, Dialog, DialogActions, @@ -13,6 +14,7 @@ import { } from "@mui/material"; import { useEffect, useState } from "react"; import TemplateService from "../services/TemplateService"; +import { nameToTemplateKey } from "../utils/templateKey"; import type { CreateTemplateMetadata, Template, TemplateVisibility } from "../types/template"; const VISIBILITY_OPTIONS: { value: TemplateVisibility; label: string }[] = [ @@ -20,23 +22,30 @@ const VISIBILITY_OPTIONS: { value: TemplateVisibility; label: string }[] = [ { value: "TENANT", label: "Tenant-wide (all users in your tenant)" }, ]; +const SUPPORTED_EXT = [".bpmn", ".json", ".dmn", ".md"]; + +export interface SaveAsTemplateFile { + name: string; + content: Blob; +} + export interface SaveAsTemplateModalProps { open: boolean; onClose: () => void; onSuccess?: (template?: Template) => void; - getBpmnXml: () => Promise; + /** Return all files to save with the template (at least one must be .bpmn). */ + getFiles: () => Promise; } export default function SaveAsTemplateModal({ open, onClose, onSuccess, - getBpmnXml, + getFiles, }: SaveAsTemplateModalProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [templateKey, setTemplateKey] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [category, setCategory] = useState(""); @@ -45,7 +54,6 @@ export default function SaveAsTemplateModal({ useEffect(() => { if (!open) { - setTemplateKey(""); setName(""); setDescription(""); setCategory(""); @@ -56,28 +64,36 @@ export default function SaveAsTemplateModal({ }, [open]); const handleSubmit = async () => { - const trimmedKey = templateKey.trim(); const trimmedName = name.trim(); - if (!trimmedKey) { - setError("Template key is required."); - return; - } 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; + } setLoading(true); setError(null); try { - const bpmnXml = await getBpmnXml(); - if (!bpmnXml || !bpmnXml.trim()) { - setError("Could not get diagram content. Please try again."); + const files = await getFiles(); + if (!files?.length) { + setError("No files to save. Please try again."); + setLoading(false); + return; + } + const hasBpmn = files.some((f) => + f.name.toLowerCase().endsWith(".bpmn") + ); + if (!hasBpmn) { + setError("At least one BPMN file is required."); setLoading(false); return; } const metadata: CreateTemplateMetadata = { - template_key: trimmedKey, + template_key, name: trimmedName, visibility, }; @@ -86,7 +102,11 @@ export default function SaveAsTemplateModal({ if (tags.trim()) { metadata.tags = tags.split(",").map((s) => s.trim()).filter(Boolean); } - const template = await TemplateService.createTemplate(bpmnXml, metadata); + const filesForApi = files.map((f) => ({ name: f.name, content: f.content })); + const template = await TemplateService.createTemplateWithFiles( + metadata, + filesForApi + ); onClose(); onSuccess?.(template); } catch (err: unknown) { @@ -111,27 +131,8 @@ export default function SaveAsTemplateModal({ {error && ( - + {error} )} - setTemplateKey(e.target.value)} - disabled={loading} - helperText="Unique identifier (letters, numbers, hyphens)" - /> 0 && ( - {template.tags.slice(0, 3).map((tag, index) => ( + {template.tags.slice(0, 3).map((tag) => ( + f.fileName.toLowerCase().endsWith(".bpmn") + )?.fileName; + + if (files.length === 0) { + return null; + } + + const handleDownload = (fileName: string) => { + TemplateService.downloadTemplateFile(templateId, fileName).catch(() => { + // Error could be shown via a toast or parent state if needed + }); + }; + + return ( + + + Files + + + + + + Name + Actions + + + + {files.map((f) => { + const viewPath = getFileViewPath(templateId, f.fileName); + const isPrimary = f.fileName === primaryFileName; + return ( + + + + {f.fileName} + + {isPrimary && ( + + – Primary File + + )} + + + + + + handleDownload(f.fileName)} + > + + + + + ); + })} + +
+
+
+ ); +} diff --git a/extensions/frontend/src/hooks/useTemplates.ts b/extensions/frontend/src/hooks/useTemplates.ts index 55148592d4..5d7d0d19a7 100644 --- a/extensions/frontend/src/hooks/useTemplates.ts +++ b/extensions/frontend/src/hooks/useTemplates.ts @@ -1,6 +1,8 @@ import { useState, useCallback } from 'react'; import HttpService from '../services/HttpService'; import { Template, TemplateFilters } from '../types/template'; +import { normalizeTemplate } from '../utils/templateHelpers'; +import { PaginationObject } from '@spiffworkflow-frontend/interfaces'; const FILTER_PARAM_KEYS: (keyof TemplateFilters)[] = [ 'search', @@ -9,6 +11,8 @@ const FILTER_PARAM_KEYS: (keyof TemplateFilters)[] = [ 'visibility', 'owner', 'latest_only', + 'page', + 'per_page', ]; function buildTemplateQueryParams(filters?: TemplateFilters): URLSearchParams { @@ -17,7 +21,7 @@ function buildTemplateQueryParams(filters?: TemplateFilters): URLSearchParams { for (const key of FILTER_PARAM_KEYS) { const value = filters[key]; if (value !== undefined && value !== null) { - params.append(key, typeof value === 'boolean' ? String(value) : value); + params.append(key, typeof value === 'boolean' ? String(value) : String(value)); } } return params; @@ -33,6 +37,7 @@ function getErrorMessage(err: unknown, fallback: string): string { interface UseTemplatesReturn { templates: Template[]; + pagination: PaginationObject | null; templatesLoading: boolean; templateByIdLoading: boolean; templateByKeyLoading: boolean; @@ -44,6 +49,7 @@ interface UseTemplatesReturn { export function useTemplates(): UseTemplatesReturn { const [templates, setTemplates] = useState([]); + const [pagination, setPagination] = useState(null); const [templatesLoading, setTemplatesLoading] = useState(false); const [templateByIdLoading, setTemplateByIdLoading] = useState(false); const [templateByKeyLoading, setTemplateByKeyLoading] = useState(false); @@ -60,8 +66,11 @@ export function useTemplates(): UseTemplatesReturn { HttpService.makeCallToBackend({ path, httpMethod: HttpService.HttpMethods.GET, - successCallback: (result: Template[]) => { - setTemplates(result); + successCallback: (result: Record) => { + const results = result.results as Record[]; + const pag = result.pagination as PaginationObject | undefined; + setTemplates(Array.isArray(results) ? results.map((r) => normalizeTemplate(r)) : []); + setPagination(pag ?? null); setTemplatesLoading(false); }, failureCallback: (err: unknown) => { @@ -82,9 +91,9 @@ export function useTemplates(): UseTemplatesReturn { HttpService.makeCallToBackend({ path: `/v1.0/m8flow/templates/${id}`, httpMethod: HttpService.HttpMethods.GET, - successCallback: (result: Template) => { + successCallback: (result: Record) => { setTemplateByIdLoading(false); - resolve(result); + resolve(normalizeTemplate(result)); }, failureCallback: (err: unknown) => { setError(getErrorMessage(err, 'Failed to fetch template')); @@ -117,9 +126,9 @@ export function useTemplates(): UseTemplatesReturn { HttpService.makeCallToBackend({ path, httpMethod: HttpService.HttpMethods.GET, - successCallback: (result: Template) => { + successCallback: (result: Record) => { setTemplateByKeyLoading(false); - resolve(result); + resolve(normalizeTemplate(result)); }, failureCallback: (err: unknown) => { setError(getErrorMessage(err, 'Failed to fetch template')); @@ -137,6 +146,7 @@ export function useTemplates(): UseTemplatesReturn { return { templates, + pagination, templatesLoading, templateByIdLoading, templateByKeyLoading, diff --git a/extensions/frontend/src/services/HttpService.ts b/extensions/frontend/src/services/HttpService.ts index 6555734850..de3850cc1d 100644 --- a/extensions/frontend/src/services/HttpService.ts +++ b/extensions/frontend/src/services/HttpService.ts @@ -248,6 +248,7 @@ const HttpService = { makeCallToBackend, fetchTextFromBackend, messageForHttpError, + getBasicHeaders, }; export default HttpService; diff --git a/extensions/frontend/src/services/TemplateService.test.ts b/extensions/frontend/src/services/TemplateService.test.ts index c7be373dff..7d78ee7738 100644 --- a/extensions/frontend/src/services/TemplateService.test.ts +++ b/extensions/frontend/src/services/TemplateService.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import HttpService from "@spiffworkflow-frontend/services/HttpService"; +import HttpService from "./HttpService"; import TemplateService from "./TemplateService"; -vi.mock("@spiffworkflow-frontend/services/HttpService", () => ({ +vi.mock("./HttpService", () => ({ default: { makeCallToBackend: vi.fn(), + getBasicHeaders: vi.fn().mockReturnValue({}), }, })); @@ -92,4 +93,36 @@ describe("TemplateService", () => { expect(HttpService.makeCallToBackend).not.toHaveBeenCalled(); }); }); + + describe("deleteTemplate", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("sends DELETE to correct URL and resolves when response is ok", async () => { + fetchMock.mockResolvedValue({ ok: true }); + + await expect(TemplateService.deleteTemplate(7)).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/templates/7"), + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }) + ); + }); + + it("rejects with error when response is not ok", async () => { + fetchMock.mockResolvedValue({ ok: false }); + + await expect(TemplateService.deleteTemplate(7)).rejects.toThrow("Delete failed"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/extensions/frontend/src/services/TemplateService.ts b/extensions/frontend/src/services/TemplateService.ts index 6bdf098218..51cf39dbba 100644 --- a/extensions/frontend/src/services/TemplateService.ts +++ b/extensions/frontend/src/services/TemplateService.ts @@ -1,11 +1,20 @@ -import HttpService from "@spiffworkflow-frontend/services/HttpService"; -import type { CreateTemplateMetadata, Template } from "../types/template"; +import { BACKEND_BASE_URL } from "@spiffworkflow-frontend/config"; +import HttpService, { getBasicHeaders } from "./HttpService"; +import type { + CreateTemplateMetadata, + Template, + TemplateFile, +} from "../types/template"; const BASE_PATH = "/v1.0/m8flow"; +function backendPath(path: string): string { + const p = path.replace(/^\/v1\.0/, ""); + return `${BACKEND_BASE_URL}${p}`; +} + function buildHeaders(metadata: CreateTemplateMetadata): Record { const headers: Record = { - "Content-Type": "application/xml", "X-Template-Key": metadata.template_key.trim(), "X-Template-Name": metadata.name.trim(), }; @@ -41,6 +50,26 @@ function buildHeaders(metadata: CreateTemplateMetadata): Record return headers; } +function secondsFromApiOrIso(seconds: unknown, iso: unknown): number { + if (typeof seconds === "number" && !Number.isNaN(seconds)) return seconds; + if (typeof iso === "string") { + const ms = Date.parse(iso); + if (!Number.isNaN(ms)) return Math.floor(ms / 1000); + } + return 0; +} + +function parseTemplateResponse(data: Record): Template { + const createdAtInSeconds = secondsFromApiOrIso(data.createdAtInSeconds, data.createdAt); + const updatedAtInSeconds = secondsFromApiOrIso(data.updatedAtInSeconds, data.updatedAt); + return { + ...data, + files: (data.files as TemplateFile[]) ?? [], + createdAtInSeconds, + updatedAtInSeconds, + } as Template; +} + const TemplateService = { /** * Create a template with BPMN XML body and metadata via X-Template-* headers. @@ -54,14 +83,18 @@ const TemplateService = { new Error("Template key and name are required") ); } - const extraHeaders = buildHeaders(metadata); + const extraHeaders = { + ...buildHeaders(metadata), + "Content-Type": "application/xml", + }; return new Promise((resolve, reject) => { HttpService.makeCallToBackend({ path: `${BASE_PATH}/templates`, httpMethod: "POST", postBody: bpmnXml, extraHeaders, - successCallback: resolve, + successCallback: (data: Record) => + resolve(parseTemplateResponse(data)), failureCallback: (err: unknown) => { const message = err && typeof err === "object" && "message" in err @@ -72,6 +105,288 @@ const TemplateService = { }); }); }, + + /** + * Create a template with multiple files (multipart). At least one BPMN required. + */ + createTemplateWithFiles( + metadata: CreateTemplateMetadata, + files: { name: string; content: Blob | File }[] + ): Promise