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:
+2
-2
@@ -10,8 +10,8 @@ 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-*
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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() };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user