feat(workflows): migrate legacy workflows and enhance example handling

- Migrated legacy workflow files from `packages/server/workflows/` to `packages/server/data/workflows/` when the new store is empty.
- Introduced new API endpoints to list and retrieve example workflows.
- Added a dialog component for selecting example workflows when creating new workflows.
- Updated workflow paths and configurations to reflect the new structure.
- Enhanced the README and AGENTS.md documentation to clarify workflow management and usage.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
This commit is contained in:
2026-08-22 18:09:22 +07:00
parent beb7e22652
commit 7456dece00
19 changed files with 504 additions and 15 deletions
+3 -3
View File
@@ -10,11 +10,11 @@ packages/web/dist
# Personal/local scripts and workflows (not for the repo) # Personal/local scripts and workflows (not for the repo)
debug-*.js debug-*.js
# Site-specific workflows (keep examples in workflows/default/) # Legacy live workflow tree (migrated to packages/server/data/workflows/)
packages/server/workflows/local/ packages/server/workflows/
# Plugin install staging and per-plugin deps # Plugin install staging and per-plugin deps
plugins/.staging-* plugins/.staging-*
plugins/*/node_modules/ plugins/*/node_modules/
.gitea/workflows/ .gitea/workflows/
+11
View File
@@ -2,6 +2,17 @@
This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only). This file tells agents how to add a **user plugin**. Do not put personal or site-specific scripts in `packages/server/scripts/` (core, read-only). Do not put them in `examples/plugins/` (shipped examples only).
## Workflows (instance data)
Live workflows are **not** product source. They live under `packages/server/data/workflows/<owner>/` (gitignored; override with `JFLOW_WORKFLOWS_DIR`).
| Kind | In git? | Path |
|---|---|---|
| Live / personal YAML | No | `packages/server/data/workflows/<owner>/` |
| Example presets | Yes | `examples/workflows/*.yaml` (copy into editor only; runner does not load them) |
Do **not** add personal YAML under `packages/server/`, `examples/workflows/`, or the legacy `packages/server/workflows/` tree. Prefer owner `local`. Example presets must use **core** scripts only (no `plugin/…` that requires install).
## Where things live ## Where things live
| Kind | YAML `script` | Editable | Path | | Kind | YAML `script` | Editable | Path |
+13
View File
@@ -45,6 +45,18 @@ The first account created becomes **admin**. Later accounts are created from Use
- Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`. - Shipped example source (install from UI/API): `examples/plugins/get-current-time` → runtime `plugin/get-current-time`.
- `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`. - `plugins/joplin-api` and `plugins/send-sms` are **personal/user plugins** appropriate for a fork — not shipped examples. See **AGENTS.md** for creating user plugins under `plugins/<id>/`.
## Workflows (instance data vs examples)
| Kind | Loaded by runner? | Location |
|---|---|---|
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
- Live YAML is **instance data**, same as SQLite and secrets — not product source. Prefer owner `local` for personal workflows.
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
```bash ```bash
# Smoke # Smoke
JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \ JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
@@ -114,6 +126,7 @@ Desired state is stored in `packages/server/data/control-state.json` (generation
| `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. | | `JFLOW_JWT_SECRET` | `jflow-dev-secret` (dev only) | **Required in production**. |
| `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. | | `JFLOW_SECRETS_KEY` | `jflow-dev-secrets-key` (dev only) | Master key for named secrets. **Required in production**. Changing it makes existing secrets unreadable. 64 hex chars are used as a raw AES-256 key; any other string is derived with scrypt. |
| `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. | | `JFLOW_DB_PATH` | `packages/server/data/jerapah-flow.db` | SQLite file. |
| `JFLOW_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). |
| `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. | | `REDIS_URL` | `redis://127.0.0.1:6379` | Redis for BullMQ workflow queue. **Required** — the server will not start if Redis is unreachable. |
| `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. | | `REDIS_PASS` | — | Optional Redis AUTH password (sent via ioredis `password`). Prefer this over embedding credentials in `REDIS_URL` so logs stay clean. |
| `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. | | `JFLOW_QUEUE_NAME` | `jerapah-workflows` | BullMQ queue name. |
@@ -0,0 +1,27 @@
name: Comic - monkeyuser to ntfy
description: |
Scrape the latest MonkeyUser comic and send it to ntfy (requires $VAR_ntfy_channel).
scripts:
- script: fetch-html.js
config:
url: https://www.monkeyuser.com/
outputVar: comic
selector: .comic img
jsonata: |
{"url": "https://www.monkeyuser.com" & [attributes.src][0], "title": [attributes.title][0]}
- script: jsonata.js
config:
expression: |
{
"title": data.comic.title,
"message": data.comic.title,
"attach": data.comic.url
}
- script: fetch-binary.js
- script: ntfy.js
config:
url: $VAR_ntfy_channel
triggers:
- type: HTTP
method: POST
path: /comic-monkeyuser
@@ -0,0 +1,22 @@
name: detect example.com changes
description: |
Fetch example.com, fingerprint the response, and report whether it changed
since the previous run. Uses detect-url-changes with a transform that builds
a human-readable message into ctx.data.message.
scripts:
- script: detect-url-changes.js
config:
url: https://example.com/
outputVar: message
transform: |
data.hasChanges
? "example.com changed (fingerprint " & data.fingerprint & ")"
: "example.com unchanged since " & data.fingerprintAt
triggers:
- type: HTTP
method: POST
path: /detect-example
- type: cron
schedule: "* * * * *"
onConsecutiveFailures: 3
enabled: false
+1 -1
View File
@@ -13,7 +13,7 @@
"start:control": "node control.js", "start:control": "node control.js",
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
"test:workflow-history": "node test/workflow-history-smoke.js", "test:workflow-history": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
"test:profiles": "node test/profiles-smoke.js", "test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js" "test:set-dry-run": "node test/set-dry-run-smoke.js"
}, },
+10 -1
View File
@@ -3,8 +3,12 @@ import { fileURLToPath } from "url";
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url)); export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.url));
export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts"); export const SCRIPTS_DIR = path.join(SERVER_ROOT, "scripts");
export const WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
export const DATA_DIR = path.join(SERVER_ROOT, "data"); export const DATA_DIR = path.join(SERVER_ROOT, "data");
/** Live instance workflows (not shipped in git). Override for tests. */
export const WORKFLOWS_DIR =
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows");
/** Pre-0.1 layout; used only for one-shot migrate into WORKFLOWS_DIR. */
export const LEGACY_WORKFLOWS_DIR = path.join(SERVER_ROOT, "workflows");
/** User plugins (repo-root /plugins, outside the pnpm workspace). */ /** User plugins (repo-root /plugins, outside the pnpm workspace). */
export const PLUGINS_DIR = export const PLUGINS_DIR =
process.env.JFLOW_PLUGINS_DIR ?? process.env.JFLOW_PLUGINS_DIR ??
@@ -14,5 +18,10 @@ export const EXAMPLE_PLUGINS_DIR = path.resolve(
SERVER_ROOT, SERVER_ROOT,
"../../examples/plugins", "../../examples/plugins",
); );
/** Example workflow YAML presets (not loaded by the runner). */
export const EXAMPLE_WORKFLOWS_DIR = path.resolve(
SERVER_ROOT,
"../../examples/workflows",
);
export const LOGS_DIR = path.join(SERVER_ROOT, "logs"); export const LOGS_DIR = path.join(SERVER_ROOT, "logs");
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist"); export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
+21
View File
@@ -45,6 +45,11 @@ import {
createWorkflowBackupBuffer, createWorkflowBackupBuffer,
restoreWorkflowBackup, restoreWorkflowBackup,
} from "../../workflow-backup.js"; } from "../../workflow-backup.js";
import {
listExampleWorkflows,
readExampleWorkflow,
assertExampleWorkflowId,
} from "../../workflow-examples.js";
/** /**
* Reload this process and notify other HTTP/worker processes via Redis. * Reload this process and notify other HTTP/worker processes via Redis.
@@ -246,6 +251,22 @@ export default function workflowsPluginFactory(registry) {
return { owners: fsStore.listOwners() }; return { owners: fsStore.listOwners() };
}); });
fastify.get("/workflow-examples", async () => {
return { examples: listExampleWorkflows() };
});
fastify.get("/workflow-examples/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
if (!assertExampleWorkflowId(id)) {
return reply.code(400).send({ error: "invalid example id" });
}
const example = readExampleWorkflow(id);
if (!example) {
return reply.code(404).send({ error: "example not found" });
}
return example;
});
fastify.get("/workflows/trash", async () => { fastify.get("/workflows/trash", async () => {
return { items: await listTrash() }; return { items: await listTrash() };
}); });
+7
View File
@@ -31,6 +31,7 @@ import {
getRedisUrlForLog, getRedisUrlForLog,
} from "./workflow-queue.js"; } from "./workflow-queue.js";
import { purgeExpiredTrash } from "./workflow-trash.js"; import { purgeExpiredTrash } from "./workflow-trash.js";
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
import { import {
getConfigGeneration, getConfigGeneration,
startHeartbeatLoop, startHeartbeatLoop,
@@ -128,6 +129,12 @@ export async function startApp(opts = {}) {
} }
}); });
try {
migrateLegacyWorkflowsIfNeeded();
} catch (err) {
log.warn({ err }, "legacy workflow migrate failed");
}
const registry = createRegistry(server, { const registry = createRegistry(server, {
queue: workflowQueue, queue: workflowQueue,
// Cron + HTTP triggers enqueue jobs; only the API process may own them. // Cron + HTTP triggers enqueue jobs; only the API process may own them.
+79
View File
@@ -0,0 +1,79 @@
import fs from "fs";
import path from "path";
import yaml from "yaml";
import { EXAMPLE_WORKFLOWS_DIR } from "./paths.js";
/**
* @param {string} id
* @returns {string | null} safe basename without extension, or null if invalid
*/
export function assertExampleWorkflowId(id) {
if (typeof id !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/i.test(id)) {
return null;
}
return id;
}
/**
* Absolute path to an example YAML, or null if missing/unsafe.
* @param {string} id
*/
export function exampleWorkflowPath(id) {
const safe = assertExampleWorkflowId(id);
if (!safe) return null;
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, `${safe}.yaml`);
const resolved = path.resolve(filePath);
if (
resolved !== EXAMPLE_WORKFLOWS_DIR &&
!resolved.startsWith(EXAMPLE_WORKFLOWS_DIR + path.sep)
) {
return null;
}
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
return null;
}
return resolved;
}
/**
* @returns {{ id: string, name: string, description: string }[]}
*/
export function listExampleWorkflows() {
if (!fs.existsSync(EXAMPLE_WORKFLOWS_DIR)) return [];
return fs
.readdirSync(EXAMPLE_WORKFLOWS_DIR)
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
.sort()
.map((f) => {
const id = f.replace(/\.ya?ml$/i, "");
const filePath = path.join(EXAMPLE_WORKFLOWS_DIR, f);
let name = id;
let description = "";
try {
const parsed = yaml.parse(fs.readFileSync(filePath, "utf8")) ?? {};
if (typeof parsed.name === "string" && parsed.name.trim()) {
name = parsed.name.trim();
}
if (parsed.description != null) {
description = String(parsed.description).trim();
}
} catch {
// keep id as name
}
return { id, name, description };
});
}
/**
* @param {string} id
* @returns {{ id: string, content: string } | null}
*/
export function readExampleWorkflow(id) {
const filePath = exampleWorkflowPath(id);
if (!filePath) return null;
const safe = assertExampleWorkflowId(id);
return {
id: /** @type {string} */ (safe),
content: fs.readFileSync(filePath, "utf8"),
};
}
+51
View File
@@ -0,0 +1,51 @@
import fs from "fs";
import path from "path";
import { LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR } from "./paths.js";
import { log } from "./logger.js";
/**
* Recursively copy a directory.
* @param {string} src
* @param {string} dest
*/
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const from = path.join(src, entry.name);
const to = path.join(dest, entry.name);
if (entry.isDirectory()) copyDir(from, to);
else fs.copyFileSync(from, to);
}
}
/**
* True when WORKFLOWS_DIR has no owner subdirectories.
* @param {string} dir
*/
function isEmptyWorkflowsDir(dir) {
if (!fs.existsSync(dir)) return true;
const entries = fs.readdirSync(dir, { withFileTypes: true });
return !entries.some((e) => e.isDirectory());
}
/**
* One-shot: copy packages/server/workflows → data/workflows when the new
* store is empty and the legacy tree still exists.
* Does not copy from examples/workflows.
*/
export function migrateLegacyWorkflowsIfNeeded() {
if (!isEmptyWorkflowsDir(WORKFLOWS_DIR)) return false;
if (!fs.existsSync(LEGACY_WORKFLOWS_DIR)) return false;
const legacyEntries = fs.readdirSync(LEGACY_WORKFLOWS_DIR, {
withFileTypes: true,
});
if (!legacyEntries.some((e) => e.isDirectory())) return false;
fs.mkdirSync(WORKFLOWS_DIR, { recursive: true });
copyDir(LEGACY_WORKFLOWS_DIR, WORKFLOWS_DIR);
log.info(
{ from: LEGACY_WORKFLOWS_DIR, to: WORKFLOWS_DIR },
"migrated legacy workflows into instance store",
);
return true;
}
@@ -3,3 +3,4 @@ scripts:
- detect-example-changes.yaml - detect-example-changes.yaml
- time-to-ntfy-example.yaml - time-to-ntfy-example.yaml
- comic-monkeyuser-to-ntfy.yaml - comic-monkeyuser-to-ntfy.yaml
- afb272d4-b217-49ac-8c8b-755a6d8dac4a.yaml
+7
View File
@@ -28,6 +28,13 @@ export function useOwners() {
}); });
} }
export function useWorkflowExamples() {
return useQuery({
queryKey: ["workflow-examples"],
queryFn: async () => (await api.get("/workflow-examples")).data.examples,
});
}
export function useSaveWorkflow() { export function useSaveWorkflow() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -0,0 +1,168 @@
import { useState } from "react";
import { api, errorMessage } from "../api/client.js";
import { useWorkflowExamples } from "../api/hooks.js";
export const SKIP_NEW_WORKFLOW_PRESET_KEY = "jflow.skipNewWorkflowPreset";
export function shouldSkipNewWorkflowPreset() {
if (typeof localStorage === "undefined") return false;
return localStorage.getItem(SKIP_NEW_WORKFLOW_PRESET_KEY) === "1";
}
export function setSkipNewWorkflowPreset(skip) {
if (typeof localStorage === "undefined") return;
if (skip) localStorage.setItem(SKIP_NEW_WORKFLOW_PRESET_KEY, "1");
else localStorage.removeItem(SKIP_NEW_WORKFLOW_PRESET_KEY);
}
/**
* @param {{
* onChoose: (content: string) => void,
* onCancel: () => void,
* }} props
*/
export function NewWorkflowPresetDialog({ onChoose, onCancel }) {
const { data: examples = [], isLoading, isError, error } = useWorkflowExamples();
const [mode, setMode] = useState(/** @type {"empty" | "example"} */ ("empty"));
const [exampleId, setExampleId] = useState("");
const [dontAsk, setDontAsk] = useState(false);
const [pending, setPending] = useState(false);
const [loadError, setLoadError] = useState(/** @type {string | null} */ (null));
async function onContinue(e) {
e.preventDefault();
setLoadError(null);
if (dontAsk) setSkipNewWorkflowPreset(true);
if (mode === "empty") {
onChoose(null);
return;
}
if (!exampleId) {
setLoadError("Select an example");
return;
}
setPending(true);
try {
const { data } = await api.get(
`/workflow-examples/${encodeURIComponent(exampleId)}`,
);
onChoose(typeof data?.content === "string" ? data.content : "");
} catch (err) {
setLoadError(errorMessage(err));
} finally {
setPending(false);
}
}
return (
<dialog className="modal modal-open">
<div className="modal-box max-w-lg">
<h3 className="font-bold text-lg">New workflow</h3>
<p className="mt-2 text-sm opacity-70">
Start empty, or copy a shipped example into the editor. Nothing is saved until you click
Save.
</p>
<form className="mt-4 space-y-4" onSubmit={onContinue}>
<fieldset className="space-y-2">
<label className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="preset"
className="radio radio-sm mt-1"
checked={mode === "empty"}
onChange={() => setMode("empty")}
/>
<span>
<span className="font-medium">Empty</span>
<span className="block text-sm opacity-70">Blank name, no scripts or triggers</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="preset"
className="radio radio-sm mt-1"
checked={mode === "example"}
onChange={() => {
setMode("example");
if (!exampleId && examples[0]) setExampleId(examples[0].id);
}}
/>
<span>
<span className="font-medium">Select example</span>
<span className="block text-sm opacity-70">
Core-script starters from examples/workflows
</span>
</span>
</label>
</fieldset>
{mode === "example" ? (
<div className="space-y-2 pl-8">
{isLoading ? <span className="loading loading-spinner loading-sm" /> : null}
{isError ? (
<p className="text-error text-sm">{errorMessage(error)}</p>
) : null}
{!isLoading && !isError && examples.length === 0 ? (
<p className="text-sm opacity-70">No examples available.</p>
) : null}
{examples.map((ex) => (
<label key={ex.id} className="flex cursor-pointer items-start gap-3">
<input
type="radio"
name="exampleId"
className="radio radio-sm mt-1"
checked={exampleId === ex.id}
onChange={() => setExampleId(ex.id)}
/>
<span>
<span className="font-medium">{ex.name}</span>
{ex.description ? (
<span className="block text-sm opacity-70 line-clamp-2">
{ex.description}
</span>
) : null}
</span>
</label>
))}
</div>
) : null}
<label className="label cursor-pointer justify-start gap-3 py-0">
<input
type="checkbox"
className="checkbox checkbox-sm"
checked={dontAsk}
onChange={(e) => setDontAsk(e.target.checked)}
/>
<span className="label-text">Don&apos;t ask this. Always create empty workflow.</span>
</label>
{loadError ? <p className="text-error text-sm">{loadError}</p> : null}
<div className="modal-action">
<button type="button" className="btn btn-ghost" onClick={onCancel} disabled={pending}>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={pending || (mode === "example" && !exampleId)}
>
{pending ? <span className="loading loading-spinner loading-xs" /> : null}
Continue
</button>
</div>
</form>
</div>
<form method="dialog" className="modal-backdrop">
<button type="button" onClick={onCancel}>
close
</button>
</form>
</dialog>
);
}
+2 -6
View File
@@ -10,12 +10,8 @@ const KNOWN_TOP = new Set(["name", "description", "enabled", "scripts", "trigger
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger }; export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger };
export const NEW_WORKFLOW_YAML = `name: new workflow export const NEW_WORKFLOW_YAML = `name: new workflow
scripts: scripts: []
- plugin/get-current-time triggers: []
triggers:
- type: HTTP
method: POST
path: /new
`; `;
let uidSeq = 0; let uidSeq = 0;
+32 -4
View File
@@ -11,6 +11,7 @@ import {
useWorkflowRevision, useWorkflowRevision,
} from "../api/hooks.js"; } from "../api/hooks.js";
import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx"; import { DuplicateWorkflowDialog } from "../components/DuplicateWorkflowDialog.jsx";
import { NewWorkflowPresetDialog, shouldSkipNewWorkflowPreset } from "../components/NewWorkflowPresetDialog.jsx";
import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx"; import { WorkflowFileIcon } from "../components/WorkflowFileIcon.jsx";
import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx"; import { WorkflowVisualEditor } from "../components/workflow/WorkflowVisualEditor.jsx";
import { WorkflowHistoryPanel } from "../components/workflow/WorkflowHistoryPanel.jsx"; import { WorkflowHistoryPanel } from "../components/workflow/WorkflowHistoryPanel.jsx";
@@ -100,15 +101,21 @@ export function WorkflowNewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useNotifications(); const { notify } = useNotifications();
const { data: owners = [] } = useOwners(); const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState(""); const [owner, setOwner] = useState("local");
const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset());
const [content, setContent] = useState(NEW_WORKFLOW_YAML); const [content, setContent] = useState(NEW_WORKFLOW_YAML);
const [savedYaml] = useState(NEW_WORKFLOW_YAML); const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML);
const [saveWarnings, setSaveWarnings] = useState(null); const [saveWarnings, setSaveWarnings] = useState(null);
const create = useCreateWorkflow(); const create = useCreateWorkflow();
useEffect(() => { useEffect(() => {
if (!owner && owners[0]) setOwner(owners[0]); // Prefer local when it exists; otherwise first owner. Do not clobber a typed value.
}, [owner, owners]); setOwner((prev) => {
if (prev && prev !== "local") return prev;
if (owners.includes("local") || owners.length === 0) return "local";
return owners[0];
});
}, [owners]);
function onSave(saveAnyway = false) { function onSave(saveAnyway = false) {
create.mutate( create.mutate(
@@ -132,6 +139,20 @@ export function WorkflowNewPage() {
); );
} }
if (!ready) {
return (
<NewWorkflowPresetDialog
onCancel={() => navigate("/workflows")}
onChoose={(yaml) => {
const initial = yaml == null || yaml === "" ? NEW_WORKFLOW_YAML : yaml;
setContent(initial);
setSavedYaml(initial);
setReady(true);
}}
/>
);
}
return ( return (
<> <>
<WorkflowEditorLayout <WorkflowEditorLayout
@@ -155,10 +176,17 @@ export function WorkflowNewPage() {
placeholder="owner" placeholder="owner"
value={owner} value={owner}
onChange={(e) => setOwner(e.target.value)} onChange={(e) => setOwner(e.target.value)}
list="new-workflow-owners"
required required
/> />
} }
/> />
<datalist id="new-workflow-owners">
{owners.includes("local") ? null : <option value="local" />}
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</div> </div>
</WorkflowEditorLayout> </WorkflowEditorLayout>
{saveWarnings ? ( {saveWarnings ? (
@@ -0,0 +1,8 @@
{
"id": "get-current-time",
"name": "Get current time",
"version": "0.1.0",
"jerapah": ">=0.1.0 <1.0.0",
"main": "script.js",
"description": "Example user plugin: return the current time as output.datetime"
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "jflow-plugin-get-current-time",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Example JerapahFlow plugin (no npm dependencies)"
}
+34
View File
@@ -0,0 +1,34 @@
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function getCurrentTime(ctx) {
const output = {
datetime: new Date().toISOString(),
processId: "1234",
};
return { output, context: { ...passContext(ctx), ...output } };
}
getCurrentTime.meta = {
description: "Return the current time as output.datetime",
config: {},
input: {},
output: {
datetime: { type: "string", description: "ISO timestamp" },
processId: { type: "string" },
},
context: {
datetime: { type: "string", description: "ISO timestamp" },
processId: { type: "string" },
},
example: {
data: {},
config: {},
},
};
export default getCurrentTime;