Add expression autocomplete, side-panel editor, and richer HTTP fetch
Automations editor:
- {{ }} expression autocomplete in CodeMirror, scoped to the braces and
graph-aware (suggests only what upstream nodes provide + variables + now);
migrate the Generate prompt to CodeMirror so it shares the same completions
- Expandable editors: an expand button slides out a side-by-side panel
(matching the sidebar card), with a minimize control; the inline field
collapses to a hint while editing in the panel
- Hover-revealed editor toolbar (expand/copy) with styled tooltips so the
buttons no longer obscure the text while reading
- Beta badge on the Automations sidebar item
- Delete a single connection with Backspace/Delete (edge selection)
- Re-key node config so switching between same-type nodes refreshes the form
HTTP fetch node — cover every JSON response shape:
- Top-level array, object map (items_path=*), array of primitives, and NDJSON
- Key-based dedup via item_key_path (seen-set, FIFO-capped) for feeds without
dates; first poll records a baseline and emits nothing (date path too)
Fan-out test visibility:
- root_run_id links every forked branch back to the run that started a test,
so the test panel aggregates all branches instead of one
Fix a few pre-existing type issues (ScheduleData import, padded minute,
optional created_at).
This commit is contained in:
parent
9a692b4608
commit
448ae73389
34 changed files with 1450 additions and 218 deletions
|
|
@ -4,9 +4,9 @@
|
|||
|
||||
namespace App\Actions\Automation\Node;
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\AutomationNodeState;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
|
|
@ -38,6 +38,7 @@ class RunFetchRssNode
|
|||
public function __construct(
|
||||
private ExpressionResolver $resolver,
|
||||
private SafeHttpFetcher $safeHttp,
|
||||
private AdvanceAutomationRun $advance,
|
||||
) {}
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
|
|
@ -76,18 +77,20 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
}
|
||||
|
||||
$nodeId = (string) $run->current_node_id;
|
||||
// Dry runs bypass the watermark entirely: they neither load nor advance
|
||||
// the persisted state, AND they use an epoch watermark so every item in
|
||||
// the feed is treated as new — otherwise a "now" default would silently
|
||||
// return zero items on any feed that hasn't published in the last second.
|
||||
$state = $run->is_dry_run ? null : AutomationNodeState::for($run->automation_id, $nodeId);
|
||||
$watermark = $run->is_dry_run
|
||||
// Test runs (dry OR real-data) bypass the watermark entirely: they use an
|
||||
// epoch watermark so every item is treated as new, process only the first
|
||||
// item, and never spawn siblings or advance the watermark. This way a
|
||||
// test ALWAYS shows real data flowing through (instead of "no new items")
|
||||
// and never floods the feed or poisons the production watermark.
|
||||
$isPreview = $run->is_manual || $run->is_dry_run;
|
||||
$state = $isPreview ? null : AutomationNodeState::for($run->automation_id, $nodeId);
|
||||
$watermark = $isPreview
|
||||
? CarbonImmutable::createFromTimestamp(0)
|
||||
: $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
||||
|
||||
[$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark);
|
||||
|
||||
if ($state !== null && $newestSeen !== null && ! $run->is_manual) {
|
||||
if ($state !== null && $newestSeen !== null) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
'last_item_date' => $newestSeen->toIso8601String(),
|
||||
])]);
|
||||
|
|
@ -99,12 +102,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
|
||||
$first = array_shift($newItems);
|
||||
|
||||
if (! $run->is_dry_run) {
|
||||
if (! $isPreview) {
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
}
|
||||
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)],
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $isPreview ? 0 : count($newItems)],
|
||||
'fetched' => $first,
|
||||
]);
|
||||
}
|
||||
|
|
@ -162,35 +165,33 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
|
|||
return;
|
||||
}
|
||||
|
||||
$nextNodeId = $this->findNextNodeId($parent, $fetchNodeId);
|
||||
// Each remaining item gets its own run that fans out across EVERY branch
|
||||
// wired to the fetch node — matching how item[0] (the current run) fans
|
||||
// out, so no branch silently drops items 2..N.
|
||||
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$sibling = AutomationRun::create([
|
||||
'automation_id' => $parent->automation_id,
|
||||
'root_run_id' => $parent->rootId(),
|
||||
'trigger_item_id' => $parent->trigger_item_id,
|
||||
'generated_post_id' => $parent->generated_post_id,
|
||||
'is_manual' => $parent->is_manual,
|
||||
'is_dry_run' => $parent->is_dry_run,
|
||||
'status' => RunStatus::Pending,
|
||||
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
|
||||
]);
|
||||
|
||||
if ($nextNodeId === null) {
|
||||
if ($targets === []) {
|
||||
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($sibling, $nextNodeId);
|
||||
$this->advance->dispatchBranches($sibling, $targets);
|
||||
}
|
||||
}
|
||||
|
||||
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
|
||||
{
|
||||
$connection = collect($run->automation->connections ?? [])
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
}
|
||||
|
||||
private function parseWatermark(?string $stored): CarbonImmutable
|
||||
{
|
||||
if ($stored === null) {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,16 @@
|
|||
|
||||
namespace App\Actions\Automation\Node;
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\AutomationNodeState;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Brand\SafeHttpFetcher;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
|
@ -22,13 +23,22 @@
|
|||
* Generalized HTTP node — supersedes the old `fetch_json` polling-only node.
|
||||
*
|
||||
* Two execution modes share one config:
|
||||
* - **Single request mode** (`items_path` empty): one HTTP call, the parsed
|
||||
* response is set on `context.fetched` as a single payload. Useful for
|
||||
* enrichment ("look up user X before generating") or webhook-style fan-out.
|
||||
* - **Polling mode** (`items_path` set): same as the old fetch — extract an
|
||||
* items array, filter by watermark (when `item_date_path` is provided),
|
||||
* process the oldest unseen item in the current run and spawn siblings for
|
||||
* the remainder. Each item ends up driving its own downstream branch.
|
||||
* - **Single request mode**: the response is a single object (or `items_path`
|
||||
* resolves to nothing), so the parsed body is set on `context.fetched` as a
|
||||
* single payload. Useful for enrichment ("look up user X before generating").
|
||||
* - **List mode**: the response is a collection, so each item drives its own
|
||||
* downstream branch. The collection is found by, in order:
|
||||
* 1. `items_path` (dot notation, e.g. `data.items`; `*` iterates a top-level
|
||||
* object map's values),
|
||||
* 2. an empty `items_path` + a top-level JSON array,
|
||||
* 3. an empty `items_path` + an NDJSON (one JSON object per line) body.
|
||||
* The current run takes the first new item; the rest spawn sibling runs.
|
||||
*
|
||||
* New items are detected (so a feed isn't reprocessed every poll) by, in order:
|
||||
* - `item_date_path` — a per-node date watermark (cheapest, bounded), or
|
||||
* - `item_key_path` — a per-node set of seen keys (covers ids/uuids/urls).
|
||||
* Either way the FIRST poll records the baseline and emits nothing, so pointing
|
||||
* the node at an existing feed never floods on day one.
|
||||
*
|
||||
* Auth types: none, bearer, basic, api_key. Credentials are stored encrypted
|
||||
* on the Automation model (see `Automation::booted()`) and decrypted here.
|
||||
|
|
@ -37,18 +47,22 @@ class RunHttpRequestNode
|
|||
{
|
||||
private const ITEM_HANDLE = 'default';
|
||||
|
||||
/**
|
||||
* Upper bound on the per-node seen-key history. FIFO-evicts the oldest keys
|
||||
* once exceeded — matching n8n's capped "history size" for its dedup store.
|
||||
*/
|
||||
private const MAX_SEEN_KEYS = 500;
|
||||
|
||||
public function __construct(
|
||||
private ExpressionResolver $resolver,
|
||||
private SafeHttpFetcher $safeHttp,
|
||||
private AdvanceAutomationRun $advance,
|
||||
) {}
|
||||
|
||||
public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
||||
{
|
||||
$url = (string) data_get($config, 'url', '');
|
||||
$method = strtoupper((string) data_get($config, 'method', 'GET'));
|
||||
$itemsPath = data_get($config, 'items_path');
|
||||
$itemKeyPath = data_get($config, 'item_key_path');
|
||||
$itemDatePath = data_get($config, 'item_date_path');
|
||||
$nodeId = (string) $run->current_node_id;
|
||||
$context = $run->resolverContext();
|
||||
|
||||
|
|
@ -68,15 +82,15 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
}
|
||||
|
||||
$request = $this->buildRequest($config, $context);
|
||||
$body = $this->buildJsonBody($method, $config, $context);
|
||||
$jsonBody = $this->buildJsonBody($method, $config, $context);
|
||||
|
||||
try {
|
||||
$response = match ($method) {
|
||||
'GET' => $request->get($resolvedUrl),
|
||||
'DELETE' => $request->delete($resolvedUrl),
|
||||
'POST' => $request->post($resolvedUrl, $body),
|
||||
'PUT' => $request->put($resolvedUrl, $body),
|
||||
'PATCH' => $request->patch($resolvedUrl, $body),
|
||||
'POST' => $request->post($resolvedUrl, $jsonBody),
|
||||
'PUT' => $request->put($resolvedUrl, $jsonBody),
|
||||
'PATCH' => $request->patch($resolvedUrl, $jsonBody),
|
||||
default => null,
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
|
|
@ -94,83 +108,218 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
]);
|
||||
}
|
||||
|
||||
$body = $response->json();
|
||||
$useItems = is_string($itemsPath) && $itemsPath !== '';
|
||||
$payload = $this->decodeBody($response);
|
||||
$itemsPath = is_string($raw = data_get($config, 'items_path')) ? trim($raw) : '';
|
||||
|
||||
if (! $useItems) {
|
||||
// Single-response mode: pass the whole body forward as `fetched`.
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => 1, 'spawned' => 0],
|
||||
'fetched' => $body,
|
||||
]);
|
||||
// No path + a top-level array (or NDJSON list) → iterate it. No path + a
|
||||
// single object/scalar → single-response mode: forward the whole body.
|
||||
if ($itemsPath === '') {
|
||||
if (! is_array($payload) || ! array_is_list($payload)) {
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => 1, 'spawned' => 0],
|
||||
'fetched' => $payload,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->processItems($run, $nodeId, $config, $payload);
|
||||
}
|
||||
|
||||
$rawItems = data_get($body, $itemsPath, []);
|
||||
if (! is_array($rawItems)) {
|
||||
// Explicit path: dot notation, or `*` to iterate a top-level object map.
|
||||
$resolved = data_get($payload, $itemsPath);
|
||||
|
||||
if (! is_array($resolved)) {
|
||||
return NodeRunResult::failed('Items path did not resolve to an array.');
|
||||
}
|
||||
|
||||
// Dry runs bypass the watermark entirely: they neither load nor advance
|
||||
// the persisted state, AND they process every item so the user actually
|
||||
// sees data flow (with a "now" watermark, fresh tests would yield 0
|
||||
// items on any feed older than today and look broken).
|
||||
$useWatermark = is_string($itemDatePath) && $itemDatePath !== '' && ! $run->is_dry_run;
|
||||
$state = $useWatermark ? AutomationNodeState::for($run->automation_id, $nodeId) : null;
|
||||
$watermark = $useWatermark
|
||||
? $this->parseWatermark(data_get($state->data, 'last_item_date'))
|
||||
: null;
|
||||
$newestSeen = null;
|
||||
return $this->processItems($run, $nodeId, $config, array_values($resolved));
|
||||
}
|
||||
|
||||
$newItems = [];
|
||||
/**
|
||||
* Decodes the response as JSON, falling back to NDJSON (one JSON value per
|
||||
* line) so streaming/log-style list endpoints work too.
|
||||
*/
|
||||
private function decodeBody(Response $response): mixed
|
||||
{
|
||||
$json = $response->json();
|
||||
|
||||
foreach ($rawItems as $item) {
|
||||
if (! is_array($item)) {
|
||||
if (is_array($json)) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return $this->parseNdjson($response->body()) ?? $json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>|null The decoded list, or null when the body is
|
||||
* not newline-delimited JSON.
|
||||
*/
|
||||
private function parseNdjson(string $body): ?array
|
||||
{
|
||||
$lines = preg_split('/\r\n|\r|\n/', trim($body)) ?: [];
|
||||
$items = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($useWatermark) {
|
||||
$itemDate = $this->parseDate(data_get($item, $itemDatePath));
|
||||
if ($itemDate === null) {
|
||||
continue;
|
||||
}
|
||||
if ($newestSeen === null || $itemDate->greaterThan($newestSeen)) {
|
||||
$newestSeen = $itemDate;
|
||||
}
|
||||
if (! $itemDate->greaterThan($watermark)) {
|
||||
continue;
|
||||
}
|
||||
$decoded = json_decode($line, true);
|
||||
if ($decoded === null && $line !== 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = $itemKeyPath ? data_get($item, $itemKeyPath) : null;
|
||||
$key = ($key === null || $key === '') ? hash('sha256', json_encode($item)) : (string) $key;
|
||||
|
||||
$newItems[] = array_merge($item, ['_key' => $key]);
|
||||
$items[] = $decoded;
|
||||
}
|
||||
|
||||
if ($useWatermark && $newestSeen !== null && $state !== null && ! $run->is_manual) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
'last_item_date' => $newestSeen->toIso8601String(),
|
||||
])]);
|
||||
return count($items) > 1 ? $items : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<int, mixed> $items
|
||||
*/
|
||||
private function processItems(AutomationRun $run, string $nodeId, array $config, array $items): NodeRunResult
|
||||
{
|
||||
$itemKeyPath = is_string($k = data_get($config, 'item_key_path')) ? trim($k) : '';
|
||||
$itemDatePath = is_string($d = data_get($config, 'item_date_path')) ? trim($d) : '';
|
||||
|
||||
// Test runs (dry OR real-data) walk a single item so the user sees data
|
||||
// flow, without spawning siblings, advancing watermarks or recording keys.
|
||||
if ($run->is_manual || $run->is_dry_run) {
|
||||
if ($items === []) {
|
||||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items');
|
||||
}
|
||||
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => count($items), 'spawned' => 0],
|
||||
'fetched' => $items[0],
|
||||
]);
|
||||
}
|
||||
|
||||
// A date watermark is cheapest and bounded, so it wins when both are set;
|
||||
// the seen-key set is the fallback for feeds without a usable date.
|
||||
$newItems = match (true) {
|
||||
$itemDatePath !== '' => $this->filterByDate($run->automation_id, $nodeId, $items, $itemDatePath),
|
||||
$itemKeyPath !== '' => $this->filterBySeenKeys($run->automation_id, $nodeId, $items, $itemKeyPath),
|
||||
default => $items,
|
||||
};
|
||||
|
||||
if ($newItems === []) {
|
||||
return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items');
|
||||
}
|
||||
|
||||
$first = array_shift($newItems);
|
||||
|
||||
// Dry runs skip sibling spawning so the test stays a single in-memory
|
||||
// walk through one item — see TestAutomation's dry-run contract.
|
||||
if (! $run->is_dry_run) {
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
}
|
||||
$this->spawnSiblings($run, $nodeId, $newItems);
|
||||
|
||||
return NodeRunResult::completed([
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)],
|
||||
'fetch' => ['count' => count($newItems) + 1, 'spawned' => count($newItems)],
|
||||
'fetched' => $first,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only items newer than the per-node date watermark, then advances the
|
||||
* watermark to the newest date seen. The first poll records the baseline and
|
||||
* emits nothing, so an existing feed never floods on day one.
|
||||
*
|
||||
* @param array<int, mixed> $items
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function filterByDate(string $automationId, string $nodeId, array $items, string $datePath): array
|
||||
{
|
||||
$state = AutomationNodeState::for($automationId, $nodeId);
|
||||
$isFirstPoll = ! array_key_exists('last_item_date', (array) $state->data);
|
||||
$watermark = $this->parseWatermark(data_get($state->data, 'last_item_date'));
|
||||
$newest = null;
|
||||
$new = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$date = $this->parseDate(data_get($item, $datePath));
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($newest === null || $date->greaterThan($newest)) {
|
||||
$newest = $date;
|
||||
}
|
||||
|
||||
// The first poll only records the baseline (the newest item's date),
|
||||
// emitting nothing — so an existing feed never floods on day one, even
|
||||
// if some items are dated slightly ahead of the server clock.
|
||||
if (! $isFirstPoll && $date->greaterThan($watermark)) {
|
||||
$new[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if ($newest !== null) {
|
||||
$state->update(['data' => array_merge($state->data ?? [], [
|
||||
'last_item_date' => $newest->toIso8601String(),
|
||||
])]);
|
||||
}
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only items whose key hasn't been seen before, recording the new keys
|
||||
* in a FIFO-capped per-node set. The first poll records every key but emits
|
||||
* nothing (baseline), matching the date-watermark semantics so pointing the
|
||||
* node at an existing feed never floods on day one.
|
||||
*
|
||||
* @param array<int, mixed> $items
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function filterBySeenKeys(string $automationId, string $nodeId, array $items, string $keyPath): array
|
||||
{
|
||||
$state = AutomationNodeState::for($automationId, $nodeId);
|
||||
$isFirstPoll = ! array_key_exists('seen_keys', (array) $state->data);
|
||||
$hashes = array_values((array) data_get($state->data, 'seen_keys', []));
|
||||
$seen = array_flip($hashes);
|
||||
$new = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
$hash = $this->keyHash($item, $keyPath);
|
||||
if (isset($seen[$hash])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$hash] = true;
|
||||
$hashes[] = $hash;
|
||||
|
||||
if (! $isFirstPoll) {
|
||||
$new[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($hashes) > self::MAX_SEEN_KEYS) {
|
||||
$hashes = array_slice($hashes, count($hashes) - self::MAX_SEEN_KEYS);
|
||||
}
|
||||
|
||||
$state->update(['data' => array_merge((array) $state->data, ['seen_keys' => $hashes])]);
|
||||
|
||||
return $new;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable hash for an item's dedup key. Objects use `item_key_path` (falling
|
||||
* back to the whole item); scalars use their own value. Hashing keeps the
|
||||
* stored set compact and avoids persisting raw payloads.
|
||||
*/
|
||||
private function keyHash(mixed $item, string $keyPath): string
|
||||
{
|
||||
if (is_array($item)) {
|
||||
$key = $keyPath !== '' ? data_get($item, $keyPath) : null;
|
||||
// Fall back to the whole item when no usable key is present; serialize
|
||||
// guards against json_encode returning false on malformed UTF-8.
|
||||
$key = ($key === null || $key === '') ? (json_encode($item) ?: serialize($item)) : (string) $key;
|
||||
} else {
|
||||
$key = (string) $item;
|
||||
}
|
||||
|
||||
return md5($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @param array<string, mixed> $context
|
||||
|
|
@ -230,14 +379,19 @@ private function buildJsonBody(string $method, array $config, array $context): a
|
|||
return [];
|
||||
}
|
||||
|
||||
$rendered = $this->resolver->resolve($template, $context);
|
||||
$decoded = json_decode($rendered, true);
|
||||
// Parse the JSON body template first, then resolve placeholders in its
|
||||
// string leaves so data containing quotes/newlines can't corrupt it.
|
||||
$decodedTemplate = json_decode($template, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
if (! is_array($decodedTemplate)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->resolver->resolveStructured($decodedTemplate, $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @param array<int, mixed> $items
|
||||
*/
|
||||
private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void
|
||||
{
|
||||
|
|
@ -245,35 +399,32 @@ private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array
|
|||
return;
|
||||
}
|
||||
|
||||
$nextNodeId = $this->findNextNodeId($parent, $fetchNodeId);
|
||||
// Each remaining item gets its own run that fans out across EVERY branch
|
||||
// wired to this node — matching how item[0] (the current run) fans out.
|
||||
$targets = $this->advance->targetsFor($parent->automation, $fetchNodeId, self::ITEM_HANDLE);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$sibling = AutomationRun::create([
|
||||
'automation_id' => $parent->automation_id,
|
||||
'root_run_id' => $parent->rootId(),
|
||||
'trigger_item_id' => $parent->trigger_item_id,
|
||||
'generated_post_id' => $parent->generated_post_id,
|
||||
'is_manual' => $parent->is_manual,
|
||||
'is_dry_run' => $parent->is_dry_run,
|
||||
'status' => RunStatus::Pending,
|
||||
'context' => array_merge($parent->context ?? [], ['fetched' => $item]),
|
||||
]);
|
||||
|
||||
if ($nextNodeId === null) {
|
||||
if ($targets === []) {
|
||||
$sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($sibling, $nextNodeId);
|
||||
$this->advance->dispatchBranches($sibling, $targets);
|
||||
}
|
||||
}
|
||||
|
||||
private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string
|
||||
{
|
||||
$connection = collect($run->automation->connections ?? [])
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
}
|
||||
|
||||
private function decrypt(string $value): string
|
||||
{
|
||||
if ($value === '') {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,16 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
$headers[$k] = $this->resolver->resolve((string) $v, $context);
|
||||
}
|
||||
|
||||
$payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $context);
|
||||
$trimmedPayload = trim($payloadJson);
|
||||
// Parse the template as JSON FIRST, then resolve placeholders in its
|
||||
// string leaves — so a value containing `"`/`&`/newlines can't corrupt
|
||||
// the JSON (the final json_encode escapes it).
|
||||
$template = $config['payload_template'] ?? '{}';
|
||||
$trimmedTemplate = trim($template);
|
||||
|
||||
if ($trimmedPayload !== '' && $trimmedPayload !== 'null') {
|
||||
$decoded = json_decode($payloadJson, true);
|
||||
if ($trimmedTemplate === '' || $trimmedTemplate === 'null') {
|
||||
$payload = [];
|
||||
} else {
|
||||
$decodedTemplate = json_decode($template, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [
|
||||
|
|
@ -50,9 +55,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
]);
|
||||
}
|
||||
|
||||
$payload = $decoded ?? [];
|
||||
} else {
|
||||
$payload = [];
|
||||
$payload = $this->resolver->resolveStructured($decodedTemplate ?? [], $context);
|
||||
}
|
||||
|
||||
if ($run->is_dry_run) {
|
||||
|
|
|
|||
|
|
@ -6,18 +6,16 @@
|
|||
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
|
||||
class AdvanceAutomationRun
|
||||
{
|
||||
public function __invoke(AutomationRun $run, string $fromNodeId, string $handle = 'default'): void
|
||||
{
|
||||
$automation = $run->automation;
|
||||
$targets = $this->targetsFor($run->automation, $fromNodeId, $handle);
|
||||
|
||||
$connection = collect($automation->connections ?? [])
|
||||
->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle);
|
||||
|
||||
if ($connection === null) {
|
||||
if ($targets === []) {
|
||||
$run->update([
|
||||
'status' => Status::Completed,
|
||||
'finished_at' => now(),
|
||||
|
|
@ -32,6 +30,50 @@ public function __invoke(AutomationRun $run, string $fromNodeId, string $handle
|
|||
return;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($run, $connection['target']);
|
||||
$this->dispatchBranches($run, $targets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every node id connected to `$fromNodeId` via the given handle. A node can
|
||||
* fan out to several targets (e.g. a trigger calling RSS and HTTP at once).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function targetsFor(Automation $automation, string $fromNodeId, string $handle = 'default'): array
|
||||
{
|
||||
return collect($automation->connections ?? [])
|
||||
->filter(fn ($c) => ($c['source'] ?? null) === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle)
|
||||
->pluck('target')
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Continues the run on the first branch and forks a sibling run — sharing the
|
||||
* accumulated context — for every additional branch, so all targets execute.
|
||||
*
|
||||
* @param array<int, string> $targets
|
||||
*/
|
||||
public function dispatchBranches(AutomationRun $run, array $targets): void
|
||||
{
|
||||
$first = array_shift($targets);
|
||||
|
||||
foreach ($targets as $target) {
|
||||
$sibling = AutomationRun::create([
|
||||
'automation_id' => $run->automation_id,
|
||||
'root_run_id' => $run->rootId(),
|
||||
'trigger_item_id' => $run->trigger_item_id,
|
||||
'generated_post_id' => $run->generated_post_id,
|
||||
'is_manual' => $run->is_manual,
|
||||
'is_dry_run' => $run->is_dry_run,
|
||||
'status' => Status::Pending,
|
||||
'context' => $run->context,
|
||||
]);
|
||||
|
||||
ProcessAutomationNode::dispatch($sibling, $target);
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($run, $first);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@
|
|||
namespace App\Actions\Automation\Run;
|
||||
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\AutomationTriggerItem;
|
||||
|
||||
class DispatchAutomationRun
|
||||
{
|
||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
||||
|
||||
public function __invoke(Automation $automation, AutomationTriggerItem $triggerItem): AutomationRun
|
||||
{
|
||||
$firstNodeId = $this->findFirstRealNodeId($automation);
|
||||
$targets = $this->triggerTargets($automation);
|
||||
|
||||
$run = AutomationRun::create([
|
||||
'automation_id' => $automation->id,
|
||||
|
|
@ -23,7 +24,7 @@ public function __invoke(Automation $automation, AutomationTriggerItem $triggerI
|
|||
'context' => ['trigger' => $triggerItem->payload],
|
||||
]);
|
||||
|
||||
if ($firstNodeId === null) {
|
||||
if ($targets === []) {
|
||||
$run->update([
|
||||
'status' => Status::Failed,
|
||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
||||
|
|
@ -33,22 +34,22 @@ public function __invoke(Automation $automation, AutomationTriggerItem $triggerI
|
|||
return $run;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($run, $firstNodeId);
|
||||
$this->advance->dispatchBranches($run, $targets);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
private function findFirstRealNodeId(Automation $automation): ?string
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function triggerTargets(Automation $automation): array
|
||||
{
|
||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
||||
|
||||
if ($triggerNode === null) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
$connection = collect($automation->connections ?? [])
|
||||
->firstWhere('source', $triggerNode['id']);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
return $this->advance->targetsFor($automation, $triggerNode['id']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
use App\Enums\Automation\Run\Status;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Post;
|
||||
|
|
@ -26,12 +25,16 @@
|
|||
*/
|
||||
class TestAutomation
|
||||
{
|
||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
||||
|
||||
public function __invoke(Automation $automation, bool $withRealData = false): AutomationRun
|
||||
{
|
||||
$triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger');
|
||||
$context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])];
|
||||
|
||||
$firstNodeId = $this->findFirstRealNodeId($automation, $triggerNode);
|
||||
$targets = $triggerNode !== null
|
||||
? $this->advance->targetsFor($automation, $triggerNode['id'])
|
||||
: [];
|
||||
|
||||
$run = AutomationRun::create([
|
||||
'automation_id' => $automation->id,
|
||||
|
|
@ -41,7 +44,7 @@ public function __invoke(Automation $automation, bool $withRealData = false): Au
|
|||
'context' => $context,
|
||||
]);
|
||||
|
||||
if ($firstNodeId === null) {
|
||||
if ($targets === []) {
|
||||
$run->update([
|
||||
'status' => Status::Failed,
|
||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
||||
|
|
@ -51,7 +54,7 @@ public function __invoke(Automation $automation, bool $withRealData = false): Au
|
|||
return $run;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($run, $firstNodeId);
|
||||
$this->advance->dispatchBranches($run, $targets);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
|
@ -104,19 +107,4 @@ private function synthesizePostPayload(Automation $automation, string $event): a
|
|||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $triggerNode
|
||||
*/
|
||||
private function findFirstRealNodeId(Automation $automation, ?array $triggerNode): ?string
|
||||
{
|
||||
if ($triggerNode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$connection = collect($automation->connections ?? [])
|
||||
->firstWhere('source', $triggerNode['id']);
|
||||
|
||||
return $connection['target'] ?? null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
|
||||
namespace App\Actions\Automation\Trigger;
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\Enums\Automation\Run\Status as RunStatus;
|
||||
use App\Enums\Automation\Status as AutomationStatus;
|
||||
use App\Enums\Automation\Trigger\Type as TriggerType;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Post;
|
||||
|
|
@ -25,6 +25,8 @@
|
|||
*/
|
||||
class DispatchPostTriggerAutomations
|
||||
{
|
||||
public function __construct(private AdvanceAutomationRun $advance) {}
|
||||
|
||||
public function __invoke(Post $post, TriggerType $triggerType): void
|
||||
{
|
||||
$automations = Automation::query()
|
||||
|
|
@ -64,12 +66,9 @@ private function dispatchRun(Automation $automation, array $triggerNode, Post $p
|
|||
'context' => $context,
|
||||
]);
|
||||
|
||||
$connection = collect($automation->connections ?? [])
|
||||
->firstWhere('source', $triggerNode['id']);
|
||||
$targets = $this->advance->targetsFor($automation, $triggerNode['id']);
|
||||
|
||||
$nextNodeId = $connection['target'] ?? null;
|
||||
|
||||
if ($nextNodeId === null) {
|
||||
if ($targets === []) {
|
||||
$run->update([
|
||||
'status' => RunStatus::Failed,
|
||||
'error' => ['message' => __('automations.errors.no_trigger_connection')],
|
||||
|
|
@ -79,6 +78,6 @@ private function dispatchRun(Automation $automation, array $triggerNode, Post $p
|
|||
return;
|
||||
}
|
||||
|
||||
ProcessAutomationNode::dispatch($run, $nextNodeId);
|
||||
$this->advance->dispatchBranches($run, $targets);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public function broadcastWith(): array
|
|||
{
|
||||
return [
|
||||
'run_id' => $this->run->id,
|
||||
'root_run_id' => $this->run->rootId(),
|
||||
'automation_id' => $this->run->automation_id,
|
||||
'status' => $this->run->status->value,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
use App\Http\Resources\AutomationRunResource;
|
||||
use App\Http\Resources\AutomationTriggerItemResource;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -158,11 +159,21 @@ public function showRun(Automation $automation, AutomationRun $run): JsonRespons
|
|||
$this->authorize('view', $automation);
|
||||
abort_unless($run->automation_id === $automation->id, 404);
|
||||
|
||||
$run->load('nodeRuns');
|
||||
// Aggregate the node runs of every branch forked by a fan-out so the test
|
||||
// panel shows the whole execution, not just the branch the root walked.
|
||||
$rootId = $run->rootId();
|
||||
|
||||
$nodeRuns = AutomationNodeRun::query()
|
||||
->whereHas('run', fn ($query) => $query
|
||||
->where('id', $rootId)
|
||||
->orWhere('root_run_id', $rootId))
|
||||
->orderBy('started_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'run' => AutomationRunResource::make($run)->resolve(),
|
||||
'node_runs' => AutomationNodeRunResource::collection($run->nodeRuns)->resolve(),
|
||||
'node_runs' => AutomationNodeRunResource::collection($nodeRuns)->resolve(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ public function automation(): BelongsTo
|
|||
return $this->belongsTo(Automation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Id of the run that started this execution. Fan-out forks sibling runs that
|
||||
* all point back at the same root, so callers can treat every branch of one
|
||||
* test/trigger as a single family. The root run points at itself.
|
||||
*/
|
||||
public function rootId(): string
|
||||
{
|
||||
return $this->root_run_id ?? $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for template (`{{ ... }}`) resolution: the run context plus the
|
||||
* automation's workflow variables, merged in-memory. Variables are NEVER
|
||||
|
|
|
|||
|
|
@ -14,7 +14,28 @@ public function resolve(string $template, array $context): string
|
|||
'/\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/',
|
||||
fn ($matches) => $this->resolveVariable($matches[1], $context),
|
||||
$template,
|
||||
);
|
||||
) ?? $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `{{ ... }}` placeholders inside an already-decoded JSON structure
|
||||
* (arrays/strings), resolving only string leaves. The caller json_encodes the
|
||||
* result, so values are never string-interpolated into raw JSON — quotes,
|
||||
* `&`, newlines etc. in the data can't corrupt the payload.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function resolveStructured(mixed $value, array $context): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->resolve($value, $context);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map(fn ($item) => $this->resolveStructured($item, $context), $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function resolveVariable(string $path, array $context): string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('automation_runs', function (Blueprint $table) {
|
||||
// Links every run forked by a fan-out back to the run that started the
|
||||
// execution, so the editor test panel can aggregate all branches of a
|
||||
// single test under one root. Null on the root run itself.
|
||||
$table->foreignUuid('root_run_id')->nullable()->after('automation_id')
|
||||
->constrained('automation_runs')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('automation_runs', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('root_run_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@
|
|||
'pause' => 'Pause',
|
||||
'delete' => 'Delete',
|
||||
'retry' => 'Retry',
|
||||
'guide' => 'Guide',
|
||||
],
|
||||
|
||||
'tabs' => [
|
||||
|
|
@ -40,6 +41,44 @@
|
|||
'add' => 'New variable',
|
||||
],
|
||||
|
||||
'guide' => [
|
||||
'title' => 'How to use automations',
|
||||
'subtitle' => 'A quick reference for building your flow.',
|
||||
'flow_title' => 'How it works',
|
||||
'flow_text' => 'A trigger (a schedule, or a published/scheduled post) starts the flow. Actions in between fetch data, generate content, or branch on conditions. Output nodes publish posts or call webhooks.',
|
||||
'data_title' => 'Passing data between nodes',
|
||||
'data_text' => 'Reference data from earlier nodes in any field. Type the name in double braces, for example:',
|
||||
'refs' => [
|
||||
'trigger_post' => 'The post that fired a post trigger',
|
||||
'fetched_title' => 'Current RSS item / HTTP response',
|
||||
'fetched_link' => 'Its link',
|
||||
'generated' => 'The AI-generated post',
|
||||
'now' => 'Current date & time',
|
||||
],
|
||||
'vars_title' => 'Variables',
|
||||
'vars_text' => 'Define reusable values (API keys, base URLs) in the Variables tab, then reference them by key. Secrets are stored encrypted.',
|
||||
'tip_text' => 'Run a Test to see exactly what data each node outputs.',
|
||||
],
|
||||
|
||||
'expr' => [
|
||||
'trigger_event' => 'Trigger event name',
|
||||
'trigger_fired_at' => 'When the trigger fired',
|
||||
'trigger_post_id' => 'Triggering post ID',
|
||||
'trigger_post_content' => 'Triggering post content',
|
||||
'trigger_post_status' => 'Triggering post status',
|
||||
'trigger_post_scheduled_at' => 'When the post is scheduled',
|
||||
'trigger_post_published_at' => 'When the post was published',
|
||||
'fetched_title' => 'Fetched item title',
|
||||
'fetched_link' => 'Fetched item link',
|
||||
'fetched_description' => 'Fetched item description',
|
||||
'fetched_pubdate' => 'Fetched item publish date',
|
||||
'fetched_http' => 'Fetched HTTP item (append a field)',
|
||||
'generated_content' => 'AI-generated post content',
|
||||
'generated_post_url' => 'AI-generated post URL',
|
||||
'variable' => 'Workflow variable',
|
||||
'now' => 'Current date & time',
|
||||
],
|
||||
|
||||
'test' => [
|
||||
'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.',
|
||||
'starting' => 'Starting test run…',
|
||||
|
|
@ -121,6 +160,9 @@
|
|||
'config' => [
|
||||
'select_placeholder' => 'Select…',
|
||||
'invalid_json' => 'This isn’t valid JSON yet.',
|
||||
'expand_editor' => 'Expand editor',
|
||||
'minimize_editor' => 'Minimize',
|
||||
'editing_in_panel' => 'Editing in the side panel',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Trigger type',
|
||||
|
|
@ -175,6 +217,7 @@
|
|||
'social_accounts_empty' => 'No connected social accounts. Connect one first.',
|
||||
'target_slide_count' => 'Slides to generate',
|
||||
'prompt_template' => 'Prompt template',
|
||||
'prompt_template_hint' => 'Type {{ to insert data from earlier steps.',
|
||||
'include_image' => 'Include image',
|
||||
'include_image_hint' => 'Generate an AI image for this post',
|
||||
],
|
||||
|
|
@ -243,12 +286,14 @@
|
|||
'header_name' => 'Header name',
|
||||
'header_value' => 'Value',
|
||||
'add_header' => 'Add header',
|
||||
'polling_section' => 'Polling (optional)',
|
||||
'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.',
|
||||
'polling_section' => 'List & deduplication (optional)',
|
||||
'polling_hint' => 'When the response is a list, each item runs the workflow separately. A single object runs once.',
|
||||
'items_path' => 'Items path',
|
||||
'items_path_hint' => 'Leave blank if the response is already an array. Use a dotted path (e.g. data.items) for a nested array, or * for an object keyed by id.',
|
||||
'item_key_path' => 'Item key path',
|
||||
'item_date_path' => 'Item date path (optional)',
|
||||
'item_date_path_hint' => 'JSON path to the item timestamp. When set, only items newer than the previous fetch are forwarded — prevents the first fetch from flooding downstream nodes.',
|
||||
'item_key_path_hint' => 'JSON path to a unique id (e.g. id). Items already seen are skipped, so a feed without dates still only forwards new entries.',
|
||||
'item_date_path' => 'Item date path',
|
||||
'item_date_path_hint' => 'JSON path to the item timestamp (e.g. published_at). Preferred over the key path when available. The first poll records the baseline and forwards nothing, so an existing feed never floods on day one.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
'pause' => 'Pausar',
|
||||
'delete' => 'Eliminar',
|
||||
'retry' => 'Reintentar',
|
||||
'guide' => 'Guía',
|
||||
],
|
||||
|
||||
'tabs' => [
|
||||
|
|
@ -40,6 +41,44 @@
|
|||
'add' => 'Nueva variable',
|
||||
],
|
||||
|
||||
'guide' => [
|
||||
'title' => 'Cómo usar automatizaciones',
|
||||
'subtitle' => 'Una referencia rápida para construir tu flujo.',
|
||||
'flow_title' => 'Cómo funciona',
|
||||
'flow_text' => 'Un disparador (una programación, o un post publicado/programado) inicia el flujo. Las acciones intermedias obtienen datos, generan contenido o se ramifican por condiciones. Los nodos de salida publican posts o llaman webhooks.',
|
||||
'data_title' => 'Pasar datos entre nodos',
|
||||
'data_text' => 'Referencia datos de nodos anteriores en cualquier campo. Escribe el nombre entre llaves dobles, por ejemplo:',
|
||||
'refs' => [
|
||||
'trigger_post' => 'El post que disparó un disparador de post',
|
||||
'fetched_title' => 'Elemento actual del RSS / respuesta HTTP',
|
||||
'fetched_link' => 'Su enlace',
|
||||
'generated' => 'El post generado por IA',
|
||||
'now' => 'Fecha y hora actuales',
|
||||
],
|
||||
'vars_title' => 'Variables',
|
||||
'vars_text' => 'Define valores reutilizables (API keys, URLs base) en la pestaña Variables y referéncialos por clave. Los secretos se almacenan cifrados.',
|
||||
'tip_text' => 'Ejecuta una Prueba para ver exactamente qué produce cada nodo.',
|
||||
],
|
||||
|
||||
'expr' => [
|
||||
'trigger_event' => 'Nombre del evento del disparador',
|
||||
'trigger_fired_at' => 'Cuándo se disparó',
|
||||
'trigger_post_id' => 'ID del post que disparó',
|
||||
'trigger_post_content' => 'Contenido del post que disparó',
|
||||
'trigger_post_status' => 'Estado del post que disparó',
|
||||
'trigger_post_scheduled_at' => 'Cuándo está programado el post',
|
||||
'trigger_post_published_at' => 'Cuándo se publicó el post',
|
||||
'fetched_title' => 'Título del ítem obtenido',
|
||||
'fetched_link' => 'Enlace del ítem obtenido',
|
||||
'fetched_description' => 'Descripción del ítem obtenido',
|
||||
'fetched_pubdate' => 'Fecha de publicación del ítem obtenido',
|
||||
'fetched_http' => 'Ítem HTTP obtenido (añade un campo)',
|
||||
'generated_content' => 'Contenido del post generado por IA',
|
||||
'generated_post_url' => 'URL del post generado por IA',
|
||||
'variable' => 'Variable del flujo',
|
||||
'now' => 'Fecha y hora actuales',
|
||||
],
|
||||
|
||||
'test' => [
|
||||
'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.',
|
||||
'starting' => 'Iniciando ejecución de prueba…',
|
||||
|
|
@ -121,6 +160,9 @@
|
|||
'config' => [
|
||||
'select_placeholder' => 'Selecciona…',
|
||||
'invalid_json' => 'Esto aún no es un JSON válido.',
|
||||
'expand_editor' => 'Expandir editor',
|
||||
'minimize_editor' => 'Minimizar',
|
||||
'editing_in_panel' => 'Editando en el panel lateral',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Tipo de disparador',
|
||||
|
|
@ -175,6 +217,7 @@
|
|||
'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.',
|
||||
'target_slide_count' => 'Diapositivas a generar',
|
||||
'prompt_template' => 'Plantilla de prompt',
|
||||
'prompt_template_hint' => 'Escribe {{ para insertar datos de pasos anteriores.',
|
||||
'include_image' => 'Incluir imagen',
|
||||
'include_image_hint' => 'Generar una imagen con IA para esta publicación',
|
||||
],
|
||||
|
|
@ -243,12 +286,14 @@
|
|||
'header_name' => 'Nombre del header',
|
||||
'header_value' => 'Valor',
|
||||
'add_header' => 'Agregar header',
|
||||
'polling_section' => 'Polling (opcional)',
|
||||
'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.',
|
||||
'polling_section' => 'Lista y deduplicación (opcional)',
|
||||
'polling_hint' => 'Cuando la respuesta es una lista, cada ítem ejecuta el flujo por separado. Un objeto único se ejecuta una vez.',
|
||||
'items_path' => 'Ruta de ítems',
|
||||
'items_path_hint' => 'Deja vacío si la respuesta ya es un array. Usa una ruta con puntos (ej: data.items) para un array anidado, o * para un objeto con claves por id.',
|
||||
'item_key_path' => 'Ruta de clave del ítem',
|
||||
'item_date_path' => 'Ruta de fecha del ítem (opcional)',
|
||||
'item_date_path_hint' => 'Ruta JSON al timestamp del ítem. Cuando se define, solo los ítems más nuevos que la última obtención se reenvían — evita que la primera obtención inunde los siguientes nodos.',
|
||||
'item_key_path_hint' => 'Ruta JSON a un id único (ej: id). Los ítems ya vistos se omiten, así un feed sin fechas aún reenvía solo los nuevos.',
|
||||
'item_date_path' => 'Ruta de fecha del ítem',
|
||||
'item_date_path_hint' => 'Ruta JSON al timestamp del ítem (ej: published_at). Preferido sobre la ruta de clave cuando existe. La primera obtención registra el punto de partida y no reenvía nada, así un feed existente nunca inunda el primer día.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
'pause' => 'Pausar',
|
||||
'delete' => 'Excluir',
|
||||
'retry' => 'Tentar novamente',
|
||||
'guide' => 'Guia',
|
||||
],
|
||||
|
||||
'tabs' => [
|
||||
|
|
@ -40,6 +41,44 @@
|
|||
'add' => 'Nova variável',
|
||||
],
|
||||
|
||||
'guide' => [
|
||||
'title' => 'Como usar automações',
|
||||
'subtitle' => 'Uma referência rápida pra montar seu fluxo.',
|
||||
'flow_title' => 'Como funciona',
|
||||
'flow_text' => 'Um trigger (um agendamento, ou um post publicado/agendado) inicia o fluxo. As ações no meio buscam dados, geram conteúdo ou ramificam por condições. Os nós de saída publicam posts ou chamam webhooks.',
|
||||
'data_title' => 'Passando dados entre nós',
|
||||
'data_text' => 'Referencie dados de nós anteriores em qualquer campo. Escreva o nome entre chaves duplas, por exemplo:',
|
||||
'refs' => [
|
||||
'trigger_post' => 'O post que disparou um trigger de post',
|
||||
'fetched_title' => 'Item atual do RSS / resposta HTTP',
|
||||
'fetched_link' => 'O link dele',
|
||||
'generated' => 'O post gerado pela IA',
|
||||
'now' => 'Data e hora atuais',
|
||||
],
|
||||
'vars_title' => 'Variáveis',
|
||||
'vars_text' => 'Defina valores reutilizáveis (API keys, base URLs) na aba Variáveis e referencie pela chave. Segredos são armazenados encriptados.',
|
||||
'tip_text' => 'Rode um Teste pra ver exatamente o que cada nó produz.',
|
||||
],
|
||||
|
||||
'expr' => [
|
||||
'trigger_event' => 'Nome do evento do gatilho',
|
||||
'trigger_fired_at' => 'Quando o gatilho disparou',
|
||||
'trigger_post_id' => 'ID do post que disparou',
|
||||
'trigger_post_content' => 'Conteúdo do post que disparou',
|
||||
'trigger_post_status' => 'Status do post que disparou',
|
||||
'trigger_post_scheduled_at' => 'Quando o post está agendado',
|
||||
'trigger_post_published_at' => 'Quando o post foi publicado',
|
||||
'fetched_title' => 'Título do item buscado',
|
||||
'fetched_link' => 'Link do item buscado',
|
||||
'fetched_description' => 'Descrição do item buscado',
|
||||
'fetched_pubdate' => 'Data de publicação do item buscado',
|
||||
'fetched_http' => 'Item HTTP buscado (adicione um campo)',
|
||||
'generated_content' => 'Conteúdo do post gerado por IA',
|
||||
'generated_post_url' => 'URL do post gerado por IA',
|
||||
'variable' => 'Variável do fluxo',
|
||||
'now' => 'Data e hora atuais',
|
||||
],
|
||||
|
||||
'test' => [
|
||||
'description' => 'Executa a automação ponta a ponta usando um payload de gatilho sintético. Útil pra validar cada nó sem esperar o agendamento ou o feed real.',
|
||||
'starting' => 'Iniciando execução de teste…',
|
||||
|
|
@ -121,6 +160,9 @@
|
|||
'config' => [
|
||||
'select_placeholder' => 'Selecione…',
|
||||
'invalid_json' => 'Isto ainda não é um JSON válido.',
|
||||
'expand_editor' => 'Expandir editor',
|
||||
'minimize_editor' => 'Minimizar',
|
||||
'editing_in_panel' => 'Editando no painel ao lado',
|
||||
|
||||
'trigger' => [
|
||||
'type' => 'Tipo de trigger',
|
||||
|
|
@ -175,6 +217,7 @@
|
|||
'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.',
|
||||
'target_slide_count' => 'Slides a gerar',
|
||||
'prompt_template' => 'Template do prompt',
|
||||
'prompt_template_hint' => 'Digite {{ para inserir dados das etapas anteriores.',
|
||||
'include_image' => 'Incluir imagem',
|
||||
'include_image_hint' => 'Gerar uma imagem com IA para esta publicação',
|
||||
],
|
||||
|
|
@ -243,12 +286,14 @@
|
|||
'header_name' => 'Nome do header',
|
||||
'header_value' => 'Valor',
|
||||
'add_header' => 'Adicionar header',
|
||||
'polling_section' => 'Polling (opcional)',
|
||||
'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.',
|
||||
'polling_section' => 'Lista e deduplicação (opcional)',
|
||||
'polling_hint' => 'Quando a resposta é uma lista, cada item roda o fluxo separadamente. Um objeto único roda uma vez.',
|
||||
'items_path' => 'Caminho dos itens',
|
||||
'items_path_hint' => 'Deixe vazio se a resposta já é um array. Use um caminho com ponto (ex: data.items) para um array aninhado, ou * para um objeto com chaves por id.',
|
||||
'item_key_path' => 'Caminho da chave do item',
|
||||
'item_date_path' => 'Caminho da data do item (opcional)',
|
||||
'item_date_path_hint' => 'Caminho JSON pro timestamp do item. Quando definido, só items mais novos que a última busca são encaminhados — evita que a primeira busca inunde os próximos nós.',
|
||||
'item_key_path_hint' => 'Caminho JSON pra um id único (ex: id). Itens já vistos são ignorados, então um feed sem datas ainda encaminha só os novos.',
|
||||
'item_date_path' => 'Caminho da data do item',
|
||||
'item_date_path_hint' => 'Caminho JSON pro timestamp do item (ex: published_at). Preferido sobre o caminho da chave quando existe. A primeira busca registra o ponto de partida e não encaminha nada, então um feed existente nunca inunda no primeiro dia.',
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ const mainNavItems = computed<NavItem[]>(() => [
|
|||
title: trans('sidebar.automations'),
|
||||
href: automations.url(),
|
||||
icon: IconBolt,
|
||||
badge: 'Beta',
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,42 @@
|
|||
<script setup lang="ts">
|
||||
import { autocompletion } from '@codemirror/autocomplete';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { type Extension, EditorState } from '@codemirror/state';
|
||||
import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view';
|
||||
import { IconCopy } from '@tabler/icons-vue';
|
||||
import { IconArrowsMaximize, IconArrowsMinimize, IconCopy } from '@tabler/icons-vue';
|
||||
import { basicSetup } from 'codemirror';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { computed, inject, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
type ExpressionSuggestion,
|
||||
expressionCompletionSource,
|
||||
} from '@/composables/useExpressionCompletions';
|
||||
import debounce from '@/debounce';
|
||||
import { copyToClipboard } from '@/lib/utils';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
language?: 'json';
|
||||
language?: 'json' | 'text';
|
||||
readOnly?: boolean;
|
||||
placeholder?: string;
|
||||
expandable?: boolean;
|
||||
label?: string;
|
||||
}>(),
|
||||
{
|
||||
language: 'json',
|
||||
readOnly: false,
|
||||
placeholder: '',
|
||||
expandable: false,
|
||||
label: '',
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -28,6 +44,34 @@ const emit = defineEmits<{
|
|||
'update:modelValue': [value: string];
|
||||
}>();
|
||||
|
||||
// Provided by the automation editor (Form.vue) for the currently selected node.
|
||||
// Empty elsewhere, so the editor degrades to a plain field with no `{{ ... }}`
|
||||
// suggestions.
|
||||
const expressionCompletions = inject<Ref<ExpressionSuggestion[]>>(
|
||||
'automationExpressionCompletions',
|
||||
ref([]),
|
||||
);
|
||||
|
||||
// Shared with Form.vue: toggling `.active` slides out the side panel (and shifts
|
||||
// the sidebar to make room). Null outside the automation editor, so `expandable`
|
||||
// only takes effect where Form provides the panel target.
|
||||
const expandedPanel = inject<{ active: boolean } | null>('automationExpandedEditor', null);
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const canExpand = computed(() => props.expandable && expandedPanel !== null);
|
||||
|
||||
watch(isExpanded, (open) => {
|
||||
if (expandedPanel) {
|
||||
expandedPanel.active = open;
|
||||
}
|
||||
});
|
||||
|
||||
const onKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape' && isExpanded.value) {
|
||||
isExpanded.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editorContainer = ref<HTMLElement>();
|
||||
let view: EditorView | null = null;
|
||||
|
||||
|
|
@ -35,8 +79,10 @@ const debouncedEmit = debounce((value: string) => {
|
|||
emit('update:modelValue', value);
|
||||
}, 250);
|
||||
|
||||
const languageExtension = () => {
|
||||
const languageExtension = (): Extension => {
|
||||
switch (props.language) {
|
||||
case 'text':
|
||||
return [];
|
||||
case 'json':
|
||||
default:
|
||||
return json();
|
||||
|
|
@ -106,6 +152,11 @@ onMounted(() => {
|
|||
keymap.of([indentWithTab]),
|
||||
languageExtension(),
|
||||
EditorView.lineWrapping,
|
||||
autocompletion({
|
||||
override: [expressionCompletionSource(() => expressionCompletions.value)],
|
||||
activateOnTyping: true,
|
||||
icons: false,
|
||||
}),
|
||||
lightTheme,
|
||||
updateListener,
|
||||
];
|
||||
|
|
@ -126,6 +177,10 @@ onMounted(() => {
|
|||
}),
|
||||
parent: editorContainer.value,
|
||||
});
|
||||
|
||||
if (props.expandable) {
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
@ -153,22 +208,89 @@ onBeforeUnmount(() => {
|
|||
debouncedEmit.cancel();
|
||||
view?.destroy();
|
||||
view = null;
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
// Switching nodes unmounts the editor; make sure the side panel collapses
|
||||
// instead of lingering empty.
|
||||
if (isExpanded.value && expandedPanel) {
|
||||
expandedPanel.active = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div ref="editorContainer" class="code-editor h-full w-full" />
|
||||
<div class="group relative h-full w-full">
|
||||
<div v-show="!isExpanded" ref="editorContainer" class="code-editor h-full w-full" />
|
||||
|
||||
<!-- While the side panel is open, the inline field collapses to a hint
|
||||
(clicking it closes the panel) instead of duplicating the editor. -->
|
||||
<button
|
||||
v-if="modelValue"
|
||||
v-if="isExpanded"
|
||||
type="button"
|
||||
class="absolute right-2 top-2 z-10 inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-[1px_1px_0_var(--foreground)] transition hover:-translate-x-px hover:-translate-y-px hover:shadow-[2px_2px_0_var(--foreground)] active:translate-x-0 active:translate-y-0 active:shadow-[0_0_0_var(--foreground)]"
|
||||
:title="$t('common.actions.copy')"
|
||||
:aria-label="$t('common.actions.copy')"
|
||||
@click="copyToClipboard(modelValue)"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-1.5 rounded-md border-2 border-dashed border-foreground/25 bg-card/40 px-3 text-center text-xs font-medium text-foreground/60 transition hover:border-foreground/40 hover:text-foreground/80"
|
||||
@click="isExpanded = false"
|
||||
>
|
||||
<IconCopy class="size-3.5" stroke-width="2.5" />
|
||||
<IconArrowsMaximize class="size-4" stroke-width="2.5" />
|
||||
{{ $t('automations.config.editing_in_panel') }}
|
||||
</button>
|
||||
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<div
|
||||
v-if="!isExpanded && (canExpand || modelValue)"
|
||||
class="absolute right-2 top-2 z-10 flex gap-0.5 rounded-lg border-2 border-foreground bg-card p-0.5 opacity-0 shadow-[1px_1px_0_var(--foreground)] transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100"
|
||||
>
|
||||
<Tooltip v-if="canExpand">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
|
||||
:aria-label="$t('automations.config.expand_editor')"
|
||||
@click="isExpanded = true"
|
||||
>
|
||||
<IconArrowsMaximize class="size-3.5" stroke-width="2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('automations.config.expand_editor') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip v-if="modelValue">
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex size-6 items-center justify-center rounded-md text-foreground/70 transition-colors hover:bg-foreground/10 hover:text-foreground"
|
||||
:aria-label="$t('common.actions.copy')"
|
||||
@click="copyToClipboard(modelValue)"
|
||||
>
|
||||
<IconCopy class="size-3.5" stroke-width="2.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('common.actions.copy') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
<Teleport v-if="canExpand && isExpanded" to="#automation-expanded-editor">
|
||||
<div class="flex shrink-0 items-center justify-between gap-2 border-b-2 border-foreground/10 px-3 py-3">
|
||||
<span class="truncate text-sm font-bold">{{ label || $t('automations.config.expand_editor') }}</span>
|
||||
<TooltipProvider :delay-duration="200">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon-sm" :aria-label="$t('automations.config.minimize_editor')" @click="isExpanded = false">
|
||||
<IconArrowsMinimize class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('automations.config.minimize_editor') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 p-4">
|
||||
<CodeEditor
|
||||
:model-value="modelValue"
|
||||
:language="language"
|
||||
:placeholder="placeholder"
|
||||
:read-only="readOnly"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
|
|
@ -36,6 +37,13 @@ const { urlIsActive } = useActiveUrl();
|
|||
<span>{{ item.title }}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
<Badge
|
||||
v-if="item.badge"
|
||||
variant="warning"
|
||||
class="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 px-1.5 group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{{ item.badge }}
|
||||
</Badge>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
|
|
|||
72
resources/js/components/automations/EditorGuide.vue
Normal file
72
resources/js/components/automations/EditorGuide.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
// The `{{ ... }}` snippets stay literal (they're code); only the short
|
||||
// descriptions are translated.
|
||||
const dataRefs = [
|
||||
{ code: '{{ trigger.post.content }}', descKey: 'automations.guide.refs.trigger_post' },
|
||||
{ code: '{{ fetched.title }}', descKey: 'automations.guide.refs.fetched_title' },
|
||||
{ code: '{{ fetched.link }}', descKey: 'automations.guide.refs.fetched_link' },
|
||||
{ code: '{{ generated.content }}', descKey: 'automations.guide.refs.generated' },
|
||||
{ code: '{{ now }}', descKey: 'automations.guide.refs.now' },
|
||||
];
|
||||
|
||||
const variableExample = '{{ variables.API_KEY }}';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet v-model:open="open">
|
||||
<SheetContent side="right" class="w-full overflow-y-auto sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{{ $t('automations.guide.title') }}</SheetTitle>
|
||||
<SheetDescription>{{ $t('automations.guide.subtitle') }}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div class="space-y-6 px-4 pb-8">
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
|
||||
{{ $t('automations.guide.flow_title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-foreground/70">{{ $t('automations.guide.flow_text') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
|
||||
{{ $t('automations.guide.data_title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-foreground/70">{{ $t('automations.guide.data_text') }}</p>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="ref in dataRefs"
|
||||
:key="ref.code"
|
||||
class="flex flex-col gap-0.5 rounded-lg border-2 border-foreground/15 bg-card/50 p-2.5"
|
||||
>
|
||||
<code class="font-mono text-xs font-bold text-foreground">{{ ref.code }}</code>
|
||||
<span class="text-xs text-foreground/55">{{ $t(ref.descKey) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[11px] font-black uppercase tracking-widest text-foreground/50">
|
||||
{{ $t('automations.guide.vars_title') }}
|
||||
</h3>
|
||||
<p class="text-sm text-foreground/70">{{ $t('automations.guide.vars_text') }}</p>
|
||||
<code class="inline-block rounded-lg border-2 border-foreground/15 bg-card/50 px-2.5 py-1.5 font-mono text-xs font-bold text-foreground">{{ variableExample }}</code>
|
||||
</section>
|
||||
|
||||
<section class="rounded-xl border-2 border-foreground bg-amber-50 p-3 shadow-[3px_3px_0_var(--foreground)]">
|
||||
<p class="text-sm font-medium text-foreground">💡 {{ $t('automations.guide.tip_text') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
|
|
@ -58,12 +58,14 @@ const fetchRun = async (runId: string): Promise<void> => {
|
|||
// Subscribe once to the automation's private channel. The backend broadcasts
|
||||
// a tiny `{ run_id, status }` payload on every run/node update; we refetch the
|
||||
// full state only when the event is for our active run. Zero polling.
|
||||
useAutomationEcho<{ run_id: string; status: string }>(
|
||||
useAutomationEcho<{ run_id: string; root_run_id: string; status: string }>(
|
||||
props.automationId,
|
||||
'.automation.run.updated',
|
||||
(payload) => {
|
||||
if (payload.run_id === activeRunId.value) {
|
||||
fetchRun(payload.run_id);
|
||||
// Refetch when any branch of the active test family advances — a fan-out
|
||||
// forks sibling runs whose root_run_id points back at the run we started.
|
||||
if (payload.root_run_id === activeRunId.value) {
|
||||
fetchRun(activeRunId.value);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { usePage } from '@inertiajs/vue3';
|
|||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import ChannelConfigurator from '@/components/ChannelConfigurator.vue';
|
||||
import CodeEditor from '@/components/CodeEditor.vue';
|
||||
import InputError from '@/components/InputError.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { getMediaRulesForContentType } from '@/composables/useMediaRules';
|
||||
import { getMediaIncompatibilityReason, getPlatformMetaIssue } from '@/composables/usePostCompliance';
|
||||
import type { PinterestBoard } from '@/types';
|
||||
|
|
@ -310,7 +310,16 @@ const channels = computed<Channel[]>(() =>
|
|||
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.generate.prompt_template') }}</label>
|
||||
<Textarea v-model="local.prompt_template" :rows="6" placeholder="Write a social media post about {{ trigger.title }}…" />
|
||||
<div class="h-40">
|
||||
<CodeEditor
|
||||
v-model="local.prompt_template"
|
||||
language="text"
|
||||
expandable
|
||||
:label="$t('automations.config.generate.prompt_template')"
|
||||
placeholder="Write a social media post about {{ trigger.title }}…"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.generate.prompt_template_hint') }}</p>
|
||||
<InputError :message="errors?.prompt_template" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ const removeHeader = (index: number): void => {
|
|||
watch(local, (val) => emit('update', val), { deep: true });
|
||||
|
||||
const supportsBody = computed(() => ['POST', 'PUT', 'PATCH'].includes(local.value.method));
|
||||
const isPollingMode = computed(() => local.value.items_path.trim() !== '');
|
||||
|
||||
const isBodyJsonInvalid = computed(() => {
|
||||
const value = local.value.body_template.trim();
|
||||
|
|
@ -183,7 +182,13 @@ const isBodyJsonInvalid = computed(() => {
|
|||
<div v-if="supportsBody">
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.body_template') }}</label>
|
||||
<div class="h-36">
|
||||
<CodeEditor v-model="local.body_template" language="json" placeholder='{"id": "{{ trigger.post.id }}"}' />
|
||||
<CodeEditor
|
||||
v-model="local.body_template"
|
||||
language="json"
|
||||
expandable
|
||||
:label="$t('automations.config.http_request.body_template')"
|
||||
placeholder='{"id": "{{ trigger.post.id }}"}'
|
||||
/>
|
||||
</div>
|
||||
<p v-if="isBodyJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
{{ $t('automations.config.invalid_json') }}
|
||||
|
|
@ -234,21 +239,21 @@ const isBodyJsonInvalid = computed(() => {
|
|||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.items_path') }}</label>
|
||||
<Input v-model="local.items_path" placeholder="data.items" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.items_path_hint') }}</p>
|
||||
<InputError :message="errors?.items_path" class="mt-1" />
|
||||
</div>
|
||||
<template v-if="isPollingMode">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_key_path') }}</label>
|
||||
<Input v-model="local.item_key_path" placeholder="id" />
|
||||
<InputError :message="errors?.item_key_path" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_date_path') }}</label>
|
||||
<Input v-model="local.item_date_path" placeholder="published_at" />
|
||||
<InputError :message="errors?.item_date_path" class="mt-1" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_date_path_hint') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_date_path') }}</label>
|
||||
<Input v-model="local.item_date_path" placeholder="published_at" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_date_path_hint') }}</p>
|
||||
<InputError :message="errors?.item_date_path" class="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.http_request.item_key_path') }}</label>
|
||||
<Input v-model="local.item_key_path" placeholder="id" />
|
||||
<p class="mt-1 text-xs text-foreground/50">{{ $t('automations.config.http_request.item_key_path_hint') }}</p>
|
||||
<InputError :message="errors?.item_key_path" class="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,9 +16,9 @@ import {
|
|||
normalizeScheduleData,
|
||||
timezoneAbbr as getTimezoneAbbr,
|
||||
userTimezone as getUserTimezone,
|
||||
type ScheduleData,
|
||||
} from '@/components/automations/schedule-summary';
|
||||
import { ScheduleField } from '@/types/automation/schedule-field';
|
||||
import type { ScheduleData } from '@/types/automation/schedule-data';
|
||||
import { TriggerType, type TriggerTypeValue } from '@/types/automation/trigger-type';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -76,7 +76,13 @@ const isPayloadJsonInvalid = computed(() => {
|
|||
<div>
|
||||
<label class="mb-1 block text-sm font-medium">{{ $t('automations.config.webhook.payload_template') }}</label>
|
||||
<div class="h-40">
|
||||
<CodeEditor v-model="local.payload_template" language="json" placeholder='{"content": "{{ post.content }}"}' />
|
||||
<CodeEditor
|
||||
v-model="local.payload_template"
|
||||
language="json"
|
||||
expandable
|
||||
:label="$t('automations.config.webhook.payload_template')"
|
||||
placeholder='{"content": "{{ post.content }}"}'
|
||||
/>
|
||||
</div>
|
||||
<p v-if="isPayloadJsonInvalid" class="mt-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
{{ $t('automations.config.invalid_json') }}
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ export const humanSchedule = (data: ScheduleData): string => {
|
|||
}
|
||||
case ScheduleField.Hours: {
|
||||
const n = num(data, 'schedule_hours_interval', 1, 1, 23);
|
||||
return transChoice(summaryKey('every_n_hours'), n, { minute });
|
||||
return transChoice(summaryKey('every_n_hours'), n, { minute: pad2(minute) });
|
||||
}
|
||||
case ScheduleField.Days: {
|
||||
const n = num(data, 'schedule_days_interval', 1, 1, 31);
|
||||
|
|
|
|||
168
resources/js/composables/useExpressionCompletions.ts
Normal file
168
resources/js/composables/useExpressionCompletions.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
|
||||
import type { AutomationVariable } from '@/types/automation/automation';
|
||||
|
||||
export interface ExpressionSuggestion {
|
||||
label: string;
|
||||
info: string;
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string;
|
||||
type?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
const suggestion = (label: string, key: string): ExpressionSuggestion => ({
|
||||
label,
|
||||
info: trans(`automations.expr.${key}`),
|
||||
});
|
||||
|
||||
/**
|
||||
* Expressions a node makes available to everything downstream — mirrors the
|
||||
* `output` each node action merges into the run context on the backend.
|
||||
*/
|
||||
const providedBy = (node: GraphNode): ExpressionSuggestion[] => {
|
||||
switch (node.type) {
|
||||
case 'trigger': {
|
||||
const triggerType = node.data?.trigger_type;
|
||||
if (triggerType === 'post_published' || triggerType === 'post_scheduled') {
|
||||
return [
|
||||
suggestion('trigger.post.id', 'trigger_post_id'),
|
||||
suggestion('trigger.post.content', 'trigger_post_content'),
|
||||
suggestion('trigger.post.status', 'trigger_post_status'),
|
||||
suggestion('trigger.post.scheduled_at', 'trigger_post_scheduled_at'),
|
||||
suggestion('trigger.post.published_at', 'trigger_post_published_at'),
|
||||
];
|
||||
}
|
||||
return [suggestion('trigger.event', 'trigger_event'), suggestion('trigger.fired_at', 'trigger_fired_at')];
|
||||
}
|
||||
case 'fetch_rss':
|
||||
return [
|
||||
suggestion('fetched.title', 'fetched_title'),
|
||||
suggestion('fetched.link', 'fetched_link'),
|
||||
suggestion('fetched.description', 'fetched_description'),
|
||||
suggestion('fetched.pubDate', 'fetched_pubdate'),
|
||||
];
|
||||
case 'http_request':
|
||||
return [suggestion('fetched', 'fetched_http')];
|
||||
case 'generate':
|
||||
return [
|
||||
suggestion('generated.content', 'generated_content'),
|
||||
suggestion('generated.post_url', 'generated_post_url'),
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Every node upstream of `nodeId`, following connections backwards. These are
|
||||
* the only nodes whose output is in scope when the given node runs.
|
||||
*/
|
||||
const ancestorNodes = (nodeId: string, nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] => {
|
||||
const byId = new Map(nodes.map((node) => [node.id, node]));
|
||||
const incoming = new Map<string, string[]>();
|
||||
edges.forEach((edge) => {
|
||||
const sources = incoming.get(edge.target) ?? [];
|
||||
sources.push(edge.source);
|
||||
incoming.set(edge.target, sources);
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const stack = [nodeId];
|
||||
const result: GraphNode[] = [];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
for (const source of incoming.get(current) ?? []) {
|
||||
if (seen.has(source)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(source);
|
||||
const node = byId.get(source);
|
||||
if (node) {
|
||||
result.push(node);
|
||||
stack.push(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `{{ ... }}` expressions available inside `nodeId`'s config: everything its
|
||||
* upstream nodes provide, plus the workflow variables and `now`.
|
||||
*/
|
||||
export const buildExpressionCatalog = (
|
||||
nodeId: string | null,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
variables: AutomationVariable[],
|
||||
): ExpressionSuggestion[] => {
|
||||
const suggestions: ExpressionSuggestion[] = [];
|
||||
|
||||
if (nodeId) {
|
||||
ancestorNodes(nodeId, nodes, edges).forEach((node) => {
|
||||
suggestions.push(...providedBy(node));
|
||||
});
|
||||
}
|
||||
|
||||
variables
|
||||
.filter((variable) => variable.key?.trim())
|
||||
.forEach((variable) => {
|
||||
suggestions.push({ label: `variables.${variable.key}`, info: trans('automations.expr.variable') });
|
||||
});
|
||||
|
||||
suggestions.push(suggestion('now', 'now'));
|
||||
|
||||
const seen = new Set<string>();
|
||||
return suggestions.filter((item) => (seen.has(item.label) ? false : seen.add(item.label)));
|
||||
};
|
||||
|
||||
/**
|
||||
* CodeMirror completion source that only fires inside a `{{ ... }}` block —
|
||||
* matching how n8n scopes completions to its `Resolvable` syntax node. The typed
|
||||
* path is replaced with the chosen expression, closing the braces if needed.
|
||||
*/
|
||||
export const expressionCompletionSource =
|
||||
(getSuggestions: () => ExpressionSuggestion[]) =>
|
||||
(context: CompletionContext): CompletionResult | null => {
|
||||
const before = context.matchBefore(/\{\{\s*[\w.]*$/);
|
||||
if (!before) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suggestions = getSuggestions();
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The path token starts after `{{` and any following whitespace.
|
||||
const openMarker = before.text.match(/^\{\{\s*/)?.[0] ?? '{{';
|
||||
const from = before.from + openMarker.length;
|
||||
|
||||
const options: Completion[] = suggestions.map((item) => ({
|
||||
label: item.label,
|
||||
info: item.info,
|
||||
type: 'variable',
|
||||
apply: (view, _completion, applyFrom, applyTo) => {
|
||||
const trailing = view.state.sliceDoc(applyTo, applyTo + 3);
|
||||
const hasClose = /^\s*\}\}/.test(trailing);
|
||||
const insert = hasClose ? item.label : `${item.label} }}`;
|
||||
view.dispatch({
|
||||
changes: { from: applyFrom, to: applyTo, insert },
|
||||
selection: { anchor: applyFrom + insert.length },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return { from, options, filter: true };
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, Link, router } from '@inertiajs/vue3';
|
||||
import { IconBolt } from '@tabler/icons-vue';
|
||||
import { IconBolt, IconHelp } from '@tabler/icons-vue';
|
||||
import { Background } from '@vue-flow/background';
|
||||
import { Controls } from '@vue-flow/controls';
|
||||
import {
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type XYPosition,
|
||||
} from '@vue-flow/core';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, markRaw, ref, watch } from 'vue';
|
||||
import { computed, markRaw, provide, reactive, ref, watch } from 'vue';
|
||||
import { toast } from 'vue-sonner';
|
||||
|
||||
import '@vue-flow/core/dist/style.css';
|
||||
|
|
@ -23,26 +23,26 @@ import '@vue-flow/controls/dist/style.css';
|
|||
|
||||
|
||||
import AutomationConnectionLine from '@/components/automations/AutomationConnectionLine.vue';
|
||||
import EndNode from '@/components/automations/nodes/EndNode.vue';
|
||||
import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue';
|
||||
import FetchRssNode from '@/components/automations/nodes/FetchRssNode.vue';
|
||||
import GenerateNode from '@/components/automations/nodes/GenerateNode.vue';
|
||||
import PublishNode from '@/components/automations/nodes/PublishNode.vue';
|
||||
import TriggerNode from '@/components/automations/nodes/TriggerNode.vue';
|
||||
import WebhookNode from '@/components/automations/nodes/WebhookNode.vue';
|
||||
|
||||
import ConditionNodeConfig from '@/components/automations/config/ConditionNodeConfig.vue';
|
||||
import DelayNodeConfig from '@/components/automations/config/DelayNodeConfig.vue';
|
||||
import EndNodeConfig from '@/components/automations/config/EndNodeConfig.vue';
|
||||
import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNodeConfig.vue';
|
||||
import FetchRssNodeConfig from '@/components/automations/config/FetchRssNodeConfig.vue';
|
||||
import GenerateNodeConfig from '@/components/automations/config/GenerateNodeConfig.vue';
|
||||
import HttpRequestNodeConfig from '@/components/automations/config/HttpRequestNodeConfig.vue';
|
||||
import PublishNodeConfig from '@/components/automations/config/PublishNodeConfig.vue';
|
||||
import TriggerNodeConfig from '@/components/automations/config/TriggerNodeConfig.vue';
|
||||
import WebhookNodeConfig from '@/components/automations/config/WebhookNodeConfig.vue';
|
||||
import EditorGuide from '@/components/automations/EditorGuide.vue';
|
||||
import EditorSidebar from '@/components/automations/EditorSidebar.vue';
|
||||
import ConditionNode from '@/components/automations/nodes/ConditionNode.vue';
|
||||
import DelayNode from '@/components/automations/nodes/DelayNode.vue';
|
||||
import EndNode from '@/components/automations/nodes/EndNode.vue';
|
||||
import FetchRssNode from '@/components/automations/nodes/FetchRssNode.vue';
|
||||
import GenerateNode from '@/components/automations/nodes/GenerateNode.vue';
|
||||
import HttpRequestNode from '@/components/automations/nodes/HttpRequestNode.vue';
|
||||
import PublishNode from '@/components/automations/nodes/PublishNode.vue';
|
||||
import TriggerNode from '@/components/automations/nodes/TriggerNode.vue';
|
||||
import WebhookNode from '@/components/automations/nodes/WebhookNode.vue';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AddEdgeCommand } from '@/composables/history/commands/AddEdgeCommand';
|
||||
import { AddNodeCommand } from '@/composables/history/commands/AddNodeCommand';
|
||||
|
|
@ -53,6 +53,7 @@ import { UpdateNodeDataCommand } from '@/composables/history/commands/UpdateNode
|
|||
import { useHistory } from '@/composables/history/useHistory';
|
||||
import { usePageErrors } from '@/composables/usePageErrors';
|
||||
import { useShortcut } from '@/composables/useShortcut';
|
||||
import { buildExpressionCatalog } from '@/composables/useExpressionCompletions';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import {
|
||||
show as showAutomation,
|
||||
|
|
@ -102,6 +103,7 @@ const hydrateEdges = (list: RawConnection[]): Edge[] =>
|
|||
const nodes = ref<Node[]>(props.automation.nodes ?? []);
|
||||
const edges = ref<Edge[]>(hydrateEdges(props.automation.connections ?? []));
|
||||
const selectedNodeId = ref<string | null>(null);
|
||||
const selectedEdgeId = ref<string | null>(null);
|
||||
const name = ref(props.automation.name);
|
||||
const variables = ref<AutomationVariable[]>(props.automation.variables ?? []);
|
||||
|
||||
|
|
@ -126,6 +128,19 @@ watch(() => props.automation.variables, (newVariables) => {
|
|||
});
|
||||
|
||||
const selectedNode = computed(() => nodes.value.find((n) => n.id === selectedNodeId.value));
|
||||
|
||||
// `{{ ... }}` suggestions available to the selected node's config editors —
|
||||
// everything its upstream nodes provide, plus workflow variables and `now`.
|
||||
// Injected by every CodeEditor inside this editor (see CodeEditor.vue).
|
||||
provide(
|
||||
'automationExpressionCompletions',
|
||||
computed(() => buildExpressionCatalog(selectedNodeId.value, nodes.value, edges.value, variables.value)),
|
||||
);
|
||||
|
||||
// Toggled by an expandable CodeEditor (see CodeEditor.vue) to slide out the
|
||||
// side-by-side editing panel, pushing the sidebar left to make room.
|
||||
const expandedEditor = reactive({ active: false });
|
||||
provide('automationExpandedEditor', expandedEditor);
|
||||
const selectedConfigComponent = computed(() =>
|
||||
selectedNode.value ? configByType[selectedNode.value.type as string] : null,
|
||||
);
|
||||
|
|
@ -152,6 +167,8 @@ const history = useHistory();
|
|||
|
||||
const {
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onPaneClick,
|
||||
onConnect,
|
||||
onNodeDragStart,
|
||||
onNodeDragStop,
|
||||
|
|
@ -161,6 +178,18 @@ const {
|
|||
|
||||
onNodeClick(({ node }) => {
|
||||
selectedNodeId.value = node.id;
|
||||
selectedEdgeId.value = null;
|
||||
});
|
||||
|
||||
// Selecting a connection closes the node config and arms it for deletion via
|
||||
// Backspace/Delete — without removing the nodes it links.
|
||||
onEdgeClick(({ edge }) => {
|
||||
selectedEdgeId.value = edge.id;
|
||||
selectedNodeId.value = null;
|
||||
});
|
||||
|
||||
onPaneClick(() => {
|
||||
selectedEdgeId.value = null;
|
||||
});
|
||||
|
||||
onConnect((connection: Connection) => {
|
||||
|
|
@ -303,8 +332,19 @@ const deleteSelectedNode = () => {
|
|||
nodes.value = nodes.value.filter((n) => n.id !== node.id);
|
||||
};
|
||||
|
||||
const deleteSelectedEdge = () => {
|
||||
if (!selectedEdgeId.value) return;
|
||||
const edge = edges.value.find((e) => e.id === selectedEdgeId.value);
|
||||
if (!edge) return;
|
||||
|
||||
history.push(new RemoveEdgeCommand(edge, edges));
|
||||
edges.value = edges.value.filter((e) => e.id !== edge.id);
|
||||
selectedEdgeId.value = null;
|
||||
};
|
||||
|
||||
const isSaving = ref(false);
|
||||
const activeTab = ref('build');
|
||||
const isGuideOpen = ref(false);
|
||||
|
||||
const sanitizeNodes = (list: Node[]) =>
|
||||
list.map((n) => ({
|
||||
|
|
@ -376,9 +416,11 @@ useShortcut('mod+shift+z', () => {
|
|||
});
|
||||
useShortcut('backspace', () => {
|
||||
if (selectedNode.value) deleteSelectedNode();
|
||||
else if (selectedEdgeId.value) deleteSelectedEdge();
|
||||
}, { ignoreOnInput: true });
|
||||
useShortcut('delete', () => {
|
||||
if (selectedNode.value) deleteSelectedNode();
|
||||
else if (selectedEdgeId.value) deleteSelectedEdge();
|
||||
}, { ignoreOnInput: true });
|
||||
|
||||
const defaultEdgeOptions = {
|
||||
|
|
@ -416,6 +458,10 @@ const defaultEdgeOptions = {
|
|||
class="w-72 rounded-md border-2 border-transparent bg-transparent px-3 py-1 text-center text-sm font-semibold text-foreground transition-colors hover:border-foreground/15 focus:border-foreground focus:bg-background focus:outline-none"
|
||||
/>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" @click="isGuideOpen = true">
|
||||
<IconHelp class="size-4" />
|
||||
{{ $t('automations.actions.guide') }}
|
||||
</Button>
|
||||
<Button size="sm" @click="save" :disabled="isSaving">{{ $t('automations.actions.save') }}</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -475,14 +521,24 @@ const defaultEdgeOptions = {
|
|||
<template v-if="selectedNode" #config>
|
||||
<component
|
||||
:is="selectedConfigComponent"
|
||||
:key="selectedNode.id"
|
||||
:data="selectedNode.data"
|
||||
:errors="selectedNodeErrors"
|
||||
@update="updateSelectedConfig"
|
||||
/>
|
||||
</template>
|
||||
</EditorSidebar>
|
||||
|
||||
<aside v-show="expandedEditor.active" class="flex w-[30rem] shrink-0 flex-col py-3 pr-3">
|
||||
<div
|
||||
id="automation-expanded-editor"
|
||||
class="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl border-2 border-foreground bg-card shadow-[3px_3px_0_var(--foreground)]"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditorGuide v-model:open="isGuideOpen" />
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ const handleCreate = () => {
|
|||
{{ statusConfig(automation.status).label }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{{ formatDate(automation.created_at) }}</TableCell>
|
||||
<TableCell>{{ automation.created_at ? formatDate(automation.created_at) : '' }}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
|
|||
1
resources/js/types/index.d.ts
vendored
1
resources/js/types/index.d.ts
vendored
|
|
@ -58,6 +58,7 @@ export interface NavItem {
|
|||
activePattern?: string;
|
||||
exact?: boolean;
|
||||
excludeActive?: string[];
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface SharedData {
|
||||
|
|
|
|||
|
|
@ -149,6 +149,40 @@
|
|||
);
|
||||
});
|
||||
|
||||
it('fans every spawned item out across all branches wired to the fetch node', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_THREE_NEW, 200)]);
|
||||
|
||||
$automation = Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'fetch_1', 'type' => 'fetch_rss', 'position' => ['x' => 200, 'y' => 0], 'data' => ['feed_url' => 'https://1.1.1.1/feed']],
|
||||
['id' => 'generate_1', 'type' => 'generate', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
|
||||
['id' => 'webhook_1', 'type' => 'webhook', 'position' => ['x' => 400, 'y' => 200], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'fetch_1'],
|
||||
['id' => 'e2', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'generate_1'],
|
||||
['id' => 'e3', 'source' => 'fetch_1', 'source_handle' => 'default', 'target' => 'webhook_1'],
|
||||
],
|
||||
]);
|
||||
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'fetch_1']);
|
||||
|
||||
app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
|
||||
|
||||
// 2 spawned items × 2 branches = 4 dispatches; both branches reached.
|
||||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 4);
|
||||
Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'generate_1');
|
||||
Bus::assertDispatched(ProcessAutomationNode::class, fn ($job) => $job->nodeId === 'webhook_1');
|
||||
});
|
||||
|
||||
it('does not persist the production watermark on a manual real-data test', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake(['1.1.1.1/*' => Http::response(FETCH_RSS_MIXED, 200)]);
|
||||
|
|
@ -165,10 +199,11 @@
|
|||
],
|
||||
]);
|
||||
|
||||
// Watermark in the FUTURE: a production run would see zero new items here.
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'fetch_1',
|
||||
'data' => ['last_item_date' => '2025-01-01T00:00:00+00:00'],
|
||||
'data' => ['last_item_date' => '2030-01-01T00:00:00+00:00'],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
|
|
@ -179,14 +214,14 @@
|
|||
|
||||
$result = app(RunFetchRssNode::class)($run, ['feed_url' => 'https://1.1.1.1/feed']);
|
||||
|
||||
// Reads the production watermark, so it finds the newer items.
|
||||
// A manual test IGNORES the watermark, so it always surfaces real data even
|
||||
// when production would have already consumed every item.
|
||||
expect($result->status)->toBe(NodeRunStatus::Completed);
|
||||
expect($result->output['fetch']['count'])->toBeGreaterThan(0);
|
||||
|
||||
// The persisted watermark stays put — a second identical manual test would
|
||||
// surface the same items.
|
||||
// ...and never advances the persisted watermark.
|
||||
$state = AutomationNodeState::for($automation->id, 'fetch_1');
|
||||
expect($state->data['last_item_date'])->toBe('2025-01-01T00:00:00+00:00');
|
||||
expect($state->data['last_item_date'])->toBe('2030-01-01T00:00:00+00:00');
|
||||
});
|
||||
|
||||
it('advances the production watermark on a non-manual real-data run', function () {
|
||||
|
|
|
|||
|
|
@ -89,16 +89,47 @@
|
|||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'items_path' => 'items',
|
||||
'item_key_path' => 'id',
|
||||
'item_date_path' => 'published_at',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched']['id'])->toBe('a1');
|
||||
expect($result->output['fetch']['count'])->toBe(3);
|
||||
expect($result->output['fetch']['spawned'])->toBe(2);
|
||||
// Parent run + one sibling per remaining item.
|
||||
expect(AutomationRun::where('automation_id', $automation->id)->count())->toBe(3);
|
||||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
|
||||
});
|
||||
|
||||
it('records the baseline on the first date-dedup poll and emits nothing', function () {
|
||||
Carbon::setTestNow('2026-01-15 10:00:00');
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response([
|
||||
'items' => [
|
||||
['id' => 'a1', 'published_at' => '2026-01-05T00:00:00Z'],
|
||||
// Dated ahead of the server clock — must NOT leak on the first poll.
|
||||
['id' => 'a2', 'published_at' => '2026-02-01T00:00:00Z'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/items',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'items_path' => 'items',
|
||||
'item_date_path' => 'published_at',
|
||||
]);
|
||||
|
||||
expect($result->output['fetch']['count'])->toBe(0);
|
||||
Bus::assertNotDispatched(ProcessAutomationNode::class);
|
||||
|
||||
$state = AutomationNodeState::for($automation->id, 'http_1');
|
||||
expect($state->data['last_item_date'])->toStartWith('2026-02-01');
|
||||
});
|
||||
|
||||
it('sends bearer token header decrypting it on the fly', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
|
|
@ -191,3 +222,160 @@
|
|||
|
||||
expect($result->status)->toBe(NodeRunStatus::Failed);
|
||||
});
|
||||
|
||||
it('iterates a top-level array with no items_path', function () {
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response([
|
||||
['id' => 1, 'title' => 'a'],
|
||||
['id' => 2, 'title' => 'b'],
|
||||
['id' => 3, 'title' => 'c'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/posts',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched'])->toBe(['id' => 1, 'title' => 'a']);
|
||||
expect($result->output['fetch']['count'])->toBe(3);
|
||||
expect($result->output['fetch']['spawned'])->toBe(2);
|
||||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 2);
|
||||
});
|
||||
|
||||
it('iterates a top-level object map via the wildcard items_path', function () {
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response([
|
||||
'16092' => ['id' => 16092, 'title' => 'a'],
|
||||
'14920' => ['id' => 14920, 'title' => 'b'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/posts',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'items_path' => '*',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched'])->toBe(['id' => 16092, 'title' => 'a']);
|
||||
expect($result->output['fetch']['spawned'])->toBe(1);
|
||||
});
|
||||
|
||||
it('iterates a top-level array of primitives, passing each scalar as fetched', function () {
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response(['https://x/1', 'https://x/2', 'https://x/3'], 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/urls',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched'])->toBe('https://x/1');
|
||||
expect($result->output['fetch']['spawned'])->toBe(2);
|
||||
});
|
||||
|
||||
it('parses an NDJSON body into items', function () {
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response("{\"id\":1}\n{\"id\":2}\n{\"id\":3}", 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/stream',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched'])->toBe(['id' => 1]);
|
||||
expect($result->output['fetch']['count'])->toBe(3);
|
||||
});
|
||||
|
||||
it('records the baseline on the first key-dedup poll and emits nothing', function () {
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response([
|
||||
['id' => 'p1'],
|
||||
['id' => 'p2'],
|
||||
['id' => 'p3'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$automation = httpAutomation();
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/posts',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'item_key_path' => 'id',
|
||||
]);
|
||||
|
||||
expect($result->output['fetch']['count'])->toBe(0);
|
||||
Bus::assertNotDispatched(ProcessAutomationNode::class);
|
||||
|
||||
$state = AutomationNodeState::for($automation->id, 'http_1');
|
||||
expect($state->data['seen_keys'])->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('emits only unseen items on a later key-dedup poll', function () {
|
||||
$automation = httpAutomation();
|
||||
|
||||
AutomationNodeState::create([
|
||||
'automation_id' => $automation->id,
|
||||
'node_id' => 'http_1',
|
||||
'data' => ['seen_keys' => [md5('p1'), md5('p2')]],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'1.1.1.1/*' => Http::response([
|
||||
['id' => 'p1'],
|
||||
['id' => 'p2'],
|
||||
['id' => 'p3'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create(['current_node_id' => 'http_1']);
|
||||
|
||||
$result = app(RunHttpRequestNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/posts',
|
||||
'method' => 'GET',
|
||||
'auth_type' => 'none',
|
||||
'item_key_path' => 'id',
|
||||
]);
|
||||
|
||||
expect($result->output['fetched'])->toBe(['id' => 'p3']);
|
||||
expect($result->output['fetch']['count'])->toBe(1);
|
||||
expect($result->output['fetch']['spawned'])->toBe(0);
|
||||
|
||||
$state = AutomationNodeState::for($automation->id, 'http_1');
|
||||
expect($state->data['seen_keys'])->toHaveCount(3);
|
||||
});
|
||||
|
||||
function httpAutomation(): Automation
|
||||
{
|
||||
return Automation::factory()->active()->create([
|
||||
'nodes' => [
|
||||
['id' => 'trigger_1', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'http_1', 'type' => 'http_request', 'position' => ['x' => 200, 'y' => 0], 'data' => []],
|
||||
['id' => 'end_1', 'type' => 'end', 'position' => ['x' => 400, 'y' => 0], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'trigger_1', 'target' => 'http_1'],
|
||||
['id' => 'e2', 'source' => 'http_1', 'target' => 'end_1'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,23 @@
|
|||
Http::assertSent(fn ($request) => $request->hasHeader('User-Agent', config('trypost.user_agent')));
|
||||
});
|
||||
|
||||
it('escapes special characters in templated payload values so the JSON stays valid', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response(['ok' => true], 200)]);
|
||||
|
||||
$run = AutomationRun::factory()->create([
|
||||
'context' => ['fetched' => ['title' => 'He said "hi" & bye']],
|
||||
]);
|
||||
|
||||
$result = app(RunWebhookNode::class)($run, [
|
||||
'url' => 'https://1.1.1.1/hook',
|
||||
'method' => 'POST',
|
||||
'payload_template' => '{"title":"{{ fetched.title }}"}',
|
||||
]);
|
||||
|
||||
expect($result->status)->toBe(Status::Completed);
|
||||
Http::assertSent(fn ($request) => $request['title'] === 'He said "hi" & bye');
|
||||
});
|
||||
|
||||
it('fails on 5xx response', function () {
|
||||
Http::fake(['1.1.1.1/*' => Http::response('err', 500)]);
|
||||
|
||||
|
|
|
|||
89
tests/Feature/Automation/Run/FanOutTest.php
Normal file
89
tests/Feature/Automation/Run/FanOutTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Actions\Automation\Run\AdvanceAutomationRun;
|
||||
use App\Jobs\Automation\ProcessAutomationNode;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\Post;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
it('fans out to all targets — continues the run on the first branch and forks a sibling for each other', function () {
|
||||
Bus::fake();
|
||||
|
||||
$automation = Automation::factory()->create([
|
||||
'nodes' => [
|
||||
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
['id' => 'a', 'type' => 'end', 'position' => ['x' => 1, 'y' => 0], 'data' => []],
|
||||
['id' => 'b', 'type' => 'end', 'position' => ['x' => 1, 'y' => 1], 'data' => []],
|
||||
['id' => 'c', 'type' => 'end', 'position' => ['x' => 1, 'y' => 2], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 't', 'target' => 'a'],
|
||||
['id' => 'e2', 'source' => 't', 'target' => 'b'],
|
||||
['id' => 'e3', 'source' => 't', 'target' => 'c'],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create(['context' => ['shared' => 'value']]);
|
||||
|
||||
app(AdvanceAutomationRun::class)($run, 't');
|
||||
|
||||
// One job per branch (the original run + two forked siblings).
|
||||
Bus::assertDispatchedTimes(ProcessAutomationNode::class, 3);
|
||||
|
||||
// Two sibling runs created (original + 2), each sharing the parent context.
|
||||
$runs = AutomationRun::where('automation_id', $automation->id)->get();
|
||||
expect($runs)->toHaveCount(3);
|
||||
$runs->each(fn ($r) => expect($r->context)->toBe(['shared' => 'value']));
|
||||
});
|
||||
|
||||
it('forks siblings that inherit the generated post, so a branch after Generate can publish', function () {
|
||||
Bus::fake();
|
||||
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$automation = Automation::factory()->create([
|
||||
'nodes' => [
|
||||
['id' => 'g', 'type' => 'generate', 'position' => ['x' => 0, 'y' => 0], 'data' => []],
|
||||
['id' => 'p', 'type' => 'publish', 'position' => ['x' => 1, 'y' => 0], 'data' => ['mode' => 'now']],
|
||||
['id' => 'w', 'type' => 'webhook', 'position' => ['x' => 1, 'y' => 1], 'data' => []],
|
||||
],
|
||||
'connections' => [
|
||||
['id' => 'e1', 'source' => 'g', 'target' => 'p'],
|
||||
['id' => 'e2', 'source' => 'g', 'target' => 'w'],
|
||||
],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'current_node_id' => 'g',
|
||||
'generated_post_id' => $post->id,
|
||||
]);
|
||||
|
||||
app(AdvanceAutomationRun::class)($run, 'g');
|
||||
|
||||
$sibling = AutomationRun::where('automation_id', $automation->id)
|
||||
->whereKeyNot($run->id)
|
||||
->sole();
|
||||
|
||||
expect($sibling->generated_post_id)->toBe($post->id);
|
||||
});
|
||||
|
||||
it('completes the run when a handle has no connected target', function () {
|
||||
Bus::fake();
|
||||
|
||||
$automation = Automation::factory()->create([
|
||||
'nodes' => [
|
||||
['id' => 't', 'type' => 'trigger', 'position' => ['x' => 0, 'y' => 0], 'data' => ['trigger_type' => 'schedule']],
|
||||
],
|
||||
'connections' => [],
|
||||
]);
|
||||
|
||||
$run = AutomationRun::factory()->for($automation)->create();
|
||||
|
||||
app(AdvanceAutomationRun::class)($run, 't');
|
||||
|
||||
expect($run->fresh()->status->value)->toBe('completed');
|
||||
Bus::assertNotDispatched(ProcessAutomationNode::class);
|
||||
});
|
||||
62
tests/Feature/Automation/Run/RunFamilyAggregationTest.php
Normal file
62
tests/Feature/Automation/Run/RunFamilyAggregationTest.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\Automation\Node\Type as NodeType;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Models\Automation;
|
||||
use App\Models\AutomationNodeRun;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->workspace = Workspace::factory()->create();
|
||||
$this->user = User::factory()->create(['current_workspace_id' => $this->workspace->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]);
|
||||
$this->user->refresh();
|
||||
});
|
||||
|
||||
it('aggregates node runs from every fan-out branch under the root run', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$root = AutomationRun::factory()->for($automation)->create();
|
||||
$sibling = AutomationRun::factory()->for($automation)->create(['root_run_id' => $root->id]);
|
||||
|
||||
AutomationNodeRun::factory()->create([
|
||||
'run_id' => $root->id,
|
||||
'node_id' => 'fetch_rss_1',
|
||||
'node_type' => NodeType::FetchRss,
|
||||
]);
|
||||
AutomationNodeRun::factory()->create([
|
||||
'run_id' => $sibling->id,
|
||||
'node_id' => 'http_1',
|
||||
'node_type' => NodeType::HttpRequest,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.automations.runs.show', [$automation, $root]));
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$nodeIds = collect($response->json('node_runs'))->pluck('node_id');
|
||||
expect($nodeIds)->toHaveCount(2)
|
||||
->and($nodeIds)->toContain('fetch_rss_1')
|
||||
->and($nodeIds)->toContain('http_1');
|
||||
});
|
||||
|
||||
it('returns the same aggregated family when queried from a sibling run', function () {
|
||||
$automation = Automation::factory()->for($this->workspace)->create();
|
||||
|
||||
$root = AutomationRun::factory()->for($automation)->create();
|
||||
$sibling = AutomationRun::factory()->for($automation)->create(['root_run_id' => $root->id]);
|
||||
|
||||
AutomationNodeRun::factory()->create(['run_id' => $root->id, 'node_id' => 'a']);
|
||||
AutomationNodeRun::factory()->create(['run_id' => $sibling->id, 'node_id' => 'b']);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->getJson(route('app.automations.runs.show', [$automation, $sibling]));
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('node_runs'))->toHaveCount(2);
|
||||
});
|
||||
Loading…
Reference in a new issue