diff --git a/extensions/frontend/src/components/CreateProcessModelFromTemplateModal.tsx b/extensions/frontend/src/components/CreateProcessModelFromTemplateModal.tsx new file mode 100644 index 000000000..c8bb43e3e --- /dev/null +++ b/extensions/frontend/src/components/CreateProcessModelFromTemplateModal.tsx @@ -0,0 +1,276 @@ +import { + Alert, + Autocomplete, + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Link, + 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 { nameToTemplateKey } from "../utils/templateKey"; +import type { Template } from "../types/template"; + +/** + * 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, +}: Readonly) { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(nameToTemplateKey(template.name)); + setDescription(template.description || ""); + } + }, [open, template?.id]); + + // Auto-generate process model ID from display name (unless manually edited) + const handleDisplayNameChange = (value: string) => { + setDisplayName(value); + if (!idManuallyEdited) { + setProcessModelId(nameToTemplateKey(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 = result.template_info?.process_model_identifier || `${selectedGroup.id}/${trimmedId}`; + + if (onSuccess) { + onSuccess(fullProcessModelId); + } else { + // Navigate to the new process model + const encodedId = fullProcessModelId.replaceAll("/", ":"); + navigate(`/process-models/${encodedId}`); + } + onClose(); + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "Failed to create process model. Please try again."; + setError(message); + } finally { + setLoading(false); + } + }; + + return ( + + + Create Process Model from Template + + + + {template && ( + + Creating from template: {template.name} ({template.version}) + + )} + {error && ( + {error} + )} + + + option.displayName} + value={selectedGroup} + onChange={(_, newValue) => setSelectedGroup(newValue)} + loading={groupsLoading} + disabled={loading} + renderInput={(params) => ( + + {groupsLoading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + {!groupsLoading && flattenedGroups.length === 0 && ( + + No process groups found.{" "} + + Create a process group + {" "} + first, then return here. + + )} + + + handleDisplayNameChange(e.target.value)} + disabled={loading} + helperText="Human-readable name for the process model" + /> + + handleProcessModelIdChange(e.target.value)} + disabled={loading} + helperText="Unique identifier (lowercase letters, numbers, and hyphens only)" + /> + + setDescription(e.target.value)} + disabled={loading} + /> + + {selectedGroup && processModelId && ( + + Full path: {selectedGroup.id}/{processModelId} + + )} + + + + + + + + ); +} diff --git a/extensions/frontend/src/hooks/useExtendedGrouping.tsx b/extensions/frontend/src/hooks/useExtendedGrouping.tsx index ebc7b591f..581ba55a3 100644 --- a/extensions/frontend/src/hooks/useExtendedGrouping.tsx +++ b/extensions/frontend/src/hooks/useExtendedGrouping.tsx @@ -71,7 +71,6 @@ export function useExtendedGrouping({ setSelectedGroupBy(null); } else if (isCustomOption(groupBy)) { // m8 Extension: Handle custom grouping options - console.log(`[m8 Extension] Custom grouping selected: ${groupBy}`); const handler = getHandler(groupBy); if (handler) { const grouped = handler(tasks); diff --git a/extensions/frontend/src/hooks/useProcessGroups.tsx b/extensions/frontend/src/hooks/useProcessGroups.tsx index 2f53fd1be..b0d1c26e6 100644 --- a/extensions/frontend/src/hooks/useProcessGroups.tsx +++ b/extensions/frontend/src/hooks/useProcessGroups.tsx @@ -36,9 +36,6 @@ export default function useProcessGroups({ const getProcessGroups = async () => { setLoading(true); - // Log the API call - console.log('[m8 Extension] Calling Process Groups API:', path); - HttpService.makeCallToBackend({ path, httpMethod: 'GET', diff --git a/extensions/frontend/src/services/TemplateService.test.ts b/extensions/frontend/src/services/TemplateService.test.ts index 7d78ee773..2e50c0792 100644 --- a/extensions/frontend/src/services/TemplateService.test.ts +++ b/extensions/frontend/src/services/TemplateService.test.ts @@ -125,4 +125,99 @@ describe("TemplateService", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); }); + + describe("updateTemplateFile", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("sends PUT request and returns parsed template on success", async () => { + const mockTemplateResponse = { + id: 5, + template_key: "test-key", + name: "Test Template", + version: "V2", + visibility: "PRIVATE", + is_published: false, + files: [{ file_type: "json", file_name: "form.json" }], + created_at_in_seconds: 1700000000, + updated_at_in_seconds: 1700000001, + }; + + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockTemplateResponse), + }); + + const result = await TemplateService.updateTemplateFile( + 3, + "form.json", + '{"updated": true}', + "application/json" + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/templates/3/files/form.json"), + expect.objectContaining({ + method: "PUT", + credentials: "include", + body: '{"updated": true}', + }) + ); + + expect(result.id).toBe(5); + expect(result.version).toBe("V2"); + expect(result.templateKey).toBe("test-key"); + }); + + it("rejects with error when response is not ok", async () => { + fetchMock.mockResolvedValue({ ok: false }); + + await expect( + TemplateService.updateTemplateFile(3, "form.json", '{"data": true}') + ).rejects.toThrow("Update failed"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("deleteTemplateFile", () => { + const fetchMock = vi.fn(); + + beforeEach(() => { + fetchMock.mockClear(); + vi.stubGlobal("fetch", fetchMock); + }); + + it("sends DELETE request and resolves on success", async () => { + fetchMock.mockResolvedValue({ + ok: true, + }); + + await TemplateService.deleteTemplateFile(4, "form.json"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining("/templates/4/files/form.json"), + expect.objectContaining({ + method: "DELETE", + credentials: "include", + }) + ); + }); + + it("rejects with error when response is not ok", async () => { + fetchMock.mockResolvedValue({ ok: false }); + + await expect( + TemplateService.deleteTemplateFile(4, "form.json") + ).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 51cf39dbb..56302873a 100644 --- a/extensions/frontend/src/services/TemplateService.ts +++ b/extensions/frontend/src/services/TemplateService.ts @@ -2,6 +2,9 @@ import { BACKEND_BASE_URL } from "@spiffworkflow-frontend/config"; import HttpService, { getBasicHeaders } from "./HttpService"; import type { CreateTemplateMetadata, + CreateProcessModelFromTemplateRequest, + CreateProcessModelFromTemplateResponse, + ProcessModelTemplateInfo, Template, TemplateFile, } from "../types/template"; @@ -227,14 +230,16 @@ const TemplateService = { }, /** - * Update a template file by name. Uses auth headers. Template must not be published. + * Update a template file by name. Uses auth headers. + * If the template is published, a new draft version is created. + * Returns the template that was actually updated (may be a new version). */ updateTemplateFile( id: number, fileName: string, content: string, contentType?: string - ): Promise { + ): Promise