diff --git a/packages/server/scripts/fetch-http.js b/packages/server/scripts/fetch-http.js index cf0704c..76ea114 100644 --- a/packages/server/scripts/fetch-http.js +++ b/packages/server/scripts/fetch-http.js @@ -114,13 +114,14 @@ async function fetchHttp(ctx) { } fetchHttp.meta = { - description: "Fetch a URL and store the response body as-is (JSON stays an object)", + description: "Fetch a URL and store the response body.", config: { url: { type: "string", required: true, description: "Request URL" }, method: { type: "string", default: "GET", - description: "HTTP method (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS)", + enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + description: "HTTP method", }, headers: { type: "object", @@ -134,7 +135,12 @@ fetchHttp.meta = { }, }, input: { - method: { type: "string", required: false, description: "Fallback when config.method is omitted" }, + method: { + type: "string", + required: false, + enum: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + description: "Fallback when config.method is omitted", + }, headers: { type: "object", required: false, diff --git a/packages/server/workflows/default/registers.yaml b/packages/server/workflows/default/registers.yaml index a2bcc32..075139f 100644 --- a/packages/server/workflows/default/registers.yaml +++ b/packages/server/workflows/default/registers.yaml @@ -6,3 +6,6 @@ scripts: - comic-monkeyuser-to-ntfy.yaml - time-and-comic-to-ntfy.yaml - rss-selfhst-to-ntfy.yaml + - send-gmail.yaml + - test-send-gmail.yaml + - track.yaml diff --git a/packages/server/workflows/default/send-gmail.yaml b/packages/server/workflows/default/send-gmail.yaml new file mode 100644 index 0000000..0bddf4d --- /dev/null +++ b/packages/server/workflows/default/send-gmail.yaml @@ -0,0 +1,40 @@ +name: send-gmail +description: | + Send email via Gmail SMTP. Configure user/from and the named secret, + then call from other workflows with trigger-workflow.js (name: send-gmail). + + Input (data): + to optional recipient(s); defaults to your Gmail below + subject required + text plain body (aliases: body, message) + html optional HTML body + from, cc, bcc, replyTo, priority, headers optional +scripts: + - script: jsonata.js + config: + expression: | + { + "data": { + "to": $exists(to) ? to : "nasyarobby@gmail.com", + "from": from, + "subject": subject, + "text": $exists(text) ? text : ($exists(body) ? body : message), + "html": html, + "cc": cc, + "bcc": bcc, + "replyTo": replyTo, + "priority": priority, + "headers": headers + } + } + - script: send-email.js + config: + service: Gmail + user: elevent16th@gmail.com + from: elevent16th@gmail.com + passwordSecret: gmail_app_password +triggers: + - type: workflow + - type: HTTP + method: POST + path: /send-gmail diff --git a/packages/server/workflows/default/test-send-gmail.yaml b/packages/server/workflows/default/test-send-gmail.yaml new file mode 100644 index 0000000..4b5b699 --- /dev/null +++ b/packages/server/workflows/default/test-send-gmail.yaml @@ -0,0 +1,18 @@ +name: test-send-gmail +description: | + Kick send-gmail with sample data. Edit the payload below, then Run + (or POST /u/default/test-send-gmail). +scripts: + - script: trigger-workflow.js + config: + name: send-gmail + expression: | + { + "subject": "scrunner test", + "text": "Hello from test-send-gmail", + "html": "

Hello from test-send-gmail

" + } +triggers: + - type: HTTP + method: POST + path: /test-send-gmail diff --git a/packages/server/workflows/default/track.yaml b/packages/server/workflows/default/track.yaml new file mode 100644 index 0000000..c4a70f2 --- /dev/null +++ b/packages/server/workflows/default/track.yaml @@ -0,0 +1,32 @@ +name: Track Gojek Trip +scripts: + - script: fetch-http.js + config: + url: https://api.gojekapi.com/live/track/491c3132c2cc7043 + method: GET + headers: + Content-Type: application/json + Accept: application/json + Origin: https://track.gojek.com + - script: fingerprint.js + config: + key: gojek-track-status + jsonata: data.httpResponse.status + - script: jsonata.js + config: + expression: | + { + "data": { + "title": httpResponse.customer_name, + "message": httpResponse.customer_name & " : " & httpResponse.status + } + } + - script: ntfy.js + config: + url: https://n.0dev.web.id/system +triggers: + - type: cron + schedule: "* * * * *" + - type: HTTP + method: GET + path: /new diff --git a/packages/web/package.json b/packages/web/package.json index b22974e..0778ade 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,9 +9,13 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@tanstack/react-query": "^5.84.0", "axios": "^1.11.0", + "cronstrue": "^3.24.0", "mermaid": "^11.9.0", "react": "^19.1.1", "react-dom": "^19.1.1", diff --git a/packages/web/src/components/ScriptMetaPanel.jsx b/packages/web/src/components/ScriptMetaPanel.jsx index cb88bfa..670b48d 100644 --- a/packages/web/src/components/ScriptMetaPanel.jsx +++ b/packages/web/src/components/ScriptMetaPanel.jsx @@ -20,6 +20,11 @@ function FieldTable({ title, fields }) { const notes = [ field.required ? "required" : null, field.default !== undefined ? `default ${JSON.stringify(field.default)}` : null, + Array.isArray(field.enum) + ? `enum ${field.enum.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}` + : Array.isArray(field.options) + ? `enum ${field.options.map((x) => (x && typeof x === "object" ? x.value : x)).join(", ")}` + : null, field.description, ] .filter(Boolean) diff --git a/packages/web/src/components/workflow/AddScriptDialog.jsx b/packages/web/src/components/workflow/AddScriptDialog.jsx new file mode 100644 index 0000000..a8c2b3c --- /dev/null +++ b/packages/web/src/components/workflow/AddScriptDialog.jsx @@ -0,0 +1,99 @@ +import { useMemo, useState } from "react"; +import { useScripts } from "../../api/hooks.js"; + +export function AddScriptDialog({ open, onClose, onPick }) { + const { data: scripts = [], isLoading } = useScripts(); + const [q, setQ] = useState(""); + + const filtered = useMemo(() => { + const term = q.trim().toLowerCase(); + const list = scripts.map((s) => ({ + name: typeof s === "string" ? s : s.name, + description: typeof s === "string" ? "" : s.meta?.description ?? "", + meta: typeof s === "string" ? null : s.meta, + metaError: typeof s === "string" ? null : s.metaError, + })); + if (!term) return list; + return list.filter( + (s) => + s.name.toLowerCase().includes(term) || + s.description.toLowerCase().includes(term), + ); + }, [scripts, q]); + + if (!open) return null; + + return ( + +
+

Add script

+ setQ(e.target.value)} + autoFocus + /> + +
+ +
+
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/workflow/ConfigFields.jsx b/packages/web/src/components/workflow/ConfigFields.jsx new file mode 100644 index 0000000..2b846e1 --- /dev/null +++ b/packages/web/src/components/workflow/ConfigFields.jsx @@ -0,0 +1,441 @@ +import { useEffect, useState } from "react"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; +import { triggerDestinations } from "../../lib/workflow-doc.js"; +import { prettyJson } from "../../lib/script.js"; +import { FieldLabel } from "./FieldHelp.jsx"; + +const MULTILINE_KEYS = new Set(["expression", "jsonata"]); + +function fieldSpec(meta, key) { + const spec = meta?.config?.[key]; + if (spec && typeof spec === "object") return spec; + return {}; +} + +/** Support meta `enum` or `options`: string[], or { value, label }[]. */ +function enumOptions(spec) { + const raw = spec?.enum ?? spec?.options; + if (!Array.isArray(raw) || raw.length === 0) return null; + return raw + .map((item) => { + if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") { + return { value: item, label: String(item) }; + } + if (item && typeof item === "object" && item.value != null) { + return { value: item.value, label: item.label != null ? String(item.label) : String(item.value) }; + } + return null; + }) + .filter(Boolean); +} + +function EditableText({ value, onCommit, className = "", placeholder = "…", disabled }) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value ?? ""); + + useEffect(() => { + setDraft(value ?? ""); + }, [value]); + + if (!editing) { + return ( + + ); + } + + return ( + setDraft(e.target.value)} + onBlur={() => { + onCommit(draft); + setEditing(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") { + setDraft(value ?? ""); + setEditing(false); + } + }} + /> + ); +} + +function ValueEditor({ value, onChange, spec, script, fieldKey, workflows, owner, excludeFile, disabled }) { + const type = spec?.type ?? (value != null && typeof value === "object" ? "object" : "string"); + + if (script === "trigger-workflow.js" && fieldKey === "name") { + return ( + + ); + } + + const options = enumOptions(spec); + if (options) { + const fallback = spec?.default ?? (spec?.required ? options[0].value : ""); + const current = value === undefined || value === null ? fallback : value; + const known = options.some((o) => o.value === current); + return ( + + ); + } + + if (type === "boolean") { + return ( + onChange(e.target.checked)} + /> + ); + } + + if (type === "number") { + return ( + { + const raw = e.target.value; + if (raw === "") { + onChange(undefined); + return; + } + const n = Number(raw); + onChange(Number.isNaN(n) ? raw : n); + }} + /> + ); + } + + if (type === "object" || (value != null && typeof value === "object" && !Array.isArray(value) && type !== "any")) { + const obj = value && typeof value === "object" && !Array.isArray(value) ? value : {}; + return ; + } + + if (type === "any") { + const text = + typeof value === "string" ? value : value === undefined ? "" : prettyJson(value) || ""; + return ( + { + if (next.trim() === "") { + onChange(undefined); + return; + } + try { + onChange(JSON.parse(next)); + } catch { + onChange(next); + } + }} + disabled={disabled} + /> + ); + } + + const str = value == null ? "" : String(value); + const multiline = MULTILINE_KEYS.has(fieldKey) || str.includes("\n"); + if (multiline) { + return ( +