Enhance Process Builder with New Variables and External Menu Support

- Added new variables for 'namaAsnaf', 'todoStatus', 'asnafScore', 'resultTimestamp', and 'resultSummary' to improve data handling in process definitions.
- Updated process definition JSON to include new output mappings and enhanced script logic for better API response handling.
- Implemented validation for external menu paths in the menu editor, ensuring proper URL formatting and handling for external links.
- Enhanced the user interface in the menu editor to support external URL inputs, improving user experience when adding or editing menu items.
- Updated API endpoints to skip file operations for external menus, streamlining the process of adding and editing external links.
This commit is contained in:
Md Afiq Iskandar 2025-07-16 11:08:38 +08:00
parent 03000b710b
commit 84e8d8e42f
8 changed files with 317 additions and 44 deletions

View File

@ -101,7 +101,8 @@
"inputMappings": [],
"assignmentType": "roles",
"outputMappings": [
{ "formField": "kategori_asnaf", "processVariable": "kategoriAsnaf" }
{ "formField": "kategori_asnaf", "processVariable": "kategoriAsnaf" },
{ "formField": "nama_asnaf", "processVariable": "namaAsnaf" }
],
"fieldConditions": [],
"assignmentVariable": "",
@ -121,7 +122,10 @@
{
"id": "api-1752550319410",
"data": {
"body": { "data": "{ \"title\" : \"{todoTitle}\"}", "type": "raw" },
"body": {
"data": "{ \"kategori_asnaf\" : \"{kategoriAsnaf}\", \"nama_asnaf\" : \"{namaAsnaf}\"}",
"type": "raw"
},
"label": "API Call",
"shape": "rectangle",
"apiUrl": "https://jsonplaceholder.typicode.com/posts",
@ -146,7 +150,7 @@
"id": "script-1752550430989",
"data": {
"label": "Script Task",
"scriptCode": "// Assign API response title to process variable\nprocessVariables.todoTitle = processVariables.apiResponse?.title || '';\n// You can add more logic here\n",
"scriptCode": "// Map API response to process variables\nconst api = processVariables.apiResponse || {};\nprocessVariables.todoTitle = api.kategori_asnaf || '';\nprocessVariables.namaAsnaf = api.nama_asnaf || '';\nprocessVariables.todoStatus = api.id > 100; // true if id > 100, otherwise false\n\n// New logic: Calculate a score\nconst katLen = (api.kategori_asnaf || '').length;\nconst namaLen = (api.nama_asnaf || '').length;\nprocessVariables.asnafScore = katLen * 10 + namaLen * 5;\n\n// New logic: Add a timestamp\nprocessVariables.resultTimestamp = new Date().toISOString();\n\n// New logic: Create a summary string\nprocessVariables.resultSummary = `Asnaf: ${processVariables.todoTitle}, Nama: ${processVariables.namaAsnaf}, Score: ${processVariables.asnafScore}, Time: ${processVariables.resultTimestamp}`;\n",
"description": "Execute JavaScript code",
"inputVariables": ["apiResponse"],
"scriptLanguage": "javascript",
@ -155,6 +159,31 @@
"name": "todoTitle",
"type": "string",
"description": "Title from API response"
},
{
"name": "namaAsnaf",
"type": "string",
"description": "Nama Asnaf from API response"
},
{
"name": "todoStatus",
"type": "boolean",
"description": "Todo Status from API response"
},
{
"name": "asnafScore",
"type": "number",
"description": "Calculated Asnaf Score"
},
{
"name": "resultTimestamp",
"type": "string",
"description": "Result Timestamp"
},
{
"name": "resultSummary",
"type": "string",
"description": "Result Summary"
}
]
},
@ -166,12 +195,16 @@
"id": "html-1752550500000",
"data": {
"label": "Show Result",
"jsCode": "",
"cssCode": ".result-card { background: #f9fafb; border: 1px solid #ddd; border-radius: 8px; padding: 16px; max-width: 400px; margin: 24px auto; }",
"htmlCode": "<div class='result-card'>\n <h2>API Result</h2>\n <p>Todo Title: <strong>{{ processVariables.todoTitle }}</strong></p>\n</div>",
"shape": "rectangle",
"jsCode": "const completed = \"{{todoStatus}}\" === 'true' || \"{{todoStatus}}\" === true;\ndocument.getElementById('todo-title').innerText = \"{{todoTitle}}\";\ndocument.getElementById('todo-status').innerText = completed ? 'Completed ✅' : 'Not Completed ❌';\ndocument.getElementById('todo-status').className = completed ? 'done' : 'not-done';",
"cssCode": ".result-box {\n background-color: #ffffff;\n border-radius: 12px;\n box-shadow: 0 8px 16px rgba(0,0,0,0.1);\n padding: 30px 40px;\n max-width: 600px;\n text-align: center;\n }\n .result-box h2 {\n color: #333;\n margin-bottom: 20px;\n }\n .result-box p {\n font-size: 1.2em;\n color: #555;\n }\n .done {\n color: green;\n font-weight: bold;\n }\n .not-done {\n color: red;\n font-weight: bold;\n }",
"htmlCode": "<div class=\"result-box\">\n <h2>Asnaf Result</h2>\n <p><strong>Title:</strong> <span id=\"todo-title\">{{todoTitle}}</span></p>\n <p><strong>Status:</strong> <span id=\"todo-status\">{{todoStatus}}</span></p>\n <p><strong>Nama:</strong> {{namaAsnaf}}</p>\n <p><strong>Score:</strong> {{asnafScore}}</p>\n <p><strong>Timestamp:</strong> {{resultTimestamp}}</p>\n <p><strong>Summary:</strong> {{resultSummary}}</p>\n</div>",
"textColor": "#333333",
"autoRefresh": true,
"borderColor": "#dddddd",
"description": "Display the todo title from API",
"inputVariables": ["todoTitle"],
"inputVariables": ["todoTitle", "todoStatus"],
"backgroundColor": "#ffffff",
"outputVariables": [],
"allowVariableAccess": true
},
@ -206,8 +239,8 @@
}
],
"viewport": {
"x": -118.4524312896406,
"y": 314.4180761099366,
"zoom": 0.6437632135306554
"x": -193.044397463002,
"y": 197.8681289640592,
"zoom": 1.049154334038055
}
}

View File

@ -6,6 +6,18 @@
"value": null,
"description": "API error from API Call"
},
"namaAsnaf": {
"name": "namaAsnaf",
"type": "string",
"scope": "global",
"description": ""
},
"todoTitle": {
"name": "todoTitle",
"type": "string",
"scope": "global",
"description": "Title from API response"
},
"apiResponse": {
"name": "apiResponse",
"type": "object",
@ -19,10 +31,28 @@
"scope": "global",
"description": ""
},
"todoTitle": {
"name": "todoTitle",
"todoStatus": {
"name": "todoStatus",
"type": "boolean",
"scope": "global",
"description": "Completion status (true if id > 100)"
},
"asnafScore": {
"name": "asnafScore",
"type": "number",
"scope": "global",
"description": "Calculated score based on input lengths"
},
"resultTimestamp": {
"name": "resultTimestamp",
"type": "string",
"scope": "global",
"description": "Title from API response"
"description": "Timestamp when script ran"
},
"resultSummary": {
"name": "resultSummary",
"type": "string",
"scope": "global",
"description": "Summary string for result"
}
}

View File

@ -12,6 +12,23 @@ export default [
},
],
},
// {
// header: "BPM",
// description: "Manage your BPM application",
// child: [
// {
// title: "Form",
// icon: "material-symbols:dynamic-form",
// path: "http://localhost:3000/workflow/7f024ce2-ce5d-43af-a18e-8e10d390e32b",
// external: true,
// },
// ],
// meta: {
// auth: {
// role: ["Developer"],
// },
// },
// },
{
header: "Design & Build",
description: "Create and design your workflows and forms",

View File

@ -33,6 +33,7 @@ const showModalEditForm = ref({
name: "",
path: "",
guardType: "",
external: false,
});
// const showModalEditEl = ref(null);
@ -41,6 +42,7 @@ const showModalAddForm = ref({
title: "",
name: "",
path: "",
external: false,
});
const systemPages = [
@ -120,6 +122,7 @@ const openModalEdit = (menu) => {
showModalEditForm.value.path = menu.path;
}
showModalEditForm.value.external = menu.external === true;
showModalEditPath.value = menu.path;
showModalEdit.value = true;
@ -140,6 +143,33 @@ const saveEditMenu = async () => {
showModalEditForm.value.title = showModalEditForm.value.title.trim();
showModalEditForm.value.name = showModalEditForm.value.name.trim();
// Path validation and formatting
let path = showModalEditForm.value.path.trim();
if (showModalEditForm.value.external) {
// Validate as URL
if (!/^https?:\/\//.test(path)) {
nuxtApp.$swal.fire({
title: "Error",
text: "External URL must start with http:// or https://",
icon: "error",
});
return;
}
} else {
// Internal path validation
if (!/^[a-z0-9/-]+$/.test(path)) {
nuxtApp.$swal.fire({
title: "Error",
text: "Path contains invalid characters or spacing before or after. Only letters, numbers, dashes, and underscores are allowed.",
icon: "error",
});
return;
}
if (!path.startsWith("/")) {
path = "/" + path;
}
}
const res = await useFetch("/api/devtool/menu/edit", {
method: "POST",
initialCache: false,
@ -148,9 +178,9 @@ const saveEditMenu = async () => {
formData: {
title: showModalEditForm.value.title || "",
name: showModalEditForm.value.name || "",
path: "/" + showModalEditForm.value.path || "",
path: path || "",
external: showModalEditForm.value.external === true,
},
// formData: showModalEditForm.value,
}),
});
@ -175,6 +205,7 @@ const openModalAdd = () => {
showModalAddForm.value.title = "";
showModalAddForm.value.name = "";
showModalAddForm.value.path = "";
showModalAddForm.value.external = false;
showModalAdd.value = true;
};
@ -194,6 +225,33 @@ const saveAddMenu = async () => {
showModalAddForm.value.title = showModalAddForm.value.title.trim();
showModalAddForm.value.name = showModalAddForm.value.name.trim();
// Path validation and formatting
let path = showModalAddForm.value.path.trim();
if (showModalAddForm.value.external) {
// Validate as URL
if (!/^https?:\/\//.test(path)) {
nuxtApp.$swal.fire({
title: "Error",
text: "External URL must start with http:// or https://",
icon: "error",
});
return;
}
} else {
// Internal path validation
if (!/^[a-z0-9/-]+$/.test(path)) {
nuxtApp.$swal.fire({
title: "Error",
text: "Path contains invalid characters or spacing before or after. Only letters, numbers, dashes, and underscores are allowed.",
icon: "error",
});
return;
}
if (!path.startsWith("/")) {
path = "/" + path;
}
}
const res = await useFetch("/api/devtool/menu/add", {
method: "POST",
initialCache: false,
@ -201,9 +259,9 @@ const saveAddMenu = async () => {
formData: {
title: showModalAddForm.value.title || "",
name: showModalAddForm.value.name || "",
path: "/" + showModalAddForm.value.path || "",
path: path || "",
external: showModalAddForm.value.external === true,
},
// formData: showModalAddForm.value
}),
});
@ -691,8 +749,8 @@ watch(
}"
v-model="showModalEditForm.title"
/>
<FormKit
v-if="!showModalEditForm.external"
type="text"
label="Path"
help="If the last path name is '/', the name of the file will be from its name property. While if the last path name is not '/', the name of the file will be from its path property."
@ -712,6 +770,24 @@ watch(
</div>
</template>
</FormKit>
<FormKit
v-else
type="text"
label="External URL"
help="Enter a full URL (e.g., https://example.com)"
:validation="[['required'], ['matches', '/^https?:\/\//']]"
:validation-messages="{
required: 'URL is required',
matches: 'URL must start with http:// or https://',
}"
v-model="showModalEditForm.path"
/>
<FormKit
type="checkbox"
label="Is External URL?"
v-model="showModalEditForm.external"
help="Check if this menu item is an external URL."
/>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalEdit = false">
Cancel
@ -742,8 +818,8 @@ watch(
}"
v-model="showModalAddForm.title"
/>
<FormKit
v-if="!showModalAddForm.external"
type="text"
label="Path"
help="If the last path name is '/', the name of the file will be from its name property. While if the last path name is not '/', the name of the file will be from its path property."
@ -763,6 +839,24 @@ watch(
</div>
</template>
</FormKit>
<FormKit
v-else
type="text"
label="External URL"
help="Enter a full URL (e.g., https://example.com)"
:validation="[['required'], ['matches', '/^https?:\/\//']]"
:validation-messages="{
required: 'URL is required',
matches: 'URL must start with http:// or https://',
}"
v-model="showModalAddForm.path"
/>
<FormKit
type="checkbox"
label="Is External URL?"
v-model="showModalAddForm.external"
help="Check if this menu item is an external URL."
/>
<div class="flex justify-end gap-2">
<rs-button variant="outline" @click="showModalAdd = false">
Cancel

View File

@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch, onUnmounted } from 'vue';
import { useProcessBuilderStore } from '~/stores/processBuilder';
import { useRouter } from 'vue-router';
import { useToast } from '~/composables/useToast';
// Define page meta
definePageMeta({
@ -15,6 +16,7 @@ definePageMeta({
// Initialize the store and router
const processStore = useProcessBuilderStore();
const router = useRouter();
const toast = useToast();
// State
const searchQuery = ref('');
@ -334,6 +336,17 @@ onMounted(async () => {
onUnmounted(() => {
clearTimeout(searchTimeout);
});
// Copy workflow run link to clipboard
const copyWorkflowLink = async (processId) => {
try {
const link = `${window.location.origin}/workflow/${processId}`;
await navigator.clipboard.writeText(link);
toast.success('Run link copied to clipboard!');
} catch (err) {
toast.error('Failed to copy link');
}
};
</script>
<template>
@ -651,14 +664,15 @@ onUnmounted(() => {
</div>
<div class="flex items-center gap-2 ml-4">
<!-- Run Workflow Button -->
<!-- Copy Run Link Button (for published processes) -->
<button
@click="viewProcessWorkflow(process.id)"
v-if="process.status === 'published'"
@click="copyWorkflowLink(process.id)"
class="p-2 text-green-600 hover:text-green-800 hover:bg-green-50 rounded-lg transition-colors"
title="Run Workflow"
:disabled="loading || process.status !== 'published'"
title="Copy Run Link"
:disabled="loading"
>
<Icon name="material-symbols:play-arrow" class="text-lg" />
<Icon name="material-symbols:link" class="text-lg" />
</button>
<!-- Analytics Button -->

View File

@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import FormScriptEngine from '~/components/FormScriptEngine.vue';
import ConditionalLogicEngine from '~/components/ConditionalLogicEngine.vue';
@ -286,14 +286,33 @@ const handleFormSubmit = async () => {
// --- Utility: Substitute variables in a string ---
function substituteVariables(str, variables) {
if (typeof str !== 'string') return str;
return str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
// Replace {{variable}} first
str = str.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (match, varName) => {
const value = variables[varName];
if (value === undefined || value === null) return '';
if (
value === undefined ||
value === null ||
(typeof value === 'object' && value.name && value.type)
) return '';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
});
// Then replace {variable}
str = str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, varName) => {
const value = variables[varName];
if (
value === undefined ||
value === null ||
(typeof value === 'object' && value.name && value.type)
) return '';
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
});
return str;
}
// --- Variable Mapping Functions ---
@ -507,10 +526,19 @@ const executeCurrentStep = async () => {
}
}
} else if (currentNode.value?.type === 'script') {
console.log(`[Workflow] Executing script node: ${currentNode.value.data?.label || currentNode.value.label}`);
// Simulate script execution
await new Promise(resolve => setTimeout(resolve, 500));
const scriptCode = currentNode.value.data?.scriptCode;
if (scriptCode) {
try {
// Expose processVariables to the script
window.processVariables = processVariables.value;
// Run the script code with processVariables in scope
// eslint-disable-next-line no-new-func
new Function('processVariables', scriptCode)(processVariables.value);
} catch (err) {
console.error('[Workflow] Error executing script node:', err);
error.value = 'Script execution failed: ' + (err.message || err);
}
}
// Only auto-progress if there's a single outgoing edge
if (canAutoProgress(currentNode.value)) {
moveToNextStep();
@ -797,9 +825,10 @@ const totalSteps = computed(() => workflowPath.value.length);
// Computed: Interpolated HTML content for HTML nodes
const interpolatedHtmlContent = computed(() => {
if (currentNode.value?.type !== 'html') return '';
const htmlContent = currentNode.value?.data?.htmlCode || currentNode.value?.data?.htmlContent || '';
return interpolateHtmlContent(htmlContent);
// Interpolate variables in HTML
const interpolated = substituteVariables(htmlContent, processVariables.value);
return interpolateHtmlContent(interpolated);
});
// Computed: CSS styles for HTML nodes
@ -810,6 +839,8 @@ const htmlNodeStyles = computed(() => {
// CSS injection for HTML nodes
let currentStyleElement = null;
// JS injection for HTML nodes
let currentHtmlScriptElement = null;
// Function to inject CSS
const injectHtmlNodeCSS = (cssCode) => {
@ -818,27 +849,64 @@ const injectHtmlNodeCSS = (cssCode) => {
currentStyleElement.remove();
currentStyleElement = null;
}
// Interpolate variables in CSS
const processedCss = substituteVariables(cssCode, processVariables.value);
// Add new styles if available
if (cssCode && cssCode.trim()) {
if (processedCss && processedCss.trim()) {
currentStyleElement = document.createElement('style');
currentStyleElement.textContent = cssCode;
currentStyleElement.textContent = processedCss;
currentStyleElement.setAttribute('data-workflow-html-node', 'true');
document.head.appendChild(currentStyleElement);
}
};
// Function to inject JS
const injectHtmlNodeJS = (jsCode) => {
// Remove previous script if exists
if (currentHtmlScriptElement) {
currentHtmlScriptElement.remove();
currentHtmlScriptElement = null;
}
// Expose processVariables to global scope
window.processVariables = processVariables.value;
// Interpolate variables in JS code
const processedJsCode = substituteVariables(jsCode, processVariables.value);
if (processedJsCode && processedJsCode.trim()) {
const script = document.createElement('script');
script.type = 'text/javascript';
script.textContent = processedJsCode;
script.setAttribute('data-workflow-html-node', 'true');
document.body.appendChild(script);
currentHtmlScriptElement = script;
}
};
// Watch for HTML node CSS changes and inject styles
watch(htmlNodeStyles, (newStyles) => {
injectHtmlNodeCSS(newStyles);
}, { immediate: true });
// Cleanup styles on unmount
// Watch for HTML node JS changes and inject script
watch(
() => currentNode.value?.type === 'html' ? currentNode.value?.data?.jsCode : '',
(newJsCode) => {
nextTick(() => {
injectHtmlNodeJS(newJsCode);
});
},
{ immediate: true }
);
// Cleanup styles and JS on unmount
onUnmounted(() => {
if (currentStyleElement) {
currentStyleElement.remove();
currentStyleElement = null;
}
if (currentHtmlScriptElement) {
currentHtmlScriptElement.remove();
currentHtmlScriptElement = null;
}
});
// Helper: Get next node object for single-path nodes
@ -1189,7 +1257,7 @@ function getNodeLabel(nodeId) {
<template v-for="edge in workflowData.edges.filter(e => e.source === currentNode.id)" :key="edge.id">
<RsButton
@click="makeDecision(edge.target)"
variant="outline"
variant="outline-primary"
class="justify-start p-4 h-auto"
>
<div class="text-left">
@ -1235,8 +1303,8 @@ function getNodeLabel(nodeId) {
</RsButton>
</div>
<!-- Variable Mapping Debug (only in development) -->
<div v-if="currentNode.type === 'form'" class="bg-gray-100 rounded-lg p-4">
<!-- Variable Mapping Debug (always visible for any node) -->
<div v-if="currentNode" class="bg-gray-100 rounded-lg p-4">
<details>
<summary class="font-medium text-gray-700 cursor-pointer mb-2">Variable Mapping Debug</summary>
<div class="space-y-3 text-xs">
@ -1245,30 +1313,31 @@ function getNodeLabel(nodeId) {
<p class="font-medium text-gray-700">Input Mappings (Process Form):</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.inputMappings, null, 2) }}</pre>
</div>
<!-- Output Mappings -->
<div v-if="currentNode.data?.outputMappings?.length">
<p class="font-medium text-gray-700">Output Mappings (Form Process):</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.outputMappings, null, 2) }}</pre>
</div>
<!-- Field Conditions -->
<div v-if="currentNode.data?.fieldConditions?.length">
<p class="font-medium text-gray-700">Field Conditions:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data.fieldConditions, null, 2) }}</pre>
</div>
<!-- Current Field States -->
<div v-if="Object.keys(fieldStates).length">
<p class="font-medium text-gray-700">Active Field States:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(fieldStates, null, 2) }}</pre>
</div>
<!-- Form Data -->
<div v-if="Object.keys(formData).length">
<p class="font-medium text-gray-700">Current Form Data:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(formData, null, 2) }}</pre>
</div>
<!-- Node Data (for non-form nodes) -->
<div v-if="currentNode.data && !currentNode.data.inputMappings && !currentNode.data.outputMappings && !currentNode.data.fieldConditions">
<p class="font-medium text-gray-700">Node Data:</p>
<pre class="text-gray-600 bg-white p-2 rounded border overflow-auto">{{ JSON.stringify(currentNode.data, null, 2) }}</pre>
</div>
</div>
</details>
</div>

View File

@ -5,6 +5,14 @@ export default defineEventHandler(async (event) => {
const body = await readBody(event);
try {
// If external, skip file operations
if (body.formData.external === true) {
return {
statusCode: 200,
message: "External menu successfully added!",
};
}
// Check if last character is not slash
if (body.formData.path.slice(-1) != "/") {
body.formData.path = body.formData.path + "/";

View File

@ -16,6 +16,14 @@ export default defineEventHandler(async (event) => {
const oldFilePath = path.join(process.cwd(), "pages", oldPath, "index.vue");
const newFilePath = path.join(process.cwd(), "pages", newPath, "index.vue");
// If external, skip file operations
if (body.formData.external === true) {
return {
statusCode: 200,
message: "External menu successfully updated!",
};
}
try {
// Create template content
const templateContent = buildNuxtTemplate({