mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
feat(plugin-workflow): migrate workflow settings page to client-v2 (#9645)
This commit is contained in:
@@ -20,6 +20,7 @@ If a file `AGENTS.local.md` exists in this repository root, read it once at the
|
||||
- For frontend components and pages, follow accessibility (a11y) best practices — add appropriate ARIA attributes, use semantic HTML, and ensure keyboard navigation works.
|
||||
- Do not use async IIFE patterns in event handlers (for example: `runAsyncTask((async () => { ... })())`). Extract the async logic into a named async function or call it directly.
|
||||
- Do not introduce new abstractions, error-handling layers, or feature flags beyond what the task requires. Three similar lines is better than a premature abstraction.
|
||||
- Do not hard-wrap `//` comments at a narrow width. Let each comment line run to the project's `printWidth` (120) before wrapping, so prose fills the line instead of breaking into many short, truncated-looking lines. Verbatim content stays as-is: ASCII diagrams, bullet/numbered lists, blank-line paragraph breaks, and directive lines (`eslint-disable*`, `@ts-*`, `prettier-ignore`) keep their own line breaks.
|
||||
|
||||
## Database & Migrations
|
||||
|
||||
|
||||
+141
@@ -44,3 +44,144 @@ _Avoid_: v2 dist, asset directory (without "build")
|
||||
## Flagged ambiguities
|
||||
|
||||
- **"v2"** was overloaded to mean three different things: (a) the **Modern client** runtime, (b) its URL **Modern client prefix**, and (c) the physical build-output directory name. Resolved: the runtime is the *modern client*; the URL segment is the *modern client prefix* (runtime-configurable, default `v`); the *modern client build directory* is a fixed internal constant (`v`), decoupled from the prefix so the prefix can change at runtime without rebuilding (see ADR-0001).
|
||||
|
||||
---
|
||||
|
||||
# Workflow Node Extension
|
||||
|
||||
How workflow node plugins contribute their config UI and output variables, and how the same node definition serves both the legacy and modern canvases during the migration. Seeded while planning the modern-client canvas migration.
|
||||
|
||||
## Language
|
||||
|
||||
**Instruction**:
|
||||
A workflow node type's client-side definition (e.g. `query`, `condition`, `delay`). A class that downstream plugins extend to register a node: it carries the node's static metadata (type, title, group, icon), its config UI, and its variable contributions. One `Instruction` instance per node type, held in the plugin's instruction registry.
|
||||
_Avoid_: node class, node handler (that's the server concern)
|
||||
|
||||
**Config UI**:
|
||||
The form shown in a node's configuration drawer. Has two forms during migration: the **legacy fieldset** (a Formily schema, rendered by the legacy canvas) and the **modern FieldsetLoader** (a lazy loader of a plain React + antd component, rendered by the modern canvas).
|
||||
_Avoid_: node form, settings form
|
||||
|
||||
**Legacy fieldset** (`fieldset`, lowercase):
|
||||
The Formily `Record<string, ISchema>` config form an Instruction has always carried. Pure data from the modern client's point of view — the modern canvas never interprets it; only the legacy canvas renders it through `SchemaComponent`.
|
||||
_Avoid_: schema fieldset
|
||||
|
||||
**Modern FieldsetLoader** (`FieldsetLoader`):
|
||||
A lazy loader — `() => Promise<{ default: ComponentType }>` — an Instruction optionally carries for the modern canvas (same `LoaderOf` shape as workflow trigger loaders). The loaded component is a plain React + antd form (no Formily) that reads/writes `config.*`. Its presence is the per-node migration switch: a node has migrated when it has a `FieldsetLoader`. (Distinguished from the legacy `fieldset` by **field name**, not letter case — see ADR-0003.)
|
||||
_Avoid_: React fieldset, config component, `Fieldset` (the contract is now a loader)
|
||||
|
||||
**Output variables** (`useVariables`):
|
||||
A hook each Instruction contributes describing the variables that node emits to downstream nodes (e.g. a query node emits the queried record's field tree). The core walks the current node's upstream chain, calls each upstream node's `useVariables`, and assembles the "Node result" branch of the variable tree. The contract keeps returning the legacy `VariableOption` shape during migration; the modern canvas adapts it to `MetaTreeNode` at the aggregation boundary.
|
||||
_Avoid_: node variables (ambiguous with config-time vs run-time)
|
||||
|
||||
**Workflow variable input**:
|
||||
The shared variable-picker embedded in node config forms, aggregating upstream-node outputs + trigger variables + scope variables + system variables + `$env`. The modern one reuses flow-engine's low-level `VariableHybridInput` (fed a workflow-constructed `MetaTreeNode` tree), not the top-level global `VariableInput` (whose tree is the global `getPropertyMetaTree()`). A downstream node author imports it from the workflow modern client and drops it in like any antd input — it reads the current node from **NodeContext** and the node list / workflow from **FlowContext** itself, so the author never wires context.
|
||||
_Avoid_: variable selector, variable picker (use consistently if at all)
|
||||
|
||||
**FlowContext** (canvas-level):
|
||||
The React context the modern canvas provides at its root, carrying `{ workflow, nodes, refresh }` — the whole node list, the workflow record, and a refetch callback. Every canvas concern (branch traversal, add/drag/remove, variable aggregation) reads it. Mirrors the legacy canvas's `FlowContext` of the same shape.
|
||||
_Avoid_: workflow context (collides with flow-engine's own FlowContext — this one is workflow-plugin-local)
|
||||
|
||||
**NodeContext** (node-level):
|
||||
The React context the modern canvas wraps around a single node (card + config drawer), carrying the node object itself (with live `upstream`/`downstream` linked-list refs) — `useNodeContext()` returns that node. Owned/provided by the workflow core; a downstream node author neither imports nor provides it. The **modern FieldsetLoader**'s loaded form renders inside it, and the shared **workflow variable input** consumes it (deriving `upstreams` via `useAvailableUpstreams(node)`). Mirrors the legacy `NodeContext.Provider value={data}` around the legacy `Node`.
|
||||
_Avoid_: workflow context
|
||||
|
||||
## Relationships
|
||||
|
||||
- An **Instruction** carries both **Config UI** (legacy fieldset and/or modern FieldsetLoader) and **Output variables**; these are independent extension points, not one schema.
|
||||
- A node has **migrated to the modern canvas** when it gains a **modern FieldsetLoader**; the **legacy fieldset** may remain so the legacy canvas keeps working until the node is fully cut over.
|
||||
- The **Instruction** class definition lives in the modern client (`src/client-v2/`); the legacy canvas reaches it via the allowed `v1 → v2` import direction. The legacy Formily *rendering* (SchemaComponent, `Node`, etc.) stays in `src/client/`.
|
||||
- **FlowContext** (canvas-level) and **NodeContext** (node-level) are two separate contexts mirroring v1, each with its own job; the **modern FieldsetLoader**'s form and the **workflow variable input** derive everything else (`upstreams`, etc.) from these two via hooks rather than receiving a merged context value.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **`fieldset` vs `FieldsetLoader`** — distinguished by **field name**, not letter case (an earlier draft used case-sensitive `fieldset`/`Fieldset`; superseded by ADR-0003). `fieldset` = legacy Formily schema (data, pass-through); `FieldsetLoader` = modern lazy loader of a React form. The `FieldsetLoader`'s presence is the per-node migration switch. (See ADR-0002 as amended by ADR-0003.)
|
||||
- **Node context shape** — an earlier config-UI draft modeled the per-node context as a single `WorkflowNodeContext` carrying `{ node, workflow, upstreams }`. Resolved during canvas planning: align with v1's two-context split instead — **FlowContext** `{ workflow, nodes, refresh }` at the canvas root + **NodeContext** = the node object at each node. `workflow`/`upstreams` are derived via hooks, not bundled into a node-context value.
|
||||
|
||||
---
|
||||
|
||||
# Workflow Trigger Extension
|
||||
|
||||
How workflow trigger plugins contribute trigger metadata, configuration forms, manual-execution inputs, and trigger variables during the migration.
|
||||
|
||||
## Language
|
||||
|
||||
**Trigger**:
|
||||
A workflow trigger type's client-side definition (e.g. `collection`, `schedule`). A class registered by type that carries trigger metadata (title, description, sync mode), configuration UI, manual-execution UI, validation, trigger variables, and block-creation hooks.
|
||||
_Avoid_: trigger option (too narrow), trigger handler (that's the server concern)
|
||||
|
||||
**Trigger config UI**:
|
||||
The form shown when configuring a workflow's trigger. Has two forms during migration: the **legacy trigger fieldset** (a Formily schema, rendered by legacy surfaces) and the **modern trigger FieldsetLoader** (a lazy loader of a plain React + antd component, rendered by modern surfaces).
|
||||
_Avoid_: workflow form, trigger settings (ambiguous with workflow metadata)
|
||||
|
||||
**Legacy trigger fieldset** (`fieldset`, `presetFieldset`, `triggerFieldset`):
|
||||
The Formily schema maps a Trigger may carry for its three legacy surfaces: create-time preset config, trigger configuration, and manual execution variables. The modern client never interprets these schemas.
|
||||
_Avoid_: trigger schema (too broad)
|
||||
|
||||
**Modern trigger loaders** (`PresetFieldsetLoader`, `FieldsetLoader`, `TriggerFieldsetLoader`):
|
||||
Lazy loaders a Trigger may carry for the same three surfaces: create-time preset config, trigger configuration, and manual execution variables. The loaded components are plain React + antd forms and use the same loader naming convention as **Instruction**.
|
||||
_Avoid_: createConfigFormLoader (retired name)
|
||||
|
||||
**Trigger variables**:
|
||||
Variables contributed by the workflow's trigger under `$context` (for example schedule trigger time or trigger data). During migration the hook remains named `useVariables` and returns `VariableOption`; the modern variable aggregator adapts it to `MetaTreeNode`.
|
||||
_Avoid_: context variables (too broad)
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Trigger** carries both legacy fieldsets and modern trigger loaders; each surface can migrate independently.
|
||||
- The Trigger contract lives in the modern client, and the legacy client may import or extend it through the allowed `v1 -> v2` direction.
|
||||
- On legacy surfaces, a non-empty legacy trigger fieldset wins. When that fieldset is absent and the matching modern trigger loader exists, the legacy surface opens the modern implementation.
|
||||
- The modern client never imports legacy trigger files or legacy Formily rendering.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **`createConfigFormLoader` vs `PresetFieldsetLoader`** — `createConfigFormLoader` was an early v2 registry option name. The trigger API now aligns with Instruction naming: `PresetFieldsetLoader` is the create-time trigger preset loader.
|
||||
|
||||
---
|
||||
|
||||
# Workflow Canvas
|
||||
|
||||
The node-graph editor where a workflow's nodes are laid out, connected, added, removed, dragged, and configured. There are two parallel implementations during migration; this section names them and their shared substrate. Seeded while planning the canvas migration to client-v2.
|
||||
|
||||
## Language
|
||||
|
||||
**Legacy canvas**:
|
||||
The v1 node-graph editor (`src/client/`: `WorkflowCanvas`, `CanvasContent`, `Branch`, `Node`), reached at `/admin/settings/workflow/workflows/:id` from the legacy settings list. Hand-rolled DOM + flexbox recursive render (no graph library); its config drawer / add-node menu / remove-branch modal are Formily.
|
||||
_Avoid_: v1 canvas (in prose), old editor
|
||||
|
||||
**Modern canvas**:
|
||||
The client-v2 node-graph editor (`src/client-v2/`), reached at `/admin/workflow/workflows/:id` from the **WorkflowPane** list. Renders the same node tree without Formily.
|
||||
_Avoid_: v2 canvas (in prose), new editor
|
||||
|
||||
**Parallel-worlds coexistence**:
|
||||
The two canvases are independent destinations over the *same* `workflows` + `flow_nodes` data, distinguished only by URL/entry list — not by any per-workflow flag. A workflow opens in whichever canvas its URL belongs to. The legacy canvas retires by deleting the legacy settings list + route once the modern canvas reaches parity.
|
||||
_Avoid_: canvas toggle, canvas feature flag (there is none)
|
||||
|
||||
**Runtime separation**:
|
||||
The legacy client runs at `/` and loads only each plugin's `client` entry; the modern client runs at `/v/` and loads only each plugin's `client-v2` entry. They never coexist in one browser runtime, so each has its own `app`/PluginManager and its own `'workflow'` plugin instance. Relocating code to client-v2 is *build-time* source sharing (bundled into v1's own output); it is orthogonal to this *runtime* separation.
|
||||
_Avoid_: shared runtime, single app instance (there are two, one per client)
|
||||
|
||||
**Instruction registry (per-runtime)**:
|
||||
Each runtime's `'workflow'` plugin holds its own instruction registry, self-populated by node plugins' entries for that runtime (`registerInstruction` from `client` fills v1's; from `client-v2` fills v2's) — mirroring the existing v2 trigger registry. The modern canvas reads only its own v2 registry (`plugin.getInstruction(type)`); a type registered only in v1 is omitted from the v2 add-node menu and renders a placeholder card if already present in a workflow.
|
||||
_Avoid_: shared registry, cross-runtime instruction read (there is none)
|
||||
|
||||
**Node tree**:
|
||||
The in-memory doubly-linked structure the canvas renders, built from the flat `flow_nodes` list by `linkNodes()` (sets live `upstream`/`downstream` refs). A branch is a node with `branchIndex != null` under a branching node (its `upstreamId`). Pure data — no Formily — so it ports verbatim to the modern canvas.
|
||||
_Avoid_: node graph (reserve for the rendered view), node list (that's the flat form)
|
||||
|
||||
**Block-creation menu item** (`getCreateModelMenuItem`):
|
||||
The Instruction method that lets a node's output be added as a *data block* inside a config drawer ("create block → node data → query data"). The v2-native counterpart of v1's `useInitializers`: same intent, but it returns a FlowModel `SubModelItem` (fed to the v2 sub-model menu, e.g. `NodeDetailsModel`) instead of a Formily `SchemaInitializerItemType`. It already exists in v2 — node authors do not migrate it, they keep both during transition.
|
||||
_Avoid_: initializer (that's the v1 term `useInitializers`)
|
||||
|
||||
## Relationships
|
||||
|
||||
- The **Legacy canvas** and **Modern canvas** are **Parallel-worlds coexistence** over one dataset; neither is the other's parent, and there is no runtime flip between them.
|
||||
- A **Modern canvas** renders the same **Node tree** as the legacy one; when it opens a node, that node's **Config UI** is chosen by the per-node `fieldset`/`FieldsetLoader` switch (see Workflow Node Extension) — so the *page-level* canvas choice and the *per-node* config-UI choice are independent axes.
|
||||
- **Two nested layers, two paradigms**: the **Modern canvas** shell (cards, lines, branches, drag) is React context (FlowContext/NodeContext), *not* FlowModel; but a *data block created inside a node's config drawer* is a genuine FlowModel sub-model. The **Block-creation menu item** is the bridge — it runs on the (shared) Instruction, reads the canvas-layer `{ node, workflow }`, and emits a FlowModel-layer `SubModelItem`. The two layers stay decoupled: canvas context reaches the block model only via its `inputArgs`.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **"canvas switch" is two different axes** — (1) *which canvas* (page-level, = URL/entry list, no flag) and (2) *which config UI a node uses inside the modern canvas* (per-node, = `FieldsetLoader` presence). They compose; they are not the same switch.
|
||||
|
||||
**Unmigrated-node placeholder**:
|
||||
In the modern canvas, a node whose Instruction still has only `fieldset` (no `FieldsetLoader`) renders its card normally (topology is intact) but its config drawer shows a placeholder ("config UI not yet migrated"), not a Formily form. This keeps the modern canvas shippable before any config UI migrates — the two axes stay orthogonal. Rendering Formily as a fallback is forbidden (would drag the Formily runtime into client-v2).
|
||||
_Avoid_: fallback form, legacy drawer (the modern canvas never renders `fieldset`)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Workflow Instruction definition lives in the modern client, with case-sensitive `fieldset`/`Fieldset` as the per-node migration switch
|
||||
|
||||
> **Amended by [ADR-0003](./0003-workflow-canvas-progressive-migration.md):** the per-node config-UI switch is no longer the case-sensitive `fieldset`/`Fieldset` pair but a distinct field name — the modern field is a lazy loader, `FieldsetLoader` (`() => Promise<{ default: ComponentType }>`), sitting beside the legacy lowercase `fieldset` data. The switch is now **field-name-based**, not case-based. Everything else below (relocation to client-v2, `import type { ISchema }` legality, the `useVariables` core adapter and its coverage) still holds; read `Fieldset` below as "the modern config UI extension point, now spelled `FieldsetLoader`".
|
||||
|
||||
The workflow node extension contract (the `Instruction` class) is relocated into the modern client (`src/client-v2/`), so node plugins extend a single definition that serves both canvases. A node's config UI is migrated incrementally by adding an uppercase `Fieldset` (a plain React + antd component the modern canvas renders) alongside — or eventually replacing — the legacy lowercase `fieldset` (a Formily schema the legacy canvas renders through `SchemaComponent`). The modern canvas prefers `Fieldset`; the legacy canvas keeps using `fieldset`. This lets the ~10 core nodes and 6+ pro-plugin nodes migrate one node at a time rather than in a single cutover.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **(A, chosen) Relocate the `Instruction` class to the modern client; legacy canvas reaches it via `v1 → v2` import.** The case-sensitive `fieldset` (legacy Formily) / `Fieldset` (modern React) pair on one shared definition is the per-node migration switch. Legal because the repo's import rule is one-way: v1 may import v2, never the reverse. The base class carries `fieldset?: Record<string, ISchema>` as a **type-only** `import type { ISchema }` — erased at build time, zero runtime, no Formily in the modern runtime. (Precedent: `@nocobase/client-v2`'s `CollectionFieldInterface.ts` and `VariableFilterItem.tsx` already do `import type { ISchema }`.)
|
||||
- **(B) Two independent instruction registries (v1 and v2); downstream double-registers via a v1-imports-v2 shim.** Rejected: two sources of truth long-term, and every downstream node needs a bridge file — more ceremony than (A) while delivering the same progressive migration.
|
||||
- **(C) Fully independent v2 registration; legacy untouched, no shared definition.** Rejected: cleanest re-architecture but abandons the "share one definition, migrate one field at a time" goal — every node would be re-registered from scratch for v2.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Only the **data/type** parts of `Instruction` (the class + pure hooks like `useAvailableUpstreams`) move to the modern client. The legacy Formily **rendering** (`Node`, `NodeDefaultView`, the `SchemaComponent` config drawer in `nodes/index.tsx`) stays in `src/client/` — moving it would drag Formily runtime into v2 and break the rule.
|
||||
- The base class keeps legacy-only data fields (`fieldset`, `view`, `scope`, `components`) as pass-through data the modern canvas does not interpret; only the legacy canvas consumes them. New modern fields are `Fieldset?: React.ComponentType<…>` and `useVariables` returning `MetaTreeNode` (not the legacy `VariableOption`).
|
||||
- Downstream pro plugins must repoint their `extends Instruction` import to the modern base class. A node migrates by gaining a `Fieldset`; its `fieldset` may stay until the legacy canvas is retired for that node.
|
||||
- **Doc/code conflict to resolve:** the migration skill's verify step greps `src/client-v2/` for `from '@formily/'` and requires zero matches, which would flag the legal `import type { ISchema }`. The skill should be amended to allow `import type` from `@formily/*` (type-only, zero runtime), matching what `@nocobase/client-v2` core already does.
|
||||
|
||||
## Output variables: a core adapter, not per-node rewrites
|
||||
|
||||
During migration, a node's `useVariables` (which returns the legacy `VariableOption` tree) is left untouched; the modern canvas converts its aggregated upstream variables to `MetaTreeNode` via a single core adapter (`VariableOption → MetaTreeNode`). A node author migrates by adding a `Fieldset` only — they never touch `useVariables`. This deliberately borrows the mature legacy field-tree logic (`getCollectionFieldOptions`: relation lazy-loading, type filtering, foreign-key handling — ~250 lines, the bug-prone heart of the variable system) rather than rewriting it concurrently with the dual-canvas migration. Rewriting that logic into a native modern field-tree builder + per-node `useVariablesV2` is deferred to a separate cleanup once the legacy canvas retires and the dual-canvas complexity is gone.
|
||||
|
||||
**Coverage is provable, not assumed.** The modern variable consumers (`FlowContextSelector` cascader, `VariableHybridInput.walk`, `VariableTag`) read exactly 7 `MetaTreeNode` fields: `title` (←`label`), `name` (←`value`), `children` (←`children`/`loadChildren`→`() => Promise`), `disabled` (←`disabled`), `disabledReason` (nullable), `type`/`interface` (only for custom `render`, derivable from `field`), and `paths`. Of these, only **`paths`** has no `VariableOption` counterpart and must be constructed by the adapter — it accumulates the parent path down the recursion (and through the `loadChildren` closure for lazy children). Everything else is a direct map or nullable. The v1-only keys (`field`/`types`/`appends`/`depth`) are captured in the adapter's `loadChildren` closure and never surface on the produced `MetaTreeNode`.
|
||||
|
||||
The adapter ships with tests pinning: basic field mapping, `paths` accumulation across nesting + lazy `loadChildren`, the "v1-only keys never leak onto MetaTreeNode" assertion, and a `formatPathToValue`/`parseValueToPath` round-trip. The adapter is a pure, context-free function so the whole suite is deletable in one move when the legacy logic is finally rewritten.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Workflow canvas migrates as parallel worlds, with v2-owned contracts imported back by the legacy canvas
|
||||
|
||||
The v1 workflow canvas (node-graph editor) and the new client-v2 canvas coexist as **parallel worlds** over the same `workflows` + `flow_nodes` data: distinguished only by URL/entry list (`/admin/settings/workflow/workflows/:id` reached from the legacy settings list vs `/admin/workflow/workflows/:id` reached from the `WorkflowPane` list), with **no per-workflow flag and no runtime flip**. The shared substrate the canvas depends on (`Instruction`, `Trigger`, dependency-free canvas contexts, render-dispatch helpers, `linkNodes`, the `getCollectionFieldOptions` field-tree builder, `nodeVariableUtils`, drag/clipboard pure logic, and the stylesheet) is **owned from `src/client-v2/` and imported back by the legacy canvas** via the allowed `v1 → v2` direction — one canonical source where possible, not two. The legacy canvas retires by deleting its settings list + route once the modern canvas reaches parity.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **(A, chosen) Parallel worlds + v2-owned contracts and shared substrate.** Two canvases over one dataset, switched by URL only. Formily-free shared substrate lives once in client-v2 and v1 imports it back. This now includes pure functions, dependency-free contexts, render-dispatch helpers, and the `Instruction` / `Trigger` contracts. Hook-ful provider shells that own runtime-specific side effects may still stay per-canvas, but once a surface has a v2 loader the legacy surface can call that v2 implementation instead of keeping a second copy.
|
||||
- **(B) One canvas URL, runtime flip by flag.** A single route renders v1 or v2 based on a per-workflow column / feature toggle. Rejected: forces the two canvases to share a mount point (dragging the settings/ProLayout shell back in) and creates a "half-migrated workflow" state to reason about, for no benefit over (A).
|
||||
- **(C) Rewrite the canvas pure logic independently in v2.** Rejected: the field-tree builder (~250 lines: relation lazy-load, type filtering, foreign keys) and the drag *calculations* (drop-impact, upstream/downstream collection) are the most bug-prone parts of the system; maintaining two copies of *those* during the dual-canvas period is exactly the risk (A) avoids — so they are shared as pure functions. (The drag/clipboard *Provider shells*, being hook-ful and side-effectful, are genuinely written twice — but they are thin wiring around the shared pure core, not the bug-prone logic.) v2 reuses the shared field-tree code, only adapting `VariableOption → MetaTreeNode` at the very end.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Import direction inverts for relocated shared code.** After relocation, `src/client/` imports v2-owned contracts, dispatch helpers, contexts, field-tree logic, node-tree logic, and selected UI openers from the workflow plugin's `src/client-v2/`. A future reader seeing the legacy canvas import workflow logic from v2 should expect this — it is the deliberate mechanism that lets one implementation serve both canvases.
|
||||
- **The build boundary allows v2-owned code to run in the legacy bundle, but v2 still cannot import v1.** The relocation is **build-time source sharing**: v1's `src/client/` imports the moved client-v2 modules and they are bundled into v1's own output. Runtime separation still holds (see below). Code shared this way must be either dependency-free or depend only on APIs valid in both runtimes (`@nocobase/client-v2`, `@nocobase/flow-engine`, antd, framework-neutral utilities). It must never import `@nocobase/client` from `src/client-v2/`. When shared code needs runtime state, v1 opens it through the current FlowEngine context or passes dependencies explicitly.
|
||||
- **The two clients are separate *runtimes*, so the instruction registry is per-runtime self-populated — not shared, not cross-read.** The v1 client runs at `/` and loads each plugin's `client` entry; the v2 client runs at `/v/` and loads each plugin's `client-v2` entry. They never coexist in one browser runtime. Consequently each has its own `app`/PluginManager and its own `'workflow'` plugin instance with its own instruction registry: v1's registry is filled by node plugins' `client` entries (`pm.get('workflow').registerInstruction(...)`), v2's by their `client-v2` entries — exactly as the v2 **trigger** registry already works (`PluginWorkflowClientV2.triggers`, self-populated; downstream `client-v2` plugins call `pm.get('workflow').registerTrigger(...)` and resolve to the v2 instance). The modern canvas therefore reads **its own** v2 registry (`plugin.getInstruction(type)`), never v1's. There is no cross-runtime reference and no iron-rule hazard. A node type registered only in v1 simply isn't in the v2 registry: it is **omitted from the v2 add-node menu**, and an existing node of that type renders a **placeholder card** (topology intact), mirroring v1's "unknown node" branch.
|
||||
- **Provider sharing is decided by dependencies, not by the file being a Provider.** Dependency-free contexts such as `FlowContext` / `NodeContext` are shared from client-v2. Provider shells with runtime-specific hooks or pointer side effects may stay per-canvas while sharing their pure core. When a v2 loader owns a whole surface, the legacy canvas can open that v2 surface directly.
|
||||
- **`compile` equivalence is a load-bearing, tested constraint.** The relocated field-tree builder uses `compile` only to expand **field titles** (plain strings or `{{t("…")}}` i18n templates). Within that scope, v1's `useCompile` (Formily `Schema.compile`) and v2's `useT()` (`flowEngine.translate`, which natively expands `{{t(…)}}`) behave identically. They are *not* equivalent for arbitrary scope expressions (`{{fn(arg)}}`, `{{$deps[0]}}`) — but field titles never carry those. A characterization test pins this with an assertion that both expand the same `{{t(…)}}` title to the same translation; if a non-i18n expression ever reaches a field title, that test fails and surfaces the drift.
|
||||
- **Render-extension points become loaders, distinguished by field name.** A node's in-canvas render and config UI are independent migration points. The modern canvas reads loader fields on the Instruction — `ComponentLoader`, `FieldsetLoader`, `PresetFieldsetLoader`. Triggers follow the same naming: `PresetFieldsetLoader`, `FieldsetLoader`, `TriggerFieldsetLoader`. These are `() => Promise<{ default: ComponentType }>` loaders rendered with Suspense. The legacy lowercase data fields (`fieldset`, `presetFieldset`, `triggerFieldset`, `view`, `scope`, `components`) remain pass-through until the legacy surface drops them, at which point it falls through to the matching v2 loader.
|
||||
- **`Branch`/`CanvasContent`/`BranchContext` are relocated as a *second copy*, not shared.** Unlike the pure logic, these couple to `<Node>` (whose card differs between v1 and v2), so v2 gets its own `Branch` and v1 keeps its own until retirement. The shared-one-copy rule applies only to Formily-free, Node-independent logic.
|
||||
- **`useVariables` stays untouched; the adapter never reaches v1.** Each Instruction's `useVariables` keeps returning the legacy `VariableOption`. Only the v2 aggregator wraps it in the `VariableOption → MetaTreeNode` adapter; the v1 aggregator (`client/variable.tsx`) consumes `VariableOption` directly and has no code path to the adapter. The adapter is a v2-only consumer of a shared data source, provably isolated.
|
||||
- **Test strategy: characterization baseline before the move.** Before relocating, golden characterization tests are written on the v1 side (injected mock `compile`/`collectionManager`) covering the full pure-logic surface; after relocation the same v1 tests re-run green (proving v1's behavior is unchanged through the back-import), and equivalent v2 tests run the same mock inputs. DOM/pointer side effects of drag/clipboard stay covered by the existing e2e; only their pure functions (`getDropImpact` math, `collectUpstreams`/`collectDownstreams`) get unit baselines. The v1 characterization tests are deleted with v1 at retirement; the v2 tests persist.
|
||||
|
||||
## Addendum: the `condition` node sets the per-node migration pattern
|
||||
|
||||
The `condition` node is the first core node to land **all three** modern loaders end-to-end; the choices it forced are the template every subsequent node follows.
|
||||
|
||||
- **`NodeDefaultView` is extracted and exported; `ComponentLoader` is whole-card replacement (mirrors v1's `Component`).** `Node.tsx`'s registered-node card is factored into an exported `NodeDefaultView({ data, children })` carrying all card chrome (tag, editable title, `…` menu, drag mousedown, click-to-open-config, copy/drag highlight) plus a `children` slot. The default render is `<NodeDefaultView data />`; when an instruction has a `ComponentLoader`, `NodeCard` renders the loader instead, and the loader re-wraps `<NodeDefaultView data>{subtree}</NodeDefaultView>` (the condition node appends its Yes/No `<Branch>` subtrees). This keeps the v1 "`Component` replaces the whole card" semantics — branch nodes self-render their nested branches — while sharing one card implementation.
|
||||
- **The add-node preset flow is wired to `PresetFieldsetLoader` + `DownstreamBranchIndex`.** `AddNodeContext.onCreate` opens a small `ctx.viewer.dialog` (v1's `Action.Modal` analogue) when the instruction has a `PresetFieldsetLoader` **OR** a branching node is inserted above an existing downstream node. The dialog hosts the lazy preset form plus the downstream-placement radio, and submission creates the node then re-parents the downstream node into the chosen branch — byte-for-byte the behaviour of v1's `useAddNodeSubmitAction`. Mode (`rejectOnFalse`) is chosen here and rendered read-only in the config drawer, because the branch topology can't be flipped cleanly after the fact.
|
||||
- **Per-node config components are re-authored in `client-v2`, mirroring v1 paths/names.** `Calculation.tsx`, `RadioWithTooltip.tsx`, `renderEngineReference.tsx` live at the same relative paths under `client-v2/components/` as their v1 counterparts (low cognitive cost), Formily-free: `css`/`cx` from `@emotion/css`, `useCompile`→`useT`, and pure helpers take an injected `t`. v1's copies are untouched.
|
||||
- **The variable aggregator is restructured into v1's multi-scope shape, lit progressively.** `useWorkflowVariableOptions` now concatenates per-scope contributors (`$jobsMapByNodeKey`, `$env`, `$context`, `$system`, `$scopes`) and filters empties — same shape as v1. Two scopes are live (node-result via `useVariables` + adapter; `$env` from the global `getPropertyMetaTree()`, independent of any node/trigger migration); the other three are explicit empty stubs lit when their v2 data sources exist (triggers' `useVariables`, a `systemVariables` registry, branch nodes' `useScopeVariables`). Lighting one up is filling a stub, not restructuring.
|
||||
- **Calculation operands reuse the core `TypedVariableInput` via an injected `metaTree`.** Rather than duplicate the constant-or-variable switcher, the core `@nocobase/client-v2` `TypedVariableInput` gained an optional `metaTree` prop (skip the global tree, use the injected one) plus lazy `loadData` for function-children (relation field drill-down) — a backward-compatible enhancement (existing `namespaces`/`extraNodes` callers are unaffected; their trees are pre-resolved arrays). The workflow operand is then a one-line `<TypedVariableInput metaTree={useWorkflowVariableOptions()} />`, structurally matching v1's one-line `<Variable.Input useTypedConstant scope={…} />`. The expression field (non-basic engines) reuses the existing `WorkflowVariableInput` (single-line `VariableHybridInput → FlowContextSelector`), which already carries double-click-to-select and lazy loading for free.
|
||||
@@ -68,7 +68,7 @@
|
||||
"type": "custom-link",
|
||||
"label": "Внешний NocoBase",
|
||||
"link": "/data-sources/data-source-external-nocobase/"
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "custom-link",
|
||||
"label": "Источник данных KingbaseES",
|
||||
|
||||
@@ -190,6 +190,10 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
||||
<Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
|
||||
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
||||
</Form.Item>
|
||||
|
||||
// Inject a custom variable tree (e.g. a workflow node's upstream outputs,
|
||||
// which are not in the global registry)
|
||||
<TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
|
||||
```
|
||||
|
||||
Key props:
|
||||
@@ -197,6 +201,7 @@ Key props:
|
||||
- `types`: allowed constant types. Shape mirrors v1 `useTypedConstant` — pass bare type names (`['number', 'boolean']`) or `[type, editorProps]` tuples (`[['number', { min, max, step }]]`) to forward props to the underlying antd editor. Defaults to `['string', 'number', 'boolean', 'date']`. **Even when only one type is allowed, the `Constant` entry still expands into a typed submenu** (Number / Boolean / Date / String) — matches v1 so users can see what type the constant is
|
||||
- `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
|
||||
- `extraNodes`: static leaves appended after the namespace-filtered nodes
|
||||
- `metaTree`: **inject the variable tree directly** instead of reading the global `flowEngine.context` meta tree. When set, `namespaces`/`extraNodes` are ignored and this tree is used verbatim — for context-scoped variable sources that are not in the global registry (typically a workflow node's upstream outputs, `$jobsMapByNodeKey`). Nodes whose `children` is a thunk (`() => Promise<MetaTreeNode[]>`) are **lazy-loaded on expand** (via flow-engine's `loadMetaTreeChildren`)
|
||||
- `nullable`: whether to expose the `Null` switcher entry. Default `true`. Combined with `Form.Item.rules={[{ required: true }]}`, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairing
|
||||
- `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
|
||||
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
|
||||
@@ -212,9 +217,10 @@ When **not** to use it:
|
||||
- **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
|
||||
- **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$env`-only, with optional password masking) or `VariableInput` (general-purpose)
|
||||
|
||||
Supported constant types: `string` / `number` / `boolean` / `date` / `object`. The `object` (JSON) type renders an inline monospace textarea (2 rows by default, drag-resizable) — it keeps the raw text as a draft while editing and `JSON.parse`s it back into an object on blur. On a parse failure it shows the raw `JSON.parse` message (e.g. `Expected property name or '}' in JSON at position …`, matching v1) on its own row below the input, and does not emit. Mirrors v1 `useTypedConstant`'s object form (default value `{}`).
|
||||
|
||||
Capabilities skipped (present in v1, not yet ported to v2):
|
||||
|
||||
- `object` constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete caller
|
||||
- Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
|
||||
|
||||
#### FileSizeInput
|
||||
|
||||
@@ -190,6 +190,9 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
||||
<Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
|
||||
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
|
||||
</Form.Item>
|
||||
|
||||
// 注入自定义变量树(如工作流节点的上游输出,不在全局注册表里)
|
||||
<TypedVariableInput types={['string', 'number']} metaTree={workflowMetaTree} />
|
||||
```
|
||||
|
||||
主要属性:
|
||||
@@ -197,6 +200,7 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
||||
- `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
|
||||
- `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
|
||||
- `extraNodes`:在命名空间过滤后追加几条静态变量节点
|
||||
- `metaTree`:**直接注入变量树**,取代读取全局 `flowEngine.context` 的 MetaTree。传了它就**忽略** `namespaces`/`extraNodes`,原样使用这棵树——用于不在全局注册表里的、上下文相关的变量源(典型如工作流节点的上游节点输出 `$jobsMapByNodeKey`)。树里 `children` 为函数(`() => Promise<MetaTreeNode[]>`)的节点会在用户展开 Cascader 时**按需懒加载**(复用 flow-engine 的 `loadMetaTreeChildren`)
|
||||
- `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
|
||||
- `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
|
||||
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
|
||||
@@ -212,9 +216,10 @@ import { TypedVariableInput } from '@nocobase/client-v2';
|
||||
- **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
|
||||
- **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
|
||||
|
||||
支持的常量类型:`string` / `number` / `boolean` / `date` / `object`。其中 `object`(即 JSON)渲染为一个等宽字体的内联 textarea(默认两行、可拖拽拉伸),编辑时保留原始文本草稿、失焦(blur)时 `JSON.parse` 回写为对象;解析失败则在输入框**下方单独一行**显示原生 `JSON.parse` 的错误信息(如 `Expected property name or '}' in JSON at position …`,对齐 v1)且不回写。对齐 v1 `useTypedConstant` 的 object 形态(默认值 `{}`)。
|
||||
|
||||
跳过的能力(v1 有但 v2 还没补):
|
||||
|
||||
- `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
|
||||
- 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
|
||||
|
||||
#### FileSizeInput
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { MenuOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { Badge, Button, Dropdown, Space, Tabs } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* A category descriptor for {@link SortableCategoryTabs}. The component is
|
||||
* i18n-agnostic: `label` is rendered verbatim, so consumers pass an
|
||||
* already-compiled/translated node. `color` is an antd preset color name or a
|
||||
* hex string; `'default'` / `undefined` renders no badge color.
|
||||
*/
|
||||
export type SortableCategoryTabItem = {
|
||||
id: string | number;
|
||||
label: React.ReactNode;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export type SortableCategoryTabsProps = {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
/** Draggable category tabs (the fixed leading tab is configured via `allTab`). */
|
||||
categories: SortableCategoryTabItem[];
|
||||
/** The fixed, non-draggable leading tab, e.g. "All". */
|
||||
allTab: { key: string; label: React.ReactNode };
|
||||
/** Show the "+" add button and handle its click. */
|
||||
onAdd?: () => void;
|
||||
/** Per-category edit menu item handler; menu is hidden when omitted. */
|
||||
onEdit?: (id: string | number) => void;
|
||||
/** Per-category delete menu item handler; menu is hidden when omitted. */
|
||||
onDelete?: (id: string | number) => void;
|
||||
/** Reorder handler. Receives the dragged and dropped category ids. */
|
||||
onSort?: (sourceId: string | number, targetId: string | number) => void | Promise<void>;
|
||||
/** Consumer-translated labels for the per-category dropdown menu. */
|
||||
menuLabels?: { edit?: string; delete?: string };
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function DraggableTab(props: { id: string; children: React.ReactNode }) {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({ id: props.id });
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableTab(props: { id: string; children: React.ReactNode }) {
|
||||
const { isOver, setNodeRef } = useDroppable({ id: props.id });
|
||||
return (
|
||||
<div ref={setNodeRef} style={isOver ? { color: 'green' } : undefined}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTabContent(props: {
|
||||
item: SortableCategoryTabItem;
|
||||
onEdit?: (id: string | number) => void;
|
||||
onDelete?: (id: string | number) => void;
|
||||
menuLabels?: { edit?: string; delete?: string };
|
||||
}) {
|
||||
const { item, onEdit, onDelete, menuLabels } = props;
|
||||
const hasMenu = Boolean(onEdit || onDelete);
|
||||
const editLabel = menuLabels?.edit ?? 'Edit';
|
||||
const deleteLabel = menuLabels?.delete ?? 'Delete';
|
||||
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Badge color={item.color === 'default' ? undefined : item.color} />
|
||||
{item.label}
|
||||
{hasMenu ? (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
onEdit ? { key: 'edit', label: editLabel } : null,
|
||||
onDelete ? { key: 'delete', label: deleteLabel } : null,
|
||||
].filter(Boolean) as { key: string; label: string }[],
|
||||
onClick({ key, domEvent }) {
|
||||
domEvent.stopPropagation();
|
||||
if (key === 'edit') {
|
||||
onEdit?.(item.id);
|
||||
return;
|
||||
}
|
||||
onDelete?.(item.id);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label={editLabel}
|
||||
icon={<MenuOutlined />}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A draggable, editable category tab bar. Renders only the tab bar (no content
|
||||
* panes) — the page renders its content below and reacts to `activeKey`.
|
||||
*
|
||||
* Shared by settings pages that group records under reorderable categories
|
||||
* (data-source collections, workflows, …). Drag-to-reorder is powered by
|
||||
* `@dnd-kit`; the "+" / per-tab edit-delete affordances are optional.
|
||||
*/
|
||||
export function SortableCategoryTabs(props: SortableCategoryTabsProps) {
|
||||
const { activeKey, onChange, categories, allTab, onAdd, onEdit, onDelete, onSort, menuLabels, className } = props;
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 10 } }));
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveDragId(String(event.active.id));
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveDragId(null);
|
||||
if (!over || over.id === active.id) {
|
||||
return;
|
||||
}
|
||||
await onSort?.(active.id as string | number, over.id as string | number);
|
||||
},
|
||||
[onSort],
|
||||
);
|
||||
|
||||
const items = useMemo<TabsProps['items']>(
|
||||
() => [
|
||||
{ key: allTab.key, label: allTab.label, closable: false },
|
||||
...categories.map((item) => ({
|
||||
key: String(item.id),
|
||||
closable: false,
|
||||
label: onSort ? (
|
||||
<DroppableTab id={String(item.id)}>
|
||||
<DraggableTab id={String(item.id)}>
|
||||
<CategoryTabContent item={item} onEdit={onEdit} onDelete={onDelete} menuLabels={menuLabels} />
|
||||
</DraggableTab>
|
||||
</DroppableTab>
|
||||
) : (
|
||||
<CategoryTabContent item={item} onEdit={onEdit} onDelete={onDelete} menuLabels={menuLabels} />
|
||||
),
|
||||
})),
|
||||
],
|
||||
[allTab.key, allTab.label, categories, onSort, onEdit, onDelete, menuLabels],
|
||||
);
|
||||
|
||||
const activeDragItem = useMemo(
|
||||
() => categories.find((item) => String(item.id) === activeDragId),
|
||||
[categories, activeDragId],
|
||||
);
|
||||
|
||||
const tabs = (
|
||||
<Tabs
|
||||
className={className}
|
||||
activeKey={activeKey}
|
||||
type="editable-card"
|
||||
hideAdd={!onAdd}
|
||||
items={items}
|
||||
onChange={onChange}
|
||||
onEdit={(_, action) => {
|
||||
if (action === 'add') {
|
||||
onAdd?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!onSort) {
|
||||
return tabs;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{tabs}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeDragItem ? (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<CategoryTabContent item={activeDragItem} menuLabels={menuLabels} />
|
||||
</span>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableCategoryTabs;
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './SortableCategoryTabs';
|
||||
@@ -10,13 +10,15 @@
|
||||
import { CloseCircleFilled } from '@ant-design/icons';
|
||||
import {
|
||||
buildContextSelectorItems,
|
||||
loadMetaTreeChildren,
|
||||
useFlowContext,
|
||||
type ContextSelectorItem,
|
||||
type MetaTreeNode,
|
||||
} from '@nocobase/flow-engine';
|
||||
import { Button, Cascader, DatePicker, Input, InputNumber, Select, Space, Tag, theme, type CascaderProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { css } from '@emotion/css';
|
||||
import {
|
||||
makeFormatVariablePath,
|
||||
makeParseVariablePath,
|
||||
@@ -25,11 +27,11 @@ import {
|
||||
} from './VariableInput';
|
||||
|
||||
/**
|
||||
* Constant types this input can edit. Subset of v1 `Variable.Input`
|
||||
* `useTypedConstant` — drops `'object'` (no v2 JSON editor yet) and `'null'`
|
||||
* (handled by the dedicated `nullable` prop).
|
||||
* Constant types this input can edit. Matches v1 `Variable.Input`
|
||||
* `useTypedConstant` minus `'null'` (handled by the dedicated `nullable` prop).
|
||||
* `'object'` renders an inline JSON editor (textarea).
|
||||
*/
|
||||
export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date';
|
||||
export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date' | 'object';
|
||||
|
||||
/**
|
||||
* One allowed constant type. Either a bare type name (`'number'`) or a
|
||||
@@ -57,10 +59,21 @@ export interface TypedVariableInputProps {
|
||||
* Restrict the variable picker to specific top-level meta-tree namespaces
|
||||
* (e.g. `['$env']`). When omitted, every registered top-level property is
|
||||
* exposed — matching `VariableInput`'s default behaviour.
|
||||
*
|
||||
* Ignored when `metaTree` is supplied (an explicit tree wins).
|
||||
*/
|
||||
namespaces?: string[];
|
||||
/** Additional leaves appended to the picker after the namespace-filtered nodes. */
|
||||
extraNodes?: MetaTreeNode[];
|
||||
/**
|
||||
* Provide the variable tree explicitly instead of reading the global
|
||||
* FlowContext meta tree. When set, `namespaces`/`extraNodes` are ignored and
|
||||
* this tree is used verbatim — use for context-scoped variable sources that
|
||||
* are not part of the global registry (e.g. a workflow node's upstream
|
||||
* outputs). Lazy `children` thunks are resolved on demand as the user
|
||||
* expands the cascader.
|
||||
*/
|
||||
metaTree?: MetaTreeNode[];
|
||||
/**
|
||||
* When true (default), the switcher exposes a `Null` option that resets the
|
||||
* value to `null`. When false, the value is constrained to one of the
|
||||
@@ -82,6 +95,7 @@ const TYPE_LABEL_KEYS: Record<TypedConstantType, string> = {
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
date: 'Date',
|
||||
object: 'JSON',
|
||||
};
|
||||
|
||||
type NormalizedType = { type: TypedConstantType; props: Record<string, unknown> };
|
||||
@@ -104,6 +118,8 @@ function defaultValueFor(type: TypedConstantType): unknown {
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
}
|
||||
case 'object':
|
||||
return {};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -121,6 +137,7 @@ function detectMode(value: unknown, parseVariablePath: (v?: string) => string[]
|
||||
if (typeof value === 'number') return { mode: 'number' };
|
||||
if (typeof value === 'boolean') return { mode: 'boolean' };
|
||||
if (value instanceof Date) return { mode: 'date' };
|
||||
if (typeof value === 'object') return { mode: 'object' };
|
||||
return { mode: 'string' };
|
||||
}
|
||||
|
||||
@@ -131,6 +148,7 @@ interface SwitcherOption {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
isLeaf?: boolean;
|
||||
loading?: boolean;
|
||||
children?: SwitcherOption[];
|
||||
meta?: MetaTreeNode;
|
||||
paths?: string[];
|
||||
@@ -175,9 +193,10 @@ function renderConstantEditor(
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
t: (text: string) => string;
|
||||
onJsonError?: (message: string | null) => void;
|
||||
},
|
||||
): React.ReactNode {
|
||||
const { typedProps, disabled, placeholder, t } = options;
|
||||
const { typedProps, disabled, placeholder, t, onJsonError } = options;
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return (
|
||||
@@ -229,11 +248,126 @@ function renderConstantEditor(
|
||||
/>
|
||||
);
|
||||
}
|
||||
case 'object':
|
||||
return (
|
||||
<JsonConstantEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onError={onJsonError}
|
||||
disabled={disabled}
|
||||
typedProps={typedProps}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonEditorClassName = css`
|
||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||
font-size: 80%;
|
||||
`;
|
||||
|
||||
/**
|
||||
* Inline JSON editor for the `'object'` constant type. Formily-free port of v1's
|
||||
* `Json` input (which used a Formily field for error feedback): keeps the raw
|
||||
* text as local draft state, parses on blur, and writes the parsed object up via
|
||||
* `onChange`. A parse error is reported up through `onError` (the raw
|
||||
* `JSON.parse` message, matching v1) so the parent can render it on its own row
|
||||
* below the input — keeping the textarea + switcher button row intact.
|
||||
*/
|
||||
function JsonConstantEditor({
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
disabled,
|
||||
typedProps,
|
||||
}: {
|
||||
value: unknown;
|
||||
onChange?: (next: unknown) => void;
|
||||
onError?: (message: string | null) => void;
|
||||
disabled?: boolean;
|
||||
typedProps: Record<string, unknown>;
|
||||
}) {
|
||||
const stringify = useCallback((v: unknown) => {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [text, setText] = useState<string>(() => stringify(value));
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
// Re-sync the draft when the external value changes (e.g. switching type).
|
||||
useEffect(() => {
|
||||
setText(stringify(value));
|
||||
setHasError(false);
|
||||
onError?.(null);
|
||||
// `onError` intentionally omitted — only resync on external value change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, stringify]);
|
||||
|
||||
// Validate as the user types (live red border + error row), mirroring v1's `Json` which calls `setFeedback` on every
|
||||
// change — not just on blur.
|
||||
const handleChange = useCallback(
|
||||
(raw: string) => {
|
||||
setText(raw);
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
setHasError(false);
|
||||
onError?.(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
setHasError(false);
|
||||
onError?.(null);
|
||||
} catch (err) {
|
||||
setHasError(true);
|
||||
onError?.((err as Error).message);
|
||||
}
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
// Commit the parsed object up on blur (v1 emits the value on blur). A still-invalid value keeps its error and is not
|
||||
// emitted.
|
||||
const commit = useCallback(
|
||||
(raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
onChange?.(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
onChange?.(JSON.parse(trimmed));
|
||||
} catch {
|
||||
/* error already shown live via handleChange */
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
// No `autoSize` — it would disable the resize grip. Default to 2 rows (mirrors v1's `Json`) and let the user
|
||||
// drag-resize from the corner.
|
||||
<Input.TextArea
|
||||
className={jsonEditorClassName}
|
||||
value={text}
|
||||
onChange={(ev) => handleChange(ev.target.value)}
|
||||
onBlur={(ev) => commit(ev.target.value)}
|
||||
disabled={disabled}
|
||||
rows={2}
|
||||
status={hasError ? 'error' : undefined}
|
||||
{...typedProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-or-variable input. Port of v1 `Variable.Input` typed-constant
|
||||
* Cascader pattern (the `[Null | Constant<type> | Variable<…namespaces>]`
|
||||
@@ -255,6 +389,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
||||
types = DEFAULT_TYPES,
|
||||
namespaces,
|
||||
extraNodes,
|
||||
metaTree: metaTreeProp,
|
||||
nullable = true,
|
||||
delimiters,
|
||||
disabled,
|
||||
@@ -266,16 +401,63 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
||||
const t = useCallback((text: string): string => (typeof ctx?.t === 'function' ? ctx.t(text) : text), [ctx]);
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
||||
// An explicit `metaTree` wins over the global FlowContext tree. The hook is still called unconditionally (Rules of
|
||||
// Hooks); its result is simply unused when a tree is injected.
|
||||
const filteredMetaTree = useFilteredMetaTree({ namespaces, extraNodes });
|
||||
const metaTree = metaTreeProp ?? filteredMetaTree;
|
||||
|
||||
const parseVariablePath = useMemo(() => makeParseVariablePath(delimiters), [delimiters]);
|
||||
const formatVariablePath = useMemo(() => makeFormatVariablePath(delimiters), [delimiters]);
|
||||
|
||||
const normalizedTypes = useMemo(() => normalizeTypes(types), [types]);
|
||||
const detected = useMemo(() => detectMode(value, parseVariablePath), [value, parseVariablePath]);
|
||||
const variableItems = useMemo(() => buildContextSelectorItems(metaTree).map(fromContextItem), [metaTree]);
|
||||
|
||||
// rc-cascader caches its options by *reference* (useEntities), so lazily filling a node's children in place is
|
||||
// invisible to it. We mirror `FlowContextSelector`: lazy `loadData` mutates the **meta tree** in place, bumps
|
||||
// `updateFlag`, and the options are rebuilt from scratch (fresh references) on every bump — forcing rc-cascader to
|
||||
// re-index the new column.
|
||||
const [updateFlag, setUpdateFlag] = useState(0);
|
||||
const triggerUpdate = useCallback(() => setUpdateFlag((prev) => prev + 1), []);
|
||||
|
||||
const loadData = useCallback(
|
||||
(selectedOptions: SwitcherOption[]) => {
|
||||
const target = selectedOptions[selectedOptions.length - 1];
|
||||
// Only variable nodes lazy-load; the Null / Constant switcher entries never do.
|
||||
if (!target || target.value === NULL_KEY || target.value === CONST_KEY) {
|
||||
return;
|
||||
}
|
||||
const meta = target.meta;
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
// Already-resolved children (array): nothing to fetch.
|
||||
if (Array.isArray(meta.children)) {
|
||||
return;
|
||||
}
|
||||
if (typeof meta.children !== 'function') {
|
||||
return;
|
||||
}
|
||||
target.loading = true;
|
||||
triggerUpdate();
|
||||
// `loadMetaTreeChildren` already swallows its own errors (returns []), so a bare chain is safe; `return`
|
||||
// satisfies the promise/catch-or-return rule.
|
||||
return loadMetaTreeChildren(meta)
|
||||
.then((childMetas) => {
|
||||
// Cache resolved children on the meta node; the options tree is then rebuilt from the mutated meta tree on
|
||||
// the next `updateFlag` bump.
|
||||
meta.children = childMetas;
|
||||
})
|
||||
.finally(() => {
|
||||
target.loading = false;
|
||||
triggerUpdate();
|
||||
});
|
||||
},
|
||||
[triggerUpdate],
|
||||
);
|
||||
|
||||
const switcherOptions = useMemo<SwitcherOption[]>(() => {
|
||||
// `updateFlag` is read so this recomputes (with fresh option references) after a lazy load mutates the meta tree.
|
||||
void updateFlag;
|
||||
const items: SwitcherOption[] = [];
|
||||
if (nullable) {
|
||||
items.push({ value: NULL_KEY, label: t('Null'), isLeaf: true });
|
||||
@@ -294,9 +476,9 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
||||
})),
|
||||
});
|
||||
}
|
||||
items.push(...variableItems);
|
||||
items.push(...buildContextSelectorItems(metaTree).map(fromContextItem));
|
||||
return items;
|
||||
}, [nullable, normalizedTypes, variableItems, t]);
|
||||
}, [nullable, normalizedTypes, metaTree, updateFlag, t]);
|
||||
|
||||
const onSwitcherChange = useCallback<NonNullable<CascaderProps<SwitcherOption>['onChange']>>(
|
||||
(path, selectedOptions) => {
|
||||
@@ -336,7 +518,7 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
||||
|
||||
const constantTypeForRendering: TypedConstantType = useMemo(() => {
|
||||
const m = detected.mode;
|
||||
if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date') return m;
|
||||
if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date' || m === 'object') return m;
|
||||
return normalizedTypes[0]?.type ?? 'string';
|
||||
}, [detected.mode, normalizedTypes]);
|
||||
|
||||
@@ -348,93 +530,161 @@ export function TypedVariableInput(props: TypedVariableInputProps) {
|
||||
const isVariable = detected.mode === 'variable';
|
||||
const isNull = detected.mode === 'null';
|
||||
|
||||
const variableLabels = useMemo(
|
||||
() => (isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : []),
|
||||
[isVariable, detected.variablePath, metaTree],
|
||||
);
|
||||
const variableLabels = useMemo(() => {
|
||||
// `updateFlag` is read so this recomputes after the preload effect below resolves a lazy level in the tree (same
|
||||
// pattern as `switcherOptions`).
|
||||
void updateFlag;
|
||||
return isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : [];
|
||||
}, [isVariable, detected.variablePath, metaTree, updateFlag]);
|
||||
|
||||
// Preload a saved variable's label path across lazy levels. `resolveVariableLabels` can only read already-loaded
|
||||
// `children`; when a saved reference points below a node whose children are still a lazy thunk (e.g. a relation field
|
||||
// that hasn't been expanded), the deep segments render as raw names. Walk the saved path on mount / value change,
|
||||
// resolving each lazy level in place (then bump `updateFlag` so the labels recompute). Mirrors v1 `Variable.Input`'s
|
||||
// preload effect.
|
||||
useEffect(() => {
|
||||
if (!isVariable || !detected.variablePath?.length) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
let nodes: MetaTreeNode[] | undefined = metaTree;
|
||||
let didLoad = false;
|
||||
for (const segment of detected.variablePath as string[]) {
|
||||
if (!nodes) {
|
||||
break;
|
||||
}
|
||||
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
||||
if (!matched) {
|
||||
break;
|
||||
}
|
||||
if (typeof matched.children === 'function') {
|
||||
const resolved = await loadMetaTreeChildren(matched);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
matched.children = resolved;
|
||||
didLoad = true;
|
||||
}
|
||||
nodes = Array.isArray(matched.children) ? matched.children : undefined;
|
||||
}
|
||||
if (didLoad && !cancelled) {
|
||||
triggerUpdate();
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// `metaTree` identity is stable per structural change (see consumers); re-run when the saved path or the tree
|
||||
// changes.
|
||||
}, [isVariable, detected.variablePath, metaTree, triggerUpdate]);
|
||||
|
||||
// JSON parse error from the object editor, rendered on its own row below the input (not squeezed into the compact
|
||||
// row). Cleared whenever the value is no longer an object literal.
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (constantTypeForRendering !== 'object') setJsonError(null);
|
||||
}, [constantTypeForRendering]);
|
||||
|
||||
return (
|
||||
<Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
{isVariable ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label="variable-tag"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: token.marginXXS,
|
||||
padding: `0 ${token.paddingSM}px`,
|
||||
minHeight: token.controlHeight,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Tag
|
||||
color="blue"
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* Default `Space.Compact` (align-items: stretch) so the switcher button
|
||||
grows to the value component's height — its `height: auto` (below) lets
|
||||
a multi-line value (the JSON textarea) stretch the button to match,
|
||||
joined like v1 (which sets `.ant-btn { height: auto }` likewise). */}
|
||||
<Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
{isVariable ? (
|
||||
<div
|
||||
role="button"
|
||||
aria-label="variable-tag"
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
maxWidth: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: token.marginXXS,
|
||||
padding: `0 ${token.paddingSM}px`,
|
||||
minHeight: token.controlHeight,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
borderRadius: token.borderRadius,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{variableLabels.map((label, index) => (
|
||||
<React.Fragment key={`${label}-${index}`}>
|
||||
{index ? ' / ' : ''}
|
||||
{label}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Tag>
|
||||
{!disabled ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="icon-close"
|
||||
onClick={onClearVariable}
|
||||
icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : isNull ? (
|
||||
// v1 used the `placeholder` slot (not `value`) so the antd default
|
||||
// placeholder colour applies — keeps the field looking visibly
|
||||
// empty/inactive rather than holding a real text value.
|
||||
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
|
||||
) : (
|
||||
renderConstantEditor(constantTypeForRendering, value, onChange, {
|
||||
typedProps,
|
||||
disabled,
|
||||
placeholder,
|
||||
t,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Cascader<SwitcherOption>
|
||||
options={switcherOptions}
|
||||
onChange={onSwitcherChange}
|
||||
disabled={disabled}
|
||||
changeOnSelect
|
||||
>
|
||||
<Button
|
||||
aria-label="variable-switcher"
|
||||
<Tag
|
||||
color="blue"
|
||||
style={{
|
||||
marginInlineEnd: 0,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{variableLabels.map((label, index) => (
|
||||
<React.Fragment key={`${label}-${index}`}>
|
||||
{index ? ' / ' : ''}
|
||||
{label}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Tag>
|
||||
{!disabled ? (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="icon-close"
|
||||
onClick={onClearVariable}
|
||||
icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : isNull ? (
|
||||
// v1 used the `placeholder` slot (not `value`) so the antd default placeholder colour applies — keeps the
|
||||
// field looking visibly empty/inactive rather than holding a real text value.
|
||||
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
|
||||
) : (
|
||||
renderConstantEditor(constantTypeForRendering, value, onChange, {
|
||||
typedProps,
|
||||
disabled,
|
||||
placeholder,
|
||||
t,
|
||||
onJsonError: setJsonError,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<Cascader<SwitcherOption>
|
||||
options={switcherOptions}
|
||||
onChange={onSwitcherChange}
|
||||
loadData={loadData}
|
||||
disabled={disabled}
|
||||
type={isVariable ? 'primary' : 'default'}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
fontStyle: 'italic',
|
||||
fontFamily: '"New York", "Times New Roman", Times, serif',
|
||||
}}
|
||||
changeOnSelect
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</Cascader>
|
||||
</Space.Compact>
|
||||
<Button
|
||||
aria-label="variable-switcher"
|
||||
disabled={disabled}
|
||||
type={isVariable ? 'primary' : 'default'}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// `height: auto` (instead of antd's fixed control height) lets the button stretch to the value
|
||||
// component's height under the compact row's default `align-items: stretch` — so it stays joined to a
|
||||
// tall JSON textarea. Mirrors v1's `.ant-btn { height: auto }`.
|
||||
height: 'auto',
|
||||
fontStyle: 'italic',
|
||||
fontFamily: '"New York", "Times New Roman", Times, serif',
|
||||
}}
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</Cascader>
|
||||
</Space.Compact>
|
||||
{jsonError ? (
|
||||
<div style={{ marginTop: token.marginXXS, color: token.colorError, fontSize: token.fontSizeSM }}>
|
||||
{jsonError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
|
||||
import { FlowContext, FlowContextProvider, type MetaTreeNode } from '@nocobase/flow-engine';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
@@ -136,6 +136,198 @@ describe('TypedVariableInput - variable rendering', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypedVariableInput - object / JSON constant', () => {
|
||||
it('renders a JSON textarea for an object value when types include object', async () => {
|
||||
const ctx = createContextWithEnv();
|
||||
renderWithCtx(
|
||||
ctx,
|
||||
<TypedVariableInput value={{ a: 1 }} types={['object']} namespaces={['$env']} onChange={() => undefined} />,
|
||||
);
|
||||
// The object is stringified into a textarea.
|
||||
const textarea = await screen.findByDisplayValue(/"a": 1/);
|
||||
expect(textarea.tagName.toLowerCase()).toBe('textarea');
|
||||
});
|
||||
|
||||
it('parses valid JSON on blur and emits the parsed object', async () => {
|
||||
const ctx = createContextWithEnv();
|
||||
const handleChange = vi.fn();
|
||||
renderWithCtx(
|
||||
ctx,
|
||||
<TypedVariableInput value={{}} types={['object']} namespaces={['$env']} onChange={handleChange} />,
|
||||
);
|
||||
const textarea = await screen.findByDisplayValue('{}');
|
||||
fireEvent.change(textarea, { target: { value: '{"x": 42}' } });
|
||||
fireEvent.blur(textarea);
|
||||
expect(handleChange).toHaveBeenLastCalledWith({ x: 42 });
|
||||
});
|
||||
|
||||
it('shows an error live on change (before blur) and does not emit on invalid JSON', async () => {
|
||||
const ctx = createContextWithEnv();
|
||||
const handleChange = vi.fn();
|
||||
renderWithCtx(
|
||||
ctx,
|
||||
<TypedVariableInput value={{}} types={['object']} namespaces={['$env']} onChange={handleChange} />,
|
||||
);
|
||||
const textarea = await screen.findByDisplayValue('{}');
|
||||
// Typing an invalid value surfaces the error immediately — no blur needed (mirrors v1's `Json`, which validates on
|
||||
// every change).
|
||||
fireEvent.change(textarea, { target: { value: '{ not json' } });
|
||||
await waitFor(() => {
|
||||
// The raw `JSON.parse` error message is shown (matching v1), e.g. "Expected property name or '}' in JSON at
|
||||
// position …".
|
||||
expect(screen.getByText(/Expected property name/i)).toBeInTheDocument();
|
||||
});
|
||||
// The value is only emitted on blur, and never for an invalid value.
|
||||
fireEvent.blur(textarea);
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('defaults to {} when the Constant > JSON type is picked', async () => {
|
||||
const ctx = createContextWithEnv();
|
||||
const handleChange = vi.fn();
|
||||
renderWithCtx(
|
||||
ctx,
|
||||
<TypedVariableInput value={null} types={['object']} namespaces={['$env']} onChange={handleChange} />,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
|
||||
// Constant submenu → JSON leaf.
|
||||
fireEvent.click(await screen.findByText('Constant'));
|
||||
fireEvent.click(await screen.findByText('JSON'));
|
||||
expect(handleChange).toHaveBeenCalledWith({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypedVariableInput - injected metaTree', () => {
|
||||
// A bare context with no global `$env` — the injected tree is the only source.
|
||||
function createBareContext() {
|
||||
const ctx = new FlowContext();
|
||||
(ctx as any).t = (key: string) => key;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Mirrors a workflow node-result tree: a root whose children load lazily.
|
||||
function makeLazyTree(loadChildren: () => Promise<MetaTreeNode[]>): MetaTreeNode[] {
|
||||
return [
|
||||
{
|
||||
name: '$jobsMapByNodeKey',
|
||||
title: 'Node result',
|
||||
type: 'object',
|
||||
paths: ['$jobsMapByNodeKey'],
|
||||
children: loadChildren,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
it('renders the injected tree in the switcher, not the global one', async () => {
|
||||
const ctx = createBareContext();
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{ name: 'n1', title: 'Approval node', type: 'object', paths: ['$jobsMapByNodeKey', 'n1'] },
|
||||
];
|
||||
renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Approval node')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('lazily resolves function children when a node is expanded', async () => {
|
||||
const ctx = createBareContext();
|
||||
const loadChildren = vi.fn(async () => [
|
||||
{ name: 'status', title: 'Status', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'status'] },
|
||||
]);
|
||||
const metaTree = makeLazyTree(loadChildren);
|
||||
renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
|
||||
const root = await screen.findByText('Node result');
|
||||
// Expanding the lazy node triggers loadData → loadMetaTreeChildren.
|
||||
fireEvent.click(root);
|
||||
await waitFor(() => {
|
||||
expect(loadChildren).toHaveBeenCalled();
|
||||
expect(screen.getByText('Status')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces lazy children on the FIRST expansion (no stuck-loading regression)', async () => {
|
||||
const ctx = createBareContext();
|
||||
// A deferred promise so loading→resolved is a real two-phase transition, reproducing the rc-cascader
|
||||
// reference-cache bug where the first expand stayed stuck on the spinner until the column was reopened.
|
||||
let resolveChildren: (nodes: MetaTreeNode[]) => void = () => undefined;
|
||||
const childrenPromise = new Promise<MetaTreeNode[]>((resolve) => {
|
||||
resolveChildren = resolve;
|
||||
});
|
||||
const loadChildren = vi.fn(() => childrenPromise);
|
||||
const metaTree = makeLazyTree(loadChildren);
|
||||
renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={() => undefined} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
|
||||
const root = await screen.findByText('Node result');
|
||||
fireEvent.click(root);
|
||||
expect(loadChildren).toHaveBeenCalledTimes(1);
|
||||
// Resolve after the click — the child must appear without reopening.
|
||||
resolveChildren([
|
||||
{ name: 'secret', title: 'SMTP_HOST', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'secret'] },
|
||||
]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('SMTP_HOST')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits the selected variable as a {{ ... }} expression with no inner spaces', async () => {
|
||||
const ctx = createBareContext();
|
||||
const handleChange = vi.fn();
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{ name: 'n1', title: 'Approval node', type: 'string', paths: ['$jobsMapByNodeKey', 'n1'] },
|
||||
];
|
||||
renderWithCtx(ctx, <TypedVariableInput value={null} metaTree={metaTree} onChange={handleChange} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'variable-switcher' }));
|
||||
const leaf = await screen.findByText('Approval node');
|
||||
fireEvent.click(leaf);
|
||||
await waitFor(() => {
|
||||
expect(handleChange).toHaveBeenCalledWith('{{$jobsMapByNodeKey.n1}}');
|
||||
});
|
||||
});
|
||||
|
||||
it('preloads a saved variable label across a lazy level (deep label not dropped on mount)', async () => {
|
||||
const ctx = createBareContext();
|
||||
// The query-node case: a saved reference `$jobsMapByNodeKey.n1.role` points below `n1`, whose children are a lazy
|
||||
// thunk (relation not yet expanded). On mount the tag must show the full path "Query / Role", not stop at "Query" —
|
||||
// the component walks the saved path resolving each lazy level. (Regression: reopening a saved condition showed
|
||||
// only "节点数据 / 查询数据" and dropped "角色标识".)
|
||||
const loadRoleFields = vi.fn(async () => [
|
||||
{ name: 'role', title: 'Role', type: 'string', paths: ['$jobsMapByNodeKey', 'n1', 'role'] },
|
||||
]);
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: '$jobsMapByNodeKey',
|
||||
title: 'Node result',
|
||||
type: 'object',
|
||||
paths: ['$jobsMapByNodeKey'],
|
||||
children: [
|
||||
{
|
||||
name: 'n1',
|
||||
title: 'Query',
|
||||
type: 'object',
|
||||
paths: ['$jobsMapByNodeKey', 'n1'],
|
||||
children: loadRoleFields,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
renderWithCtx(
|
||||
ctx,
|
||||
<TypedVariableInput value="{{$jobsMapByNodeKey.n1.role}}" metaTree={metaTree} onChange={() => undefined} />,
|
||||
);
|
||||
const tag = screen.getByRole('button', { name: 'variable-tag' });
|
||||
// Preload resolves the lazy level so the deep label appears without expanding.
|
||||
await waitFor(() => {
|
||||
expect(loadRoleFields).toHaveBeenCalled();
|
||||
expect(tag.textContent).toContain('Role');
|
||||
});
|
||||
expect(tag.textContent).toContain('Query');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TypedVariableInput - editor onChange propagation', () => {
|
||||
it('forwards numeric edits via onChange', async () => {
|
||||
const ctx = createContextWithEnv();
|
||||
|
||||
@@ -58,6 +58,16 @@ export interface CollectionFilterItemProps {
|
||||
t?: (key: string) => string;
|
||||
/** Optional v2 app registry used to resolve plugin-provided operator components. */
|
||||
app?: { getComponent?: (name: string) => React.ComponentType<any> | undefined };
|
||||
/** Optional override for the left field picker placeholder. */
|
||||
fieldPlaceholder?: string;
|
||||
/** Optional override for the operator picker placeholder. */
|
||||
operatorPlaceholder?: string;
|
||||
/** Optional override for the value input placeholder. Pass `null` to suppress it. */
|
||||
valuePlaceholder?: string | null;
|
||||
/** Optional override for the left field picker width. Defaults to v2's original 200px. */
|
||||
fieldWidth?: number;
|
||||
/** Optional override for the operator picker min-width. Defaults to v2's original 120px. */
|
||||
operatorMinWidth?: number;
|
||||
}
|
||||
|
||||
const identity = (s: string) => s;
|
||||
@@ -98,6 +108,12 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
|
||||
const { path: leftValue, operator, value: rightValue } = props.value;
|
||||
const flowEngine = useFlowEngine({ throwError: false }) as any;
|
||||
const app = props.app || flowEngine?.context?.app;
|
||||
const fieldPlaceholder = props.fieldPlaceholder ?? t('Select field');
|
||||
const operatorPlaceholder = props.operatorPlaceholder ?? t('Comparision');
|
||||
const valuePlaceholder =
|
||||
props.valuePlaceholder === undefined ? t('Enter value') : props.valuePlaceholder || undefined;
|
||||
const fieldWidth = props.fieldWidth ?? 200;
|
||||
const operatorMinWidth = props.operatorMinWidth ?? 120;
|
||||
|
||||
const options = useFilterOptions(collection, { filterableFieldNames, nonfilterableFieldNames, noIgnore, t });
|
||||
|
||||
@@ -135,8 +151,8 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
|
||||
return (
|
||||
<Space wrap>
|
||||
<Cascader
|
||||
style={{ width: 200 }}
|
||||
placeholder={t('Select field')}
|
||||
style={{ width: fieldWidth }}
|
||||
placeholder={fieldPlaceholder}
|
||||
options={cascaderOptions}
|
||||
value={fieldPath}
|
||||
onChange={handleFieldChange}
|
||||
@@ -145,15 +161,15 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
|
||||
popupClassName={cascaderPopupClass}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder={t('Comparision')}
|
||||
style={{ minWidth: operatorMinWidth }}
|
||||
placeholder={operatorPlaceholder}
|
||||
value={operator || undefined}
|
||||
onChange={handleOperatorChange}
|
||||
disabled={!leftValue || operatorOptions.length === 0}
|
||||
>
|
||||
{operatorOptions.map((op) => (
|
||||
<Select.Option key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
{typeof op.label === 'string' ? t(op.label) : op.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
@@ -162,7 +178,7 @@ export const CollectionFilterItem: FC<CollectionFilterItemProps> = observer(
|
||||
operator={selectedOperator}
|
||||
value={rightValue}
|
||||
onChange={handleValueChange}
|
||||
placeholder={t('Enter value')}
|
||||
placeholder={valuePlaceholder}
|
||||
t={t}
|
||||
app={app}
|
||||
/>
|
||||
@@ -179,7 +195,16 @@ export function createCollectionFilterItem(
|
||||
collection: Collection,
|
||||
bound?: Pick<
|
||||
CollectionFilterItemProps,
|
||||
'filterableFieldNames' | 'nonfilterableFieldNames' | 'noIgnore' | 't' | 'app'
|
||||
| 'filterableFieldNames'
|
||||
| 'nonfilterableFieldNames'
|
||||
| 'noIgnore'
|
||||
| 't'
|
||||
| 'app'
|
||||
| 'fieldPlaceholder'
|
||||
| 'operatorPlaceholder'
|
||||
| 'valuePlaceholder'
|
||||
| 'fieldWidth'
|
||||
| 'operatorMinWidth'
|
||||
>,
|
||||
) {
|
||||
const Component: FC<{ value: CollectionFilterItemValue }> = (props) => (
|
||||
|
||||
+38
@@ -244,4 +244,42 @@ describe('CollectionFilterItem', () => {
|
||||
expect(value.path).toBe('username');
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps default widths at 200/120 and honours explicit width/placeholder overrides', () => {
|
||||
const value = observable({ path: 'username', operator: '$eq', value: '' });
|
||||
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
||||
|
||||
const { container, rerender } = render(<CollectionFilterItem value={value} collection={collection} />);
|
||||
|
||||
let selects = container.querySelectorAll('.ant-select');
|
||||
expect(selects[0]).toHaveStyle({ width: '200px' });
|
||||
expect(selects[1]).toHaveStyle({ minWidth: '120px' });
|
||||
|
||||
rerender(
|
||||
<CollectionFilterItem
|
||||
value={observable({ path: '', operator: '', value: '' })}
|
||||
collection={collection}
|
||||
fieldWidth={160}
|
||||
operatorMinWidth={110}
|
||||
fieldPlaceholder="Choose field"
|
||||
operatorPlaceholder="Choose operator"
|
||||
/>,
|
||||
);
|
||||
|
||||
selects = container.querySelectorAll('.ant-select');
|
||||
expect(selects[0]).toHaveStyle({ width: '160px' });
|
||||
expect(selects[1]).toHaveStyle({ minWidth: '110px' });
|
||||
expect(screen.getByText('Choose field')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets callers suppress the fallback value placeholder', () => {
|
||||
const value = observable({ path: 'username', operator: '$eq', value: '' });
|
||||
const collection = buildStubCollection([{ name: 'username', title: 'Username' }]);
|
||||
|
||||
const { rerender } = render(<CollectionFilterItem value={value} collection={collection} />);
|
||||
expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument();
|
||||
|
||||
rerender(<CollectionFilterItem value={value} collection={collection} valuePlaceholder={null} />);
|
||||
expect(screen.queryByPlaceholderText('Enter value')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
export { CollectionFilter } from './CollectionFilter';
|
||||
export type { CollectionFilterProps } from './CollectionFilter';
|
||||
export { CollectionFilterPanel } from './CollectionFilterPanel';
|
||||
export { CollectionFilterItem } from './CollectionFilterItem';
|
||||
export type { CollectionFilterItemValue } from './CollectionFilterItem';
|
||||
export type { CollectionFilterPanelProps, CollectionFilterPanelRef } from './CollectionFilterPanel';
|
||||
export type { CompiledFilter } from './useFilterActionProps';
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
export * from './AppComponents';
|
||||
export * from './BlankComponent';
|
||||
export * from './category-tabs';
|
||||
export * from './form/table/dnd';
|
||||
export * from './form';
|
||||
export * from './Icon';
|
||||
|
||||
@@ -17,6 +17,17 @@ describe('FlowI18n', () => {
|
||||
expect(i18n.translate("{{ t('Hello') }}")).toBe('你好');
|
||||
});
|
||||
|
||||
it('keeps embedded quotes of a different type inside the key', () => {
|
||||
// A single-quoted key whose text contains double quotes (and vice versa) must not be truncated at the first inner
|
||||
// quote.
|
||||
const key = 'Unlike "Post-action event", it listens for data changes.';
|
||||
const table: Record<string, string> = { [key]: '与“操作后事件”不同,它监听数据变动。' };
|
||||
const i18n = new FlowI18n({ i18n: { t: (k: string) => table[k] ?? k } });
|
||||
|
||||
expect(i18n.translate(`{{t('${key}', { ns: "workflow" })}}`)).toBe(table[key]);
|
||||
expect(i18n.translate(`{{t("It's here", { ns: "workflow" })}}`)).toBe("It's here");
|
||||
});
|
||||
|
||||
it('template compile ignores malformed options', () => {
|
||||
const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const i18n = new FlowI18n({ i18n: { t: (k: string) => k } });
|
||||
|
||||
@@ -92,6 +92,7 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
open,
|
||||
onlyLeafSelectable = false,
|
||||
ignoreFieldNames,
|
||||
dropdownFooter,
|
||||
...cascaderProps
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
@@ -360,12 +361,41 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
[cascaderOnDropdownVisibleChange, open],
|
||||
);
|
||||
|
||||
// Footer hint at the bottom of the dropdown. Defaults to the "double click to choose entire object" hint whenever
|
||||
// non-leaf selection is allowed (double-clicking a non-leaf selects the whole object). Callers can override with
|
||||
// their own node, or pass `null` to hide it.
|
||||
const footerNode = useMemo(() => {
|
||||
if (dropdownFooter !== undefined) {
|
||||
return dropdownFooter;
|
||||
}
|
||||
if (onlyLeafSelectable) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
padding: 6px 12px;
|
||||
color: ${token.colorTextDescription};
|
||||
border-top: 1px solid ${token.colorSplit};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{flowCtx.t('Double click to choose entire object')}
|
||||
</div>
|
||||
);
|
||||
}, [dropdownFooter, onlyLeafSelectable, token, flowCtx]);
|
||||
|
||||
const renderDropdown = useCallback(
|
||||
(menu: React.ReactElement) => {
|
||||
const cascaderMenuNode = cascaderDropdownRender ? cascaderDropdownRender(menu) : menu;
|
||||
const cascaderMenu = React.isValidElement(cascaderMenuNode) ? cascaderMenuNode : <>{cascaderMenuNode}</>;
|
||||
if (!isSearchEnabled || children === null) {
|
||||
return cascaderMenu;
|
||||
return (
|
||||
<>
|
||||
{cascaderMenu}
|
||||
{footerNode}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -381,10 +411,11 @@ const FlowContextSelectorComponent: React.FC<FlowContextSelectorProps> = ({
|
||||
/>
|
||||
</div>
|
||||
{cascaderMenu}
|
||||
{footerNode}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText],
|
||||
[cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode],
|
||||
);
|
||||
|
||||
const inlinePlaceholder =
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { Space, theme } from 'antd';
|
||||
import React, { isValidElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FormItemInputContext } from 'antd/es/form/context';
|
||||
import React, { isValidElement, useContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { MetaTreeNode } from '../../flowContext';
|
||||
import { useFlowContext } from '../../FlowContextProvider';
|
||||
import { FlowContextSelector } from '../FlowContextSelector';
|
||||
import { useResolvedMetaTree } from './useResolvedMetaTree';
|
||||
import { formatPathToValue as defaultFormatPathToValue, parseValueToPath as defaultParseValueToPath } from './utils';
|
||||
import {
|
||||
formatPathToValue as defaultFormatPathToValue,
|
||||
loadMetaTreeChildren,
|
||||
parseValueToPath as defaultParseValueToPath,
|
||||
} from './utils';
|
||||
|
||||
type RangeIndexes = [number, number, number, number];
|
||||
|
||||
@@ -37,6 +42,14 @@ export interface VariableHybridInputProps {
|
||||
converters?: VariableHybridInputConverters;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
/**
|
||||
* Validation status — turns the input border red (`error`) or amber
|
||||
* (`warning`). Usually omitted: when rendered inside an antd `Form.Item`, the
|
||||
* status is read automatically from `FormItemInputContext`, so dropping this
|
||||
* into a `Form.Item` with failing rules colours the border with no extra
|
||||
* wiring. An explicit prop wins over the inherited form status.
|
||||
*/
|
||||
status?: 'error' | 'warning';
|
||||
}
|
||||
|
||||
function reactNodeToPlainText(node: React.ReactNode): string {
|
||||
@@ -83,14 +96,38 @@ function normalizeVariableKey(value: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function renderHTML(value: string, labelMap: Map<string, string>, regExp: RegExp) {
|
||||
function renderHTML(value: string, regExp: RegExp, resolveLabel: (matched: string) => string | undefined) {
|
||||
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
||||
return escapeHtml(value || '').replace(re, (matched) => {
|
||||
const label = labelMap.get(normalizeVariableKey(matched)) || matched;
|
||||
const label = resolveLabel(matched) || matched;
|
||||
return createTagHTML(matched, label);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve a `{{ … }}` reference path to its slash-joined title chain by walking the (possibly lazily-expanded) meta tree
|
||||
// live. Unlike `buildLabelMap` — which pre-walks only already-loaded (array) children into a memoized map — this reads
|
||||
// the tree's CURRENT contents at call time, so a level expanded in place (by the cascader's loadData when the user
|
||||
// drills in, or by the preload effect) is reflected on the very next render without the memoized map having to rebuild.
|
||||
// This is what fixes a just-picked deep node rendering as its raw `{{ … }}` token. Returns undefined if any segment is
|
||||
// missing or sits below a still-unresolved (thunk) level — the caller then falls back to the raw token.
|
||||
function resolveTitlesByPath(
|
||||
roots: MetaTreeNode[] | undefined,
|
||||
path: string[] | undefined,
|
||||
ctxT: (text: string) => string,
|
||||
): string | undefined {
|
||||
if (!roots || !path || !path.length) return undefined;
|
||||
const titles: string[] = [];
|
||||
let nodes: MetaTreeNode[] | undefined = roots;
|
||||
for (const segment of path) {
|
||||
if (!nodes) return undefined;
|
||||
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
||||
if (!matched) return undefined;
|
||||
titles.push(reactNodeToPlainText(matched.title || matched.name));
|
||||
nodes = Array.isArray(matched.children) ? (matched.children as MetaTreeNode[]) : undefined;
|
||||
}
|
||||
return titles.map(ctxT).join('/');
|
||||
}
|
||||
|
||||
function buildLabelMap(
|
||||
nodes: MetaTreeNode[] | undefined,
|
||||
ctxT: (text: string) => string,
|
||||
@@ -116,6 +153,42 @@ function buildLabelMap(
|
||||
return map;
|
||||
}
|
||||
|
||||
// Collect every variable reference path in `value` (one per `{{ … }}` token). Used to preload lazy meta-tree levels so
|
||||
// a saved reference whose label lives below an unexpanded (thunk) level still resolves to a readable tag.
|
||||
function collectReferencePaths(
|
||||
value: string,
|
||||
regExp: RegExp,
|
||||
parseValueToPath: (value?: string) => string[] | undefined,
|
||||
): string[][] {
|
||||
const re = new RegExp(regExp.source, regExp.flags.includes('g') ? regExp.flags : `${regExp.flags}g`);
|
||||
const paths: string[][] = [];
|
||||
for (const matched of value.match(re) ?? []) {
|
||||
const path = parseValueToPath(matched);
|
||||
if (path && path.length) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
// Walk one reference path down the meta tree, resolving each lazy `children` thunk in place. Returns true if it
|
||||
// resolved at least one level (so the caller knows to recompute the label map). Mirrors `TypedVariableInput`'s preload.
|
||||
async function preloadReferencePath(path: string[], roots: MetaTreeNode[]): Promise<boolean> {
|
||||
let nodes: MetaTreeNode[] | undefined = roots;
|
||||
let didLoad = false;
|
||||
for (const segment of path) {
|
||||
if (!nodes) break;
|
||||
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
|
||||
if (!matched) break;
|
||||
if (typeof matched.children === 'function') {
|
||||
matched.children = await loadMetaTreeChildren(matched);
|
||||
didLoad = true;
|
||||
}
|
||||
nodes = Array.isArray(matched.children) ? matched.children : undefined;
|
||||
}
|
||||
return didLoad;
|
||||
}
|
||||
|
||||
function pasteHTML(container: HTMLElement, html: string, indexes?: RangeIndexes) {
|
||||
const selection = window.getSelection?.();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
@@ -226,6 +299,10 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
const { token } = theme.useToken();
|
||||
const ctx = useFlowContext();
|
||||
const { resolvedMetaTree } = useResolvedMetaTree(metaTree);
|
||||
// Inherit the antd Form.Item validation status (red/amber border) unless an explicit `status` prop overrides it — so
|
||||
// the border colours automatically inside a failing `Form.Item`, no extra wiring for callers.
|
||||
const formItemStatus = useContext(FormItemInputContext)?.status;
|
||||
const effectiveStatus = props.status ?? formItemStatus;
|
||||
const inputRef = useRef<HTMLDivElement>(null);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
const [changed, setChanged] = useState(false);
|
||||
@@ -233,13 +310,63 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
|
||||
const value = typeof props.value === 'string' ? props.value : props.value == null ? '' : String(props.value);
|
||||
const variableRegExp = converters?.variableRegExp ?? DEFAULT_VARIABLE_REGEXP;
|
||||
const parseValueToPath = converters?.parseValueToPath ?? defaultParseValueToPath;
|
||||
|
||||
// Bumped after a saved reference's lazy meta-tree levels are resolved, so the label map (below) recomputes once the
|
||||
// deep titles are actually loaded.
|
||||
const [loadedFlag, setLoadedFlag] = useState(0);
|
||||
|
||||
// Preload the lazy levels every `{{ … }}` reference in `value` points through. `buildLabelMap` only walks
|
||||
// already-loaded (array) children, so without this a reference below an unexpanded thunk level (e.g. a workflow
|
||||
// node's output fields under `$jobsMapByNodeKey.<nodeKey>`) renders as the raw `{{ … }}` text instead of its
|
||||
// node/field labels. Mirrors `TypedVariableInput`'s preload.
|
||||
useEffect(() => {
|
||||
if (!value || !Array.isArray(resolvedMetaTree) || !resolvedMetaTree.length) {
|
||||
return;
|
||||
}
|
||||
const paths = collectReferencePaths(value, variableRegExp, parseValueToPath);
|
||||
if (!paths.length) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
let didLoad = false;
|
||||
for (const path of paths) {
|
||||
const loaded = await preloadReferencePath(path, resolvedMetaTree as MetaTreeNode[]);
|
||||
if (cancelled) return;
|
||||
didLoad = didLoad || loaded;
|
||||
}
|
||||
if (didLoad && !cancelled) {
|
||||
setLoadedFlag((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [value, resolvedMetaTree, variableRegExp, parseValueToPath]);
|
||||
|
||||
const labelMap = useMemo(
|
||||
() => buildLabelMap(resolvedMetaTree as MetaTreeNode[] | undefined, ctx.t, converters),
|
||||
[resolvedMetaTree, ctx, converters],
|
||||
// `loadedFlag` is read so the map recomputes after a lazy level resolves.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[resolvedMetaTree, ctx, converters, loadedFlag],
|
||||
);
|
||||
|
||||
const [html, setHtml] = useState(() => renderHTML(value, labelMap, variableRegExp));
|
||||
// Resolve one `{{ … }}` token to its label: the pre-built map first (cheap, covers statically-loaded levels), then a LIVE walk of the current meta tree. The live fallback is what makes a just-picked deep reference render its label immediately — when the user drills into a lazy level, the cascader resolves that level onto the meta tree in place WITHOUT changing the tree reference or bumping `loadedFlag`, so the memoized `labelMap` still misses it; walking the tree's current contents finds the freshly-loaded titles on the same render.
|
||||
const resolveLabel = useCallback(
|
||||
(matched: string): string | undefined => {
|
||||
const mapped = labelMap.get(normalizeVariableKey(matched));
|
||||
if (mapped) return mapped;
|
||||
const path = parseValueToPath(matched);
|
||||
return resolveTitlesByPath(resolvedMetaTree as MetaTreeNode[] | undefined, path, ctx.t);
|
||||
},
|
||||
// `loadedFlag` is read so a resolved lazy level re-creates this callback and re-renders the tags. `ctx` carries the translation fn.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[labelMap, parseValueToPath, resolvedMetaTree, ctx, loadedFlag],
|
||||
);
|
||||
|
||||
const [html, setHtml] = useState(() => renderHTML(value, variableRegExp, resolveLabel));
|
||||
|
||||
const emitChange = useCallback(
|
||||
(target: HTMLElement) => {
|
||||
@@ -249,12 +376,12 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setHtml(renderHTML(value, labelMap, variableRegExp));
|
||||
setHtml(renderHTML(value, variableRegExp, resolveLabel));
|
||||
if (!changed) {
|
||||
setRange([-1, 0, -1, 0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, labelMap]);
|
||||
}, [value, resolveLabel]);
|
||||
|
||||
// Restore caret position after html update
|
||||
useEffect(() => {
|
||||
@@ -492,6 +619,34 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
border-color: ${token.colorBorder};
|
||||
}
|
||||
}
|
||||
|
||||
&.is-error {
|
||||
border-color: ${token.colorError};
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorErrorBorderHover};
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: ${token.colorError};
|
||||
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorErrorOutline};
|
||||
}
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
border-color: ${token.colorWarning};
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorWarningBorderHover};
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: ${token.colorWarning};
|
||||
box-shadow: 0 0 0 ${token.controlOutlineWidth}px ${token.colorWarningOutline};
|
||||
}
|
||||
}
|
||||
`;
|
||||
}, [token, addonBefore]);
|
||||
|
||||
@@ -505,6 +660,8 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
aria-label="textbox"
|
||||
className={cx(editorClassName, {
|
||||
'is-disabled': disabled,
|
||||
'is-error': effectiveStatus === 'error',
|
||||
'is-warning': effectiveStatus === 'warning',
|
||||
})}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
@@ -519,7 +676,7 @@ const VariableHybridInputComponent: React.FC<VariableHybridInputProps> = (props)
|
||||
<FlowContextSelector
|
||||
metaTree={metaTree}
|
||||
disabled={disabled}
|
||||
parseValueToPath={converters?.parseValueToPath ?? defaultParseValueToPath}
|
||||
parseValueToPath={parseValueToPath}
|
||||
formatPathToValue={(item) => converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)}
|
||||
onChange={handleSelectorChange}
|
||||
/>
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pins how a `{{ … }}` reference renders as its label when the label lives below a lazily-loaded meta-tree level (e.g. a workflow node's output fields under `$jobsMapByNodeKey.<nodeKey>`). `buildLabelMap` only pre-walks already-loaded (array) children into a memoized map, so the two real user flows each need their own resolution path, both pinned here:
|
||||
* - after reload — the value is already a deep reference at mount, its level still an unresolved thunk → the preload effect resolves it.
|
||||
* - after picking — the user drills into a lazy level (cascader resolves it IN PLACE, no tree-ref change) then picks a leaf → the live walk of the tree's current contents resolves it on the same render.
|
||||
* Plus the top-level (already-loaded) and not-in-tree (raw-token fallback) cases.
|
||||
*/
|
||||
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { VariableHybridInput, type VariableHybridInputConverters } from '../VariableHybridInput';
|
||||
import type { MetaTreeNode } from '../../../flowContext';
|
||||
import { createTestFlowContext, TestFlowContextWrapper } from './test-utils';
|
||||
|
||||
// Workflow-style converters: `{{$root.a.b}}` (dotted path, no inner spaces).
|
||||
const VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||
const workflowConverters: VariableHybridInputConverters = {
|
||||
formatPathToValue: (item?: MetaTreeNode) => {
|
||||
const path = item?.paths ?? [];
|
||||
return path.length ? `{{${path.join('.')}}}` : '';
|
||||
},
|
||||
parseValueToPath: (value?: string) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const match = value.trim().match(/^\{\{\s*(.+?)\s*\}\}$/);
|
||||
return match ? match[1].split('.') : undefined;
|
||||
},
|
||||
variableRegExp: VARIABLE_REGEXP,
|
||||
};
|
||||
|
||||
const TAG_SELECTOR = '.nb-variable-tag';
|
||||
|
||||
describe('VariableHybridInput — saved reference labels', () => {
|
||||
it('renders a top-level (already-loaded) reference as its label, not the raw token', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: '$user',
|
||||
title: 'User',
|
||||
type: '',
|
||||
paths: ['$user'],
|
||||
children: [{ name: 'name', title: 'Name', type: 'string', paths: ['$user', 'name'] }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<VariableHybridInput value="{{$user.name}}" metaTree={metaTree} converters={workflowConverters} />
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const tag = document.querySelector(TAG_SELECTOR);
|
||||
expect(tag).toBeTruthy();
|
||||
expect(tag?.textContent).toBe('User/Name');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the label after reload: a deep reference present at mount preloads its lazy level', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
// The node's output fields are behind a lazy `children` thunk — exactly the `$jobsMapByNodeKey.<nodeKey>` shape produced by the workflow adapter.
|
||||
const loadChildren = vi.fn(
|
||||
async (): Promise<MetaTreeNode[]> => [
|
||||
{ name: 'name', title: 'Name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
|
||||
],
|
||||
);
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: '$jobsMapByNodeKey',
|
||||
title: 'Node result',
|
||||
type: '',
|
||||
paths: ['$jobsMapByNodeKey'],
|
||||
children: [
|
||||
{
|
||||
name: 'node1',
|
||||
title: 'Query',
|
||||
type: '',
|
||||
paths: ['$jobsMapByNodeKey', 'node1'],
|
||||
children: loadChildren,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<VariableHybridInput
|
||||
value="{{$jobsMapByNodeKey.node1.name}}"
|
||||
metaTree={metaTree}
|
||||
converters={workflowConverters}
|
||||
/>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
// The lazy thunk is invoked by the preload, and the deep label appears.
|
||||
await waitFor(() => {
|
||||
expect(loadChildren).toHaveBeenCalled();
|
||||
const tag = document.querySelector(TAG_SELECTOR);
|
||||
expect(tag?.textContent).toBe('Node result/Query/Name');
|
||||
});
|
||||
// It must NOT leave the raw token visible.
|
||||
expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
|
||||
});
|
||||
|
||||
it('shows the label after picking: a level expanded in place then selected resolves live', async () => {
|
||||
// Reproduces drilling into a lazy level in the cascader: that resolves the node's `children` onto the SAME meta-tree object in place — WITHOUT changing the tree reference — then the user picks a leaf, so `value` updates. The memoized `labelMap` (keyed on the tree reference) still misses the freshly loaded leaf; only a live walk of the tree's current contents resolves it.
|
||||
const flowContext = createTestFlowContext();
|
||||
const node1: MetaTreeNode = {
|
||||
name: 'node1',
|
||||
title: 'Query',
|
||||
type: '',
|
||||
paths: ['$jobsMapByNodeKey', 'node1'],
|
||||
// Starts WITHOUT children (an unexpanded branch). No thunk — so the preload effect/`loadedFlag` path does not fire; the fix must come from the live walk.
|
||||
};
|
||||
const metaTree: MetaTreeNode[] = [
|
||||
{
|
||||
name: '$jobsMapByNodeKey',
|
||||
title: 'Node result',
|
||||
type: '',
|
||||
paths: ['$jobsMapByNodeKey'],
|
||||
children: [node1],
|
||||
},
|
||||
];
|
||||
|
||||
// Mount with no reference yet — the deep level is not loaded.
|
||||
const { rerender } = render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<VariableHybridInput value="" metaTree={metaTree} converters={workflowConverters} />
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
// Simulate the cascader's loadData: resolve node1's children in place on the SAME tree object (no new reference, no thunk).
|
||||
node1.children = [
|
||||
{ name: 'name', title: 'Role name', type: 'string', paths: ['$jobsMapByNodeKey', 'node1', 'name'] },
|
||||
];
|
||||
|
||||
// The user picks the leaf → value updates to the deep reference.
|
||||
rerender(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<VariableHybridInput
|
||||
value="{{$jobsMapByNodeKey.node1.name}}"
|
||||
metaTree={metaTree}
|
||||
converters={workflowConverters}
|
||||
/>
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const tag = document.querySelector(TAG_SELECTOR);
|
||||
expect(tag?.textContent).toBe('Node result/Query/Role name');
|
||||
});
|
||||
expect(document.body.textContent).not.toContain('{{$jobsMapByNodeKey.node1.name}}');
|
||||
});
|
||||
|
||||
it('falls back to the raw token when the reference is not in the tree', async () => {
|
||||
const flowContext = createTestFlowContext();
|
||||
const metaTree: MetaTreeNode[] = [{ name: '$user', title: 'User', type: '', paths: ['$user'], children: [] }];
|
||||
|
||||
render(
|
||||
<TestFlowContextWrapper context={flowContext}>
|
||||
<VariableHybridInput value="{{$missing.field}}" metaTree={metaTree} converters={workflowConverters} />
|
||||
</TestFlowContextWrapper>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const tag = document.querySelector(TAG_SELECTOR);
|
||||
expect(tag?.textContent).toBe('{{$missing.field}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,14 @@ export interface FlowContextSelectorProps
|
||||
open?: boolean;
|
||||
onlyLeafSelectable?: boolean;
|
||||
ignoreFieldNames?: string[];
|
||||
/**
|
||||
* Footer rendered at the bottom of the dropdown. Defaults to a muted
|
||||
* "Double click to choose entire object" hint when non-leaf selection is
|
||||
* allowed (`onlyLeafSelectable` is false) — since double-clicking a non-leaf
|
||||
* node selects the whole object. Pass an explicit node to override, or `null`
|
||||
* to hide it.
|
||||
*/
|
||||
dropdownFooter?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ContextSelectorItem {
|
||||
|
||||
@@ -64,7 +64,9 @@ export class FlowI18n {
|
||||
* @private
|
||||
*/
|
||||
private isTemplate(str: string): boolean {
|
||||
return /\{\{\s*t\s*\(\s*["'`].*?["'`]\s*(?:,\s*.*?)?\s*\)\s*\}\}/g.test(str);
|
||||
// The closing quote is a backreference to the opening one (group 1) so an embedded quote of a different type — e.g.
|
||||
// {{t('… "Post-action event" …')}} — does not terminate the key early.
|
||||
return /\{\{\s*t\s*\(\s*(["'`])(?:\\.|(?!\1).)*?\1\s*(?:,\s*.*?)?\s*\)\s*\}\}/.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,9 +74,12 @@ export class FlowI18n {
|
||||
* @private
|
||||
*/
|
||||
private compileTemplate(template: string): string {
|
||||
// `(["'`])` captures the opening quote; the key allows escaped chars (`\\.`) and any char that is not that same
|
||||
// quote (`(?!\1).`), and `\1` closes on the matching quote. This keeps embedded quotes of a different type inside
|
||||
// the key instead of truncating it at the first quote of any kind.
|
||||
return template.replace(
|
||||
/\{\{\s*t\s*\(\s*["'`](.*?)["'`]\s*(?:,\s*((?:[^{}]|\{[^}]*\})*?))?\s*\)\s*\}\}/g,
|
||||
(match, key, optionsStr) => {
|
||||
/\{\{\s*t\s*\(\s*(["'`])((?:\\.|(?!\1).)*?)\1\s*(?:,\s*((?:[^{}]|\{[^}]*\})*?))?\s*\)\s*\}\}/g,
|
||||
(match, _quote, key, optionsStr) => {
|
||||
try {
|
||||
let templateOptions = {};
|
||||
if (optionsStr) {
|
||||
|
||||
+40
-163
@@ -13,17 +13,15 @@ import {
|
||||
ExclamationCircleFilled,
|
||||
FilterOutlined,
|
||||
ImportOutlined,
|
||||
MenuOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { DndContext, DragOverlay, MouseSensor, useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DrawerFormLayout,
|
||||
FilterContent,
|
||||
SortableCategoryTabs,
|
||||
Table,
|
||||
normalizeCollectionTemplateFields,
|
||||
} from '@nocobase/client-v2';
|
||||
@@ -34,7 +32,6 @@ import type { FilterGroupType } from '@nocobase/utils/client';
|
||||
import { useRequest } from 'ahooks';
|
||||
import {
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
@@ -48,7 +45,6 @@ import {
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
Transfer,
|
||||
@@ -128,138 +124,6 @@ const colorLabels: Record<string, string> = {
|
||||
purple: 'Purple',
|
||||
};
|
||||
|
||||
function DraggableCategoryTab(props: { children: React.ReactNode; item: CollectionCategoryRecord }) {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: String(props.item.id),
|
||||
data: props.item,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DroppableCategoryTab(props: { children: React.ReactNode; item: CollectionCategoryRecord }) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: String(props.item.id),
|
||||
data: props.item,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={isOver ? { color: 'green' } : undefined}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTabContent(props: {
|
||||
item: CollectionCategoryRecord;
|
||||
onDelete: (category: CollectionCategoryRecord) => void;
|
||||
onEdit: (category: CollectionCategoryRecord) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Badge color={props.item.color === 'default' ? undefined : props.item.color} />
|
||||
{compileLegacyTemplate(props.item.name || props.item.id, t)}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'edit', label: t('Edit category') },
|
||||
{ key: 'delete', label: t('Delete category') },
|
||||
],
|
||||
onClick({ key, domEvent }) {
|
||||
domEvent.stopPropagation();
|
||||
if (key === 'edit') {
|
||||
props.onEdit(props.item);
|
||||
return;
|
||||
}
|
||||
props.onDelete(props.item);
|
||||
},
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
aria-label={t('Edit category')}
|
||||
icon={<MenuOutlined />}
|
||||
size="small"
|
||||
type="text"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCategoryTab(props: {
|
||||
item: CollectionCategoryRecord;
|
||||
onDelete: (category: CollectionCategoryRecord) => void;
|
||||
onEdit: (category: CollectionCategoryRecord) => void;
|
||||
}) {
|
||||
return (
|
||||
<DroppableCategoryTab item={props.item}>
|
||||
<DraggableCategoryTab item={props.item}>
|
||||
<CategoryTabContent item={props.item} onDelete={props.onDelete} onEdit={props.onEdit} />
|
||||
</DraggableCategoryTab>
|
||||
</DroppableCategoryTab>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTabsDndProvider(props: {
|
||||
children: React.ReactNode;
|
||||
onSort: (from: CollectionCategoryRecord, to: CollectionCategoryRecord) => Promise<void>;
|
||||
}) {
|
||||
const { children, onSort } = props;
|
||||
const [activeTab, setActiveTab] = useState<CollectionCategoryRecord>();
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveTab(event.active.data.current as CollectionCategoryRecord | undefined);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveTab(undefined);
|
||||
|
||||
if (!over || over.id === active.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = active.data.current as CollectionCategoryRecord | undefined;
|
||||
const target = over.data.current as CollectionCategoryRecord | undefined;
|
||||
if (!source || !target) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onSort(source, target);
|
||||
},
|
||||
[onSort],
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeTab ? (
|
||||
<span style={{ whiteSpace: 'nowrap' }}>
|
||||
<CategoryTabContent item={activeTab} onDelete={() => undefined} onEdit={() => undefined} />
|
||||
</span>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function createEmptyFilter(): FilterGroupValue {
|
||||
return observable({ logic: '$and', items: [] }) as FilterGroupValue;
|
||||
}
|
||||
@@ -1874,20 +1738,19 @@ function CollectionsPage(props: CollectionsPageProps) {
|
||||
});
|
||||
}, [availableTables, tableSearchValue]);
|
||||
|
||||
const categoryTabs = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'all',
|
||||
label: t('All collections'),
|
||||
closable: false,
|
||||
},
|
||||
...categories.map((item) => ({
|
||||
key: String(item.id),
|
||||
label: <SortableCategoryTab item={item} onDelete={handleDeleteCategory} onEdit={openEditCategoryModal} />,
|
||||
closable: false,
|
||||
const categoryTabItems = useMemo(
|
||||
() =>
|
||||
categories.map((item) => ({
|
||||
id: item.id,
|
||||
label: compileLegacyTemplate(item.name || item.id, t),
|
||||
color: item.color,
|
||||
})),
|
||||
],
|
||||
[categories, handleDeleteCategory, openEditCategoryModal, t],
|
||||
[categories, t],
|
||||
);
|
||||
|
||||
const findCategoryById = useCallback(
|
||||
(id: string | number) => categories.find((item) => String(item.id) === String(id)),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<Record<string, any>>>(() => {
|
||||
@@ -1980,19 +1843,33 @@ function CollectionsPage(props: CollectionsPageProps) {
|
||||
return (
|
||||
<Card title={compileLegacyTemplate(props.title, t)} variant="borderless">
|
||||
{isMainDataSource ? (
|
||||
<CategoryTabsDndProvider onSort={handleSortCategory}>
|
||||
<Tabs
|
||||
activeKey={activeCategoryKey}
|
||||
type="editable-card"
|
||||
items={categoryTabs}
|
||||
onChange={handleCategoryChange}
|
||||
onEdit={(_, action) => {
|
||||
if (action === 'add') {
|
||||
openCategoryModal();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CategoryTabsDndProvider>
|
||||
<SortableCategoryTabs
|
||||
activeKey={activeCategoryKey}
|
||||
onChange={handleCategoryChange}
|
||||
allTab={{ key: 'all', label: t('All collections') }}
|
||||
categories={categoryTabItems}
|
||||
onAdd={openCategoryModal}
|
||||
onEdit={(id) => {
|
||||
const category = findCategoryById(id);
|
||||
if (category) {
|
||||
openEditCategoryModal(category);
|
||||
}
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
const category = findCategoryById(id);
|
||||
if (category) {
|
||||
handleDeleteCategory(category);
|
||||
}
|
||||
}}
|
||||
onSort={(sourceId, targetId) => {
|
||||
const from = findCategoryById(sourceId);
|
||||
const to = findCategoryById(targetId);
|
||||
if (from && to) {
|
||||
return handleSortCategory(from, to);
|
||||
}
|
||||
}}
|
||||
menuLabels={{ edit: t('Edit category'), delete: t('Delete category') }}
|
||||
/>
|
||||
) : null}
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<CollectionFilterPopover
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './dist/client-v2';
|
||||
export { default } from './dist/client-v2';
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client-v2/index.js');
|
||||
@@ -36,6 +36,7 @@
|
||||
"peerDependencies": {
|
||||
"@nocobase/actions": "2.x",
|
||||
"@nocobase/client": "2.x",
|
||||
"@nocobase/client-v2": "2.x",
|
||||
"@nocobase/database": "2.x",
|
||||
"@nocobase/evaluators": "2.x",
|
||||
"@nocobase/logger": "2.x",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Button, Result } from 'antd';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CanvasContent } from './canvas/CanvasContent';
|
||||
import { FlowContext } from './canvas/contexts';
|
||||
import { linkNodes } from './canvas/nodeTree';
|
||||
import { ExecutionViewHeader } from './components/ExecutionViewHeader';
|
||||
import { JobResultModal } from './components/JobResultModal';
|
||||
import { useWorkflowTranslation } from './locale';
|
||||
|
||||
function attachJobs(nodes: any[], jobs: any[] = []) {
|
||||
const nodesMap = new Map();
|
||||
nodes.forEach((item) => {
|
||||
item.jobs = [];
|
||||
nodesMap.set(item.id, item);
|
||||
});
|
||||
jobs.forEach((item) => {
|
||||
const node = nodesMap.get(item.nodeId);
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
node.jobs.push(item);
|
||||
item.node = {
|
||||
id: node.id,
|
||||
key: node.key,
|
||||
title: node.title,
|
||||
type: node.type,
|
||||
};
|
||||
});
|
||||
nodes.forEach((item) => {
|
||||
item.jobs = item.jobs.sort((a, b) => a.id - b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function ExecutionCanvas({ record, resource, refresh }: { record: any; resource: any; refresh: () => void }) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [viewJob, setViewJob] = useState<any>(null);
|
||||
|
||||
const { jobs = [], workflow, ...execution } = record ?? {};
|
||||
const nodes = useMemo(() => {
|
||||
const nextNodes = [...(workflow?.nodes ?? [])];
|
||||
linkNodes(nextNodes);
|
||||
attachJobs(nextNodes, jobs);
|
||||
return nextNodes;
|
||||
}, [jobs, workflow?.nodes]);
|
||||
|
||||
const entry = useMemo(() => nodes.find((item) => !item.upstream) ?? null, [nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewJob?.id) {
|
||||
return;
|
||||
}
|
||||
const latest = jobs.find((job) => String(job.id) === String(viewJob.id));
|
||||
if (latest) {
|
||||
setViewJob(latest);
|
||||
}
|
||||
}, [jobs, viewJob?.id]);
|
||||
|
||||
const onBack = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
if (!workflow) {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title={t('Not found')}
|
||||
subTitle={t('Workflow of execution is not existed')}
|
||||
extra={<Button onClick={onBack}>{t('Go back')}</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlowContext.Provider
|
||||
value={{
|
||||
workflow: workflow.type ? workflow : null,
|
||||
nodes,
|
||||
execution,
|
||||
viewJob,
|
||||
setViewJob,
|
||||
}}
|
||||
>
|
||||
<ExecutionViewHeader execution={record} resource={resource} refresh={refresh} />
|
||||
<CanvasContent entry={entry} />
|
||||
<JobResultModal />
|
||||
</FlowContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionCanvas;
|
||||
@@ -0,0 +1,16 @@
|
||||
# plugin-workflow `client-v2`
|
||||
|
||||
The v2 (FlowEngine / `@nocobase/client-v2`) client runtime for the workflow plugin. See `docs/adr/0003-workflow-canvas-progressive-migration.md` for the migration design.
|
||||
|
||||
> **Iron rule:** code under `client-v2/` must never import a **value** from `@nocobase/client` or `@formily/*`. `import type` from `@formily/*` is allowed (erased at build). The three Formily-load-bearing v1 spots (config drawer, add-node flow, test-run modal) are **rebuilt natively** here, never ported.
|
||||
|
||||
## Directory layout
|
||||
|
||||
- **`canvas/`** — everything tied to the node-graph canvas: the `Node` / `Branch` cards, drag / clipboard / add-node / remove-node contexts, the node config drawer, the in-canvas pure logic (`nodeTree`, `collectionFieldOptions`, `dropImpact`, the variable aggregator), and the shared `Instruction` base class. If it only makes sense on the canvas, it lives here.
|
||||
- **`nodes/`** — one file per core node type (`condition.tsx`, `calculation.tsx`, …), each `default`-exporting its `Instruction` class, mirroring v1's `client/nodes/` layout. The plugin registers them in `plugin.tsx`.
|
||||
- **`components/`** — reusable pieces that are **neither canvas-specific nor tied to a single node**: e.g. `TestRunButton`, `Calculation` (condition builder), `RadioWithTooltip`, `renderEngineReference`, the execution status tags / dropdowns. A node's config form may compose these, but they don't depend on the canvas or on one node type.
|
||||
- **`triggers/`** — per-trigger create-config forms (collection / schedule).
|
||||
- **`pages/`** — route-level pages (workflow list pane, canvas page, execution view, drawers).
|
||||
- **`models/`** — FlowModel definitions (node details blocks, task cards).
|
||||
|
||||
Rule of thumb when adding a file: **canvas-related → `canvas/`; a node's own definition → `nodes/`; otherwise a reusable widget → `components/`.**
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
export type SharedAddNodeAnchor = {
|
||||
upstream?: any;
|
||||
branchIndex?: number | null;
|
||||
branchContext?: {
|
||||
syncOnly?: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SharedAddNodeContextValue = {
|
||||
creating?: { upstreamId: any; branchIndex: number | null } | null;
|
||||
anchor?: SharedAddNodeAnchor | null;
|
||||
onMenuOpen?: (anchor: SharedAddNodeAnchor) => void;
|
||||
onMenuCancel?: () => void;
|
||||
onCreate?: (args: SharedAddNodeAnchor & { type: string }) => Promise<void> | void;
|
||||
presetting?: any;
|
||||
setPresetting?: (value: any) => void;
|
||||
setCreating?: (value: any) => void;
|
||||
};
|
||||
|
||||
export const AddNodeContext = createContext<SharedAddNodeContextValue | null>(null);
|
||||
|
||||
export function useAddNodeContext() {
|
||||
return useContext(AddNodeContext);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern canvas add-node flow (doc §9.6), a native-antd rewrite of v1's
|
||||
* Formily `AddNodeContext`. Opens a `ctx.viewer.drawer` (50% width, two-column
|
||||
* grouped menu — 1:1 with v1) listing the v2-registered instructions; selecting
|
||||
* a type creates the node via `workflows.nodes.create` and refreshes.
|
||||
*
|
||||
* Only types present in *this* (v2) runtime's instruction registry are listed —
|
||||
* a type implemented only in v1 simply does not appear (doc §9.1).
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense, useMemo, useState } from 'react';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { App, Button, Form, Menu, Skeleton, Space } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import { DialogFormLayout } from '@nocobase/client-v2';
|
||||
import { useFlowContext as useFlowEngineContext, useFlowView } from '@nocobase/flow-engine';
|
||||
import { uid } from '@nocobase/utils/client';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useFlowContext } from './contexts';
|
||||
import { useT } from '../locale';
|
||||
import { PluginWorkflowClientV2 } from '../plugin';
|
||||
import type { Instruction } from './Instruction';
|
||||
import DownstreamBranchIndex, { getDownstreamBranchOptions } from './DownstreamBranchIndex';
|
||||
import { AddNodeContext, useAddNodeContext } from './AddNodeContext.shared';
|
||||
import { createNodeAndMaybeReparent, resolveAddNodeDecision } from './addNodeController';
|
||||
|
||||
type Anchor = { upstream?: any; branchIndex?: number | null };
|
||||
|
||||
// Two-column grouped menu — mirrors v1's `.ant-menu-item-group-list` grid.
|
||||
const menuGridClass = css`
|
||||
.ant-menu-item-group-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
&.ant-menu-root.ant-menu-vertical {
|
||||
border-inline-end: none;
|
||||
}
|
||||
.ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
/** The drawer body: the grouped instruction menu. Rendered inside
|
||||
* `ctx.viewer.drawer`, so it gets the drawer chrome (title + native close).
|
||||
* Closes itself (via `useFlowView`) once a type is picked and created. */
|
||||
function AddNodeMenu({ items, onPick }: { items: MenuProps['items']; onPick: (type: string) => Promise<void> }) {
|
||||
const view = useFlowView();
|
||||
return (
|
||||
<Menu
|
||||
className={menuGridClass}
|
||||
mode="vertical"
|
||||
selectable={false}
|
||||
items={items}
|
||||
onClick={async ({ key }) => {
|
||||
await onPick(String(key));
|
||||
await view.close();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The add-time preset dialog body (small modal, mirrors v1's `Action.Modal`).
|
||||
* Hosts the instruction's `PresetFieldsetLoader` (e.g. the condition node's mode
|
||||
* picker) and, when inserting a branching node above an existing downstream node,
|
||||
* the `DownstreamBranchIndex` field. On submit it validates and delegates to
|
||||
* `onSubmit(values)` — the provider owns the actual create + downstream
|
||||
* re-parenting.
|
||||
*/
|
||||
export function PresetDialogForm({
|
||||
instruction,
|
||||
hasDownstream,
|
||||
onSubmit,
|
||||
}: {
|
||||
instruction: Instruction;
|
||||
hasDownstream: boolean;
|
||||
onSubmit: (values: any) => Promise<void>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const Preset = useMemo(
|
||||
() => (instruction.PresetFieldsetLoader ? lazy(instruction.PresetFieldsetLoader) : null),
|
||||
[instruction],
|
||||
);
|
||||
|
||||
const config = Form.useWatch('config', form);
|
||||
const downstreamOptions = useMemo(
|
||||
() =>
|
||||
getDownstreamBranchOptions({
|
||||
instruction,
|
||||
config: config ?? {},
|
||||
hasDownstream,
|
||||
t,
|
||||
}),
|
||||
[instruction, config, hasDownstream, t],
|
||||
);
|
||||
|
||||
const handleSubmit = useMemoizedFn(async () => {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogFormLayout title={t('Add node')} onSubmit={handleSubmit} submitting={submitting}>
|
||||
<Form form={form} layout="vertical">
|
||||
{Preset ? (
|
||||
<Suspense fallback={<Skeleton active paragraph={{ rows: 2 }} />}>
|
||||
<Preset />
|
||||
</Suspense>
|
||||
) : null}
|
||||
<DownstreamBranchIndex options={downstreamOptions} />
|
||||
</Form>
|
||||
</DialogFormLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddNodeContextProvider(props: { children: React.ReactNode }) {
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
const { message } = App.useApp();
|
||||
const plugin = ctx.app.pm.get(PluginWorkflowClientV2) as PluginWorkflowClientV2;
|
||||
const { workflow, nodes, refresh } = useFlowContext() ?? {};
|
||||
|
||||
const [creating, setCreating] = useState<{ upstreamId: any; branchIndex: number | null } | null>(null);
|
||||
|
||||
const buildItems = useMemoizedFn((): MenuProps['items'] => {
|
||||
const instructionList = Array.from(plugin?.instructions?.getValues?.() ?? []) as Instruction[];
|
||||
const groupList = Array.from(plugin?.instructionGroups?.getValues?.() ?? []) as Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
}>;
|
||||
return groupList
|
||||
.map((group) => {
|
||||
const children = instructionList
|
||||
.filter((item) => item.group === group.key)
|
||||
.map((item) => ({ key: item.type, label: t(item.title as string), icon: item.icon }));
|
||||
return children.length ? { type: 'group' as const, key: group.key, label: t(group.label), children } : null;
|
||||
})
|
||||
.filter(Boolean) as MenuProps['items'];
|
||||
});
|
||||
|
||||
const createNode = useMemoizedFn(async (anchor: Anchor, instruction: Instruction, presetValues: any) => {
|
||||
const upstreamId = anchor.upstream?.id ?? null;
|
||||
const branchIndex = anchor.branchIndex ?? null;
|
||||
const { downstreamBranchIndex, config: presetConfig } = presetValues ?? {};
|
||||
setCreating({ upstreamId, branchIndex });
|
||||
try {
|
||||
await createNodeAndMaybeReparent({
|
||||
workflowId: workflow.id,
|
||||
api: ctx.api,
|
||||
refresh,
|
||||
values: {
|
||||
key: uid(),
|
||||
type: instruction.type,
|
||||
upstreamId,
|
||||
branchIndex,
|
||||
title: t(instruction.title as string),
|
||||
config: { ...(instruction.createDefaultConfig?.() ?? {}), ...(presetConfig ?? {}) },
|
||||
},
|
||||
downstreamBranchIndex,
|
||||
});
|
||||
} catch (err) {
|
||||
message.error(t('Failed to add node'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setCreating(null);
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = useMemoizedFn(async (anchor: Anchor, type: string) => {
|
||||
if (!workflow?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = resolveAddNodeDecision({
|
||||
type,
|
||||
anchor,
|
||||
runtime: {
|
||||
workflow,
|
||||
nodes: nodes ?? [],
|
||||
getInstruction: (instructionType) => plugin?.getInstruction(instructionType),
|
||||
translateTitle: (title) => t(title),
|
||||
},
|
||||
});
|
||||
|
||||
if (decision.kind === 'missing' || decision.kind === 'blocked') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (decision.kind === 'modern-preset') {
|
||||
ctx.viewer.dialog({
|
||||
width: 520,
|
||||
closable: true,
|
||||
content: () => (
|
||||
<PresetDialogForm
|
||||
instruction={decision.instruction}
|
||||
hasDownstream={decision.hasDownstream}
|
||||
onSubmit={(values) => createNode(decision.anchor, decision.instruction, values)}
|
||||
/>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createNode(anchor, decision.instruction, {});
|
||||
});
|
||||
|
||||
const onMenuOpen = useMemoizedFn((anchor: Anchor) => {
|
||||
const items = buildItems();
|
||||
ctx.viewer.drawer({
|
||||
width: '50%',
|
||||
closable: true,
|
||||
title: t('Add node'),
|
||||
content: () => <AddNodeMenu items={items} onPick={(type) => onCreate(anchor, type)} />,
|
||||
});
|
||||
});
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
creating,
|
||||
anchor: null,
|
||||
onMenuOpen,
|
||||
}),
|
||||
[creating, onMenuOpen],
|
||||
);
|
||||
|
||||
return <AddNodeContext.Provider value={value}>{props.children}</AddNodeContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The "+" add-node anchor between nodes (doc §9.6). Normally opens the add-node
|
||||
* drawer. When a node has been copied to the clipboard, it instead becomes a
|
||||
* paste zone (mirrors v1's `AddNodeSlot` → `AddNodePasteZone` switch): clicking
|
||||
* pastes the copied node here. Disabled while the workflow is executed.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Button } from 'antd';
|
||||
import { PlusOutlined, SnippetsOutlined } from '@ant-design/icons';
|
||||
import useStyles from './style';
|
||||
import { useFlowContext, useWorkflowCanvasExecuted } from './contexts';
|
||||
import { useAddNodeContext } from './AddNodeContext.shared';
|
||||
import { useBranchContext } from './BranchContext';
|
||||
import { useNodeClipboardContext } from './NodeClipboardContext';
|
||||
import { useNodeDragContext } from './NodeDragContext';
|
||||
|
||||
function AddButtonPlaceholder() {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<div className={`${styles.addButtonClass} workflow-add-node-button`}>
|
||||
<span className="ant-btn-placeholder" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** During a drag, every add-slot becomes a drop zone: it registers its DOM
|
||||
* element for hit-testing and reflects the drop impact (safe / warning /
|
||||
* disabled), highlighting when it's the active target. Mirrors v1. */
|
||||
function DropZone({
|
||||
upstream,
|
||||
branchIndex,
|
||||
ariaLabel,
|
||||
}: {
|
||||
upstream?: any;
|
||||
branchIndex: number | null;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const { styles, cx } = useStyles();
|
||||
const branchContext = useBranchContext();
|
||||
const dragContext = useNodeDragContext();
|
||||
const target = useMemo(() => ({ upstream, branchIndex }), [upstream, branchIndex]);
|
||||
const impact = dragContext?.getDropImpact?.(target);
|
||||
const status = impact?.status ?? 'disabled';
|
||||
const disabled = branchContext?.addable === false || status === 'disabled';
|
||||
const dropKey = dragContext?.getDropKey?.(target);
|
||||
const isActive = Boolean(dropKey && dragContext?.activeDropKey === dropKey);
|
||||
const registerDropZone = dragContext?.registerDropZone;
|
||||
const zoneRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!registerDropZone || !zoneRef.current || disabled) {
|
||||
return;
|
||||
}
|
||||
return registerDropZone(target, zoneRef.current);
|
||||
}, [registerDropZone, disabled, target]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.addButtonClass} workflow-add-node-button`}>
|
||||
<div
|
||||
role="button"
|
||||
aria-label={ariaLabel || 'drop-zone'}
|
||||
ref={zoneRef}
|
||||
className={cx(styles.dropZoneClass, {
|
||||
'drop-safe': status === 'safe',
|
||||
'drop-warning': status === 'warning',
|
||||
'drop-active': isActive,
|
||||
'drop-disabled': disabled,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasteZone({
|
||||
upstream,
|
||||
branchIndex,
|
||||
ariaLabel,
|
||||
}: {
|
||||
upstream?: any;
|
||||
branchIndex: number | null;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const { styles, cx } = useStyles();
|
||||
const branchContext = useBranchContext();
|
||||
const clipboard = useNodeClipboardContext();
|
||||
const target = { upstream, branchIndex };
|
||||
const impact = clipboard?.getPasteImpact(target);
|
||||
const status = impact?.status ?? 'disabled';
|
||||
const disabled = branchContext?.addable === false || status === 'disabled';
|
||||
|
||||
return (
|
||||
<div className={`${styles.addButtonClass} workflow-add-node-button`}>
|
||||
<Button
|
||||
aria-label={ariaLabel || 'paste-zone'}
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<SnippetsOutlined />}
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && clipboard?.pasteNode(target)}
|
||||
className={cx(styles.pasteButtonClass, {
|
||||
'paste-safe': status === 'safe',
|
||||
'paste-warning': status === 'warning',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddNodeSlot({
|
||||
upstream,
|
||||
branchIndex = null,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
upstream?: any;
|
||||
branchIndex?: number | null;
|
||||
'aria-label'?: string;
|
||||
}) {
|
||||
const { styles } = useStyles();
|
||||
const { workflow } = useFlowContext() ?? {};
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
const branchContext = useBranchContext();
|
||||
const addNodeContext = useAddNodeContext();
|
||||
const clipboard = useNodeClipboardContext();
|
||||
const dragContext = useNodeDragContext();
|
||||
|
||||
const onOpen = useCallback(
|
||||
() =>
|
||||
addNodeContext?.onMenuOpen?.({
|
||||
upstream,
|
||||
branchIndex,
|
||||
branchContext: {
|
||||
syncOnly: branchContext?.syncOnly ?? false,
|
||||
},
|
||||
}),
|
||||
[addNodeContext, upstream, branchIndex, branchContext?.syncOnly],
|
||||
);
|
||||
|
||||
const loading = Boolean(
|
||||
addNodeContext?.creating &&
|
||||
addNodeContext.creating.upstreamId === (upstream?.id ?? null) &&
|
||||
addNodeContext.creating.branchIndex === branchIndex,
|
||||
);
|
||||
|
||||
if (!workflow || !addNodeContext || branchContext?.addable === false) {
|
||||
return <AddButtonPlaceholder />;
|
||||
}
|
||||
|
||||
if (executed) {
|
||||
return <AddButtonPlaceholder />;
|
||||
}
|
||||
|
||||
// While dragging, every slot is a drop zone (v1 behavior, takes precedence).
|
||||
if (dragContext?.dragging) {
|
||||
return <DropZone upstream={upstream} branchIndex={branchIndex} ariaLabel={ariaLabel} />;
|
||||
}
|
||||
|
||||
// A copied node turns every add-slot into a paste zone (v1 behavior).
|
||||
if (clipboard?.clipboard) {
|
||||
return <PasteZone upstream={upstream} branchIndex={branchIndex} ariaLabel={ariaLabel} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.addButtonClass} workflow-add-node-button`}>
|
||||
<Button
|
||||
aria-label={ariaLabel || 'add-button'}
|
||||
shape="circle"
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern canvas branch column (doc §9.6). A second copy of v1 `Branch` — same
|
||||
* DOM/flexbox structure and stylesheet, but rendering the v2 `<Node>` and using
|
||||
* the v2 contexts. Branch nodes recurse by self-rendering nested `<Branch>` from
|
||||
* their `ComponentLoader` (the modern render-extension point).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Node } from './Node';
|
||||
import { AddNodeSlot } from './AddNodeSlot';
|
||||
import { BranchContext } from './BranchContext';
|
||||
import { useBranchNodeRenderer } from './BranchRenderContext';
|
||||
import useStyles from './style';
|
||||
|
||||
function EndSign({ title }: { title?: React.ReactNode }) {
|
||||
const content = (
|
||||
<div className="end-sign">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
);
|
||||
return title ? <Tooltip title={title}>{content}</Tooltip> : content;
|
||||
}
|
||||
|
||||
export function Branch({
|
||||
from = null,
|
||||
entry = null,
|
||||
branchIndex = null,
|
||||
controller = null,
|
||||
className,
|
||||
end,
|
||||
addable = true,
|
||||
syncOnly = false,
|
||||
start = false,
|
||||
startTitle,
|
||||
dashed = false,
|
||||
NodeComponent,
|
||||
addButtonAriaLabel,
|
||||
}: {
|
||||
from?: any;
|
||||
entry?: any;
|
||||
branchIndex?: number | null;
|
||||
controller?: React.ReactNode;
|
||||
className?: string;
|
||||
end?: true | React.ReactNode | null;
|
||||
addable?: boolean;
|
||||
syncOnly?: boolean;
|
||||
start?: boolean;
|
||||
startTitle?: React.ReactNode;
|
||||
dashed?: boolean;
|
||||
NodeComponent?: React.ComponentType<{ data: any }>;
|
||||
addButtonAriaLabel?: string;
|
||||
}) {
|
||||
const { styles, cx } = useStyles();
|
||||
const injectedNodeRenderer = useBranchNodeRenderer();
|
||||
const ResolvedNodeComponent = NodeComponent ?? injectedNodeRenderer ?? Node;
|
||||
const list: any[] = [];
|
||||
for (let node = entry; node; node = node.downstream) {
|
||||
list.push(node);
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={{ branchIndex, addable, syncOnly }}>
|
||||
<div className={cx('workflow-branch', styles.branchClass, className, { 'workflow-branch-dashed': dashed })}>
|
||||
<div className="workflow-branch-lines" />
|
||||
{controller ? <div className="workflow-branch-controller">{controller}</div> : null}
|
||||
<div className="workflow-node-list">
|
||||
{start ? <EndSign title={startTitle} /> : null}
|
||||
{addable ? <AddNodeSlot aria-label={addButtonAriaLabel} upstream={from} branchIndex={branchIndex} /> : null}
|
||||
{list.map((item) => (
|
||||
<ResolvedNodeComponent data={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
{end === true ? <EndSign /> : end}
|
||||
</div>
|
||||
</BranchContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-branch context, shared by BOTH canvases (ADR-0003). Carries the branch
|
||||
* index, whether the branch accepts added nodes, and whether the branch is
|
||||
* sync-only. Zero dependencies (bare `React.createContext`, no runtime hooks),
|
||||
* so — like `NodeContext` — a single definition serves both runtimes: v1
|
||||
* re-exports it from here via the allowed `v1 → v2` import direction. Each
|
||||
* canvas's own `Branch` component supplies the value; `syncOnly` is optional, so
|
||||
* the modern canvas (which doesn't set it) is unaffected.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type BranchContextValue = {
|
||||
branchIndex: number | null;
|
||||
addable: boolean;
|
||||
/** Whether the branch is restricted to synchronous nodes. Optional — only the
|
||||
* legacy canvas's `Branch` sets it; consumers read it via `?.syncOnly`. */
|
||||
syncOnly?: boolean;
|
||||
};
|
||||
|
||||
// Default `null` (matches v1): every consumer reads through `useBranchContext()?.`, so the absence of a provider is
|
||||
// handled the same in both canvases.
|
||||
export const BranchContext = createContext<BranchContextValue | null>(null);
|
||||
|
||||
export function useBranchContext() {
|
||||
return useContext(BranchContext);
|
||||
}
|
||||
|
||||
export function useBranchIndex() {
|
||||
return useBranchContext()?.branchIndex ?? null;
|
||||
}
|
||||
|
||||
export function useBranchSyncOnly() {
|
||||
return useBranchContext()?.syncOnly ?? false;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
|
||||
export type BranchNodeRenderer = React.ComponentType<{ data: any }>;
|
||||
|
||||
export const BranchRenderContext = createContext<BranchNodeRenderer | null>(null);
|
||||
|
||||
export function useBranchNodeRenderer() {
|
||||
return useContext(BranchRenderContext);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern canvas content (doc §9.6). A second copy of v1 `CanvasContent` — same
|
||||
* stylesheet/layout (trigger block → entry branch → End sign, with a zoom
|
||||
* slider), rebuilt on the v2 contexts and rendering the v2 `<Branch>`.
|
||||
*
|
||||
* The trigger config block is a placeholder for now (the v2 trigger config UI
|
||||
* is a separate surface); the focus here is the node-graph render.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Button, Slider } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import useStyles from './style';
|
||||
import { Branch } from './Branch';
|
||||
import { useFlowContext, useWorkflowCanvasExecuted } from './contexts';
|
||||
import { useNodeClipboardContext } from './NodeClipboardContext';
|
||||
import { useT } from '../locale';
|
||||
import { PluginWorkflowClientV2 } from '../plugin';
|
||||
import { TriggerConfig } from '../triggers/TriggerConfig';
|
||||
|
||||
/** "Copied node" preview, top-left of the canvas (mirrors v1). Shown while a
|
||||
* node is on the clipboard; the X clears it. */
|
||||
function ClipboardPreview() {
|
||||
const { styles } = useStyles();
|
||||
const t = useT();
|
||||
const flowEngine = useFlowEngine();
|
||||
const clipboard = useNodeClipboardContext();
|
||||
const copied = clipboard?.clipboard;
|
||||
if (!copied) {
|
||||
return null;
|
||||
}
|
||||
const plugin = flowEngine.context.app.pm.get(PluginWorkflowClientV2) as PluginWorkflowClientV2;
|
||||
const instruction = plugin?.getInstruction(copied.type);
|
||||
const typeTitle = instruction ? t(instruction.title as string) : copied.type;
|
||||
return (
|
||||
<div className={styles.clipboardPreviewClass}>
|
||||
<div className="workflow-clipboard-header">
|
||||
<span>{t('Copied node')}</span>
|
||||
<Button type="text" size="small" icon={<CloseOutlined />} onClick={() => clipboard?.clearClipboard()} />
|
||||
</div>
|
||||
<div className="workflow-clipboard-card">
|
||||
<div className="workflow-clipboard-type">{typeTitle}</div>
|
||||
<div className="workflow-clipboard-title">{copied.title ?? copied.type}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasContent({ entry }: { entry?: any }) {
|
||||
const { styles, cx } = useStyles();
|
||||
const t = useT();
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
const { workflow } = useFlowContext() ?? {};
|
||||
const [zoom, setZoom] = useState(100);
|
||||
|
||||
return (
|
||||
// The `.workflow-canvas-*` layout rules (centering, padding, the zoomer position) are defined *nested* under
|
||||
// `workflowPageClass`, so the wrapper must live inside an element carrying that class — exactly as v1's
|
||||
// WorkflowPage wraps its canvas.
|
||||
<div className={cx(styles.workflowPageClass, css({ height: '100%' }))}>
|
||||
<div className="workflow-canvas-wrapper">
|
||||
<div className="workflow-canvas" style={{ zoom: zoom / 100 }}>
|
||||
<div className={cx(styles.branchBlockClass, css({ marginTop: '0 !important' }))}>
|
||||
<div className={styles.branchClass}>
|
||||
{executed ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('Executed workflow cannot be modified. Could be copied to a new version to modify.')}
|
||||
style={{ marginBottom: '1em' }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TriggerConfig />
|
||||
|
||||
<div className={cx(styles.branchBlockClass, css({ marginTop: '0 !important' }))}>
|
||||
<Branch entry={entry} />
|
||||
</div>
|
||||
<div className={styles.terminalClass}>{t('End')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClipboardPreview />
|
||||
<div className="workflow-canvas-zoomer">
|
||||
<Slider vertical reverse defaultValue={100} step={10} min={10} value={zoom} onChange={setZoom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Add-node preset field: "Move all downstream nodes to" (`downstreamBranchIndex`).
|
||||
* v2 mirror of v1's `DownstreamBranchIndex` (`client/AddNodeContext.tsx`). Shown
|
||||
* when a **branching** node is inserted **above an existing downstream node** —
|
||||
* the user chooses whether that downstream node stays after the branches
|
||||
* (`false`) or moves inside one of the new branches (a `branchIndex`).
|
||||
*
|
||||
* Returns null (renders nothing) when the node isn't branching or there's no
|
||||
* downstream node to move.
|
||||
*/
|
||||
|
||||
import { Form } from 'antd';
|
||||
import React from 'react';
|
||||
import { useT } from '../locale';
|
||||
import { RadioWithTooltip, type RadioWithTooltipOption } from '../components/RadioWithTooltip';
|
||||
import type { Instruction } from './Instruction';
|
||||
|
||||
const DEFAULT_BRANCHING_OPTIONS = [{ value: 0 }];
|
||||
|
||||
export function getDownstreamBranchOptions({
|
||||
instruction,
|
||||
config,
|
||||
hasDownstream,
|
||||
t,
|
||||
}: {
|
||||
instruction?: Instruction;
|
||||
config: Record<string, any>;
|
||||
hasDownstream: boolean;
|
||||
t: (key: string, options?: Record<string, any>) => string;
|
||||
}): RadioWithTooltipOption[] {
|
||||
if (!instruction || !hasDownstream) {
|
||||
return [];
|
||||
}
|
||||
const branching =
|
||||
typeof instruction.branching === 'function' ? instruction.branching(config ?? {}) : instruction.branching;
|
||||
if (!branching) {
|
||||
return [];
|
||||
}
|
||||
const br = branching === true ? DEFAULT_BRANCHING_OPTIONS : branching;
|
||||
return [
|
||||
{ label: t('After end of branches'), value: false },
|
||||
...br.map((item: any) => ({
|
||||
...item,
|
||||
label: item.label ? t('Inside of "{{branchName}}" branch', { branchName: t(item.label) }) : t('Inside of branch'),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
export default function DownstreamBranchIndex({ options }: { options: RadioWithTooltipOption[] }) {
|
||||
const t = useT();
|
||||
if (!options.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
name="downstreamBranchIndex"
|
||||
label={t('Move all downstream nodes to')}
|
||||
rules={[{ required: true }]}
|
||||
initialValue={false}
|
||||
>
|
||||
<RadioWithTooltip options={options} direction="vertical" />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The `Instruction` base class — the workflow node extension contract — and its
|
||||
* pure logic hooks, relocated to client-v2 so a single definition serves both
|
||||
* canvases (ADR-0002 as amended by ADR-0003, doc §2).
|
||||
*
|
||||
* Only the data/type parts move here. The legacy Formily *rendering*
|
||||
* (`Node`, `NodeDefaultView`, the `SchemaComponent` config drawer) stays in
|
||||
* `src/client/nodes/index.tsx`. v1 re-exports this class + the pure hooks from
|
||||
* here via the allowed `v1 → v2` import direction, so the ~16 node files that
|
||||
* `extends Instruction` from the `./nodes` barrel are unchanged.
|
||||
*
|
||||
* Iron-rule notes:
|
||||
* - `ISchema` is a **type-only** import from `@formily/react` (erased at build,
|
||||
* zero runtime — explicitly allowed; see the migration skill).
|
||||
* - `SchemaInitializerItemType` (the legacy `useInitializers` return type) is
|
||||
* NOT imported from `@nocobase/client` (forbidden). It is typed structurally
|
||||
* as `unknown` here — the modern canvas never calls `useInitializers` (it
|
||||
* uses `getCreateModelMenuItem`), and v1 callers cast as needed.
|
||||
*/
|
||||
|
||||
import type { ComponentType } from 'react';
|
||||
import type { ISchema } from '@formily/react';
|
||||
import type { SubModelItem } from '@nocobase/flow-engine';
|
||||
import type { UseVariableOptions, VariableOption } from './collectionFieldOptions';
|
||||
|
||||
/** `() => Promise<{ default: Component }>` loader, matching the trigger `*Loader` convention (doc §9.5). */
|
||||
export type LoaderOf<P = {}> = () => Promise<{ default: ComponentType<P> }>;
|
||||
|
||||
export type NodeAvailableContext = {
|
||||
/** The workflow client plugin instance (v1 `WorkflowPlugin` / v2 `PluginWorkflowClientV2`). */
|
||||
engine: any;
|
||||
workflow: object;
|
||||
upstream: object;
|
||||
branchIndex: number;
|
||||
syncOnly?: boolean;
|
||||
};
|
||||
|
||||
type Config = Record<string, any>;
|
||||
|
||||
type Options = { label: string; value: any }[];
|
||||
|
||||
export type TempAssociationSource = {
|
||||
collection: string;
|
||||
nodeId: string | number;
|
||||
nodeKey: string;
|
||||
nodeType: 'workflow' | 'node';
|
||||
};
|
||||
|
||||
export abstract class Instruction {
|
||||
title: string;
|
||||
type: string;
|
||||
group: string;
|
||||
description?: string;
|
||||
icon?: JSX.Element;
|
||||
async?: boolean;
|
||||
/**
|
||||
* @deprecated migrate to `presetFieldset` instead
|
||||
*/
|
||||
options?: { label: string; value: any; key: string }[];
|
||||
|
||||
// —— legacy config UI (Formily; pass-through data the modern canvas never
|
||||
// interprets — only the legacy canvas renders it) ——
|
||||
fieldset: Record<string, ISchema>;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
presetFieldset?: Record<string, ISchema>;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
view?: ISchema;
|
||||
scope?: Record<string, any>;
|
||||
components?: Record<string, any>;
|
||||
/** Legacy in-canvas node render (Formily canvas). */
|
||||
Component?(props): JSX.Element;
|
||||
|
||||
// —— modern canvas extension points (loaders; doc §9.5) ——
|
||||
/** Modern in-canvas node render (branch nodes self-render nested `<Branch>`).
|
||||
* Absent → the modern canvas uses its default card. */
|
||||
ComponentLoader?: LoaderOf<{ data: any }>;
|
||||
/** Modern config-drawer form. Absent → the drawer shows a "not yet migrated"
|
||||
* placeholder (never a Formily fallback). */
|
||||
FieldsetLoader?: LoaderOf;
|
||||
/** Modern add-time preset form (v1 `presetFieldset`). */
|
||||
PresetFieldsetLoader?: LoaderOf;
|
||||
|
||||
/**
|
||||
* To presentation if the instruction is creating a branch
|
||||
* @experimental
|
||||
*/
|
||||
branching?: boolean | Options | ((config: Config) => boolean | Options);
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
createDefaultConfig?(): Config {
|
||||
return {};
|
||||
}
|
||||
useVariables?(node, options?: UseVariableOptions): VariableOption;
|
||||
useScopeVariables?(node, options?): VariableOption[];
|
||||
/** Legacy block initializer (v1 Schema Initializer). Return type is the
|
||||
* legacy `SchemaInitializerItemType`, kept structural to avoid importing
|
||||
* `@nocobase/client` into client-v2; v1 callers cast as needed. */
|
||||
useInitializers?(node): unknown | null;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
isAvailable?(ctx: NodeAvailableContext): boolean;
|
||||
end?: boolean | ((node) => boolean);
|
||||
testable?: boolean;
|
||||
/**
|
||||
* 2.0 — v2-native block-creation menu item (the `useInitializers` counterpart).
|
||||
*/
|
||||
getCreateModelMenuItem?({ node, workflow }): SubModelItem | null;
|
||||
/**
|
||||
* @experimental
|
||||
*/
|
||||
useTempAssociationSource?(node): TempAssociationSource | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Walk the upstream chain of a node (exclusive of the node itself), optionally
|
||||
* filtered. Pure linked-list traversal — no hooks, despite the `use*` name kept
|
||||
* for v1 call-site compatibility.
|
||||
*/
|
||||
export function useAvailableUpstreams(node, filter?) {
|
||||
const stack: any[] = [];
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
for (let current = node.upstream; current; current = current.upstream) {
|
||||
if (typeof filter !== 'function' || filter(current)) {
|
||||
stack.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
* Collect the upstream branching scopes for a node (upstreams that open a
|
||||
* branch the node sits inside). Pure traversal.
|
||||
*/
|
||||
export function useUpstreamScopes(node) {
|
||||
const stack: any[] = [];
|
||||
|
||||
for (let current = node; current; current = current.upstream) {
|
||||
if (current.upstream && current.branchIndex != null) {
|
||||
stack.push(current.upstream);
|
||||
}
|
||||
}
|
||||
|
||||
return stack;
|
||||
}
|
||||
|
||||
export { NodeContext, useNodeContext } from './contexts';
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import { useFlowContext } from './contexts';
|
||||
import useStyles from './style';
|
||||
import { useT } from '../locale';
|
||||
import { formatTime } from '../components/workflowCanvas';
|
||||
import { JOB_STATUS_OPTIONS_MAP } from '../components/jobStatus';
|
||||
|
||||
const statusButtonClass = css`
|
||||
border: none;
|
||||
|
||||
.ant-tag {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin-right: 0;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const emptyStatusButtonClass = css`
|
||||
border-width: 2px;
|
||||
`;
|
||||
|
||||
function JobStatusButton({
|
||||
status,
|
||||
disabled,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
className,
|
||||
}: {
|
||||
status?: number | null;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLElement>;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { styles, cx } = useStyles();
|
||||
const option = typeof status !== 'undefined' && status !== null ? JOB_STATUS_OPTIONS_MAP[status] : undefined;
|
||||
const content = option ? <Tag color={option.color}>{option.icon}</Tag> : null;
|
||||
const button = (
|
||||
<Button
|
||||
shape="circle"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
onMouseDown={onMouseDown}
|
||||
className={cx(styles.nodeJobButtonClass, className, content ? statusButtonClass : emptyStatusButtonClass)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return option ? <Tooltip title={t(option.label)}>{button}</Tooltip> : button;
|
||||
}
|
||||
|
||||
export function JobButton({ data }: { data: any }) {
|
||||
const t = useT();
|
||||
const { styles } = useStyles();
|
||||
const { execution, setViewJob } = useFlowContext() ?? {};
|
||||
const jobs = useMemo(() => data?.jobs ?? [], [data?.jobs]);
|
||||
const stopPropagation = useCallback((event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const onOpenJobInList = useCallback(
|
||||
({ key }: { key: string }) => {
|
||||
const job = jobs.find((item: any) => String(item.id) === String(key));
|
||||
if (job) {
|
||||
setViewJob?.(job);
|
||||
}
|
||||
},
|
||||
[jobs, setViewJob],
|
||||
);
|
||||
|
||||
const onOpenOnlyJob = useCallback(() => {
|
||||
const job = jobs?.[0];
|
||||
if (job) {
|
||||
setViewJob?.(job);
|
||||
}
|
||||
}, [jobs, setViewJob]);
|
||||
|
||||
if (!execution || !setViewJob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!jobs?.length) {
|
||||
return (
|
||||
<span onClick={stopPropagation} onMouseDown={stopPropagation}>
|
||||
<Tooltip title={t('View result')}>
|
||||
<JobStatusButton disabled onMouseDown={stopPropagation} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (jobs.length === 1) {
|
||||
return (
|
||||
<span onClick={stopPropagation} onMouseDown={stopPropagation}>
|
||||
<JobStatusButton status={jobs[0].status} onClick={onOpenOnlyJob} onMouseDown={stopPropagation} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const latestJob = jobs[jobs.length - 1];
|
||||
|
||||
return (
|
||||
<Tooltip title={t('View result')}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: jobs.map((job: any) => ({
|
||||
key: `${job.id}`,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1.5em',
|
||||
minWidth: '14em',
|
||||
}}
|
||||
>
|
||||
<JobStatusButton
|
||||
status={job.status}
|
||||
className={css`
|
||||
pointer-events: none;
|
||||
`}
|
||||
/>
|
||||
<time>{formatTime(job.updatedAt)}</time>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
onClick: onOpenJobInList,
|
||||
className: styles.dropdownClass,
|
||||
}}
|
||||
>
|
||||
<span onClick={stopPropagation} onMouseDown={stopPropagation}>
|
||||
<JobStatusButton status={latestJob.status} onMouseDown={stopPropagation} />
|
||||
</span>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default JobButton;
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern canvas node card (doc §9.5/§9.6). A second copy of the v1 `Node`,
|
||||
* rebuilt with native antd + the v2 contexts/registry — it shares no Formily
|
||||
* code, but reuses the *same* stylesheet classes (`nodeBlockClass`,
|
||||
* `nodeClass`, `nodeCardClass`, `nodeHeaderClass`, `nodeMetaClass`) and DOM
|
||||
* shape so the card is visually 1:1 with v1. Renders:
|
||||
* - a registered node → type tag + editable title (+ optional `ComponentLoader`
|
||||
* self-render for branch nodes);
|
||||
* - an unregistered type (only implemented in v1) → a placeholder card,
|
||||
* keeping topology intact (mirrors v1's "unknown node" branch, doc §9.1).
|
||||
*/
|
||||
|
||||
import React, { Suspense, lazy, useCallback, useMemo, useState } from 'react';
|
||||
import { Button, Dropdown, Input, Skeleton, Tag, Tooltip } from 'antd';
|
||||
import { CloseOutlined, CopyOutlined, DeleteOutlined, EllipsisOutlined } from '@ant-design/icons';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { NodeContext, useFlowContext, useWorkflowCanvasExecuted } from './contexts';
|
||||
import useStyles from './style';
|
||||
import { useT } from '../locale';
|
||||
import { useInstruction } from './useWorkflowInstruction';
|
||||
import { nodeTypeClassName } from './nodeRenderDispatch';
|
||||
import { AddNodeSlot } from './AddNodeSlot';
|
||||
import { useRemoveNodeContext } from './RemoveNodeContext';
|
||||
import { useNodeClipboardContext } from './NodeClipboardContext';
|
||||
import { useNodeDragContext } from './NodeDragContext';
|
||||
import { openNodeConfigDrawer } from './NodeConfigDrawer';
|
||||
import { JobButton } from './JobButton';
|
||||
|
||||
function NodeActions({ data }: { data: any }) {
|
||||
const t = useT();
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
const removeNodeContext = useRemoveNodeContext();
|
||||
const clipboard = useNodeClipboardContext();
|
||||
const isCopiedSelf = Boolean(clipboard?.clipboard?.sourceId && clipboard.clipboard.sourceId === data.id);
|
||||
if (executed || !removeNodeContext) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['hover']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'copy',
|
||||
label: isCopiedSelf ? t('Cancel copy') : t('Copy'),
|
||||
icon: isCopiedSelf ? undefined : <CopyOutlined />,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: t('Delete'), icon: <DeleteOutlined />, danger: true },
|
||||
],
|
||||
onClick: ({ key, domEvent }) => {
|
||||
// Menu items render in a portal; stop the click from bubbling to the card (which would otherwise open the
|
||||
// config drawer).
|
||||
domEvent.stopPropagation();
|
||||
if (key === 'copy') {
|
||||
if (isCopiedSelf) {
|
||||
clipboard?.clearClipboard();
|
||||
} else {
|
||||
clipboard?.copyNode(data);
|
||||
}
|
||||
}
|
||||
if (key === 'delete') {
|
||||
removeNodeContext.requestRemove(data);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={<EllipsisOutlined />}
|
||||
className="workflow-node-action-button"
|
||||
// Clicking the "..." trigger must not open the config drawer.
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** Editable node title — mirrors v1's `Input.TextArea` styled by `nodeCardClass`. */
|
||||
function NodeTitle({ data, fallback }: { data: any; fallback: string }) {
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
const { refresh } = useFlowContext() ?? {};
|
||||
const [title, setTitle] = useState<string>(data.title ?? '');
|
||||
|
||||
const onSave = useCallback(
|
||||
async (next: string) => {
|
||||
const value = next || fallback;
|
||||
setTitle(value);
|
||||
if (value === data.title) {
|
||||
return;
|
||||
}
|
||||
await flowEngine.context.api.resource('flow_nodes').update({ filterByTk: data.id, values: { title: value } });
|
||||
refresh?.();
|
||||
},
|
||||
[data.id, data.title, fallback, flowEngine, refresh],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={title}
|
||||
disabled={Boolean(executed)}
|
||||
onChange={(ev) => setTitle(ev.target.value)}
|
||||
onBlur={(ev) => onSave(ev.target.value)}
|
||||
autoSize
|
||||
aria-label={t('Node title')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Open the node config drawer when the card body is clicked, but not when the
|
||||
* click originates from the title input, the actions menu, or any control. */
|
||||
function isInteractiveClickTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(target.closest('textarea, input, button, a, .ant-dropdown, .workflow-node-actions'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The default node card — type tag + editable title + actions menu, wrapped in
|
||||
* the canvas `nodeClass`/`nodeCardClass` chrome (drag mousedown, click-to-open
|
||||
* config, copy/drag highlight). Exported and given a `children` slot so a node's
|
||||
* `ComponentLoader` can reuse the exact card and append its own subtree after it
|
||||
* (e.g. the condition node's Yes/No branches) — the v2 mirror of v1's
|
||||
* `NodeDefaultView` (doc §9.5, ADR-0003). Assumes the type is registered; the
|
||||
* unregistered placeholder is handled by `NodeCard`.
|
||||
*/
|
||||
export function NodeDefaultView({ data, children }: { data: any; children?: React.ReactNode }) {
|
||||
const { styles, cx } = useStyles();
|
||||
const t = useT();
|
||||
const flowEngine = useFlowEngine();
|
||||
const { workflow, refresh } = useFlowContext() ?? {};
|
||||
const clipboard = useNodeClipboardContext();
|
||||
const dragContext = useNodeDragContext();
|
||||
const instruction = useInstruction(data.type);
|
||||
// Highlight (blue dashed outline) when this node is the one copied to the clipboard or being dragged — mirrors v1's
|
||||
// `active`/`dragging` state.
|
||||
const isCopiedSelf = Boolean(clipboard?.clipboard?.sourceId && clipboard.clipboard.sourceId === data.id);
|
||||
const isDraggingSelf = Boolean(dragContext?.dragging && dragContext?.dragNode?.id === data.id);
|
||||
|
||||
const openConfig = useCallback(() => {
|
||||
openNodeConfigDrawer({ ctx: flowEngine.context, data, instruction, t, workflow, refresh });
|
||||
}, [flowEngine, data, instruction, t, workflow, refresh]);
|
||||
|
||||
const onCardMouseDown = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
// React synthetic events bubble through the component tree (not the DOM tree), so a mousedown inside a portal
|
||||
// (e.g. the config drawer) would also trigger this. Skip when the target isn't a DOM descendant of the card.
|
||||
if (!(event.currentTarget as HTMLElement).contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
dragContext?.onNodeMouseDown?.(data, event);
|
||||
},
|
||||
[data, dragContext],
|
||||
);
|
||||
|
||||
const typeTitle = t(instruction?.title as string);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.nodeClass, nodeTypeClassName(data.type))}>
|
||||
<div
|
||||
className={cx(styles.nodeCardClass, { active: isCopiedSelf || isDraggingSelf, dragging: isDraggingSelf })}
|
||||
role="button"
|
||||
aria-label={`${typeTitle}-${data.title ?? data.id}`}
|
||||
onMouseDown={onCardMouseDown}
|
||||
onClick={(ev) => {
|
||||
// A drag just ended → swallow the click so it doesn't open the drawer.
|
||||
if (dragContext?.consumeClick?.()) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!isInteractiveClickTarget(ev.target)) {
|
||||
openConfig();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.nodeHeaderClass}>
|
||||
<div className={cx(styles.nodeMetaClass, 'workflow-node-meta')}>
|
||||
<Tag icon={instruction?.icon}>{typeTitle}</Tag>
|
||||
<span className="workflow-node-id">{data.id}</span>
|
||||
</div>
|
||||
<div className="workflow-node-actions">
|
||||
<NodeActions data={data} />
|
||||
<JobButton data={data} />
|
||||
</div>
|
||||
</div>
|
||||
<NodeTitle data={data} fallback={typeTitle} />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ data }: { data: any }) {
|
||||
const { styles, cx } = useStyles();
|
||||
const t = useT();
|
||||
const instruction = useInstruction(data.type);
|
||||
|
||||
const Rendered = useMemo(() => {
|
||||
if (instruction?.ComponentLoader) {
|
||||
return lazy(instruction.ComponentLoader);
|
||||
}
|
||||
return null;
|
||||
}, [instruction]);
|
||||
|
||||
// Unregistered in v2 (only implemented in v1) → placeholder card, topology intact.
|
||||
if (!instruction) {
|
||||
return (
|
||||
<div className={cx(styles.nodeClass, nodeTypeClassName(data.type))}>
|
||||
<Tooltip title={t('This node type is not available in the new canvas yet.')}>
|
||||
<div className={cx(styles.nodeCardClass, 'invalid')}>
|
||||
<div className={styles.nodeHeaderClass}>
|
||||
<div className={cx(styles.nodeMetaClass, 'workflow-node-meta')}>
|
||||
<Tag color="warning">{t('Unsupported node')}</Tag>
|
||||
<span className="workflow-node-id">{data.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input.TextArea value={data.title ?? `#${data.id}`} disabled autoSize />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch nodes self-render via ComponentLoader (it draws its own card by wrapping `NodeDefaultView` and appending
|
||||
// nested <Branch>); otherwise the default card.
|
||||
if (Rendered) {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton.Button active block style={{ width: '16em', height: '4em' }} />}>
|
||||
<Rendered data={data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return <NodeDefaultView data={data} />;
|
||||
}
|
||||
|
||||
export function Node({ data }: { data: any }) {
|
||||
const { styles } = useStyles();
|
||||
const instruction = useInstruction(data.type);
|
||||
const endFlag = instruction?.end;
|
||||
const isEnd = typeof endFlag === 'function' ? endFlag(data) : Boolean(endFlag);
|
||||
|
||||
return (
|
||||
<NodeContext.Provider value={data}>
|
||||
<div className={styles.nodeBlockClass}>
|
||||
<NodeCard data={data} />
|
||||
{isEnd ? (
|
||||
<div className="end-sign">
|
||||
<CloseOutlined />
|
||||
</div>
|
||||
) : (
|
||||
<AddNodeSlot upstream={data} />
|
||||
)}
|
||||
</div>
|
||||
</NodeContext.Provider>
|
||||
);
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canvas copy/paste, shared by BOTH canvases (ADR-0003). Copy a node to the
|
||||
* clipboard; an add-slot becomes a paste zone; pasting into a position where the
|
||||
* node's variable references are no longer in scope prompts to strip them (or
|
||||
* keep, with a warning). Pastes via `flow_nodes.duplicate`.
|
||||
*
|
||||
* The provider's runtime-neutral dependencies (`api`, `t`, antd `modal`/`message`)
|
||||
* resolve identically in either runtime (flow-engine `ctx.api`, `useT`,
|
||||
* `App.useApp`). The two genuinely runtime-specific bits — the canvas flow data
|
||||
* (`{ workflow, nodes, refresh }`) and the `executed` flag — are read through an
|
||||
* injected `useCanvasRuntime` hook so each canvas supplies its own source: the
|
||||
* modern canvas reads its `FlowContext` + `workflow.executed`, the legacy canvas
|
||||
* its own `FlowContext` + `versionStats.executed`. v1 imports this provider from
|
||||
* here (the allowed `v1 → v2` direction) and passes its own runtime hook, so a
|
||||
* single implementation serves both.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { App, Checkbox } from 'antd';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine';
|
||||
import { useFlowContext, useWorkflowCanvasExecuted } from './contexts';
|
||||
import { useT } from '../locale';
|
||||
import { collectUpstreams, extractDependencyKeys, stripVariableReferences } from './nodeVariableUtils';
|
||||
|
||||
type ClipboardNode = {
|
||||
sourceId?: number;
|
||||
sourceKey?: string;
|
||||
type: string;
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
|
||||
type PasteImpactItem = { key: string; title: string };
|
||||
type PasteImpact = {
|
||||
status: 'safe' | 'warning' | 'disabled';
|
||||
impactedSelf: PasteImpactItem[];
|
||||
impactedDependents: PasteImpactItem[];
|
||||
};
|
||||
|
||||
type NodeClipboardContextValue = {
|
||||
clipboard: ClipboardNode | null;
|
||||
copyNode: (node: any) => void;
|
||||
clearClipboard: () => void;
|
||||
getPasteImpact: (target: any) => PasteImpact;
|
||||
pasteNode: (target: any) => Promise<void>;
|
||||
};
|
||||
|
||||
/** The per-canvas runtime data the provider needs but that differs by runtime —
|
||||
* injected via `NodeClipboardContextProvider`'s `useCanvasRuntime` prop. */
|
||||
export type CanvasClipboardRuntime = {
|
||||
workflow: any;
|
||||
nodes: any[] | undefined;
|
||||
refresh?: () => void;
|
||||
executed: boolean;
|
||||
};
|
||||
|
||||
/** Default (modern-canvas) runtime source: the v2 `FlowContext` + `executed`. */
|
||||
function useModernCanvasRuntime(): CanvasClipboardRuntime {
|
||||
const { workflow, nodes, refresh } = useFlowContext() ?? {};
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
return { workflow, nodes, refresh, executed: Boolean(executed) };
|
||||
}
|
||||
|
||||
const NodeClipboardContext = createContext<NodeClipboardContextValue | null>(null);
|
||||
|
||||
export function useNodeClipboardContext() {
|
||||
return useContext(NodeClipboardContext);
|
||||
}
|
||||
|
||||
export function NodeClipboardContextProvider(props: {
|
||||
children: React.ReactNode;
|
||||
/** Injected per-canvas runtime source. Defaults to the modern canvas's. The
|
||||
* legacy canvas passes its own (v1 `FlowContext` + `versionStats.executed`). */
|
||||
useCanvasRuntime?: () => CanvasClipboardRuntime;
|
||||
}) {
|
||||
const { useCanvasRuntime = useModernCanvasRuntime } = props;
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
const { workflow, nodes, refresh, executed } = useCanvasRuntime();
|
||||
const { modal, message } = App.useApp();
|
||||
const [clipboard, setClipboard] = useState<ClipboardNode | null>(null);
|
||||
|
||||
const nodesByKey = useMemo(() => {
|
||||
const map = new Map<string, any>();
|
||||
(nodes ?? []).forEach((node: any) => {
|
||||
if (node?.key != null) {
|
||||
map.set(String(node.key), node);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
|
||||
const copyNode = useCallback(
|
||||
(node: any) => {
|
||||
if (!node || executed) {
|
||||
return;
|
||||
}
|
||||
setClipboard({
|
||||
sourceId: node.id,
|
||||
sourceKey: node.key ? String(node.key) : undefined,
|
||||
type: node.type,
|
||||
title: node.title,
|
||||
config: cloneDeep(node.config ?? {}),
|
||||
});
|
||||
},
|
||||
[executed],
|
||||
);
|
||||
|
||||
const clearClipboard = useCallback(() => setClipboard(null), []);
|
||||
|
||||
const getPasteImpact = useCallback(
|
||||
(target: any): PasteImpact => {
|
||||
if (!clipboard || !target) {
|
||||
return { status: 'disabled', impactedSelf: [], impactedDependents: [] };
|
||||
}
|
||||
const upstream = target.upstream ?? null;
|
||||
const upstreamSet = upstream ? collectUpstreams(upstream) : new Set<number>();
|
||||
const deps = extractDependencyKeys(clipboard.config ?? {});
|
||||
const impactedSelf: PasteImpactItem[] = [];
|
||||
|
||||
deps.forEach((depKey) => {
|
||||
const depNode = nodesByKey.get(String(depKey));
|
||||
if (!depNode) {
|
||||
impactedSelf.push({ key: String(depKey), title: String(depKey) });
|
||||
return;
|
||||
}
|
||||
if (!upstreamSet.has(depNode.id)) {
|
||||
impactedSelf.push({ key: String(depKey), title: depNode.title });
|
||||
}
|
||||
});
|
||||
|
||||
const status = impactedSelf.length ? 'warning' : 'safe';
|
||||
return { status, impactedSelf, impactedDependents: [] };
|
||||
},
|
||||
[clipboard, nodesByKey],
|
||||
);
|
||||
|
||||
const duplicateNode = useCallback(
|
||||
async (values: Record<string, any>) => {
|
||||
if (!workflow?.id || !clipboard?.sourceId) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await ctx.api.resource('flow_nodes').duplicate({ filterByTk: clipboard.sourceId, values });
|
||||
setClipboard(null);
|
||||
refresh?.();
|
||||
return true;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
message.error(t('Failed to paste node'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[ctx, clipboard?.sourceId, message, refresh, workflow?.id, t],
|
||||
);
|
||||
|
||||
const pasteNode = useCallback(
|
||||
async (target: any) => {
|
||||
if (!clipboard || executed) {
|
||||
return;
|
||||
}
|
||||
const impact = getPasteImpact(target);
|
||||
if (impact.status === 'disabled') {
|
||||
return;
|
||||
}
|
||||
const upstream = target?.upstream ?? null;
|
||||
const branchIndex = upstream ? target?.branchIndex ?? null : null;
|
||||
const baseConfig = cloneDeep(clipboard.config ?? {});
|
||||
const baseValues = { upstreamId: upstream?.id ?? null, branchIndex };
|
||||
|
||||
if (impact.status === 'warning') {
|
||||
const impactedSelfTitles = impact.impactedSelf.map((item) => item.title).join(', ');
|
||||
const keepVariablesRef = { current: false };
|
||||
const keysToRemove = new Set(impact.impactedSelf.map((item) => item.key).filter(Boolean));
|
||||
modal.confirm({
|
||||
title: t('Confirm paste'),
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
{t('This action will remove invalid variable references, otherwise the workflow cannot run correctly.')}
|
||||
</div>
|
||||
{impactedSelfTitles ? (
|
||||
<div>{t('Impacted current node variables') + ': ' + impactedSelfTitles}</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: '0.75em' }}>
|
||||
<Checkbox onChange={(ev) => (keepVariablesRef.current = ev.target.checked)}>
|
||||
{t('Keep variable references')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
if (keepVariablesRef.current) {
|
||||
const created = await duplicateNode(baseValues);
|
||||
if (created) {
|
||||
message.warning(
|
||||
t('Keeping variable references requires manual adjustment, otherwise workflow may fail.'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cleaned = stripVariableReferences(baseConfig, keysToRemove);
|
||||
await duplicateNode({ ...baseValues, config: cleaned.value });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await duplicateNode(baseValues);
|
||||
},
|
||||
[clipboard, duplicateNode, executed, getPasteImpact, message, modal, t],
|
||||
);
|
||||
|
||||
const value = useMemo<NodeClipboardContextValue>(
|
||||
() => ({ clipboard, copyNode, clearClipboard, getPasteImpact, pasteNode }),
|
||||
[clipboard, copyNode, clearClipboard, getPasteImpact, pasteNode],
|
||||
);
|
||||
|
||||
return <NodeClipboardContext.Provider value={value}>{props.children}</NodeClipboardContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern canvas node config drawer (doc §3/§9.3). Opened via `ctx.viewer.drawer`
|
||||
* from a node card click, using the shared `DrawerFormLayout` chrome (title +
|
||||
* native close + Cancel/Submit footer) for consistency with the rest of the v2
|
||||
* client.
|
||||
*
|
||||
* The drawer body is the shared antd `<Form>`; it renders the instruction's
|
||||
* `FieldsetLoader` (lazy) when present, else the "not yet migrated" placeholder
|
||||
* — never a Formily fallback. Submitting writes `config` via `flow_nodes.update`.
|
||||
* Testable nodes get a "Test run" action grafted into the footer's left side.
|
||||
*/
|
||||
|
||||
import React, { Suspense, lazy, useMemo, useState } from 'react';
|
||||
import { App, Button, Form, Skeleton, Space, Tag, Tooltip, theme } from 'antd';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { DrawerFormLayout } from '@nocobase/client-v2';
|
||||
import { useFlowContext as useFlowEngineContext, useFlowView } from '@nocobase/flow-engine';
|
||||
import { useT } from '../locale';
|
||||
import { CurrentWorkflowContext, NodeContext } from './contexts';
|
||||
import { TestRunButton } from '../components/TestRunButton';
|
||||
import type { Instruction } from './Instruction';
|
||||
|
||||
/**
|
||||
* The grey "node type" description region at the top of the config drawer body —
|
||||
* v2 mirror of v1's `DrawerDescription` (`client/components/DrawerDescription.tsx`).
|
||||
* Shows a `Node type: <icon tag>` definition row and, when the instruction has a
|
||||
* `description`, a muted paragraph below it. Rendered only for nodes that carry
|
||||
* a `description`.
|
||||
*/
|
||||
function NodeTypeDescription({
|
||||
instruction,
|
||||
t,
|
||||
}: {
|
||||
instruction: Instruction;
|
||||
t: (key: string, options?: Record<string, any>) => string;
|
||||
}) {
|
||||
const { token } = theme.useToken();
|
||||
if (!instruction.description) {
|
||||
return null;
|
||||
}
|
||||
const containerClass = css`
|
||||
margin-bottom: 1.5em;
|
||||
padding: 1em;
|
||||
background-color: ${token.colorFillAlter};
|
||||
|
||||
> *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
color: ${token.colorText};
|
||||
&:after {
|
||||
content: ':';
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: ${token.colorTextDescription};
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
`;
|
||||
return (
|
||||
<div className={cx(containerClass)}>
|
||||
<dl>
|
||||
<dt>{t('Node type')}</dt>
|
||||
<dd>
|
||||
<Tag icon={instruction.icon} style={{ background: 'none' }}>
|
||||
{t(instruction.title as string)}
|
||||
</Tag>
|
||||
</dd>
|
||||
</dl>
|
||||
<p>{t(instruction.description as string)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the node config drawer for a node. Call from a card click handler.
|
||||
* `ctx` is the flow-engine context (from `useFlowContext()`).
|
||||
*
|
||||
* `workflow` is threaded in explicitly because the drawer renders at the React
|
||||
* root (`ctx.viewer.drawer`), OUTSIDE the canvas `FlowContext.Provider` — so the
|
||||
* config form can't read the workflow from the canvas tree. It is re-provided via
|
||||
* `CurrentWorkflowContext` so the variable aggregator's trigger scope (and the
|
||||
* `executed` read-only state) resolve correctly.
|
||||
*/
|
||||
export function openNodeConfigDrawer(opts: {
|
||||
ctx: any;
|
||||
data: any;
|
||||
instruction?: Instruction;
|
||||
t: (key: string, options?: Record<string, any>) => string;
|
||||
workflow?: any;
|
||||
refresh?: () => void;
|
||||
}) {
|
||||
const { ctx, data, instruction, workflow } = opts;
|
||||
ctx.viewer.drawer({
|
||||
width: '50%',
|
||||
closable: true,
|
||||
content: () => (
|
||||
<NodeConfigForm data={data} instruction={instruction} workflow={workflow} onSubmitted={opts.refresh} />
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function NodeConfigForm({
|
||||
data,
|
||||
instruction,
|
||||
workflow,
|
||||
onSubmitted,
|
||||
}: {
|
||||
data: any;
|
||||
instruction?: Instruction;
|
||||
workflow?: any;
|
||||
onSubmitted?: () => void;
|
||||
}) {
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
const { message } = App.useApp();
|
||||
const view = useFlowView();
|
||||
const executed = Boolean(workflow?.versionStats?.executed);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const Fieldset = useMemo(() => {
|
||||
if (instruction?.FieldsetLoader) {
|
||||
return lazy(instruction.FieldsetLoader);
|
||||
}
|
||||
return null;
|
||||
}, [instruction]);
|
||||
|
||||
const initialValues = useMemo(() => ({ config: data.config ?? {} }), [data]);
|
||||
|
||||
React.useEffect(() => {
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [form, initialValues]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await ctx.api.resource('flow_nodes').update({
|
||||
filterByTk: data.id,
|
||||
values: { config: values.config ?? {} },
|
||||
});
|
||||
onSubmitted?.();
|
||||
} catch (err) {
|
||||
message.error(t('Failed to save node'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
throw err; // keep the drawer open on failure
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const typeTitle = instruction ? t(instruction.title as string) : data.type;
|
||||
|
||||
const footer = executed ? (
|
||||
<span />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>{instruction?.testable ? <TestRunButton data={data} form={form} /> : null}</div>
|
||||
<Space>
|
||||
<Button onClick={() => view.close()}>{t('Cancel')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
onClick={async () => {
|
||||
await onSubmit();
|
||||
await view.close();
|
||||
}}
|
||||
>
|
||||
{t('Submit')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CurrentWorkflowContext.Provider value={workflow}>
|
||||
<NodeContext.Provider value={data}>
|
||||
<DrawerFormLayout
|
||||
title={
|
||||
// `justify-content: space-between` pushes the node-key tag to the drawer's far right (mirrors v1's flex
|
||||
// title), the native close X sitting just left of the title.
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', width: '100%' }}>
|
||||
<strong>{data.title ?? typeTitle}</strong>
|
||||
<Tooltip title={t('Variable key of node')}>
|
||||
<Tag style={{ marginInlineEnd: 0 }}>
|
||||
<code>{data.key}</code>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
footer={footer}
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={executed}>
|
||||
{instruction ? <NodeTypeDescription instruction={instruction} t={t} /> : null}
|
||||
{Fieldset ? (
|
||||
<Suspense fallback={<Skeleton active paragraph={{ rows: 4 }} />}>
|
||||
<Fieldset />
|
||||
</Suspense>
|
||||
) : (
|
||||
<div style={{ padding: '2em 0', textAlign: 'center', color: 'var(--colorTextTertiary, #999)' }}>
|
||||
{t("This node's configuration has not been migrated to the new canvas yet.")}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</DrawerFormLayout>
|
||||
</NodeContext.Provider>
|
||||
</CurrentWorkflowContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canvas node drag-to-reorder, shared by BOTH canvases (ADR-0003, doc §9.6). The
|
||||
* ~700-line pointer machinery (mousemove tracking, RAF drag preview, auto-scroll,
|
||||
* drop-zone hit-testing, the move API) is framework-agnostic DOM logic; the
|
||||
* runtime-specific bits at the top (`api`, the i18n functions, the instruction
|
||||
* registry, the executed flag) are read through an injected `useCanvasRuntime`
|
||||
* so v1 can re-import this single provider and pass its own runtime (the allowed
|
||||
* v1 → v2 direction). `workflow`/`nodes`/`refresh` come from the now-shared
|
||||
* `FlowContext` directly, and the pure graph walks from the relocated modules.
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { App, Checkbox } from 'antd';
|
||||
import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine';
|
||||
|
||||
import { useFlowContext, useWorkflowCanvasExecuted, type CanvasNode } from './contexts';
|
||||
import { useT, useWorkflowTranslation } from '../locale';
|
||||
import useStyles from './style';
|
||||
import { useWorkflowPlugin } from './useWorkflowInstruction';
|
||||
import type { Instruction } from './Instruction';
|
||||
import { collectUpstreams, extractDependencyKeys, stripVariableReferences } from './nodeVariableUtils';
|
||||
import { collectDownstreams, collectBranchSubtree } from './dropImpact';
|
||||
|
||||
/** Per-canvas runtime the drag provider needs but that differs by runtime —
|
||||
* injected via `NodeDragContextProvider`'s `useCanvasRuntime` prop. */
|
||||
export type CanvasDragRuntime = {
|
||||
// The API client (`resource('flow_nodes').move/update`). Left loose to avoid importing `APIClient` from
|
||||
// `@nocobase/client` into client-v2; the body never needs a cast — only the `flow_nodes` move/update actions are
|
||||
// called.
|
||||
api: any;
|
||||
/** Plain-key translation (no `{{t("…")}}` expansion) — v1 `lang`, v2 `useWorkflowTranslation().t`. */
|
||||
lang: (key: string, options?: Record<string, unknown>) => string;
|
||||
/** Template expander for instruction titles (`{{t("…")}}`) — v1 `useCompile()`, v2 `useT()`. */
|
||||
compile: (source: string) => string;
|
||||
/** Resolve an instruction by type — for the drag-preview node title. */
|
||||
getInstruction: (type: string) => Instruction | undefined;
|
||||
/** Whether the workflow has been executed (read-only) — the move API is disabled. */
|
||||
executed: boolean;
|
||||
};
|
||||
|
||||
/** Default (modern-canvas) runtime source: flow-engine `ctx.api`, the two v2
|
||||
* translators (plain `useWorkflowTranslation().t` + template-expanding `useT()`),
|
||||
* the v2 instruction registry, and the v2 executed flag. */
|
||||
function useModernCanvasRuntime(): CanvasDragRuntime {
|
||||
const { api } = useFlowEngineContext();
|
||||
const lang = useWorkflowTranslation().t;
|
||||
const compile = useT();
|
||||
const plugin = useWorkflowPlugin();
|
||||
const executed = useWorkflowCanvasExecuted();
|
||||
return {
|
||||
api,
|
||||
lang,
|
||||
compile,
|
||||
// Bind the registry at render time → a plain `.get` callable any time (the body calls it inside callbacks, where a
|
||||
// hook would break the rules).
|
||||
getInstruction: (type: string) => plugin?.instructions.get(type),
|
||||
executed: Boolean(executed),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A drop target: the add-slot's position in the graph, identified by its upstream
|
||||
* node (`null`/absent = the root slot) and, for a branch slot, its branch index.
|
||||
* Built by `AddNodeSlot`'s `DropZone` as `{ upstream, branchIndex }`.
|
||||
*/
|
||||
export type DropTarget = {
|
||||
upstream?: CanvasNode | null;
|
||||
branchIndex?: number | null;
|
||||
};
|
||||
|
||||
/** The result of dropping the dragged node onto a target: whether the move is
|
||||
* allowed (`safe`), allowed-with-consequences (`warning` — lists the nodes whose
|
||||
* variable references would break), or forbidden (`disabled`). */
|
||||
export type DropImpact = {
|
||||
status: 'safe' | 'warning' | 'disabled';
|
||||
impactedSelf: CanvasNode[];
|
||||
impactedDependents: CanvasNode[];
|
||||
};
|
||||
|
||||
/** The minimal node snapshot driving the floating drag preview (set on drag start,
|
||||
* read by the preview-rendering effect). */
|
||||
export type DragPreviewNode = {
|
||||
id: number;
|
||||
key?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
hasBranches: boolean;
|
||||
};
|
||||
|
||||
/** What `useNodeDragContext()` exposes to node cards / add-slots / the canvas. */
|
||||
export type NodeDragContextValue = {
|
||||
dragging: boolean;
|
||||
dragNode: DragPreviewNode | null;
|
||||
onNodeMouseDown: (node: CanvasNode, event: React.MouseEvent) => void;
|
||||
getDropImpact: (target: DropTarget) => DropImpact;
|
||||
setActiveDrop: (target: DropTarget | null) => void;
|
||||
clearActiveDrop: (target: DropTarget) => void;
|
||||
/** Registers an add-slot's DOM element as a drop zone; returns a cleanup fn
|
||||
* that unregisters it (used directly as a `useEffect` teardown). */
|
||||
registerDropZone: (target: DropTarget, element: HTMLElement | null) => () => void;
|
||||
getDropKey: (target: DropTarget) => string;
|
||||
activeDropKey: string | null;
|
||||
consumeClick: () => boolean;
|
||||
};
|
||||
|
||||
const NodeDragContext = createContext<NodeDragContextValue | null>(null);
|
||||
|
||||
export function useNodeDragContext() {
|
||||
return useContext(NodeDragContext);
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
target.closest(
|
||||
[
|
||||
'input',
|
||||
'textarea',
|
||||
'button',
|
||||
'a',
|
||||
'.workflow-node-actions',
|
||||
'.workflow-node-remove-button',
|
||||
'.workflow-node-action-button',
|
||||
'.workflow-node-config-button',
|
||||
'.ant-select',
|
||||
'.ant-dropdown',
|
||||
'.ant-picker',
|
||||
'.ant-switch',
|
||||
'.ant-modal',
|
||||
'.ant-drawer',
|
||||
].join(','),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDragContextProvider(props: {
|
||||
children: React.ReactNode;
|
||||
/** Injected per-canvas runtime source. Defaults to the modern canvas's; the
|
||||
* legacy canvas passes its own (v1 `useAPIClient` / `lang` / `useCompile` /
|
||||
* `usePlugin` registry / `useWorkflowExecuted`). */
|
||||
useCanvasRuntime?: () => CanvasDragRuntime;
|
||||
}) {
|
||||
const { useCanvasRuntime = useModernCanvasRuntime } = props;
|
||||
const { api, lang, compile, getInstruction, executed } = useCanvasRuntime();
|
||||
const { workflow, nodes, refresh } = useFlowContext() ?? {};
|
||||
const { modal, message } = App.useApp();
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragNode, setDragNode] = useState<DragPreviewNode | null>(null);
|
||||
const [activeDropKey, setActiveDropKey] = useState<string | null>(null);
|
||||
|
||||
const dragNodeRef = useRef<CanvasNode | null>(null);
|
||||
const dragSubtreeRef = useRef<Set<number>>(new Set());
|
||||
const activeDropRef = useRef<DropTarget | null>(null);
|
||||
const activeDropKeyRef = useRef<string | null>(null);
|
||||
const pendingRef = useRef<{ node: CanvasNode; startX: number; startY: number } | null>(null);
|
||||
const draggingRef = useRef(false);
|
||||
const pointerRef = useRef({ x: 0, y: 0 });
|
||||
const suppressClickRef = useRef(false);
|
||||
const clearSuppressTimer = useRef<number | null>(null);
|
||||
const onMouseMoveRef = useRef<(event: MouseEvent) => void>(() => {});
|
||||
const onMouseUpRef = useRef<() => void>(() => {});
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewRafRef = useRef<number | null>(null);
|
||||
const previewOffsetRef = useRef({ x: 0, y: 0 });
|
||||
const previewSizeRef = useRef({ width: 0, height: 0 });
|
||||
const dropZonesRef = useRef<Map<string, { target: DropTarget; element: HTMLElement }>>(new Map());
|
||||
const updateActiveDropRef = useRef<() => void>(() => {});
|
||||
const autoScrollRef = useRef<{
|
||||
raf: number | null;
|
||||
vx: number;
|
||||
vy: number;
|
||||
container: HTMLElement | null;
|
||||
}>({ raf: null, vx: 0, vy: 0, container: null });
|
||||
|
||||
const branchChildrenMap = useMemo(() => {
|
||||
const map = new Map<number, CanvasNode[]>();
|
||||
if (!nodes) {
|
||||
return map;
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
if (node.branchIndex != null && node.upstreamId != null) {
|
||||
const list = map.get(node.upstreamId) ?? [];
|
||||
list.push(node);
|
||||
map.set(node.upstreamId, list);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
|
||||
const nodesByKey = useMemo(() => {
|
||||
const map = new Map<string, CanvasNode>();
|
||||
if (!nodes) {
|
||||
return map;
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
if (node?.key != null) {
|
||||
map.set(String(node.key), node);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [nodes]);
|
||||
|
||||
const { nodeDepsMap, dependentsMap } = useMemo(() => {
|
||||
const deps = new Map<number, Set<string>>();
|
||||
const dependents = new Map<string, Set<CanvasNode>>();
|
||||
if (!nodes) {
|
||||
return { nodeDepsMap: deps, dependentsMap: dependents };
|
||||
}
|
||||
nodes.forEach((node) => {
|
||||
const nodeDeps = extractDependencyKeys(node.config ?? {});
|
||||
deps.set(node.id, nodeDeps);
|
||||
nodeDeps.forEach((depKey) => {
|
||||
const list = dependents.get(depKey) ?? new Set<CanvasNode>();
|
||||
list.add(node);
|
||||
dependents.set(depKey, list);
|
||||
});
|
||||
});
|
||||
return { nodeDepsMap: deps, dependentsMap: dependents };
|
||||
}, [nodes]);
|
||||
|
||||
const resetSuppressClick = useCallback(() => {
|
||||
suppressClickRef.current = false;
|
||||
if (clearSuppressTimer.current) {
|
||||
window.clearTimeout(clearSuppressTimer.current);
|
||||
clearSuppressTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const consumeClick = useCallback(() => {
|
||||
if (suppressClickRef.current) {
|
||||
resetSuppressClick();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, [resetSuppressClick]);
|
||||
|
||||
const handleMouseMove = useCallback((event: MouseEvent) => {
|
||||
onMouseMoveRef.current?.(event);
|
||||
}, []);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
onMouseUpRef.current?.();
|
||||
}, []);
|
||||
|
||||
const getScrollContainer = useCallback(() => {
|
||||
const cached = autoScrollRef.current.container;
|
||||
if (cached && document.contains(cached)) {
|
||||
return cached;
|
||||
}
|
||||
const container = document.querySelector('.workflow-canvas') as HTMLElement | null;
|
||||
autoScrollRef.current.container = container;
|
||||
return container;
|
||||
}, []);
|
||||
|
||||
const stopAutoScroll = useCallback(() => {
|
||||
autoScrollRef.current.vx = 0;
|
||||
autoScrollRef.current.vy = 0;
|
||||
if (autoScrollRef.current.raf) {
|
||||
window.cancelAnimationFrame(autoScrollRef.current.raf);
|
||||
autoScrollRef.current.raf = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stepAutoScroll = useCallback(() => {
|
||||
const { vx, vy } = autoScrollRef.current;
|
||||
const container = getScrollContainer();
|
||||
if (!container || (!vx && !vy) || !draggingRef.current) {
|
||||
autoScrollRef.current.raf = null;
|
||||
return;
|
||||
}
|
||||
container.scrollBy({ left: vx, top: vy });
|
||||
updateActiveDropRef.current();
|
||||
autoScrollRef.current.raf = window.requestAnimationFrame(stepAutoScroll);
|
||||
}, [getScrollContainer]);
|
||||
|
||||
const updateAutoScroll = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) {
|
||||
stopAutoScroll();
|
||||
return;
|
||||
}
|
||||
const rect = container.getBoundingClientRect();
|
||||
const edge = 80;
|
||||
const maxSpeed = 20;
|
||||
const calcSpeed = (distance: number) => {
|
||||
if (distance <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const ratio = Math.min(distance / edge, 1);
|
||||
return ratio * maxSpeed;
|
||||
};
|
||||
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
const topDistance = edge - (clientY - rect.top);
|
||||
const bottomDistance = edge - (rect.bottom - clientY);
|
||||
const leftDistance = edge - (clientX - rect.left);
|
||||
const rightDistance = edge - (rect.right - clientX);
|
||||
|
||||
if (topDistance > 0) {
|
||||
vy = -calcSpeed(topDistance);
|
||||
} else if (bottomDistance > 0) {
|
||||
vy = calcSpeed(bottomDistance);
|
||||
}
|
||||
|
||||
if (leftDistance > 0) {
|
||||
vx = -calcSpeed(leftDistance);
|
||||
} else if (rightDistance > 0) {
|
||||
vx = calcSpeed(rightDistance);
|
||||
}
|
||||
|
||||
autoScrollRef.current.vx = vx;
|
||||
autoScrollRef.current.vy = vy;
|
||||
if (vx || vy) {
|
||||
if (!autoScrollRef.current.raf) {
|
||||
autoScrollRef.current.raf = window.requestAnimationFrame(stepAutoScroll);
|
||||
}
|
||||
} else {
|
||||
stopAutoScroll();
|
||||
}
|
||||
},
|
||||
[getScrollContainer, stepAutoScroll, stopAutoScroll],
|
||||
);
|
||||
|
||||
const cleanupDrag = useCallback(() => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
pendingRef.current = null;
|
||||
draggingRef.current = false;
|
||||
activeDropRef.current = null;
|
||||
dragNodeRef.current = null;
|
||||
dragSubtreeRef.current = new Set();
|
||||
setDragging(false);
|
||||
setDragNode(null);
|
||||
activeDropKeyRef.current = null;
|
||||
setActiveDropKey(null);
|
||||
stopAutoScroll();
|
||||
clearSuppressTimer.current = window.setTimeout(() => {
|
||||
resetSuppressClick();
|
||||
}, 0);
|
||||
}, [handleMouseMove, handleMouseUp, resetSuppressClick, stopAutoScroll]);
|
||||
|
||||
const getDropKey = useCallback((target: DropTarget) => {
|
||||
const upstreamId = target?.upstream?.id ?? 'root';
|
||||
const branchIndex = target?.upstream ? target?.branchIndex ?? 'null' : 'root';
|
||||
return `${upstreamId}:${branchIndex}`;
|
||||
}, []);
|
||||
|
||||
const updateActiveDropByPreview = useCallback(() => {
|
||||
if (!draggingRef.current || !previewRef.current) {
|
||||
return;
|
||||
}
|
||||
const { width, height } = previewSizeRef.current;
|
||||
if (!width || !height) {
|
||||
return;
|
||||
}
|
||||
const centerX = pointerRef.current.x;
|
||||
const centerY = pointerRef.current.y;
|
||||
const previewRect = {
|
||||
left: centerX - width / 2,
|
||||
right: centerX + width / 2,
|
||||
top: centerY - height / 2,
|
||||
bottom: centerY + height / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const isLeaving = Boolean(activeDropKeyRef.current);
|
||||
const widthThreshold = width * (isLeaving ? 0.18 : 0.25);
|
||||
const heightThreshold = height * (isLeaving ? 0.18 : 0.25);
|
||||
let best: { target: DropTarget; area: number; key: string } | null = null;
|
||||
|
||||
for (const [key, item] of dropZonesRef.current.entries()) {
|
||||
if (!document.contains(item.element)) {
|
||||
dropZonesRef.current.delete(key);
|
||||
continue;
|
||||
}
|
||||
const zoneRect = item.element.getBoundingClientRect();
|
||||
const overlapWidth = Math.min(previewRect.right, zoneRect.right) - Math.max(previewRect.left, zoneRect.left);
|
||||
const overlapHeight = Math.min(previewRect.bottom, zoneRect.bottom) - Math.max(previewRect.top, zoneRect.top);
|
||||
if (overlapWidth <= 0 || overlapHeight <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (overlapWidth < widthThreshold && overlapHeight < heightThreshold) {
|
||||
continue;
|
||||
}
|
||||
const area = overlapWidth * overlapHeight;
|
||||
if (!best || area > best.area) {
|
||||
best = { target: item.target, area, key };
|
||||
}
|
||||
}
|
||||
|
||||
activeDropRef.current = best ? best.target : null;
|
||||
const nextKey = best?.key ?? null;
|
||||
if (nextKey !== activeDropKeyRef.current) {
|
||||
activeDropKeyRef.current = nextKey;
|
||||
setActiveDropKey(nextKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateActiveDropRef.current = updateActiveDropByPreview;
|
||||
}, [updateActiveDropByPreview]);
|
||||
|
||||
const getTargetDownstream = useCallback(
|
||||
(upstream: CanvasNode | null, branchIndex: number | null, currentNode: CanvasNode | null) => {
|
||||
if (!nodes) {
|
||||
return null;
|
||||
}
|
||||
if (!upstream) {
|
||||
return nodes.find((item) => item.upstreamId == null && item.id !== currentNode?.id) ?? null;
|
||||
}
|
||||
if (branchIndex == null) {
|
||||
return upstream.downstream ?? null;
|
||||
}
|
||||
return nodes.find((item) => item.upstreamId === upstream.id && item.branchIndex === branchIndex) ?? null;
|
||||
},
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const getDropImpact = useCallback(
|
||||
(target: DropTarget): DropImpact => {
|
||||
const node = dragNodeRef.current;
|
||||
if (!node || !target) {
|
||||
return { status: 'disabled', impactedSelf: [], impactedDependents: [] };
|
||||
}
|
||||
const upstream = target.upstream ?? null;
|
||||
const branchIndex = upstream ? target.branchIndex ?? null : null;
|
||||
|
||||
const sameUpstream = (node.upstreamId ?? null) === (upstream?.id ?? null);
|
||||
const sameBranchIndex = (node.branchIndex ?? null) === (branchIndex ?? null);
|
||||
if (sameUpstream && sameBranchIndex) {
|
||||
return { status: 'disabled', impactedSelf: [], impactedDependents: [] };
|
||||
}
|
||||
if (upstream && upstream.id === node.id) {
|
||||
return { status: 'disabled', impactedSelf: [], impactedDependents: [] };
|
||||
}
|
||||
if (upstream && dragSubtreeRef.current.has(upstream.id)) {
|
||||
return { status: 'disabled', impactedSelf: [], impactedDependents: [] };
|
||||
}
|
||||
|
||||
const upstreamSet = upstream ? collectUpstreams(upstream) : new Set<number>();
|
||||
const targetDownstream = getTargetDownstream(upstream, branchIndex, node);
|
||||
const downstreamSet = targetDownstream
|
||||
? collectDownstreams(targetDownstream, branchChildrenMap)
|
||||
: new Set<number>();
|
||||
|
||||
const deps = nodeDepsMap.get(node.id) ?? new Set<string>();
|
||||
const impactedSelf: CanvasNode[] = [];
|
||||
deps.forEach((depKey) => {
|
||||
const depNode = nodesByKey.get(String(depKey));
|
||||
if (!depNode) {
|
||||
return;
|
||||
}
|
||||
if (!upstreamSet.has(depNode.id)) {
|
||||
impactedSelf.push(depNode);
|
||||
}
|
||||
});
|
||||
|
||||
const dependents = dependentsMap.get(String(node.key)) ?? new Set<CanvasNode>();
|
||||
const impactedDependents: CanvasNode[] = [];
|
||||
dependents.forEach((depNode) => {
|
||||
if (depNode.id === node.id) {
|
||||
return;
|
||||
}
|
||||
if (dragSubtreeRef.current.has(depNode.id)) {
|
||||
return;
|
||||
}
|
||||
if (downstreamSet.has(depNode.id)) {
|
||||
return;
|
||||
}
|
||||
impactedDependents.push(depNode);
|
||||
});
|
||||
|
||||
const status = impactedSelf.length || impactedDependents.length ? 'warning' : 'safe';
|
||||
return { status, impactedSelf, impactedDependents };
|
||||
},
|
||||
[branchChildrenMap, dependentsMap, getTargetDownstream, nodeDepsMap, nodesByKey],
|
||||
);
|
||||
|
||||
const moveNode = useCallback(
|
||||
async (nodeId: number, target: DropTarget | null, options?: { refresh?: boolean }) => {
|
||||
if (!nodeId) {
|
||||
return false;
|
||||
}
|
||||
const upstream = target?.upstream ?? null;
|
||||
const branchIndex = upstream ? target?.branchIndex ?? null : null;
|
||||
try {
|
||||
await api.resource('flow_nodes').move({
|
||||
filterByTk: nodeId,
|
||||
values: {
|
||||
upstreamId: upstream?.id ?? null,
|
||||
branchIndex,
|
||||
},
|
||||
});
|
||||
if (options?.refresh !== false) {
|
||||
refresh?.();
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(lang('Failed to move node'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[api, lang, message, refresh],
|
||||
);
|
||||
|
||||
const updateNodeConfigs = useCallback(
|
||||
async (items: { node: CanvasNode; keys: Set<string> }[]) => {
|
||||
let updated = false;
|
||||
for (const item of items) {
|
||||
const { node, keys } = item;
|
||||
if (!node || !keys.size) {
|
||||
continue;
|
||||
}
|
||||
const result = stripVariableReferences(node.config ?? {}, keys);
|
||||
if (!result.changed) {
|
||||
continue;
|
||||
}
|
||||
updated = true;
|
||||
await api.resource('flow_nodes').update({
|
||||
filterByTk: node.id,
|
||||
values: {
|
||||
config: result.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
},
|
||||
[api],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(async () => {
|
||||
const node = dragNodeRef.current;
|
||||
const target = activeDropRef.current;
|
||||
if (!node || !target) {
|
||||
return;
|
||||
}
|
||||
const nodeId = node.id;
|
||||
const impact = getDropImpact(target);
|
||||
if (impact.status === 'disabled') {
|
||||
return;
|
||||
}
|
||||
if (impact.status === 'warning') {
|
||||
const impactedSelfTitles = impact.impactedSelf.map((item) => item.title).join(', ');
|
||||
const impactedDependentTitles = impact.impactedDependents.map((item) => item.title).join(', ');
|
||||
const keepVariablesRef = { current: false };
|
||||
const updates: { node: CanvasNode; keys: Set<string> }[] = [];
|
||||
const selfKeys = new Set(impact.impactedSelf.map((item) => String(item.key)).filter(Boolean));
|
||||
if (selfKeys.size) {
|
||||
updates.push({ node, keys: selfKeys });
|
||||
}
|
||||
const currentKey = node?.key ? String(node.key) : '';
|
||||
if (currentKey) {
|
||||
impact.impactedDependents.forEach((dep) => {
|
||||
updates.push({ node: dep, keys: new Set([currentKey]) });
|
||||
});
|
||||
}
|
||||
modal.confirm({
|
||||
title: lang('Confirm move'),
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
{lang(
|
||||
'This action will remove invalid variable references, otherwise the workflow cannot run correctly.',
|
||||
)}
|
||||
</div>
|
||||
{impactedSelfTitles ? (
|
||||
<div>{lang('Impacted current node variables') + ': ' + impactedSelfTitles}</div>
|
||||
) : null}
|
||||
{impactedDependentTitles ? (
|
||||
<div>{lang('Impacted dependent node variables') + ': ' + impactedDependentTitles}</div>
|
||||
) : null}
|
||||
<div style={{ marginTop: '0.75em' }}>
|
||||
<Checkbox onChange={(ev) => (keepVariablesRef.current = ev.target.checked)}>
|
||||
{lang('Keep variable references')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
onOk: async () => {
|
||||
if (keepVariablesRef.current) {
|
||||
const moved = await moveNode(nodeId, target);
|
||||
if (moved) {
|
||||
message.warning(
|
||||
lang('Keeping variable references requires manual adjustment, otherwise workflow may fail.'),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const moved = await moveNode(nodeId, target, { refresh: false });
|
||||
if (!moved) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateNodeConfigs(updates);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(lang('Failed to update node variables'));
|
||||
} finally {
|
||||
refresh?.();
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await moveNode(nodeId, target);
|
||||
}, [getDropImpact, lang, message, modal, moveNode, refresh, updateNodeConfigs]);
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMoveRef.current = (event: MouseEvent) => {
|
||||
if (!pendingRef.current) {
|
||||
return;
|
||||
}
|
||||
const { startX, startY, node } = pendingRef.current;
|
||||
const dx = event.clientX - startX;
|
||||
const dy = event.clientY - startY;
|
||||
if (!draggingRef.current) {
|
||||
if (Math.abs(dx) + Math.abs(dy) < 3) {
|
||||
return;
|
||||
}
|
||||
draggingRef.current = true;
|
||||
suppressClickRef.current = true;
|
||||
dragNodeRef.current = node;
|
||||
dragSubtreeRef.current = collectBranchSubtree(node, branchChildrenMap);
|
||||
setDragging(true);
|
||||
setDragNode({
|
||||
id: node.id,
|
||||
key: node.key,
|
||||
title: node.title,
|
||||
type: node.type,
|
||||
hasBranches: (branchChildrenMap.get(node.id)?.length ?? 0) > 0,
|
||||
});
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'grabbing';
|
||||
}
|
||||
pointerRef.current = { x: event.clientX, y: event.clientY };
|
||||
if (draggingRef.current) {
|
||||
updateAutoScroll(event.clientX, event.clientY);
|
||||
updateActiveDropByPreview();
|
||||
}
|
||||
};
|
||||
}, [branchChildrenMap, updateActiveDropByPreview, updateAutoScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
onMouseUpRef.current = () => {
|
||||
if (draggingRef.current) {
|
||||
handleDrop();
|
||||
}
|
||||
cleanupDrag();
|
||||
};
|
||||
}, [cleanupDrag, handleDrop]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging || !dragNode) {
|
||||
if (previewRafRef.current) {
|
||||
window.cancelAnimationFrame(previewRafRef.current);
|
||||
previewRafRef.current = null;
|
||||
}
|
||||
if (previewRef.current) {
|
||||
previewRef.current.remove();
|
||||
previewRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const instruction = dragNode.type ? getInstruction(dragNode.type) : undefined;
|
||||
const typeTitle = instruction ? compile(instruction.title) : dragNode.type ?? '';
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = styles.dragPreviewClass;
|
||||
if (dragNode.hasBranches) {
|
||||
preview.classList.add('drag-preview-group');
|
||||
}
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.top = '0';
|
||||
preview.style.left = '0';
|
||||
preview.style.zIndex = '10000';
|
||||
preview.style.pointerEvents = 'none';
|
||||
const previewType = document.createElement('div');
|
||||
previewType.className = 'workflow-drag-preview-type';
|
||||
previewType.textContent = typeTitle;
|
||||
const previewTitle = document.createElement('div');
|
||||
previewTitle.className = 'workflow-drag-preview-title';
|
||||
previewTitle.textContent = dragNode.title ?? '';
|
||||
if (dragNode.hasBranches) {
|
||||
const stack2 = document.createElement('div');
|
||||
stack2.className = 'workflow-drag-preview-stack stack-2';
|
||||
const stack1 = document.createElement('div');
|
||||
stack1.className = 'workflow-drag-preview-stack stack-1';
|
||||
preview.append(stack2, stack1, previewType, previewTitle);
|
||||
} else {
|
||||
preview.append(previewType, previewTitle);
|
||||
}
|
||||
|
||||
document.body.appendChild(preview);
|
||||
previewRef.current = preview;
|
||||
previewOffsetRef.current = { x: 0, y: 0 };
|
||||
previewSizeRef.current = { width: preview.offsetWidth, height: preview.offsetHeight };
|
||||
|
||||
const tick = () => {
|
||||
const { x, y } = pointerRef.current || { x: 0, y: 0 };
|
||||
if (!previewOffsetRef.current.x && !previewOffsetRef.current.y) {
|
||||
const rect = preview.getBoundingClientRect();
|
||||
previewOffsetRef.current = { x: rect.width / 2, y: rect.height / 2 };
|
||||
}
|
||||
const { x: offsetX, y: offsetY } = previewOffsetRef.current;
|
||||
preview.style.transform = `translate3d(${x - offsetX}px, ${y - offsetY}px, 0) rotate(-6deg)`;
|
||||
previewRafRef.current = window.requestAnimationFrame(tick);
|
||||
};
|
||||
previewRafRef.current = window.requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
if (previewRafRef.current) {
|
||||
window.cancelAnimationFrame(previewRafRef.current);
|
||||
previewRafRef.current = null;
|
||||
}
|
||||
if (previewRef.current) {
|
||||
previewRef.current.remove();
|
||||
previewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [compile, dragNode, dragging, styles.dragPreviewClass, getInstruction]);
|
||||
|
||||
const onNodeMouseDown = useCallback(
|
||||
(node: CanvasNode, event: React.MouseEvent) => {
|
||||
if (!workflow || executed) {
|
||||
return;
|
||||
}
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
if (isInteractiveTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
pendingRef.current = {
|
||||
node,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
};
|
||||
pointerRef.current = { x: event.clientX, y: event.clientY };
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
},
|
||||
[executed, handleMouseMove, handleMouseUp, workflow],
|
||||
);
|
||||
|
||||
const setActiveDrop = useCallback(
|
||||
(target: DropTarget | null) => {
|
||||
activeDropRef.current = target;
|
||||
const nextKey = target ? getDropKey(target) : null;
|
||||
if (nextKey !== activeDropKeyRef.current) {
|
||||
activeDropKeyRef.current = nextKey;
|
||||
setActiveDropKey(nextKey);
|
||||
}
|
||||
},
|
||||
[getDropKey],
|
||||
);
|
||||
|
||||
const clearActiveDrop = useCallback((target: DropTarget) => {
|
||||
if (activeDropRef.current === target) {
|
||||
activeDropRef.current = null;
|
||||
if (activeDropKeyRef.current) {
|
||||
activeDropKeyRef.current = null;
|
||||
setActiveDropKey(null);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const registerDropZone = useCallback(
|
||||
(target: DropTarget, element: HTMLElement | null) => {
|
||||
if (!element || !target) {
|
||||
return () => {};
|
||||
}
|
||||
const key = getDropKey(target);
|
||||
dropZonesRef.current.set(key, { target, element });
|
||||
return () => {
|
||||
dropZonesRef.current.delete(key);
|
||||
};
|
||||
},
|
||||
[getDropKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
dragging,
|
||||
dragNode,
|
||||
onNodeMouseDown,
|
||||
getDropImpact,
|
||||
setActiveDrop,
|
||||
clearActiveDrop,
|
||||
registerDropZone,
|
||||
getDropKey,
|
||||
activeDropKey,
|
||||
consumeClick,
|
||||
}),
|
||||
[
|
||||
activeDropKey,
|
||||
consumeClick,
|
||||
dragging,
|
||||
dragNode,
|
||||
getDropImpact,
|
||||
getDropKey,
|
||||
onNodeMouseDown,
|
||||
setActiveDrop,
|
||||
clearActiveDrop,
|
||||
registerDropZone,
|
||||
],
|
||||
);
|
||||
|
||||
return <NodeDragContext.Provider value={value}>{props.children}</NodeDragContext.Provider>;
|
||||
}
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canvas remove-node flow, shared by BOTH canvases (ADR-0003, doc §9.6). A leaf
|
||||
* node deletes after the variable-reference safety check (blocked when another
|
||||
* node still references its result, else a confirm); a branching node opens a
|
||||
* "keep which branch" modal, then runs the same safety check over the kept-branch
|
||||
* + downstream subtree before deleting. Deletes via `flow_nodes.destroy` + refresh.
|
||||
*
|
||||
* This aligns the modern canvas to v1: the previous v2 implementation deleted
|
||||
* immediately and skipped the reference guard entirely. The check logic is the
|
||||
* shared pure `findNodesReferencing` / `collectBranchNodes`; the runtime-specific
|
||||
* bits (`api`, `nodes`, `refresh`, the instruction registry for branch labels)
|
||||
* are read through an injected `useCanvasRuntime` so v1 can re-import this single
|
||||
* provider and pass its own runtime (the allowed v1 → v2 direction).
|
||||
*/
|
||||
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { App, Modal, Radio, Select, Space } from 'antd';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { useFlowContext } from './contexts';
|
||||
import { useT } from '../locale';
|
||||
import { PluginWorkflowClientV2 } from '../plugin';
|
||||
import { collectBranchNodes, findNodesReferencing } from './removeNodeUtils';
|
||||
|
||||
type RemoveNodeContextValue = {
|
||||
/** Request deletion of a node — runs the safety check and confirm/keep-branch
|
||||
* flow, then deletes. Mirrors v1's `RemoveButton.onRemove`. */
|
||||
requestRemove: (node: any) => void;
|
||||
};
|
||||
|
||||
/** Per-canvas runtime the provider needs but that differs by runtime — injected
|
||||
* via `RemoveNodeContextProvider`'s `useCanvasRuntime` prop. */
|
||||
export type CanvasRemoveRuntime = {
|
||||
api: any;
|
||||
nodes: any[] | undefined;
|
||||
refresh?: () => void;
|
||||
/** Resolve an instruction by type — for the branch-label `branching` metadata. */
|
||||
getInstruction: (type: string) => any;
|
||||
};
|
||||
|
||||
/** Default (modern-canvas) runtime source: flow-engine `ctx.api`, the v2
|
||||
* `FlowContext`, and the v2 instruction registry. */
|
||||
function useModernCanvasRuntime(): CanvasRemoveRuntime {
|
||||
const flowEngine = useFlowEngine();
|
||||
const { nodes, refresh } = useFlowContext() ?? {};
|
||||
const plugin = flowEngine.context.app.pm.get(PluginWorkflowClientV2) as PluginWorkflowClientV2;
|
||||
return {
|
||||
api: flowEngine.context.api,
|
||||
nodes,
|
||||
refresh,
|
||||
getInstruction: (type: string) => plugin?.getInstruction(type),
|
||||
};
|
||||
}
|
||||
|
||||
const RemoveNodeContext = createContext<RemoveNodeContextValue | null>(null);
|
||||
|
||||
export function useRemoveNodeContext() {
|
||||
return useContext(RemoveNodeContext);
|
||||
}
|
||||
|
||||
export function RemoveNodeContextProvider(props: {
|
||||
children: React.ReactNode;
|
||||
/** Injected per-canvas runtime source. Defaults to the modern canvas's; the
|
||||
* legacy canvas passes its own (v1 `FlowContext` + `usePlugin` registry). */
|
||||
useCanvasRuntime?: () => CanvasRemoveRuntime;
|
||||
}) {
|
||||
const { useCanvasRuntime = useModernCanvasRuntime } = props;
|
||||
const t = useT();
|
||||
const { modal, message } = App.useApp();
|
||||
const { api, nodes, refresh, getInstruction } = useCanvasRuntime();
|
||||
|
||||
const [deletingNode, setDeletingNode] = useState<any>(null);
|
||||
const [keepBranch, setKeepBranch] = useState<number | null>(null);
|
||||
|
||||
const deletingBranches = useMemo(
|
||||
() => (nodes ?? []).filter((item: any) => item.upstream === deletingNode && item.branchIndex != null),
|
||||
[nodes, deletingNode],
|
||||
);
|
||||
|
||||
const destroy = useCallback(
|
||||
async (nodeId: any, values?: Record<string, any>) => {
|
||||
try {
|
||||
await api.resource('flow_nodes').destroy({ filterByTk: nodeId, ...values });
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
message.error(t('Failed to delete node'));
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
},
|
||||
[api, refresh, message, t],
|
||||
);
|
||||
|
||||
/** The "referenced by other nodes" blocker — shared with v1. `candidates` is the
|
||||
* pool to scan and `includeScopes` widens it to `$scopes` (branching path). */
|
||||
const blockedByReferences = useCallback(
|
||||
(target: any, candidates: any[], includeScopes: boolean) => {
|
||||
const using = findNodesReferencing(candidates, target, { includeScopes });
|
||||
if (!using.length) {
|
||||
return false;
|
||||
}
|
||||
modal.error({
|
||||
title: t('Can not delete'),
|
||||
content: t(
|
||||
'The result of this node has been referenced by other nodes ({{nodes}}), please remove the usage before deleting.',
|
||||
{ nodes: using.map((item) => item.title).join(', ') },
|
||||
),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[modal, t],
|
||||
);
|
||||
|
||||
const requestRemove = useCallback(
|
||||
(node: any) => {
|
||||
const branches = (nodes ?? []).filter((item: any) => item.upstream === node && item.branchIndex != null);
|
||||
if (!branches.length) {
|
||||
// Leaf delete: block when another node references this node's result, else confirm and delete (mirrors v1's
|
||||
// `RemoveButton.onRemove`).
|
||||
if (blockedByReferences(node, nodes ?? [], false)) {
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: t('Delete'),
|
||||
content: t('Are you sure you want to delete it?'),
|
||||
onOk: () => destroy(node.id),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Branching delete: open the keep-branch modal; the actual check + delete happen on confirm (over the kept-branch
|
||||
// + downstream subtree).
|
||||
setKeepBranch(null);
|
||||
setDeletingNode(node);
|
||||
},
|
||||
[nodes, modal, t, destroy, blockedByReferences],
|
||||
);
|
||||
|
||||
const branchOptions = useMemo(() => {
|
||||
if (!deletingNode) {
|
||||
return [];
|
||||
}
|
||||
const instruction = getInstruction(deletingNode.type);
|
||||
const branching =
|
||||
typeof instruction?.branching === 'function'
|
||||
? instruction.branching(deletingNode.config ?? {})
|
||||
: instruction?.branching;
|
||||
return deletingBranches.map((item: any, index: number) => {
|
||||
const option = Array.isArray(branching) ? branching.find((b: any) => b.value === item.branchIndex) ?? {} : {};
|
||||
return {
|
||||
label: option.label ? t(option.label) : t('Branch {{index}}', { index: index + 1 }),
|
||||
value: item.branchIndex,
|
||||
};
|
||||
});
|
||||
}, [deletingNode, deletingBranches, getInstruction, t]);
|
||||
|
||||
const onConfirmKeepBranch = useCallback(async () => {
|
||||
if (!deletingNode) {
|
||||
return;
|
||||
}
|
||||
// Same reference guard as v1's `useRemoveNodeSubmitAction`, over the kept branch + downstream subtree, including
|
||||
// `$scopes` references.
|
||||
const branchHead =
|
||||
keepBranch != null ? deletingBranches.find((item: any) => item.branchIndex === keepBranch) : null;
|
||||
const relatedNodes = collectBranchNodes(nodes ?? [], branchHead);
|
||||
const downstreamNodes = collectBranchNodes(nodes ?? [], deletingNode.downstream);
|
||||
for (const [key, node] of downstreamNodes) {
|
||||
relatedNodes.set(key, node);
|
||||
}
|
||||
if (blockedByReferences(deletingNode, [...relatedNodes.values()], true)) {
|
||||
return;
|
||||
}
|
||||
const values = keepBranch != null ? { keepBranch } : {};
|
||||
await destroy(deletingNode.id, values);
|
||||
setDeletingNode(null);
|
||||
}, [deletingNode, keepBranch, deletingBranches, nodes, destroy, blockedByReferences]);
|
||||
|
||||
const value = useMemo<RemoveNodeContextValue>(() => ({ requestRemove }), [requestRemove]);
|
||||
|
||||
return (
|
||||
<RemoveNodeContext.Provider value={value}>
|
||||
{props.children}
|
||||
<Modal
|
||||
title={t('Delete node')}
|
||||
open={Boolean(deletingBranches.length)}
|
||||
onCancel={() => setDeletingNode(null)}
|
||||
onOk={onConfirmKeepBranch}
|
||||
okButtonProps={{ danger: true }}
|
||||
okText={t('Delete')}
|
||||
>
|
||||
<Radio.Group
|
||||
value={keepBranch != null ? 1 : 0}
|
||||
onChange={(e) => setKeepBranch(e.target.value === 0 ? null : deletingBranches[0]?.branchIndex ?? null)}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
<Radio value={0}>{t('Delete all')}</Radio>
|
||||
<Space>
|
||||
<Radio value={1}>{t('Keep')}</Radio>
|
||||
<Select
|
||||
options={branchOptions}
|
||||
value={keepBranch ?? undefined}
|
||||
onChange={(v) => setKeepBranch(v)}
|
||||
disabled={keepBranch == null}
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</RemoveNodeContext.Provider>
|
||||
);
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The v2 workflow variable picker (doc §5). A downstream node author drops this
|
||||
* into a `FieldsetLoader` form like any antd input — it reads the current node
|
||||
* from NodeContext and the upstream chain itself, so the author never wires
|
||||
* context.
|
||||
*
|
||||
* It reuses flow-engine's low-level `VariableHybridInput` (fed a
|
||||
* workflow-constructed `MetaTreeNode` tree from `useWorkflowVariableOptions`),
|
||||
* NOT the top-level global `VariableInput` (whose tree is the global registry).
|
||||
*
|
||||
* Workflow variables serialize as `{{$jobsMapByNodeKey.<nodeKey>.<field>}}` —
|
||||
* the adapter already builds `paths` as `['$jobsMapByNodeKey', nodeKey, …]`, so
|
||||
* the converters here just join/split that path inside `{{ }}`.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { VariableHybridInput, type MetaTreeNode, type VariableHybridInputConverters } from '@nocobase/flow-engine';
|
||||
import { useWorkflowVariableOptions, type UseWorkflowVariableOptions } from './useWorkflowVariableOptions';
|
||||
|
||||
const VARIABLE_REGEXP = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
||||
|
||||
const workflowConverters: VariableHybridInputConverters = {
|
||||
formatPathToValue: (item?: MetaTreeNode) => {
|
||||
const path = item?.paths ?? [];
|
||||
return path.length ? `{{${path.join('.')}}}` : '';
|
||||
},
|
||||
parseValueToPath: (value?: string) => {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const match = value.trim().match(/^\{\{\s*(.+?)\s*\}\}$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
return match[1].split('.');
|
||||
},
|
||||
variableRegExp: VARIABLE_REGEXP,
|
||||
};
|
||||
|
||||
export type WorkflowVariableInputProps = {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
/** Validation status (red/amber border). Usually omitted — inside a
|
||||
* `Form.Item` the status is inherited automatically. */
|
||||
status?: 'error' | 'warning';
|
||||
/** Variable-tree options forwarded to each upstream `useVariables` (types
|
||||
* filter, appends, depth, fieldNames). */
|
||||
variableOptions?: UseWorkflowVariableOptions;
|
||||
};
|
||||
|
||||
export function WorkflowVariableInput(props: WorkflowVariableInputProps) {
|
||||
const { variableOptions, ...rest } = props;
|
||||
const metaTree = useWorkflowVariableOptions(variableOptions);
|
||||
const tree = useMemo(() => metaTree, [metaTree]);
|
||||
return <VariableHybridInput {...rest} metaTree={tree} converters={workflowConverters} />;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { App } from 'antd';
|
||||
import { PresetDialogForm } from '../AddNodeContext';
|
||||
import ConditionInstruction from '../../nodes/condition';
|
||||
|
||||
vi.mock('../../locale', () => ({
|
||||
NAMESPACE: 'workflow',
|
||||
useT: () => (key: string, options?: Record<string, unknown>) =>
|
||||
String(key)
|
||||
.replace(/\{\{t\("([^"]+)"(?:,\s*\{[^}]*\})?\)\}\}/g, (_match, text) => text)
|
||||
.replace(/\{\{(\w+)\}\}/g, (_match, name) => String(options?.[name] ?? `{{${name}}}`)),
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
|
||||
return {
|
||||
...actual,
|
||||
DialogFormLayout: ({ children }: any) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
describe('PresetDialogForm', () => {
|
||||
it('shows downstream-branch placement options after condition switches to yes/no branching', async () => {
|
||||
const instruction = new ConditionInstruction();
|
||||
|
||||
render(
|
||||
<App>
|
||||
<PresetDialogForm instruction={instruction} hasDownstream onSubmit={vi.fn()} />
|
||||
</App>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Move all downstream nodes to')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByRole('radio', { name: 'Branch into "Yes" and "No"' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Move all downstream nodes to')).toBeInTheDocument();
|
||||
expect(screen.getByText('After end of branches')).toBeInTheDocument();
|
||||
expect(screen.getByText('Inside of "Yes" branch')).toBeInTheDocument();
|
||||
expect(screen.getByText('Inside of "No" branch')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AddNodeSlot } from '../AddNodeSlot';
|
||||
import { AddNodeContext } from '../AddNodeContext.shared';
|
||||
import { FlowContext } from '../contexts';
|
||||
import { BranchContext } from '../BranchContext';
|
||||
|
||||
vi.mock('../style', () => ({
|
||||
default: () => ({
|
||||
styles: {
|
||||
addButtonClass: 'add-button-class',
|
||||
dropZoneClass: 'drop-zone-class',
|
||||
pasteButtonClass: 'paste-button-class',
|
||||
},
|
||||
cx: (...args: any[]) => args.filter(Boolean).join(' '),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../NodeClipboardContext', () => ({
|
||||
useNodeClipboardContext: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../NodeDragContext', () => ({
|
||||
useNodeDragContext: () => ({ dragging: false }),
|
||||
}));
|
||||
|
||||
vi.mock('../contexts', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../contexts')>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkflowCanvasExecuted: () => 0n,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AddNodeSlot', () => {
|
||||
it('opens the runtime-provided add-node drawer via the shared AddNodeContext', () => {
|
||||
const onMenuOpen = vi.fn();
|
||||
|
||||
render(
|
||||
<FlowContext.Provider value={{ workflow: { id: 1 } as any }}>
|
||||
<BranchContext.Provider value={{ branchIndex: 0, addable: true }}>
|
||||
<AddNodeContext.Provider value={{ creating: null, onMenuOpen }}>
|
||||
<AddNodeSlot upstream={{ id: 123 }} branchIndex={0} />
|
||||
</AddNodeContext.Provider>
|
||||
</BranchContext.Provider>
|
||||
</FlowContext.Provider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'add-button' }));
|
||||
|
||||
expect(onMenuOpen).toHaveBeenCalledWith({
|
||||
upstream: { id: 123 },
|
||||
branchIndex: 0,
|
||||
branchContext: {
|
||||
syncOnly: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Branch } from '../Branch';
|
||||
import { BranchRenderContext } from '../BranchRenderContext';
|
||||
|
||||
vi.mock('../Node', () => ({
|
||||
Node: ({ data }: any) => <div data-testid={`node-${data.id}`}>{data.title}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../AddNodeSlot', () => ({
|
||||
AddNodeSlot: ({ branchIndex }: any) => <div data-testid={`add-slot-${branchIndex ?? 'root'}`} />,
|
||||
}));
|
||||
|
||||
vi.mock('../style', () => ({
|
||||
default: () => ({
|
||||
styles: {
|
||||
branchClass: 'branch-class',
|
||||
},
|
||||
cx: (...args: any[]) => args.filter(Boolean).join(' '),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Branch', () => {
|
||||
it('renders the branch controller before the node list', () => {
|
||||
render(
|
||||
<Branch
|
||||
branchIndex={1}
|
||||
controller={<div data-testid="branch-controller">controller</div>}
|
||||
entry={{ id: 1, title: 'Node 1', downstream: null }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('branch-controller')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports runtime-injected node renderer and add-button aria label', () => {
|
||||
render(
|
||||
<Branch
|
||||
branchIndex={0}
|
||||
addButtonAriaLabel="legacy-add-button"
|
||||
NodeComponent={({ data }: any) => <div data-testid={`legacy-node-${data.id}`}>{data.title}</div>}
|
||||
entry={{ id: 2, title: 'Legacy Node', downstream: null }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('legacy-node-2')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('add-slot-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the runtime-provided branch node renderer when NodeComponent is omitted', () => {
|
||||
render(
|
||||
<BranchRenderContext.Provider
|
||||
value={({ data }: any) => <div data-testid={`injected-node-${data.id}`}>{data.title}</div>}
|
||||
>
|
||||
<Branch branchIndex={0} entry={{ id: 3, title: 'Injected Node', downstream: null }} />
|
||||
</BranchRenderContext.Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('injected-node-3')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The shared branch context (ADR-0003 layer 2) is a single instance serving both
|
||||
* canvases — v1 re-exports it from here. Its provider value differs by canvas: the
|
||||
* legacy canvas's `Branch` sets `{ branchIndex, addable, syncOnly }`, the modern
|
||||
* canvas's `{ branchIndex, addable }` (no `syncOnly`). These pins assert the hooks
|
||||
* read both shapes correctly, and that the absent-provider default (`null`) is
|
||||
* handled — the contract every consumer relies on via `?.`.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BranchContext, useBranchContext, useBranchIndex, useBranchSyncOnly } from '../BranchContext';
|
||||
|
||||
function wrapper(value: any) {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BranchContext.Provider value={value}>{children}</BranchContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('BranchContext — shared by both canvases', () => {
|
||||
it('reads the legacy-canvas value shape ({ branchIndex, addable, syncOnly })', () => {
|
||||
const value = { branchIndex: 1, addable: false, syncOnly: true };
|
||||
const wrap = wrapper(value);
|
||||
expect(renderHook(() => useBranchContext(), { wrapper: wrap }).result.current).toBe(value);
|
||||
expect(renderHook(() => useBranchIndex(), { wrapper: wrap }).result.current).toBe(1);
|
||||
expect(renderHook(() => useBranchSyncOnly(), { wrapper: wrap }).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the modern-canvas value shape ({ branchIndex, addable }) — syncOnly defaults to false', () => {
|
||||
const wrap = wrapper({ branchIndex: 0, addable: true });
|
||||
expect(renderHook(() => useBranchIndex(), { wrapper: wrap }).result.current).toBe(0);
|
||||
// `syncOnly` is optional; absent → false (the modern canvas never sets it).
|
||||
expect(renderHook(() => useBranchSyncOnly(), { wrapper: wrap }).result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to null/defaults with no provider (the `?.` contract consumers rely on)', () => {
|
||||
expect(renderHook(() => useBranchContext()).result.current).toBeNull();
|
||||
expect(renderHook(() => useBranchIndex()).result.current).toBeNull();
|
||||
expect(renderHook(() => useBranchSyncOnly()).result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Proves the shared clipboard provider (ADR-0003 layer 2) is driven entirely by
|
||||
* its injected `useCanvasRuntime` hook — the seam that lets ONE provider serve
|
||||
* both canvases. The legacy canvas injects v1's runtime (its `FlowContext` +
|
||||
* `versionStats.executed`), the modern canvas the v2 one; here we inject a
|
||||
* controlled runtime and assert copy/paste reads from it (the `executed` guard,
|
||||
* the flow data, the `flow_nodes.duplicate` call). The pure paste-impact math is
|
||||
* covered separately by `nodeVariableUtils.characterization.test.ts`.
|
||||
*/
|
||||
|
||||
import { render, act, waitFor } from '@testing-library/react';
|
||||
import { App } from 'antd';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Controlled flow-engine ctx (only `api` is read by the provider).
|
||||
const holder = vi.hoisted(() => ({ duplicate: vi.fn(async () => ({})) }));
|
||||
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useFlowContext: () => ({ api: { resource: () => ({ duplicate: holder.duplicate }) } }),
|
||||
};
|
||||
});
|
||||
vi.mock('../../locale', () => ({ useT: () => (key: string) => key }));
|
||||
|
||||
import {
|
||||
NodeClipboardContextProvider,
|
||||
useNodeClipboardContext,
|
||||
type CanvasClipboardRuntime,
|
||||
} from '../NodeClipboardContext';
|
||||
|
||||
type Clipboard = NonNullable<ReturnType<typeof useNodeClipboardContext>>;
|
||||
|
||||
function setup(runtime: CanvasClipboardRuntime) {
|
||||
const captured: { ctx: Clipboard } = { ctx: {} as Clipboard };
|
||||
function Capture() {
|
||||
const ctx = useNodeClipboardContext();
|
||||
if (ctx) {
|
||||
captured.ctx = ctx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<App>
|
||||
<NodeClipboardContextProvider useCanvasRuntime={() => runtime}>
|
||||
<Capture />
|
||||
</NodeClipboardContextProvider>
|
||||
</App>,
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
const NODE = { id: 1, key: 'n1', type: 'query', title: 'Query', config: {} };
|
||||
|
||||
describe('NodeClipboardContextProvider — injected runtime drives copy/paste', () => {
|
||||
it('copies a node when the injected runtime is not executed', () => {
|
||||
const captured = setup({ workflow: { id: 9 }, nodes: [NODE], refresh: vi.fn(), executed: false });
|
||||
act(() => captured.ctx.copyNode(NODE));
|
||||
expect(captured.ctx.clipboard?.sourceId).toBe(1);
|
||||
});
|
||||
|
||||
it('refuses to copy when the injected runtime reports executed (read-only)', () => {
|
||||
// The legacy canvas derives `executed` from `versionStats.executed`; whatever the source, the provider honors the
|
||||
// injected boolean.
|
||||
const captured = setup({ workflow: { id: 9 }, nodes: [NODE], refresh: vi.fn(), executed: true });
|
||||
act(() => captured.ctx.copyNode(NODE));
|
||||
expect(captured.ctx.clipboard).toBeNull();
|
||||
});
|
||||
|
||||
it('pastes via flow_nodes.duplicate using the injected workflow + refresh', async () => {
|
||||
const refresh = vi.fn();
|
||||
const captured = setup({ workflow: { id: 9 }, nodes: [NODE], refresh, executed: false });
|
||||
act(() => captured.ctx.copyNode(NODE));
|
||||
// A safe paste (no variable refs) duplicates immediately and refreshes.
|
||||
await act(async () => {
|
||||
await captured.ctx.pasteNode({ upstream: null, branchIndex: null });
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(holder.duplicate).toHaveBeenCalledWith(expect.objectContaining({ filterByTk: 1 }));
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The shared remove-node provider (ADR-0003 layer 2), now aligned to v1: it runs
|
||||
* the variable-reference safety check the modern canvas previously skipped. These
|
||||
* pins assert the behaviour through the injected runtime — a leaf node referenced
|
||||
* by another node is BLOCKED (error, no destroy); an unreferenced leaf confirms
|
||||
* and destroys; a branching node opens the keep-branch modal instead of deleting
|
||||
* outright. The pure check logic is characterized separately in
|
||||
* `removeNodeUtils.characterization.test.ts`.
|
||||
*/
|
||||
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { App } from 'antd';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
// antd App.useApp() drives modal/message; capture modal.confirm / modal.error.
|
||||
const modalMock = vi.hoisted(() => ({ confirm: vi.fn(), error: vi.fn() }));
|
||||
vi.mock('antd', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const App: any = (props: any) => props.children;
|
||||
App.useApp = () => ({ modal: modalMock, message: { error: vi.fn() } });
|
||||
return { ...actual, App };
|
||||
});
|
||||
// useFlowEngine is only hit by the DEFAULT runtime; we always inject our own, so it never runs — but the import must
|
||||
// resolve.
|
||||
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, useFlowEngine: () => ({ context: { app: { pm: { get: () => null } }, api: null } }) };
|
||||
});
|
||||
vi.mock('../../locale', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, useT: () => (key: string) => key };
|
||||
});
|
||||
|
||||
import { RemoveNodeContextProvider, useRemoveNodeContext, type CanvasRemoveRuntime } from '../RemoveNodeContext';
|
||||
|
||||
type Remove = NonNullable<ReturnType<typeof useRemoveNodeContext>>;
|
||||
|
||||
function setup(runtime: CanvasRemoveRuntime) {
|
||||
const captured: { ctx: Remove } = { ctx: {} as Remove };
|
||||
function Capture() {
|
||||
const ctx = useRemoveNodeContext();
|
||||
if (ctx) {
|
||||
captured.ctx = ctx;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
render(
|
||||
<App>
|
||||
<RemoveNodeContextProvider useCanvasRuntime={() => runtime}>
|
||||
<Capture />
|
||||
</RemoveNodeContextProvider>
|
||||
</App>,
|
||||
);
|
||||
return captured;
|
||||
}
|
||||
|
||||
function makeRuntime(nodes: any[], destroy = vi.fn(async () => ({}))): CanvasRemoveRuntime {
|
||||
return {
|
||||
api: { resource: () => ({ destroy }) },
|
||||
nodes,
|
||||
refresh: vi.fn(),
|
||||
getInstruction: () => ({ branching: false }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('RemoveNodeContextProvider — variable-reference safety (aligned to v1)', () => {
|
||||
beforeEach(() => {
|
||||
modalMock.confirm.mockReset();
|
||||
modalMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('blocks deleting a leaf node whose result is referenced (error, no confirm)', () => {
|
||||
const nodes = [
|
||||
{ id: 1, key: 'query', config: {}, upstream: null },
|
||||
{ id: 2, key: 'calc', title: 'Calc', config: { x: '{{$jobsMapByNodeKey.query.id}}' }, upstream: null },
|
||||
];
|
||||
const captured = setup(makeRuntime(nodes));
|
||||
act(() => captured.ctx.requestRemove(nodes[0]));
|
||||
expect(modalMock.error).toHaveBeenCalledTimes(1);
|
||||
expect(modalMock.confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirms (then would destroy) an unreferenced leaf node', () => {
|
||||
const nodes = [
|
||||
{ id: 1, key: 'query', config: {}, upstream: null },
|
||||
{ id: 2, key: 'calc', config: { x: 'no refs' }, upstream: null },
|
||||
];
|
||||
const destroy = vi.fn(async () => ({}));
|
||||
const captured = setup(makeRuntime(nodes, destroy));
|
||||
act(() => captured.ctx.requestRemove(nodes[0]));
|
||||
expect(modalMock.error).not.toHaveBeenCalled();
|
||||
expect(modalMock.confirm).toHaveBeenCalledTimes(1);
|
||||
// The confirm's onOk performs the destroy.
|
||||
const onOk = modalMock.confirm.mock.calls[0][0].onOk;
|
||||
act(() => {
|
||||
onOk();
|
||||
});
|
||||
expect(destroy).toHaveBeenCalledWith(expect.objectContaining({ filterByTk: 1 }));
|
||||
});
|
||||
|
||||
it('opens the keep-branch modal for a branching node instead of confirming', () => {
|
||||
// node 1 has a child branch (node 10 with branchIndex) → branching delete path.
|
||||
const branchHead = { id: 10, key: 'bh', branchIndex: 0, config: {} };
|
||||
const node = { id: 1, key: 'cond', config: {}, upstream: null };
|
||||
branchHead.upstream = node as any;
|
||||
const nodes = [node, branchHead];
|
||||
const captured = setup(makeRuntime(nodes));
|
||||
act(() => captured.ctx.requestRemove(node));
|
||||
// Neither a leaf confirm nor an error — the keep-branch <Modal> opens instead.
|
||||
expect(modalMock.confirm).not.toHaveBeenCalled();
|
||||
expect(modalMock.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Correctness contract + retirement-regression baseline for the
|
||||
* `VariableOption → MetaTreeNode` adapter (migration doc §6, the 15 cases).
|
||||
*
|
||||
* The adapter is pure and context-free; this whole suite is deleted in one move
|
||||
* when the legacy field-tree logic is finally rewritten to produce MetaTreeNode
|
||||
* natively (case 15 pins that nothing else consumes the adapter).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatPathToValue, parseValueToPath } from '@nocobase/flow-engine';
|
||||
import { adaptVariableOptionToMetaTree } from '../adaptVariableOptionToMetaTree';
|
||||
import { getCollectionFieldOptions } from '../collectionFieldOptions';
|
||||
|
||||
describe('adaptVariableOptionToMetaTree', () => {
|
||||
// --- 基础映射 -------------------------------------------------------------
|
||||
|
||||
it('1. maps label→title, value→name', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ label: 'Title', value: 'title' });
|
||||
expect(node.title).toBe('Title');
|
||||
expect(node.name).toBe('title');
|
||||
});
|
||||
|
||||
it('2. tolerates a ReactNode label (does not crash; preserved for walk to plain-text)', () => {
|
||||
const label = React.createElement('span', null, 'Rich');
|
||||
const node = adaptVariableOptionToMetaTree({ label, value: 'x' });
|
||||
expect(node.name).toBe('x');
|
||||
// title carries the ReactNode through unchanged; the walk side plain-texts it.
|
||||
expect(node.title).toBe(label);
|
||||
});
|
||||
|
||||
it('3. falls back to name when label is missing', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'onlyValue' });
|
||||
expect(node.name).toBe('onlyValue');
|
||||
expect(node.title).toBe('onlyValue');
|
||||
});
|
||||
|
||||
// --- paths 累积(核心,唯一构造项)---------------------------------------
|
||||
|
||||
it('4. top-level paths = [own value]', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'a' });
|
||||
expect(node.paths).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('5. accumulates paths across two nested levels', () => {
|
||||
const node = adaptVariableOptionToMetaTree({
|
||||
value: 'a',
|
||||
children: [{ value: 'b', children: [{ value: 'c' }] }],
|
||||
});
|
||||
const a = node;
|
||||
const b = (a.children as any[])[0];
|
||||
const c = (b.children as any[])[0];
|
||||
expect(a.paths).toEqual(['a']);
|
||||
expect(b.paths).toEqual(['a', 'b']);
|
||||
expect(c.paths).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('6. accumulates under a custom root prefix (node output mounted at $jobsMapByNodeKey.<nodeKey>)', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'field1' }, ['$jobsMapByNodeKey', 'node1']);
|
||||
expect(node.paths).toEqual(['$jobsMapByNodeKey', 'node1', 'field1']);
|
||||
});
|
||||
|
||||
// --- children / 懒加载 ----------------------------------------------------
|
||||
|
||||
it('7. maps a static children array recursively with correct paths', () => {
|
||||
const node = adaptVariableOptionToMetaTree({
|
||||
value: 'root',
|
||||
children: [{ value: 'x' }, { value: 'y' }],
|
||||
});
|
||||
expect(Array.isArray(node.children)).toBe(true);
|
||||
const children = node.children as any[];
|
||||
expect(children.map((c) => c.name)).toEqual(['x', 'y']);
|
||||
expect(children[0].paths).toEqual(['root', 'x']);
|
||||
});
|
||||
|
||||
it('8. children:null / isLeaf:true → no children (no expand arrow)', () => {
|
||||
const leaf = adaptVariableOptionToMetaTree({ value: 'leaf', children: null, isLeaf: true });
|
||||
expect(leaf.children).toBeUndefined();
|
||||
});
|
||||
|
||||
it('9. loadChildren → children:()=>Promise; v1-only keys captured by closure, absent from MetaTreeNode', async () => {
|
||||
const v1Option: any = {
|
||||
value: 'assoc',
|
||||
isLeaf: false,
|
||||
// v1-only keys that must never surface on the produced node:
|
||||
field: { type: 'belongsTo', target: 'users' },
|
||||
types: ['string'],
|
||||
appends: ['assoc'],
|
||||
depth: 1,
|
||||
loadChildren(option: any) {
|
||||
option.loadChildren = null;
|
||||
option.children = [{ value: 'id' }, { value: 'nickname' }];
|
||||
},
|
||||
};
|
||||
const node = adaptVariableOptionToMetaTree(v1Option);
|
||||
|
||||
// v1-only keys do not leak onto the MetaTreeNode.
|
||||
expect(node).not.toHaveProperty('field');
|
||||
expect(node).not.toHaveProperty('types');
|
||||
expect(node).not.toHaveProperty('appends');
|
||||
expect(node).not.toHaveProperty('depth');
|
||||
// type/interface are derived from field (allowed), not the raw field object.
|
||||
expect(node.type).toBe('belongsTo');
|
||||
|
||||
expect(typeof node.children).toBe('function');
|
||||
const loaded = await (node.children as () => Promise<any[]>)();
|
||||
expect(loaded.map((c) => c.name)).toEqual(['id', 'nickname']);
|
||||
// paths continue to accumulate through the lazy boundary.
|
||||
expect(loaded[0].paths).toEqual(['assoc', 'id']);
|
||||
});
|
||||
|
||||
it('10. loadChildren resolving empty → node becomes a leaf (mirrors v1 isLeaf)', async () => {
|
||||
const v1Option: any = {
|
||||
value: 'assoc',
|
||||
loadChildren(option: any) {
|
||||
option.loadChildren = null;
|
||||
option.children = [];
|
||||
option.isLeaf = true;
|
||||
},
|
||||
};
|
||||
const node = adaptVariableOptionToMetaTree(v1Option);
|
||||
const loaded = await (node.children as () => Promise<any[]>)();
|
||||
expect(loaded).toEqual([]);
|
||||
});
|
||||
|
||||
// --- disabled / 过滤 ------------------------------------------------------
|
||||
|
||||
it('11. disabled=true (type mismatch) → MetaTreeNode.disabled=true', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'x', disabled: true });
|
||||
expect(node.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('12. disabled passes through without dropping the node (no accidental prune)', () => {
|
||||
const node = adaptVariableOptionToMetaTree({
|
||||
value: 'parent',
|
||||
disabled: true,
|
||||
children: [{ value: 'child', disabled: true }],
|
||||
});
|
||||
expect(node.disabled).toBe(true);
|
||||
expect((node.children as any[])[0].disabled).toBe(true);
|
||||
});
|
||||
|
||||
// --- 端到端 ---------------------------------------------------------------
|
||||
|
||||
it('13. real getCollectionFieldOptions output → adapter → walkable tree (paths complete, no throw)', () => {
|
||||
const compile = (s: unknown) => {
|
||||
if (typeof s !== 'string') return s;
|
||||
const m = s.match(/^\{\{\s*t\(["'](.+?)["'].*\)\s*\}\}$/);
|
||||
return m ? m[1] : s;
|
||||
};
|
||||
const collectionManager = {
|
||||
getCollectionAllFields: (c: string) =>
|
||||
({
|
||||
posts: [
|
||||
{ name: 'id', type: 'bigInt', interface: 'integer', uiSchema: { title: 'ID' }, primaryKey: true },
|
||||
{ name: 'title', type: 'string', interface: 'input', uiSchema: { title: '{{t("Title")}}' } },
|
||||
],
|
||||
})[c] ?? [],
|
||||
};
|
||||
const options = getCollectionFieldOptions({ collection: 'posts', compile, collectionManager });
|
||||
const nodes = options.map((o) => adaptVariableOptionToMetaTree(o, ['$jobsMapByNodeKey', 'node1']));
|
||||
|
||||
// Build a value→title map the way the walk would, asserting paths are complete.
|
||||
const map = new Map(nodes.map((n) => [n.paths.join('.'), n.title]));
|
||||
expect(map.get('$jobsMapByNodeKey.node1.title')).toBe('Title');
|
||||
expect(map.get('$jobsMapByNodeKey.node1.id')).toBe('ID');
|
||||
});
|
||||
|
||||
it('14. round-trip: leaf → formatPathToValue → parseValueToPath === the leaf paths', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'c', children: null }, ['a', 'b']);
|
||||
expect(node.paths).toEqual(['a', 'b', 'c']);
|
||||
const value = formatPathToValue(node); // {{ ctx.a.b.c }}
|
||||
expect(parseValueToPath(value)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
// --- 退役安全 -------------------------------------------------------------
|
||||
|
||||
it('15. is pure: same input yields an equivalent fresh tree, input is not mutated', () => {
|
||||
const input: any = { value: 'a', children: [{ value: 'b' }] };
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
const first = adaptVariableOptionToMetaTree(input);
|
||||
const second = adaptVariableOptionToMetaTree(input);
|
||||
// No shared identity between calls (fresh tree each time).
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.children).not.toBe(second.children);
|
||||
// Input untouched (no side effects on the source VariableOption).
|
||||
expect(input).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createNodeAndMaybeReparent, resolveAddNodeDecision } from '../addNodeController';
|
||||
|
||||
describe('resolveAddNodeDecision', () => {
|
||||
const translateTitle = (title: string) => title;
|
||||
|
||||
it('returns modern-preset for loader-based nodes and carries downstream presence', () => {
|
||||
const instruction = {
|
||||
type: 'condition',
|
||||
title: 'Condition',
|
||||
createDefaultConfig: () => ({ rejectOnFalse: true }),
|
||||
PresetFieldsetLoader: async () => ({ default: () => null }),
|
||||
} as any;
|
||||
|
||||
const decision = resolveAddNodeDecision({
|
||||
type: 'condition',
|
||||
anchor: { upstream: { id: 1 }, branchIndex: 0 },
|
||||
runtime: {
|
||||
workflow: { id: 1 },
|
||||
nodes: [{ id: 2, upstreamId: 1, branchIndex: 0 }],
|
||||
getInstruction: () => instruction,
|
||||
translateTitle,
|
||||
},
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
kind: 'modern-preset',
|
||||
instruction,
|
||||
hasDownstream: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns branch-fallback for branching nodes without preset loaders', () => {
|
||||
const instruction = {
|
||||
type: 'multi-conditions',
|
||||
title: 'Multi conditions',
|
||||
createDefaultConfig: () => ({ conditions: [{ uid: '1' }] }),
|
||||
branching: [{ label: 'First condition', value: 1 }],
|
||||
} as any;
|
||||
|
||||
const decision = resolveAddNodeDecision({
|
||||
type: 'multi-conditions',
|
||||
anchor: { upstream: { id: 1 }, branchIndex: 0 },
|
||||
runtime: {
|
||||
workflow: { id: 1 },
|
||||
nodes: [{ id: 2, upstreamId: 1, branchIndex: 0 }],
|
||||
getInstruction: () => instruction,
|
||||
translateTitle,
|
||||
},
|
||||
});
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
kind: 'branch-fallback',
|
||||
instruction,
|
||||
draft: {
|
||||
type: 'multi-conditions',
|
||||
upstreamId: 1,
|
||||
branchIndex: 0,
|
||||
title: 'Multi conditions',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns blocked when runtime availability says no', () => {
|
||||
const instruction = {
|
||||
type: 'async-node',
|
||||
title: 'Async node',
|
||||
createDefaultConfig: () => ({}),
|
||||
} as any;
|
||||
|
||||
const decision = resolveAddNodeDecision({
|
||||
type: 'async-node',
|
||||
anchor: { upstream: { id: 1 }, branchIndex: 0, branchContext: { syncOnly: true } },
|
||||
runtime: {
|
||||
workflow: { id: 1 },
|
||||
nodes: [],
|
||||
getInstruction: () => instruction,
|
||||
getInstructionAvailable: () => 'This branch does not support asynchronous nodes.',
|
||||
translateTitle,
|
||||
},
|
||||
});
|
||||
|
||||
expect(decision).toEqual({
|
||||
kind: 'blocked',
|
||||
message: 'This branch does not support asynchronous nodes.',
|
||||
instruction,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNodeAndMaybeReparent', () => {
|
||||
it('re-parents downstream only when downstreamBranchIndex is numeric', async () => {
|
||||
const create = vi.fn().mockResolvedValue({ data: { data: { id: 10, downstreamId: 20 } } });
|
||||
const update = vi.fn().mockResolvedValue({});
|
||||
const refresh = vi.fn();
|
||||
const api = {
|
||||
resource: (name: string) => {
|
||||
if (name === 'workflows.nodes') {
|
||||
return { create };
|
||||
}
|
||||
if (name === 'flow_nodes') {
|
||||
return { update };
|
||||
}
|
||||
throw new Error(`unexpected resource ${name}`);
|
||||
},
|
||||
};
|
||||
|
||||
await createNodeAndMaybeReparent({
|
||||
workflowId: 1,
|
||||
api,
|
||||
refresh,
|
||||
values: { type: 'condition' },
|
||||
downstreamBranchIndex: 0,
|
||||
});
|
||||
|
||||
expect(create).toHaveBeenCalledWith({ values: { type: 'condition' } });
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
filterByTk: 20,
|
||||
values: {
|
||||
branchIndex: 0,
|
||||
upstream: { id: 10, downstreamId: null },
|
||||
},
|
||||
updateAssociationValues: ['upstream'],
|
||||
});
|
||||
expect(refresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* client-v2 copy of the getCollectionFieldOptions golden baseline (ADR-0003,
|
||||
* migration doc §9.8). Feeds the SAME injected mocks as the v1 characterization
|
||||
* test against the relocated source, proving the move caused zero drift and
|
||||
* pinning the contract the v2 aggregator + adapter build on.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { getCollectionFieldOptions } from '../collectionFieldOptions';
|
||||
|
||||
// Same `compile` contract both v1 `useCompile` and v2 `useT` must satisfy: expand `{{t("…")}}` → translation, pass
|
||||
// plain strings through.
|
||||
const compile = (source: unknown) => {
|
||||
if (typeof source !== 'string') {
|
||||
return source;
|
||||
}
|
||||
const m = source.match(/^\{\{\s*t\(["'](.+?)["'].*\)\s*\}\}$/);
|
||||
return m ? m[1] : source;
|
||||
};
|
||||
|
||||
type MockField = {
|
||||
name: string;
|
||||
type: string;
|
||||
interface?: string;
|
||||
uiSchema?: { title?: string };
|
||||
target?: string;
|
||||
targetKey?: string;
|
||||
foreignKey?: string;
|
||||
isForeignKey?: boolean;
|
||||
primaryKey?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
function makeCollectionManager(collections: Record<string, MockField[]>) {
|
||||
return {
|
||||
getCollectionAllFields: vi.fn((collection: string) => collections[collection] ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
const postFields: MockField[] = [
|
||||
{ name: 'id', type: 'bigInt', interface: 'integer', uiSchema: { title: 'ID' }, primaryKey: true },
|
||||
{ name: 'title', type: 'string', interface: 'input', uiSchema: { title: '{{t("Title")}}' } },
|
||||
{ name: 'count', type: 'integer', interface: 'integer', uiSchema: { title: 'Count' } },
|
||||
{ name: 'secret', type: 'string', interface: 'input', uiSchema: { title: 'Secret' }, hidden: true },
|
||||
{ name: 'noIface', type: 'string', uiSchema: { title: 'NoIface' } },
|
||||
];
|
||||
|
||||
const postWithRelationFields: MockField[] = [
|
||||
{ name: 'id', type: 'bigInt', interface: 'integer', uiSchema: { title: 'ID' }, primaryKey: true },
|
||||
{ name: 'title', type: 'string', interface: 'input', uiSchema: { title: 'Title' } },
|
||||
{
|
||||
name: 'author',
|
||||
type: 'belongsTo',
|
||||
interface: 'm2o',
|
||||
target: 'users',
|
||||
targetKey: 'id',
|
||||
foreignKey: 'authorId',
|
||||
uiSchema: { title: 'Author' },
|
||||
},
|
||||
{
|
||||
name: 'authorId',
|
||||
type: 'bigInt',
|
||||
interface: 'integer',
|
||||
foreignKey: 'authorId',
|
||||
isForeignKey: true,
|
||||
uiSchema: { title: 'Author ID' },
|
||||
},
|
||||
];
|
||||
|
||||
const userFields: MockField[] = [
|
||||
{ name: 'id', type: 'bigInt', interface: 'integer', uiSchema: { title: 'ID' }, primaryKey: true },
|
||||
{ name: 'nickname', type: 'string', interface: 'input', uiSchema: { title: 'Nickname' } },
|
||||
];
|
||||
|
||||
describe('getCollectionFieldOptions (client-v2)', () => {
|
||||
it('maps scalar fields and drops hidden / no-interface fields', () => {
|
||||
const collectionManager = makeCollectionManager({ posts: postFields });
|
||||
const result = getCollectionFieldOptions({ collection: 'posts', compile, collectionManager });
|
||||
expect(result.map((o) => o.value)).toEqual(['id', 'title', 'count']);
|
||||
const title = result.find((o) => o.value === 'title');
|
||||
expect(title?.label).toBe('Title');
|
||||
expect(title?.isLeaf).toBe(true);
|
||||
expect(title?.loadChildren).toBeNull();
|
||||
});
|
||||
|
||||
it('un-appended association dropped, FK surfaced (belongsTo splicing)', () => {
|
||||
const collectionManager = makeCollectionManager({ posts: postWithRelationFields, users: userFields });
|
||||
const result = getCollectionFieldOptions({ collection: 'posts', compile, collectionManager });
|
||||
expect(result.map((o) => o.value)).toEqual(['id', 'title', 'authorId']);
|
||||
});
|
||||
|
||||
it('appended association is last, non-leaf, and loadChildren resolves its fields', () => {
|
||||
const collectionManager = makeCollectionManager({ posts: postWithRelationFields, users: userFields });
|
||||
const result = getCollectionFieldOptions({ collection: 'posts', appends: ['author'], compile, collectionManager });
|
||||
expect(result.map((o) => o.value)).toEqual(['id', 'title', 'authorId', 'author']);
|
||||
const author = result.find((o) => o.value === 'author');
|
||||
expect(author?.isLeaf).toBe(false);
|
||||
author?.loadChildren?.(author);
|
||||
expect((author?.children ?? []).map((c: any) => c.value)).toEqual(['id', 'nickname']);
|
||||
});
|
||||
|
||||
it('respects fieldNames overrides and type filtering and pre-supplied fields', () => {
|
||||
const cmFieldNames = makeCollectionManager({ posts: postFields });
|
||||
const renamed = getCollectionFieldOptions({
|
||||
collection: 'posts',
|
||||
compile,
|
||||
collectionManager: cmFieldNames,
|
||||
fieldNames: { label: 'title', value: 'name', children: 'options' },
|
||||
});
|
||||
expect(renamed.find((o) => o.name === 'title')?.title).toBe('Title');
|
||||
|
||||
const cmTypes = makeCollectionManager({ posts: postFields });
|
||||
const numbersOnly = getCollectionFieldOptions({
|
||||
collection: 'posts',
|
||||
types: ['number'],
|
||||
compile,
|
||||
collectionManager: cmTypes,
|
||||
});
|
||||
expect(numbersOnly.map((o) => o.value).sort()).toEqual(['count', 'id']);
|
||||
|
||||
const cmEmpty = makeCollectionManager({});
|
||||
const preSupplied = getCollectionFieldOptions({
|
||||
fields: [{ name: 'x', type: 'string', interface: 'input', uiSchema: { title: 'X' } }],
|
||||
compile,
|
||||
collectionManager: cmEmpty,
|
||||
});
|
||||
expect(preSupplied.map((o) => o.value)).toEqual(['x']);
|
||||
expect(cmEmpty.getCollectionAllFields).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('parses dataSource-qualified collection names (inlined parseCollectionName)', () => {
|
||||
const collectionManager = makeCollectionManager({ roles: userFields });
|
||||
getCollectionFieldOptions({ collection: 'main:roles', compile, collectionManager });
|
||||
expect(collectionManager.getCollectionAllFields).toHaveBeenCalledWith('roles');
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* client-v2 copy of the drop-impact pure-walk baseline (ADR-0003, doc §9.8).
|
||||
* Same cases as the v1 characterization test against the relocated source,
|
||||
* proving the move caused zero drift. Shared by both canvases' drag/clipboard
|
||||
* Providers.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { collectDownstreams, collectBranchSubtree } from '../dropImpact';
|
||||
|
||||
// condition node (1) with two branches, then node 2.
|
||||
// 1 ─┬─ branch(idx1): 10 → 11
|
||||
// └─ branch(idx0): 20
|
||||
// 1 → 2 (main downstream)
|
||||
function makeGraph() {
|
||||
const n1: any = { id: 1, branchIndex: null, upstreamId: null };
|
||||
const n2: any = { id: 2, branchIndex: null, upstreamId: null };
|
||||
const n10: any = { id: 10, branchIndex: 1, upstreamId: 1 };
|
||||
const n11: any = { id: 11, branchIndex: null, upstreamId: null };
|
||||
const n20: any = { id: 20, branchIndex: 0, upstreamId: 1 };
|
||||
n1.downstream = n2;
|
||||
n10.downstream = n11;
|
||||
const branchChildrenMap = new Map<number, any[]>([[1, [n10, n20]]]);
|
||||
return { n1, n2, n10, n11, n20, branchChildrenMap };
|
||||
}
|
||||
|
||||
describe('collectDownstreams (client-v2)', () => {
|
||||
it('walks the main chain and recurses into every branch subtree', () => {
|
||||
const { n1, branchChildrenMap } = makeGraph();
|
||||
expect([...collectDownstreams(n1, branchChildrenMap)].sort((a, b) => a - b)).toEqual([1, 2, 10, 11, 20]);
|
||||
});
|
||||
|
||||
it('null start → empty; mid-branch start walks only that branch', () => {
|
||||
const { n10, branchChildrenMap } = makeGraph();
|
||||
expect(collectDownstreams(null, branchChildrenMap).size).toBe(0);
|
||||
expect([...collectDownstreams(n10, branchChildrenMap)].sort((a, b) => a - b)).toEqual([10, 11]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectBranchSubtree (client-v2)', () => {
|
||||
it('collects root + branch subtrees, excluding the main downstream', () => {
|
||||
const { n1, branchChildrenMap } = makeGraph();
|
||||
expect([...collectBranchSubtree(n1, branchChildrenMap)].sort((a, b) => a - b)).toEqual([1, 10, 11, 20]);
|
||||
});
|
||||
|
||||
it('a node with no branches yields just itself; null root → empty', () => {
|
||||
const { n11, branchChildrenMap } = makeGraph();
|
||||
expect([...collectBranchSubtree(n11, branchChildrenMap)]).toEqual([11]);
|
||||
expect(collectBranchSubtree(null, branchChildrenMap).size).toBe(0);
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pins the legacy-canvas render dispatch across all three migratable surfaces
|
||||
* (ADR-0003). The rule is uniform and v1-first: a legacy artifact (`Component` /
|
||||
* `fieldset` / `presetFieldset`) is the opt-out signal that keeps that one surface
|
||||
* on Formily; only dropping it lets the inherited loader switch that surface to
|
||||
* v2. The surfaces dispatch independently, so a node can migrate its card, drawer,
|
||||
* and preset one at a time.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
nodeTypeClassName,
|
||||
resolveLegacyNodeRenderMode,
|
||||
resolveLegacyConfigRenderMode,
|
||||
resolveLegacyPresetRenderMode,
|
||||
} from '../nodeRenderDispatch';
|
||||
|
||||
const loader = () => Promise.resolve({ default: () => null });
|
||||
|
||||
describe('resolveLegacyNodeRenderMode', () => {
|
||||
it('prefers the legacy Component when present (opt-out of v2 card)', () => {
|
||||
const Component = () => null;
|
||||
// Even with an inherited loader, an own Component keeps the node on Formily.
|
||||
expect(resolveLegacyNodeRenderMode({ Component, ComponentLoader: loader })).toBe('legacy-component');
|
||||
expect(resolveLegacyNodeRenderMode({ Component })).toBe('legacy-component');
|
||||
});
|
||||
|
||||
it('renders via ComponentLoader when there is no Component (full v2 card)', () => {
|
||||
expect(resolveLegacyNodeRenderMode({ ComponentLoader: loader })).toBe('modern-loader');
|
||||
});
|
||||
|
||||
it('falls back to the default card when neither renderer is defined', () => {
|
||||
expect(resolveLegacyNodeRenderMode({})).toBe('default-card');
|
||||
expect(resolveLegacyNodeRenderMode(undefined)).toBe('default-card');
|
||||
});
|
||||
|
||||
it('ignores non-function values (defensive against bad registry data)', () => {
|
||||
expect(resolveLegacyNodeRenderMode({ Component: true as unknown })).toBe('default-card');
|
||||
expect(resolveLegacyNodeRenderMode({ ComponentLoader: {} as unknown })).toBe('default-card');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLegacyConfigRenderMode', () => {
|
||||
it('prefers the legacy fieldset when it has entries (opt-out of v2 drawer)', () => {
|
||||
const fieldset = { engine: { type: 'string' } };
|
||||
// Even with an inherited loader, a non-empty own fieldset keeps the Formily drawer.
|
||||
expect(resolveLegacyConfigRenderMode({ fieldset, FieldsetLoader: loader })).toBe('legacy-fieldset');
|
||||
expect(resolveLegacyConfigRenderMode({ fieldset })).toBe('legacy-fieldset');
|
||||
});
|
||||
|
||||
it('treats an inherited-but-empty fieldset as absent (a dropped fieldset → v2)', () => {
|
||||
// The node dropped its fieldset but still inherits `{}` from the base class.
|
||||
expect(resolveLegacyConfigRenderMode({ fieldset: {}, FieldsetLoader: loader })).toBe('modern-loader');
|
||||
});
|
||||
|
||||
it('renders via FieldsetLoader when there is no legacy fieldset (v2 drawer)', () => {
|
||||
expect(resolveLegacyConfigRenderMode({ FieldsetLoader: loader })).toBe('modern-loader');
|
||||
});
|
||||
|
||||
it('returns none when neither side configures the node', () => {
|
||||
expect(resolveLegacyConfigRenderMode({})).toBe('none');
|
||||
expect(resolveLegacyConfigRenderMode({ fieldset: {} })).toBe('none');
|
||||
expect(resolveLegacyConfigRenderMode(undefined)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLegacyPresetRenderMode', () => {
|
||||
it('prefers the legacy presetFieldset when it has entries (opt-out of v2 preset dialog)', () => {
|
||||
const presetFieldset = { rejectOnFalse: { type: 'boolean' } };
|
||||
expect(resolveLegacyPresetRenderMode({ presetFieldset, PresetFieldsetLoader: loader })).toBe('legacy-fieldset');
|
||||
expect(resolveLegacyPresetRenderMode({ presetFieldset })).toBe('legacy-fieldset');
|
||||
});
|
||||
|
||||
it('treats an inherited-but-empty presetFieldset as absent (a dropped preset → v2)', () => {
|
||||
expect(resolveLegacyPresetRenderMode({ presetFieldset: {}, PresetFieldsetLoader: loader })).toBe('modern-loader');
|
||||
});
|
||||
|
||||
it('renders via PresetFieldsetLoader when there is no legacy presetFieldset', () => {
|
||||
expect(resolveLegacyPresetRenderMode({ PresetFieldsetLoader: loader })).toBe('modern-loader');
|
||||
});
|
||||
|
||||
it('returns none when neither side defines a preset (caller applies branch fallback)', () => {
|
||||
expect(resolveLegacyPresetRenderMode({})).toBe('none');
|
||||
expect(resolveLegacyPresetRenderMode(undefined)).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('nodeTypeClassName', () => {
|
||||
it('produces the stable `workflow-node-type-<type>` card hook (matches the live next DOM)', () => {
|
||||
expect(nodeTypeClassName('calculation')).toBe('workflow-node-type-calculation');
|
||||
expect(nodeTypeClassName('condition')).toBe('workflow-node-type-condition');
|
||||
});
|
||||
});
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* client-v2 copy of the linkNodes + nodeVariableUtils golden baseline (ADR-0003,
|
||||
* migration doc §9.8). Runs the SAME cases as the v1 characterization tests
|
||||
* against the relocated source, proving the move caused zero behavioral drift.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { linkNodes } from '../nodeTree';
|
||||
import { extractDependencyKeys, stripVariableReferences, collectUpstreams } from '../nodeVariableUtils';
|
||||
|
||||
describe('extractDependencyKeys (client-v2)', () => {
|
||||
it('collects node keys referenced via $jobsMapByNodeKey.<key>.<field>', () => {
|
||||
const keys = extractDependencyKeys({
|
||||
a: '{{$jobsMapByNodeKey.node1.title}}',
|
||||
nested: { b: '{{$jobsMapByNodeKey.node2.data.id}}' },
|
||||
});
|
||||
expect([...keys].sort()).toEqual(['node1', 'node2']);
|
||||
});
|
||||
|
||||
it('collects node keys referenced via $scopes.<key>', () => {
|
||||
expect([...extractDependencyKeys({ a: '{{$scopes.loop1.item}}' })]).toEqual(['loop1']);
|
||||
});
|
||||
|
||||
it('ignores non-node references', () => {
|
||||
expect(extractDependencyKeys({ a: '{{$context.data}}', b: 'plain', c: 42 }).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripVariableReferences (client-v2)', () => {
|
||||
it('removes a matching reference and nulls a string that becomes empty', () => {
|
||||
const result = stripVariableReferences('{{$jobsMapByNodeKey.node1.title}}', new Set(['node1']));
|
||||
expect(result).toEqual({ value: null, changed: true });
|
||||
});
|
||||
|
||||
it('keeps a non-matching reference untouched (same identity)', () => {
|
||||
const input = '{{$jobsMapByNodeKey.other.title}}';
|
||||
const result = stripVariableReferences(input, new Set(['node1']));
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.value).toBe(input);
|
||||
});
|
||||
|
||||
it('strips only the matching reference inside a mixed string', () => {
|
||||
const result = stripVariableReferences(
|
||||
'x {{$jobsMapByNodeKey.node1.a}} y {{$jobsMapByNodeKey.keep.b}} z',
|
||||
new Set(['node1']),
|
||||
);
|
||||
expect(result.value).toBe('x y {{$jobsMapByNodeKey.keep.b}} z');
|
||||
});
|
||||
|
||||
it('recurses into arrays and objects, preserving identity when nothing changes', () => {
|
||||
const changed = stripVariableReferences(
|
||||
{ list: ['{{$jobsMapByNodeKey.node1.a}}', 'plain'], n: 1 },
|
||||
new Set(['node1']),
|
||||
);
|
||||
expect(changed.value).toEqual({ list: [null, 'plain'], n: 1 });
|
||||
|
||||
const unchanged = { keep: '{{$jobsMapByNodeKey.other.a}}' };
|
||||
const r2 = stripVariableReferences(unchanged, new Set(['node1']));
|
||||
expect(r2.changed).toBe(false);
|
||||
expect(r2.value).toBe(unchanged);
|
||||
});
|
||||
});
|
||||
|
||||
describe('linkNodes + collectUpstreams (client-v2)', () => {
|
||||
it('wires upstream/downstream refs and walks the upstream chain inclusively', () => {
|
||||
const nodes: any[] = [
|
||||
{ id: 1, upstreamId: null, downstreamId: 2 },
|
||||
{ id: 2, upstreamId: 1, downstreamId: 3 },
|
||||
{ id: 3, upstreamId: 2, downstreamId: null },
|
||||
];
|
||||
linkNodes(nodes);
|
||||
expect(nodes[0].downstream).toBe(nodes[1]);
|
||||
expect(nodes[2].upstream).toBe(nodes[1]);
|
||||
expect([...collectUpstreams(nodes[2])].sort()).toEqual([1, 2, 3]);
|
||||
expect([...collectUpstreams(nodes[0])]).toEqual([1]);
|
||||
});
|
||||
});
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Proves the variable aggregator is **runtime-neutral** (ADR-0003 layer 1): it
|
||||
* resolves the workflow plugin via the neutral `'workflow'` alias and reads the
|
||||
* `instructions` / `systemVariables` registries that BOTH the v1
|
||||
* (`PluginWorkflowClient`) and v2 (`PluginWorkflowClientV2`) plugins expose — so
|
||||
* the same hook feeds the variable tree when the v1 canvas back-imports it.
|
||||
*
|
||||
* The hook previously hard-coded `pm.get(PluginWorkflowClientV2)`, which is
|
||||
* `undefined` in the v1 runtime → empty tree. These tests feed a v1-SHAPED plugin
|
||||
* (including a v1-style system variable whose label is already-rendered JSX, not a
|
||||
* `{{t}}` template) and assert the `$jobsMapByNodeKey` + `$system` scopes are
|
||||
* produced.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Controlled doubles, hoisted so the vi.mock factories can close over them.
|
||||
const holder = vi.hoisted(() => ({
|
||||
engine: null as any,
|
||||
currentNode: null as any,
|
||||
workflow: null as any,
|
||||
}));
|
||||
|
||||
// The aggregator reads the engine via `useFlowEngine()`; everything it needs (`context.app.pm.get`, `context.t`,
|
||||
// `context.getPropertyMetaTree`, `context.app.getGlobalVar`) hangs off the engine we inject here. Keep the rest of
|
||||
// flow-engine real (MetaTreeNode etc).
|
||||
vi.mock('@nocobase/flow-engine', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return { ...actual, useFlowEngine: () => holder.engine };
|
||||
});
|
||||
|
||||
// `useNodeContext()` returns the current node (with a live `upstream` chain); `useCurrentWorkflowContext()` returns the
|
||||
// workflow threaded into the drawer. `useAvailableUpstreams` / `useUpstreamScopes` are the real pure traversals.
|
||||
vi.mock('../contexts', async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useNodeContext: () => holder.currentNode,
|
||||
useCurrentWorkflowContext: () => holder.workflow,
|
||||
};
|
||||
});
|
||||
|
||||
import { useWorkflowVariableOptions } from '../useWorkflowVariableOptions';
|
||||
|
||||
/**
|
||||
* Build a v1-shaped plugin: `instructions` + `systemVariables` + `triggers`
|
||||
* Registry-likes. `triggers.get(type)` returns a trigger with a `useVariables`
|
||||
* (v1 shape — v2 triggers have none).
|
||||
*/
|
||||
function makeV1ShapedPlugin() {
|
||||
const instructions = new Map<string, any>();
|
||||
// An upstream "calculation" node that contributes a single result variable — its `useVariables` returns the legacy
|
||||
// `VariableOption` shape the adapter eats.
|
||||
instructions.set('calculation', {
|
||||
useVariables: (node: any) => ({
|
||||
value: node.key,
|
||||
label: node.title,
|
||||
children: [{ value: 'result', label: 'Result' }],
|
||||
}),
|
||||
});
|
||||
|
||||
// v1-style system variables: `now` is a plain string label; `instanceId` carries an already-rendered JSX label (with
|
||||
// tooltip inlined) — NOT a `{{t}}` template.
|
||||
const systemVariables = [
|
||||
{ key: 'now', label: 'System time', value: 'now' },
|
||||
{ key: 'instanceId', label: <span data-testid="v1-jsx-label">Instance ID</span>, value: 'instanceId' },
|
||||
];
|
||||
|
||||
// A v1-style trigger: `useVariables(config, options)` returns a VariableOption[] (the trigger's `data` output tree).
|
||||
const triggers = new Map<string, any>();
|
||||
triggers.set('collection', {
|
||||
useVariables: () => [{ value: 'data', label: 'Trigger data', children: [{ value: 'title', label: 'Title' }] }],
|
||||
});
|
||||
|
||||
return {
|
||||
instructions: { get: (type: string) => instructions.get(type) },
|
||||
systemVariables: { getValues: () => systemVariables },
|
||||
triggers: { get: (type: string) => triggers.get(type) },
|
||||
};
|
||||
}
|
||||
|
||||
function setupEngine(plugin: any, { propertyTree = [] as any[] } = {}) {
|
||||
holder.engine = {
|
||||
context: {
|
||||
t: (key: string) => key,
|
||||
getPropertyMetaTree: () => propertyTree,
|
||||
app: {
|
||||
pm: { get: (name: string) => (name === 'workflow' ? plugin : undefined) },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useWorkflowVariableOptions — runtime-neutral resolution', () => {
|
||||
beforeEach(() => {
|
||||
holder.workflow = null;
|
||||
});
|
||||
|
||||
it('resolves the workflow plugin via the neutral "workflow" alias and reads its registries', () => {
|
||||
setupEngine(makeV1ShapedPlugin());
|
||||
holder.currentNode = {
|
||||
key: 'n2',
|
||||
type: 'condition',
|
||||
upstream: { key: 'n1', type: 'calculation', title: 'Calc 1', upstream: null },
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
const roots = result.current.map((node) => node.name);
|
||||
|
||||
// Node-result scope: upstream calculation node hangs under $jobsMapByNodeKey.
|
||||
expect(roots).toContain('$jobsMapByNodeKey');
|
||||
const nodeResult = result.current.find((n) => n.name === '$jobsMapByNodeKey');
|
||||
expect(nodeResult?.children?.map((c: any) => c.name)).toContain('n1');
|
||||
|
||||
// System scope: from the v1-shaped systemVariables registry.
|
||||
expect(roots).toContain('$system');
|
||||
const system = result.current.find((n) => n.name === '$system');
|
||||
expect(system?.children?.map((c: any) => c.name)).toEqual(['now', 'instanceId']);
|
||||
});
|
||||
|
||||
it('lights up the trigger scope ($context) from the workflow trigger useVariables', () => {
|
||||
setupEngine(makeV1ShapedPlugin());
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
// Workflow threaded into the drawer via CurrentWorkflowContext.
|
||||
holder.workflow = { id: 7, type: 'collection', config: { collection: 'posts' } };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
const trigger = result.current.find((n) => n.name === '$context');
|
||||
expect(trigger).toBeTruthy();
|
||||
// The trigger's `data` output sits under $context, its fields beneath.
|
||||
expect(trigger?.children?.map((c: any) => c.name)).toContain('data');
|
||||
});
|
||||
|
||||
it('omits the trigger scope when no workflow is in context (drawer without workflow)', () => {
|
||||
setupEngine(makeV1ShapedPlugin());
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
holder.workflow = null;
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
expect(result.current.find((n) => n.name === '$context')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('exposes $env from the flow-engine property tree (registered in both runtimes)', () => {
|
||||
// The env plugin registers `$env` on `flowEngine.context` in BOTH runtimes (v1 via the shared
|
||||
// `registerEnvProperty`, v2 in its own plugin) — a lazy `children` thunk that resolves once and is cached. The
|
||||
// aggregator returns that node as-is, so it works from the detached config drawer.
|
||||
const envChildren = async () => [];
|
||||
setupEngine(makeV1ShapedPlugin(), {
|
||||
propertyTree: [{ name: '$env', type: 'object', children: envChildren }],
|
||||
});
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
const env = result.current.find((n) => n.name === '$env');
|
||||
// Returned verbatim (lazy children preserved for on-expand resolution).
|
||||
expect(env?.children).toBe(envChildren);
|
||||
});
|
||||
|
||||
it('omits $env when it is absent from the property tree (env plugin not loaded)', () => {
|
||||
setupEngine(makeV1ShapedPlugin(), { propertyTree: [] });
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
expect(result.current.find((n) => n.name === '$env')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns a referentially stable tree across re-renders for the same inputs', () => {
|
||||
setupEngine(makeV1ShapedPlugin());
|
||||
holder.currentNode = {
|
||||
key: 'n2',
|
||||
type: 'condition',
|
||||
upstream: { key: 'n1', type: 'calculation', title: 'Calc 1', upstream: null },
|
||||
};
|
||||
|
||||
const { result, rerender } = renderHook(() => useWorkflowVariableOptions());
|
||||
const first = result.current;
|
||||
rerender();
|
||||
// Same tree object — load-bearing so lazily-resolved relation children (mutated onto the meta nodes by the picker)
|
||||
// survive a re-render instead of being discarded (the infinite-spinner bug).
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
it('returns an empty tree when no workflow plugin is registered (no crash)', () => {
|
||||
// The pre-fix failure mode: `pm.get` returns undefined in the "wrong" runtime.
|
||||
setupEngine(undefined);
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
// No upstreams, no system vars, no env → empty (not a throw).
|
||||
expect(result.current).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces a v1 JSX system-variable label to plain-text title (MetaTreeNode.title is a string)', () => {
|
||||
setupEngine(makeV1ShapedPlugin());
|
||||
holder.currentNode = { key: 'n1', type: 'condition', upstream: null };
|
||||
|
||||
const { result } = renderHook(() => useWorkflowVariableOptions());
|
||||
const system = result.current.find((n) => n.name === '$system');
|
||||
const instanceId = system?.children?.find((c: any) => c.name === 'instanceId');
|
||||
// `MetaTreeNode.title` is a string, so the v1 `<span>Instance ID</span>` label is reduced to its text ("Instance
|
||||
// ID"), not kept as a React element.
|
||||
expect(typeof instanceId?.title).toBe('string');
|
||||
expect(instanceId?.title).toBe('Instance ID');
|
||||
// A plain-string v2-style label is translated through `t` (identity in tests).
|
||||
const now = system?.children?.find((c: any) => c.name === 'now');
|
||||
expect(now?.title).toBe('System time');
|
||||
});
|
||||
});
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The WorkflowVariableInput converters bridge a MetaTreeNode's `paths` to the
|
||||
* workflow server-template form `{{$jobsMapByNodeKey.<nodeKey>.<field>}}`. This
|
||||
* pins the format/parse round-trip against the adapter-built paths, the
|
||||
* value-shape the server consumes.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { adaptVariableOptionToMetaTree } from '../adaptVariableOptionToMetaTree';
|
||||
|
||||
// Re-declare the converters here as a pure-logic mirror (the component wires the same functions into
|
||||
// VariableHybridInput). Keeping them inline avoids importing the .tsx component (and React) into a pure logic test.
|
||||
const formatPathToValue = (item: { paths?: string[] }) => {
|
||||
const path = item?.paths ?? [];
|
||||
return path.length ? `{{${path.join('.')}}}` : '';
|
||||
};
|
||||
const parseValueToPath = (value?: string) => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const match = value.trim().match(/^\{\{\s*(.+?)\s*\}\}$/);
|
||||
return match ? match[1].split('.') : undefined;
|
||||
};
|
||||
|
||||
describe('workflow variable converters', () => {
|
||||
it('formats an adapter-built leaf path to the $jobsMapByNodeKey template', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'title', children: null }, ['$jobsMapByNodeKey', 'node1']);
|
||||
expect(node.paths).toEqual(['$jobsMapByNodeKey', 'node1', 'title']);
|
||||
expect(formatPathToValue(node)).toBe('{{$jobsMapByNodeKey.node1.title}}');
|
||||
});
|
||||
|
||||
it('round-trips: format → parse === the leaf paths', () => {
|
||||
const node = adaptVariableOptionToMetaTree({ value: 'field' }, ['$jobsMapByNodeKey', 'nodeX']);
|
||||
const value = formatPathToValue(node);
|
||||
expect(parseValueToPath(value)).toEqual(node.paths);
|
||||
});
|
||||
|
||||
it('parse tolerates inner whitespace and returns undefined for non-variable strings', () => {
|
||||
expect(parseValueToPath('{{ $jobsMapByNodeKey.n.f }}')).toEqual(['$jobsMapByNodeKey', 'n', 'f']);
|
||||
expect(parseValueToPath('plain text')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single, core, one-way adapter `VariableOption → MetaTreeNode` (ADR-0003,
|
||||
* migration doc §6). The modern canvas reuses the mature legacy field-tree
|
||||
* logic (`getCollectionFieldOptions`, which returns `VariableOption`) unchanged,
|
||||
* and converts its aggregated upstream variables to the flow-engine
|
||||
* `MetaTreeNode` shape with this adapter just before feeding `VariableHybridInput`.
|
||||
*
|
||||
* Pure, side-effect-free, React-context-free — so it is independently unit
|
||||
* testable and the whole suite is deletable in one move when the legacy
|
||||
* field-tree logic is finally rewritten to produce `MetaTreeNode` natively.
|
||||
*
|
||||
* The modern consumers (`FlowContextSelector` cascader, `VariableHybridInput.walk`,
|
||||
* `VariableTag`) read exactly 7 `MetaTreeNode` fields; this adapter maps:
|
||||
* title ← label (reactNode preserved; walk plain-texts it)
|
||||
* name ← value (or label) direct, with name fallback
|
||||
* children ← children / loadChildren → () => Promise<MetaTreeNode[]>
|
||||
* disabled ← disabled direct
|
||||
* disabledReason ← (v1 has none) left undefined
|
||||
* type/interface ← field.type/.interface (only used by custom render)
|
||||
* paths ← (no v1 counterpart) constructed here, accumulated down
|
||||
* the recursion (incl. lazy children).
|
||||
*
|
||||
* The v1-only keys (`field`/`types`/`appends`/`depth`) are captured in the
|
||||
* `loadChildren` closure and never surface on the produced `MetaTreeNode`.
|
||||
*/
|
||||
|
||||
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
||||
import type { VariableOption } from './collectionFieldOptions';
|
||||
|
||||
type LoadChildren = (option: VariableOption) => void;
|
||||
|
||||
/**
|
||||
* @param option a single VariableOption (as produced by getCollectionFieldOptions
|
||||
* / useWorkflowVariableOptions).
|
||||
* @param parentPaths the path array accumulated from the root down to (but not
|
||||
* including) this node. Top-level callers pass `[]` (or a custom
|
||||
* root prefix, e.g. `['$jobsMapByNodeKey', nodeKey]`).
|
||||
*/
|
||||
export function adaptVariableOptionToMetaTree(option: VariableOption, parentPaths: string[] = []): MetaTreeNode {
|
||||
const name = String(option.value ?? option.label ?? '');
|
||||
const paths = [...parentPaths, name];
|
||||
|
||||
const node: MetaTreeNode = {
|
||||
name,
|
||||
title: (option.label as string) ?? name,
|
||||
type: option.field?.type ?? '',
|
||||
paths,
|
||||
};
|
||||
|
||||
if (option.field?.interface != null) {
|
||||
node.interface = option.field.interface;
|
||||
}
|
||||
if (option.disabled != null) {
|
||||
node.disabled = option.disabled;
|
||||
}
|
||||
|
||||
// children: a static array maps recursively; a v1 `loadChildren` thunk (which mutates the option in place) becomes a
|
||||
// flow-engine lazy `() => Promise<...>`.
|
||||
if (Array.isArray(option.children)) {
|
||||
node.children = option.children.map((child) => adaptVariableOptionToMetaTree(child, paths));
|
||||
} else if (typeof (option.loadChildren as LoadChildren | null | undefined) === 'function') {
|
||||
const loadChildren = option.loadChildren as LoadChildren;
|
||||
node.children = async () => {
|
||||
// v1 loadChildren mutates `option`: sets `option.children`, clears `option.loadChildren`, may set
|
||||
// `isLeaf`/`disabled`. The v1-only keys it reads (`field`/`types`/`appends`/`depth`) live on `option` — captured
|
||||
// by this closure, never copied onto the MetaTreeNode.
|
||||
loadChildren(option);
|
||||
const loaded = Array.isArray(option.children) ? option.children : [];
|
||||
return loaded.map((child) => adaptVariableOptionToMetaTree(child, paths));
|
||||
};
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt a list of top-level options (e.g. one aggregated variable scope) under
|
||||
* an optional root path prefix.
|
||||
*/
|
||||
export function adaptVariableOptionsToMetaTree(options: VariableOption[], rootPaths: string[] = []): MetaTreeNode[] {
|
||||
return options.map((option) => adaptVariableOptionToMetaTree(option, rootPaths));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { uid } from '@nocobase/utils/client';
|
||||
import { resolveLegacyPresetRenderMode } from './nodeRenderDispatch';
|
||||
import type { Instruction } from './Instruction';
|
||||
import type { SharedAddNodeAnchor } from './AddNodeContext.shared';
|
||||
|
||||
export type AddNodeControllerRuntime = {
|
||||
workflow: any;
|
||||
nodes: any[];
|
||||
getInstruction: (type: string) => Instruction | undefined;
|
||||
getInstructionAvailable?: (instruction: Instruction, context: Record<string, any>) => string | null;
|
||||
translateTitle: (title: string) => string;
|
||||
api: any;
|
||||
refresh?: () => void;
|
||||
};
|
||||
|
||||
export type AddNodeDraft = {
|
||||
key: string;
|
||||
type: string;
|
||||
upstreamId: any;
|
||||
branchIndex: number | null | undefined;
|
||||
title: string;
|
||||
config: Record<string, any>;
|
||||
};
|
||||
|
||||
export type AddNodeDecision =
|
||||
| { kind: 'missing'; type: string }
|
||||
| { kind: 'blocked'; message: string; instruction: Instruction }
|
||||
| { kind: 'legacy-preset'; instruction: Instruction; draft: AddNodeDraft }
|
||||
| { kind: 'modern-preset'; instruction: Instruction; anchor: SharedAddNodeAnchor; hasDownstream: boolean }
|
||||
| { kind: 'branch-fallback'; instruction: Instruction; draft: AddNodeDraft }
|
||||
| { kind: 'direct'; instruction: Instruction; draft: AddNodeDraft };
|
||||
|
||||
export function findDownstream(nodes: any[] = [], upstream?: any, branchIndex?: number | null) {
|
||||
const upstreamId = upstream?.id ?? null;
|
||||
return upstream?.id
|
||||
? nodes.find((item) => item.upstreamId === upstreamId && item.branchIndex === branchIndex)
|
||||
: nodes.find((item) => item.upstreamId == null);
|
||||
}
|
||||
|
||||
export function createNodeDraft({
|
||||
instruction,
|
||||
anchor,
|
||||
translateTitle,
|
||||
}: {
|
||||
instruction: Instruction;
|
||||
anchor: SharedAddNodeAnchor;
|
||||
translateTitle: (title: string) => string;
|
||||
}): AddNodeDraft {
|
||||
return {
|
||||
key: uid(),
|
||||
type: instruction.type,
|
||||
upstreamId: anchor.upstream?.id ?? null,
|
||||
branchIndex: anchor.branchIndex ?? null,
|
||||
title: translateTitle(instruction.title as string),
|
||||
config: instruction.createDefaultConfig?.() ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAddNodeDecision({
|
||||
type,
|
||||
anchor,
|
||||
runtime,
|
||||
}: {
|
||||
type: string;
|
||||
anchor: SharedAddNodeAnchor;
|
||||
runtime: Pick<
|
||||
AddNodeControllerRuntime,
|
||||
'workflow' | 'nodes' | 'getInstruction' | 'getInstructionAvailable' | 'translateTitle'
|
||||
>;
|
||||
}): AddNodeDecision {
|
||||
const instruction = runtime.getInstruction(type);
|
||||
if (!instruction) {
|
||||
return { kind: 'missing', type };
|
||||
}
|
||||
|
||||
const unavailableMessage = runtime.getInstructionAvailable?.(instruction, {
|
||||
engine: null,
|
||||
workflow: runtime.workflow,
|
||||
upstream: anchor.upstream,
|
||||
branchIndex: anchor.branchIndex ?? null,
|
||||
branchContext: anchor.branchContext ?? null,
|
||||
});
|
||||
|
||||
if (unavailableMessage) {
|
||||
return { kind: 'blocked', message: unavailableMessage, instruction };
|
||||
}
|
||||
|
||||
const draft = createNodeDraft({
|
||||
instruction,
|
||||
anchor,
|
||||
translateTitle: runtime.translateTitle,
|
||||
});
|
||||
const downstream = findDownstream(runtime.nodes, anchor.upstream, anchor.branchIndex ?? null);
|
||||
const presetMode = resolveLegacyPresetRenderMode(instruction);
|
||||
|
||||
if (presetMode === 'legacy-fieldset') {
|
||||
return { kind: 'legacy-preset', instruction, draft };
|
||||
}
|
||||
|
||||
if (presetMode === 'modern-loader') {
|
||||
return {
|
||||
kind: 'modern-preset',
|
||||
instruction,
|
||||
anchor: { upstream: anchor.upstream, branchIndex: anchor.branchIndex ?? null },
|
||||
hasDownstream: Boolean(downstream),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(typeof instruction.branching === 'function' ? instruction.branching(draft.config) : instruction.branching) &&
|
||||
downstream
|
||||
) {
|
||||
return { kind: 'branch-fallback', instruction, draft };
|
||||
}
|
||||
|
||||
return { kind: 'direct', instruction, draft };
|
||||
}
|
||||
|
||||
export async function createNodeAndMaybeReparent({
|
||||
workflowId,
|
||||
api,
|
||||
refresh,
|
||||
values,
|
||||
downstreamBranchIndex,
|
||||
}: {
|
||||
workflowId: any;
|
||||
api: any;
|
||||
refresh?: () => void;
|
||||
values: Record<string, any>;
|
||||
downstreamBranchIndex?: number | null;
|
||||
}) {
|
||||
const {
|
||||
data: { data: newNode },
|
||||
} = await api.resource('workflows.nodes', workflowId).create({ values });
|
||||
if (typeof downstreamBranchIndex === 'number' && newNode?.downstreamId) {
|
||||
await api.resource('flow_nodes').update({
|
||||
filterByTk: newNode.downstreamId,
|
||||
values: {
|
||||
branchIndex: downstreamBranchIndex,
|
||||
upstream: { id: newNode.id, downstreamId: null },
|
||||
},
|
||||
updateAssociationValues: ['upstream'],
|
||||
});
|
||||
}
|
||||
refresh?.();
|
||||
return newNode;
|
||||
}
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Field-tree builder shared by the legacy and modern canvases (ADR-0003,
|
||||
* migration doc §6/§9.7). Relocated verbatim from `src/client/variable.tsx` —
|
||||
* the ~250-line, bug-prone heart of the variable system (relation lazy-load,
|
||||
* type filtering, foreign-key splicing). It is deliberately NOT rewritten
|
||||
* during the dual-canvas migration; instead both canvases share this one copy.
|
||||
*
|
||||
* It is a pure function cluster: dependencies (`compile`, `collectionManager`)
|
||||
* are injected as parameters, so it carries no React hooks and no `ctx` read,
|
||||
* and works identically whether v1 injects `useCompile()` or v2 injects
|
||||
* `useT()`. v1 re-exports these from here; the v1 golden-baseline tests re-run
|
||||
* unchanged against this copy.
|
||||
*
|
||||
* It produces the legacy `VariableOption` shape (direction NOT reversed); the
|
||||
* modern canvas converts to `MetaTreeNode` with a separate adapter at the end.
|
||||
*
|
||||
* `parseCollectionName` is inlined (a byte-identical 8-line string split from
|
||||
* `@nocobase/data-source-manager`) to avoid pulling a new cross-package
|
||||
* dependency into client-v2 for a trivial helper; the `main:roles` baseline
|
||||
* test pins its behavior.
|
||||
*/
|
||||
|
||||
import { uniqBy } from 'lodash';
|
||||
import type React from 'react';
|
||||
|
||||
// Minimal structural type for the injected collection manager — only the one method the field-tree logic calls. Avoids
|
||||
// importing the concrete `CollectionManager` from `@nocobase/client` (iron rule).
|
||||
export type FieldTreeCollectionManager = {
|
||||
getCollectionAllFields(collection: string): any[];
|
||||
};
|
||||
|
||||
export type VariableOption = {
|
||||
key?: string;
|
||||
value?: string;
|
||||
label?: string | React.ReactNode;
|
||||
children?: VariableOption[] | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type VariableDataType =
|
||||
| 'boolean'
|
||||
| 'number'
|
||||
| 'string'
|
||||
| 'date'
|
||||
| {
|
||||
type: 'reference';
|
||||
options: {
|
||||
collection: string;
|
||||
multiple?: boolean;
|
||||
entity?: boolean;
|
||||
};
|
||||
}
|
||||
| ((field: any, options: { collectionManager?: FieldTreeCollectionManager }) => boolean);
|
||||
|
||||
export type UseVariableOptions = {
|
||||
types?: VariableDataType[];
|
||||
fieldNames?: {
|
||||
label?: string;
|
||||
value?: string;
|
||||
children?: string;
|
||||
};
|
||||
appends?: string[] | null;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export const defaultFieldNames = { label: 'label', value: 'value', children: 'children' } as const;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const BaseTypeSets = {
|
||||
boolean: new Set(['checkbox']),
|
||||
number: new Set(['integer', 'number', 'percent']),
|
||||
string: new Set(['input', 'password', 'email', 'phone', 'select', 'radioGroup', 'text', 'markdown', 'richText']),
|
||||
date: new Set(['datetime', 'datetimeNoTz', 'dateOnly', 'createdAt', 'updatedAt']),
|
||||
};
|
||||
|
||||
// Inlined from `@nocobase/data-source-manager` (byte-identical) — see file header.
|
||||
function parseCollectionName(collection: string) {
|
||||
if (!collection) {
|
||||
return [];
|
||||
}
|
||||
const dataSourceCollection = collection.split(':');
|
||||
const collectionName = dataSourceCollection.pop();
|
||||
const dataSourceName = dataSourceCollection[0] ?? 'main';
|
||||
return [dataSourceName, collectionName];
|
||||
}
|
||||
|
||||
function matchFieldType(
|
||||
field,
|
||||
type: VariableDataType,
|
||||
{ collectionManager }: { collectionManager?: FieldTreeCollectionManager },
|
||||
): boolean {
|
||||
if (typeof type === 'string') {
|
||||
return BaseTypeSets[type]?.has(field.interface);
|
||||
}
|
||||
|
||||
if (typeof type === 'object' && type.type === 'reference') {
|
||||
if (isAssociationField(field)) {
|
||||
return (
|
||||
type.options?.entity && (field.collectionName === type.options?.collection || type.options?.collection === '*')
|
||||
);
|
||||
} else if (field.isForeignKey) {
|
||||
return (
|
||||
(field.collectionName === type.options?.collection && field.name === 'id') ||
|
||||
field.target === type.options?.collection
|
||||
);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof type === 'function') {
|
||||
return type(field, { collectionManager });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAssociationField(field): boolean {
|
||||
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'].includes(field.type);
|
||||
}
|
||||
|
||||
function getNextAppends(field, appends: string[] | null): string[] | null {
|
||||
if (appends == null) {
|
||||
return null;
|
||||
}
|
||||
const fieldPrefix = `${field.name}.`;
|
||||
return appends.filter((item) => item.startsWith(fieldPrefix)).map((item) => item.replace(fieldPrefix, ''));
|
||||
}
|
||||
|
||||
function filterTypedFields({ fields, types, appends, depth = 1, compile, collectionManager }) {
|
||||
return fields.filter((field) => {
|
||||
const match = types?.length ? types.some((type) => matchFieldType(field, type, { collectionManager })) : true;
|
||||
if (isAssociationField(field)) {
|
||||
if (appends === null) {
|
||||
if (!depth) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
match ||
|
||||
filterTypedFields({
|
||||
fields: getNormalizedFields(field.target, { compile, collectionManager }),
|
||||
types,
|
||||
depth: depth - 1,
|
||||
appends,
|
||||
compile,
|
||||
collectionManager,
|
||||
})
|
||||
);
|
||||
}
|
||||
const nextAppends = getNextAppends(field, appends);
|
||||
const included = appends.includes(field.name);
|
||||
if (match) {
|
||||
return included;
|
||||
} else {
|
||||
return (
|
||||
(nextAppends?.length || included) &&
|
||||
filterTypedFields({
|
||||
fields: getNormalizedFields(field.target, { compile, collectionManager }),
|
||||
types,
|
||||
// depth: depth - 1,
|
||||
appends: nextAppends,
|
||||
compile,
|
||||
collectionManager,
|
||||
}).length
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getNormalizedFields(collectionName, { compile, collectionManager }) {
|
||||
// NOTE: for compatibility with legacy version
|
||||
const [, collection] = parseCollectionName(collectionName);
|
||||
// NOTE: `dataSourceName` will be ignored in new version
|
||||
const fields = collectionManager.getCollectionAllFields(collection);
|
||||
const fkFields: any[] = [];
|
||||
const result: any[] = [];
|
||||
fields.forEach((field) => {
|
||||
if (field.isForeignKey && !field.primaryKey) {
|
||||
fkFields.push(field);
|
||||
} else {
|
||||
const fkField = fields.find((f) => f.name === field.foreignKey);
|
||||
if (fkField) {
|
||||
fkFields.push(fkField);
|
||||
}
|
||||
result.push(field);
|
||||
}
|
||||
});
|
||||
const foreignKeyFields = uniqBy(fkFields, 'name');
|
||||
// NOTE: for all foreignKey fields
|
||||
for (let i = result.length - 1; i >= 0; i--) {
|
||||
const field = result[i];
|
||||
if (field.type === 'belongsTo') {
|
||||
const foreignKeyFieldIndex = foreignKeyFields.findIndex((f) => f.name === field.foreignKey);
|
||||
if (foreignKeyFieldIndex > -1) {
|
||||
const foreignKeyField = foreignKeyFields[foreignKeyFieldIndex];
|
||||
result.splice(i, 0, {
|
||||
...foreignKeyField,
|
||||
target: field.target,
|
||||
targetKey: field.targetKey,
|
||||
interface: foreignKeyField.interface ?? field.interface,
|
||||
isForeignKey: true,
|
||||
uiSchema: {
|
||||
...field.uiSchema,
|
||||
...foreignKeyField.uiSchema,
|
||||
title: foreignKeyField.uiSchema?.title ? compile(foreignKeyField.uiSchema?.title) : foreignKeyField.name,
|
||||
},
|
||||
});
|
||||
foreignKeyFields.splice(foreignKeyFieldIndex, 1);
|
||||
} else {
|
||||
result.splice(i, 0, {
|
||||
...field,
|
||||
name: field.foreignKey,
|
||||
type: 'bigInt',
|
||||
isForeignKey: true,
|
||||
interface: field.interface,
|
||||
uiSchema: {
|
||||
...field.uiSchema,
|
||||
title: field.uiSchema?.title ? `${compile(field.uiSchema?.title)} ID` : field.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (field.type === 'context' && field.collectionName === 'users') {
|
||||
result.splice(i, 1);
|
||||
}
|
||||
}
|
||||
result.push(...foreignKeyFields);
|
||||
|
||||
return uniqBy(result, 'name').filter((field) => field.interface && !field.hidden);
|
||||
}
|
||||
|
||||
function loadChildren(option) {
|
||||
const appends = getNextAppends(option.field, option.appends);
|
||||
const result = getCollectionFieldOptions({
|
||||
collection: `${
|
||||
option.field.dataSourceKey && option.field.dataSourceKey !== 'main' ? `${option.field.dataSourceKey}:` : ''
|
||||
}${option.field.target}`,
|
||||
types: option.types,
|
||||
appends,
|
||||
depth: option.depth - 1,
|
||||
...this,
|
||||
});
|
||||
option.loadChildren = null;
|
||||
if (result.length) {
|
||||
option.children = result;
|
||||
} else {
|
||||
option.isLeaf = true;
|
||||
const matchingType = option.types
|
||||
? option.types.some((type) => matchFieldType(option.field, type, { collectionManager: this.collectionManager }))
|
||||
: true;
|
||||
if (!matchingType) {
|
||||
option.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getCollectionFieldOptions(options): VariableOption[] {
|
||||
const {
|
||||
fields,
|
||||
collection,
|
||||
types,
|
||||
appends = [],
|
||||
depth = 1,
|
||||
compile,
|
||||
collectionManager,
|
||||
fieldNames = defaultFieldNames,
|
||||
} = options;
|
||||
const computedFields = fields ?? getNormalizedFields(collection, { compile, collectionManager });
|
||||
const boundLoadChildren = loadChildren.bind({ compile, collectionManager, fieldNames });
|
||||
|
||||
const result: VariableOption[] = filterTypedFields({
|
||||
fields: computedFields,
|
||||
types,
|
||||
depth,
|
||||
appends,
|
||||
compile,
|
||||
collectionManager,
|
||||
}).map((field) => {
|
||||
const label = compile(field.uiSchema?.title || field.name);
|
||||
const nextAppends = getNextAppends(field, appends);
|
||||
// TODO: no matching fields in next appends should consider isLeaf as true
|
||||
const isLeaf =
|
||||
!isAssociationField(field) || (nextAppends && !nextAppends.length && !appends.includes(field.name)) || false;
|
||||
|
||||
return {
|
||||
[fieldNames.label]: label,
|
||||
key: field.name,
|
||||
[fieldNames.value]: field.name,
|
||||
isLeaf,
|
||||
loadChildren: isLeaf ? null : boundLoadChildren,
|
||||
field,
|
||||
depth,
|
||||
appends,
|
||||
types,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The two canvas React contexts, shared by both canvases (ADR-0003, doc §9.4).
|
||||
*
|
||||
* Aligns with v1's two-context split:
|
||||
* - FlowContext (canvas root) = `{ workflow, nodes, refresh }`
|
||||
* - NodeContext (per node) = the node object itself (with live
|
||||
* `upstream`/`downstream` linked-list refs)
|
||||
*
|
||||
* Zero dependencies (bare `React.createContext`), so both v1 and v2 share this
|
||||
* one definition. v1 re-exports `NodeContext`/`useNodeContext` from here via the
|
||||
* allowed `v1 → v2` import direction.
|
||||
*
|
||||
* `workflow`/`upstreams` are derived via hooks (`useAvailableUpstreams`), never
|
||||
* bundled into the node-context value.
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import type { WorkflowCanvasRecord, WorkflowRevision } from '../components/workflowCanvas';
|
||||
|
||||
/**
|
||||
* A canvas node — the live linked-list element produced by `linkNodes`: the flat
|
||||
* `flow_nodes` row plus the wired `upstream`/`downstream` object refs. Kept loose
|
||||
* (index signature) because node `config` shapes are per-instruction; the named
|
||||
* fields are the ones the canvas itself reads.
|
||||
*/
|
||||
export type CanvasNode = {
|
||||
// `flow_nodes` rows are integer-keyed (matches v1's `Set<number>` / `filterByTk`).
|
||||
id: number;
|
||||
key?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
config?: Record<string, any>;
|
||||
upstreamId?: number | null;
|
||||
downstreamId?: number | null;
|
||||
branchIndex?: number | null;
|
||||
upstream?: CanvasNode | null;
|
||||
downstream?: CanvasNode | null;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* The canvas-root context value, shared by both canvases and matching v1's
|
||||
* `FlowContext.Provider` shapes:
|
||||
* - editor canvas → `{ workflow, nodes, refresh }`
|
||||
* - execution canvas → `{ workflow, nodes, execution, viewJob, setViewJob }`
|
||||
* - manual-todo canvas → `{ workflow, nodes, execution, userJob }`
|
||||
* All fields optional so a consumer reading e.g. only `nodes` is typed against
|
||||
* any canvas, and the bare-`{}` default (no provider) still satisfies it.
|
||||
*/
|
||||
export type WorkflowCanvasFlowContextValue = {
|
||||
workflow?: WorkflowCanvasRecord | null;
|
||||
nodes?: CanvasNode[];
|
||||
refresh?: () => void;
|
||||
/** Editor canvas: sibling versions of the workflow (the version dropdown). */
|
||||
revisions?: WorkflowRevision[];
|
||||
/** Execution canvas only: the execution being viewed (read-only nodes). */
|
||||
execution?: Record<string, any> | null;
|
||||
/** Execution canvas only: the job whose result modal is open. */
|
||||
viewJob?: Record<string, any> | null;
|
||||
/** Execution canvas only: open/close the job result modal. */
|
||||
setViewJob?: (job: Record<string, any> | null) => void;
|
||||
/** Manual-todo canvas only (plugin-workflow-manual): the current user job
|
||||
* record whose form/status the todo card renders. */
|
||||
userJob?: Record<string, any> | null;
|
||||
};
|
||||
|
||||
export const FlowContext = React.createContext<WorkflowCanvasFlowContextValue>({});
|
||||
|
||||
export function useFlowContext() {
|
||||
return useContext(FlowContext);
|
||||
}
|
||||
|
||||
// Holds the bare workflow record (v1's split from FlowContext). Default `{}` (not null) preserves v1's behavior, so
|
||||
// unguarded `.type`/`.config` reads at existing call sites stay safe; `Partial` makes the empty default assignable.
|
||||
export const CurrentWorkflowContext = React.createContext<Partial<WorkflowCanvasRecord>>({});
|
||||
|
||||
export function useCurrentWorkflowContext() {
|
||||
return useContext(CurrentWorkflowContext);
|
||||
}
|
||||
|
||||
// Default `{}` (not null) to match v1's `NodeContext` default, so existing v1 call sites that read
|
||||
// `useNodeContext().config` without guarding are unaffected.
|
||||
export const NodeContext = React.createContext<CanvasNode>({} as CanvasNode);
|
||||
|
||||
export function useNodeContext() {
|
||||
return useContext(NodeContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* The executed-version count of the workflow shown on the canvas — `> 0n` means
|
||||
* its nodes are read-only. A BigInt mirroring v1's `useWorkflowExecuted`, reading
|
||||
* the same `versionStats.executed` field (the workflow record has NO top-level
|
||||
* `executed` — the previous `workflow?.executed` read was always undefined, so
|
||||
* the canvas never went read-only after an execution). Callers needing a boolean
|
||||
* coerce with `Boolean(...)`.
|
||||
*/
|
||||
export function useWorkflowCanvasExecuted(): bigint {
|
||||
const { workflow } = useFlowContext() ?? {};
|
||||
return BigInt(workflow?.versionStats?.executed || 0);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure graph walks for drag/clipboard impact analysis (ADR-0003, doc §9.6).
|
||||
*
|
||||
* Relocated verbatim from `src/client/NodeDragContext.tsx` — Formily-free,
|
||||
* hook-free — so both canvases' drag/clipboard Providers share one copy of the
|
||||
* tricky topology math while each keeps its own hook-ful Provider shell. v1
|
||||
* re-exports these; the v1 golden-baseline tests re-run unchanged against this
|
||||
* copy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Walk the full downstream reachable set from `start`: the main chain plus
|
||||
* every branch subtree (via `branchChildrenMap: upstreamId → branch-head nodes`).
|
||||
*/
|
||||
export function collectDownstreams(
|
||||
start: any,
|
||||
branchChildrenMap: Map<number, any[]>,
|
||||
visited = new Set<number>(),
|
||||
): Set<number> {
|
||||
const result = new Set<number>();
|
||||
const stack = start ? [start] : [];
|
||||
while (stack.length) {
|
||||
const head = stack.pop();
|
||||
for (let node = head; node; node = node.downstream) {
|
||||
if (!node || visited.has(node.id)) {
|
||||
break;
|
||||
}
|
||||
visited.add(node.id);
|
||||
result.add(node.id);
|
||||
const branches = branchChildrenMap.get(node.id) ?? [];
|
||||
branches.forEach((branch) => stack.push(branch));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a branching node's own subtree: the node itself plus all of its
|
||||
* branch subtrees (but NOT its main-chain downstream). Used to know which nodes
|
||||
* move together when a branch node is dragged.
|
||||
*/
|
||||
export function collectBranchSubtree(root: any, branchChildrenMap: Map<number, any[]>): Set<number> {
|
||||
const result = new Set<number>();
|
||||
if (!root) {
|
||||
return result;
|
||||
}
|
||||
result.add(root.id);
|
||||
const branchHeads = branchChildrenMap.get(root.id) ?? [];
|
||||
branchHeads.forEach((branch) => {
|
||||
collectDownstreams(branch, branchChildrenMap, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which renderer a node uses on the LEGACY canvas, across all three migratable
|
||||
* surfaces — the in-canvas card, the config drawer, and the add-time preset
|
||||
* (ADR-0003).
|
||||
*
|
||||
* The dispatch rule is the same for all three and is the key to progressive,
|
||||
* surface-by-surface migration: **a v1 artifact always wins.** During migration a
|
||||
* node's instruction carries both generations — its own legacy artifact and the
|
||||
* modern loader it inherits when the v1 instruction `extends` its v2 counterpart.
|
||||
* Keeping the legacy artifact is the opt-out signal ("this surface is not switched
|
||||
* to v2 yet"), so the legacy canvas keeps rendering it; only when a node DROPS its
|
||||
* legacy artifact (but still inherits the loader) does that one surface render via
|
||||
* v2. The three surfaces switch independently — a node can move its card to v2
|
||||
* while its drawer stays on Formily, or vice-versa.
|
||||
*
|
||||
* card: `Component` ⟶ falls back to `ComponentLoader` (this card)
|
||||
* drawer: `fieldset` ⟶ falls back to `FieldsetLoader` (this drawer)
|
||||
* preset: `presetFieldset` ⟶ falls back to `PresetFieldsetLoader` (this preset)
|
||||
*
|
||||
* A legacy *schema* artifact (`fieldset` / `presetFieldset`) counts only when it
|
||||
* actually has entries — an inherited-but-empty `{}` is treated as absent, so it
|
||||
* does not pin a node that meant to drop it.
|
||||
*
|
||||
* Pure decisions (no JSX, no hooks) so they are unit-testable and shared; the
|
||||
* legacy callers read the verdict and render accordingly. The modern canvas does
|
||||
* NOT use these — it always renders via the loaders (it never had the legacy
|
||||
* artifacts).
|
||||
*/
|
||||
|
||||
type RenderableInstruction = {
|
||||
Component?: unknown;
|
||||
ComponentLoader?: unknown;
|
||||
};
|
||||
|
||||
type ConfigurableInstruction = {
|
||||
fieldset?: unknown;
|
||||
FieldsetLoader?: unknown;
|
||||
};
|
||||
|
||||
type PresettableInstruction = {
|
||||
presetFieldset?: unknown;
|
||||
PresetFieldsetLoader?: unknown;
|
||||
};
|
||||
|
||||
export type LegacyNodeRenderMode =
|
||||
/** Render the legacy Formily `Component` (still on v1, or no v2 loader). */
|
||||
| 'legacy-component'
|
||||
/** Render fully via the modern `ComponentLoader` (v1 dropped its `Component`). */
|
||||
| 'modern-loader'
|
||||
/** Neither renderer — the legacy canvas falls back to its default card. */
|
||||
| 'default-card';
|
||||
|
||||
export type LegacyFieldsetRenderMode =
|
||||
/** Render the legacy Formily schema (`fieldset` / `presetFieldset` has entries). */
|
||||
| 'legacy-fieldset'
|
||||
/** Render via the modern loader (the legacy schema was dropped). */
|
||||
| 'modern-loader'
|
||||
/** Neither — caller applies its own no-config fallback. */
|
||||
| 'none';
|
||||
|
||||
export function resolveLegacyNodeRenderMode(instruction: RenderableInstruction | undefined): LegacyNodeRenderMode {
|
||||
if (typeof instruction?.Component === 'function') {
|
||||
return 'legacy-component';
|
||||
}
|
||||
if (typeof instruction?.ComponentLoader === 'function') {
|
||||
return 'modern-loader';
|
||||
}
|
||||
return 'default-card';
|
||||
}
|
||||
|
||||
/** A legacy Formily schema map (`fieldset` / `presetFieldset`) counts as present
|
||||
* only when it has at least one entry — an inherited empty `{}` reads as absent. */
|
||||
function hasSchemaEntries(schema: unknown): boolean {
|
||||
return typeof schema === 'object' && schema !== null && Object.keys(schema as object).length > 0;
|
||||
}
|
||||
|
||||
function resolveFieldsetMode(schema: unknown, loader: unknown): LegacyFieldsetRenderMode {
|
||||
if (hasSchemaEntries(schema)) {
|
||||
return 'legacy-fieldset';
|
||||
}
|
||||
if (typeof loader === 'function') {
|
||||
return 'modern-loader';
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Config-drawer dispatch: legacy `fieldset` (with entries) wins, else the modern
|
||||
* `FieldsetLoader`, else neither (caller opens its empty Formily drawer). */
|
||||
export function resolveLegacyConfigRenderMode(
|
||||
instruction: ConfigurableInstruction | undefined,
|
||||
): LegacyFieldsetRenderMode {
|
||||
return resolveFieldsetMode(instruction?.fieldset, instruction?.FieldsetLoader);
|
||||
}
|
||||
|
||||
/** Add-time preset dispatch: legacy `presetFieldset` (with entries) wins, else the
|
||||
* modern `PresetFieldsetLoader`, else neither (caller applies its preset/branch
|
||||
* fallback). */
|
||||
export function resolveLegacyPresetRenderMode(
|
||||
instruction: PresettableInstruction | undefined,
|
||||
): LegacyFieldsetRenderMode {
|
||||
return resolveFieldsetMode(instruction?.presetFieldset, instruction?.PresetFieldsetLoader);
|
||||
}
|
||||
|
||||
/**
|
||||
* The stable `workflow-node-type-<type>` hook applied to a node's inner card
|
||||
* (`nodeClass`), matching the live `next` DOM. It carries no CSS of its own — it
|
||||
* lets external code, tests, and debugging target a node by type. Shared so every
|
||||
* card stamps it identically regardless of which renderer (Formily `Component`,
|
||||
* the v2 default card, or a v2 `ComponentLoader`) draws the node.
|
||||
*/
|
||||
export function nodeTypeClassName(type: string | undefined): string {
|
||||
return `workflow-node-type-${type}`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure node-tree helpers shared by the legacy and modern canvases (ADR-0003).
|
||||
*
|
||||
* Relocated verbatim from `src/client/utils.ts` — Formily-free, hook-free,
|
||||
* no `ctx` reads — so both canvases share one copy. v1 re-exports these from
|
||||
* here via the allowed `v1 → v2` import direction; the v1 golden-baseline tests
|
||||
* re-run unchanged against this copy.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Wire live `upstream`/`downstream` object refs onto each node from the flat
|
||||
* `flow_nodes` list (by `upstreamId`/`downstreamId`). Mutates in place.
|
||||
*/
|
||||
export function linkNodes(nodes): void {
|
||||
const nodesMap = new Map();
|
||||
nodes.forEach((item) => nodesMap.set(item.id, item));
|
||||
for (const node of nodesMap.values()) {
|
||||
if (node.upstreamId) {
|
||||
node.upstream = nodesMap.get(node.upstreamId);
|
||||
}
|
||||
|
||||
if (node.downstreamId) {
|
||||
node.downstream = nodesMap.get(node.downstreamId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure variable-reference utilities shared by the legacy and modern canvases'
|
||||
* drag/clipboard logic (ADR-0003, migration doc §9.6).
|
||||
*
|
||||
* Relocated verbatim from `src/client/nodeVariableUtils.ts` — Formily-free,
|
||||
* hook-free (only depends on the `parse` template parser) — so both canvases
|
||||
* share one copy. v1 re-exports these from here. The v1 golden-baseline tests
|
||||
* re-run unchanged against this copy.
|
||||
*/
|
||||
|
||||
import { parse } from '@nocobase/utils/client';
|
||||
|
||||
export function extractDependencyKeys(config: Record<string, any>): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
try {
|
||||
const template = parse(config);
|
||||
const params = template?.parameters ?? [];
|
||||
for (const { key } of params) {
|
||||
if (typeof key !== 'string') {
|
||||
continue;
|
||||
}
|
||||
if (key.startsWith('$jobsMapByNodeKey.')) {
|
||||
const rest = key.slice('$jobsMapByNodeKey.'.length);
|
||||
const nodeKey = rest.split('.')[0];
|
||||
if (nodeKey) {
|
||||
keys.add(nodeKey);
|
||||
}
|
||||
} else if (key.startsWith('$scopes.')) {
|
||||
const rest = key.slice('$scopes.'.length);
|
||||
const nodeKey = rest.split('.')[0];
|
||||
if (nodeKey) {
|
||||
keys.add(nodeKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function getTemplateRefKey(expression: string): string | null {
|
||||
const trimmed = expression.trim();
|
||||
const prefixes = ['$jobsMapByNodeKey.', '$scopes.'];
|
||||
for (const prefix of prefixes) {
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
const rest = trimmed.slice(prefix.length);
|
||||
const key = rest.split(/[.\s]/)[0];
|
||||
return key || null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function stripVariableReferences(value: any, keysToRemove: Set<string>): { value: any; changed: boolean } {
|
||||
if (typeof value === 'string') {
|
||||
const regex = /{{\s*([^{}]+?)\s*}}/g;
|
||||
let changed = false;
|
||||
const next = value.replace(regex, (match, expr) => {
|
||||
const key = getTemplateRefKey(expr);
|
||||
if (key && keysToRemove.has(key)) {
|
||||
changed = true;
|
||||
return '';
|
||||
}
|
||||
return match;
|
||||
});
|
||||
if (!changed) {
|
||||
return { value, changed: false };
|
||||
}
|
||||
if (next.trim() === '') {
|
||||
return { value: null, changed: true };
|
||||
}
|
||||
return { value: next, changed: true };
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const next = value.map((item) => {
|
||||
const result = stripVariableReferences(item, keysToRemove);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
}
|
||||
return result.value;
|
||||
});
|
||||
return { value: changed ? next : value, changed };
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
let changed = false;
|
||||
const next: Record<string, any> = {};
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
const result = stripVariableReferences(item, keysToRemove);
|
||||
if (result.changed) {
|
||||
changed = true;
|
||||
}
|
||||
next[key] = result.value;
|
||||
});
|
||||
return { value: changed ? next : value, changed };
|
||||
}
|
||||
|
||||
return { value, changed: false };
|
||||
}
|
||||
|
||||
export function collectUpstreams(node): Set<number> {
|
||||
const result = new Set<number>();
|
||||
for (let current = node; current; current = current.upstream) {
|
||||
result.add(current.id);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pure remove-node helpers shared by both canvases (ADR-0003, migration doc §9.6).
|
||||
* Formily-free, hook-free (only the `parse` template parser) — relocated from v1's
|
||||
* inline `RemoveButton.onRemove` / `useRemoveNodeSubmitAction` logic so the modern
|
||||
* canvas can apply the SAME delete-safety behaviour the legacy canvas has (the v2
|
||||
* remove flow previously skipped the variable-reference guard entirely). v1
|
||||
* re-imports these; the golden-baseline test pins the behaviour for both.
|
||||
*/
|
||||
|
||||
import { parse } from '@nocobase/utils/client';
|
||||
|
||||
/**
|
||||
* Collect a branch subtree: every node reachable from `branchHead` following the
|
||||
* `downstream` chain, descending into each node's nested branches (children whose
|
||||
* `upstream` is that node and which carry a `branchIndex`). Keyed by node id.
|
||||
*
|
||||
* Verbatim port of v1's `findBranchNodes` (`client/RemoveNodeContext.tsx`). The
|
||||
* linked-list fields (`upstream`/`downstream`) are assumed already wired by
|
||||
* `linkNodes`.
|
||||
*/
|
||||
export function collectBranchNodes(nodes: any[], branchHead: any): Map<any, any> {
|
||||
const result = new Map<any, any>();
|
||||
for (let node = branchHead; node; node = node.downstream) {
|
||||
result.set(node.id, node);
|
||||
const subBranches = (nodes ?? []).filter((item) => item.upstream === node && item.branchIndex != null);
|
||||
for (const subBranch of subBranches) {
|
||||
const subBranchNodes = collectBranchNodes(nodes, subBranch);
|
||||
for (const [key, value] of subBranchNodes) {
|
||||
result.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a node's config reference `targetKey`'s output? Mirrors v1's two inline
|
||||
* checks: always matches `$jobsMapByNodeKey.<targetKey>` (exact or `.<field>`),
|
||||
* and — when `includeScopes` is set (the branching-node delete path) — also
|
||||
* `$scopes.<targetKey>`.
|
||||
*/
|
||||
function referencesNodeKey(config: any, targetKey: string, includeScopes: boolean): boolean {
|
||||
let template: { parameters?: { key: string }[] };
|
||||
try {
|
||||
template = parse(config);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const params = template?.parameters ?? [];
|
||||
return params.some(({ key }) => {
|
||||
if (typeof key !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (key === `$jobsMapByNodeKey.${targetKey}` || key.startsWith(`$jobsMapByNodeKey.${targetKey}.`)) {
|
||||
return true;
|
||||
}
|
||||
if (includeScopes && (key === `$scopes.${targetKey}` || key.startsWith(`$scopes.${targetKey}.`))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nodes (excluding the target itself) that reference `target`'s output —
|
||||
* the set v1 blocks deletion on ("The result of this node has been referenced by
|
||||
* other nodes …"). `candidates` is the pool to scan: for a **leaf** node delete
|
||||
* it's all other nodes (`$jobsMapByNodeKey` only, `includeScopes: false`); for a
|
||||
* **branching** node delete it's the related/downstream subtree
|
||||
* (`includeScopes: true`).
|
||||
*/
|
||||
export function findNodesReferencing(
|
||||
candidates: any[],
|
||||
target: { key: string },
|
||||
{ includeScopes = false }: { includeScopes?: boolean } = {},
|
||||
): any[] {
|
||||
return (candidates ?? []).filter((node) => {
|
||||
if (!node || node.key === target.key) {
|
||||
return false;
|
||||
}
|
||||
return referencesNodeKey(node.config, target.key, includeScopes);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Canvas stylesheet, shared by both canvases (ADR-0003, doc §9.6). Relocated
|
||||
* verbatim from `src/client/style.tsx`; the only change is the `createStyles`
|
||||
* import source (`@nocobase/client` → `antd-style`, the same function the
|
||||
* `@nocobase/client` re-export points at), keeping client-v2 free of any
|
||||
* `@nocobase/client` import. v1 re-exports this from here.
|
||||
*/
|
||||
|
||||
import { createStyles } from 'antd-style';
|
||||
|
||||
const useStyles = createStyles(({ css, token }) => {
|
||||
return {
|
||||
workflowPageClass: css`
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.workflow-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
padding: 0.5rem 1rem;
|
||||
background: ${token.colorBgContainer};
|
||||
border-bottom: 1px solid ${token.colorBorderSecondary};
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.workflow-versions {
|
||||
label {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-canvas-wrapper {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workflow-canvas-zoomer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 2em;
|
||||
right: 2em;
|
||||
height: 10em;
|
||||
padding: 1em 0;
|
||||
border-radius: 0.5em;
|
||||
background: ${token.colorBgContainer};
|
||||
}
|
||||
|
||||
.workflow-canvas {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2em;
|
||||
|
||||
> .ant-alert {
|
||||
margin-bottom: 2em;
|
||||
font-size: 85%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
dropdownClass: css`
|
||||
.ant-dropdown-menu-item {
|
||||
justify-content: flex-end;
|
||||
.ant-dropdown-menu-title-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: flex-end;
|
||||
text-align: right;
|
||||
|
||||
time {
|
||||
width: 14em;
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
workflowVersionDropdownClass: css`
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
.ant-dropdown-menu-title-content {
|
||||
strong {
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&.unexecuted {
|
||||
strong {
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
executionsDropdownRowClass: css`
|
||||
.ant-dropdown-menu-item {
|
||||
.id {
|
||||
flex-grow: 1;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
workflowDetailsDescriptionClass: css`
|
||||
&.ant-input {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
cursor: text;
|
||||
resize: none;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${token.colorPrimaryBorderHover};
|
||||
background: ${token.colorBgContainer};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: ${token.colorPrimary};
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: 0 0 0 2px ${token.colorPrimaryBg};
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
branchBlockClass: css`
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 2em auto auto auto;
|
||||
|
||||
:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(50% - 0.5px);
|
||||
width: 1px;
|
||||
background-color: ${token.colorBgLayout};
|
||||
}
|
||||
`,
|
||||
|
||||
branchClass: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 16em;
|
||||
padding: 0 2em;
|
||||
|
||||
.workflow-node-list {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
> :last-child {
|
||||
> .workflow-add-node-button {
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-branch-lines {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: ${token.colorBorder};
|
||||
}
|
||||
|
||||
:before,
|
||||
:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
background-color: ${token.colorBorder};
|
||||
}
|
||||
|
||||
:before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
:after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
:not(:first-child):not(:last-child) {
|
||||
:before,
|
||||
:after {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
:last-child:not(:first-child) {
|
||||
:before,
|
||||
:after {
|
||||
right: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
:first-child:not(:last-child) {
|
||||
:before,
|
||||
:after {
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.end-sign {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 0;
|
||||
height: 4em;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(50% - 0.5px);
|
||||
width: 1px;
|
||||
background-color: ${token.colorBorder};
|
||||
background-image: repeating-linear-gradient(to bottom, ${token.colorBgLayout} 0 2px, transparent 2px 4px);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 1.5em;
|
||||
line-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.workflow-branch-dashed {
|
||||
.workflow-branch-lines {
|
||||
background-color: ${token.colorBorder};
|
||||
background-image: repeating-linear-gradient(to bottom, ${token.colorBgLayout} 0 2px, transparent 2px 4px);
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
nodeBlockClass: css`
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
`,
|
||||
|
||||
nodeClass: css`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
`,
|
||||
|
||||
nodeCardClass: css`
|
||||
position: relative;
|
||||
width: 16em;
|
||||
background: ${token.colorBgContainer};
|
||||
padding: 0.75em;
|
||||
box-shadow: ${token.boxShadowTertiary};
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: ${token.boxShadow};
|
||||
|
||||
.workflow-node-action-button,
|
||||
.workflow-node-remove-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.configuring {
|
||||
box-shadow: ${token.boxShadow};
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.active {
|
||||
outline: 2px dashed ${token.colorPrimaryBorder};
|
||||
}
|
||||
|
||||
.workflow-node-action-button,
|
||||
.workflow-node-remove-button {
|
||||
opacity: 0;
|
||||
color: ${token.colorText};
|
||||
font-size: ${token.fontSizeIcon}px;
|
||||
line-height: 1em;
|
||||
|
||||
&[disabled] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: ${token.colorErrorHover};
|
||||
}
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:focus) {
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
border-color: ${token.colorBorderBg};
|
||||
background-color: ${token.colorBgContainerDisabled};
|
||||
|
||||
&:not(:disabled):hover {
|
||||
border-color: ${token.colorPrimaryBorderHover};
|
||||
}
|
||||
|
||||
&:disabled:hover {
|
||||
border-color: ${token.colorBorderBg};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-node-config-button {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0.25em 0.5em rgba(0, 0, 0, 0.25);
|
||||
|
||||
.workflow-node-action-button,
|
||||
.workflow-node-remove-button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
nodeJobButtonClass: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${token.colorTextLightSolid};
|
||||
`,
|
||||
|
||||
nodeHeaderClass: css`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.workflow-node-actions {
|
||||
}
|
||||
`,
|
||||
|
||||
nodeMetaClass: css`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.ant-tag {
|
||||
max-width: 14em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.workflow-node-id {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
|
||||
nodeTitleClass: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
.workflow-node-id {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
|
||||
nodeSubtreeClass: css`
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
`,
|
||||
|
||||
nodeJobResultClass: css`
|
||||
background-color: #f3f3f3;
|
||||
`,
|
||||
|
||||
addButtonClass: css`
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
padding: 1em 0;
|
||||
|
||||
> .ant-btn {
|
||||
line-height: 1em;
|
||||
&:disabled {
|
||||
visibility: hidden;
|
||||
}
|
||||
&.anchoring {
|
||||
box-shadow: ${token.boxShadow};
|
||||
border-color: ${token.colorPrimaryBorder};
|
||||
color: ${token.colorPrimaryText};
|
||||
}
|
||||
}
|
||||
|
||||
> .ant-btn-placeholder {
|
||||
display: block;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
bottom: 0.1em;
|
||||
left: calc(50% - 0.25em);
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
border: 1px solid ${token.colorBorder};
|
||||
border-width: 0 1px 1px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&:first-child:last-child:after {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
|
||||
dropZoneClass: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
height: calc(2em + 1px);
|
||||
width: 12em;
|
||||
margin: -0.25em 0;
|
||||
border-radius: 0.5em;
|
||||
border: 1px dashed ${token.colorBorder};
|
||||
background: ${token.colorBgContainer};
|
||||
color: ${token.colorTextSecondary};
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
color 0.2s ease,
|
||||
opacity 0.2s ease;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -1em;
|
||||
bottom: -1em;
|
||||
left: -1em;
|
||||
right: -1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-style: solid;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.drop-active {
|
||||
border-style: solid;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.drop-safe {
|
||||
border-color: ${token.colorSuccess};
|
||||
background: ${token.colorSuccessBg};
|
||||
color: ${token.colorSuccessText};
|
||||
}
|
||||
|
||||
&.drop-warning {
|
||||
border-color: ${token.colorWarning};
|
||||
background: ${token.colorWarningBg};
|
||||
color: ${token.colorWarningText};
|
||||
}
|
||||
|
||||
&.drop-disabled {
|
||||
visibility: hidden;
|
||||
width: 1.5em;
|
||||
}
|
||||
`,
|
||||
|
||||
pasteButtonClass: css`
|
||||
&.ant-btn-variant-outlined:not(:disabled):not(.ant-btn-disabled):hover {
|
||||
&.paste-safe {
|
||||
color: ${token.colorSuccess};
|
||||
border-color: ${token.colorSuccessBorder};
|
||||
}
|
||||
|
||||
&.paste-warning {
|
||||
color: ${token.colorWarning};
|
||||
border-color: ${token.colorWarningBorder};
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
dragPreviewClass: css`
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
width: 12em;
|
||||
padding: 0.5em 0.75em;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadow};
|
||||
opacity: 0.9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
overflow: visible;
|
||||
|
||||
.workflow-drag-preview-type {
|
||||
font-size: 0.8em;
|
||||
color: ${token.colorTextSecondary};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workflow-drag-preview-title {
|
||||
font-weight: 600;
|
||||
color: ${token.colorText};
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.drag-preview-group {
|
||||
position: fixed;
|
||||
|
||||
.workflow-drag-preview-stack {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: ${token.borderRadiusLG}px;
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadowTertiary};
|
||||
}
|
||||
|
||||
.workflow-drag-preview-stack.stack-1 {
|
||||
transform: translate(6px, 6px) rotate(2deg);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.workflow-drag-preview-stack.stack-2 {
|
||||
transform: translate(12px, 12px) rotate(4deg);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
||||
clipboardPreviewClass: css`
|
||||
position: absolute;
|
||||
top: 2em;
|
||||
left: 2em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
|
||||
.workflow-clipboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85em;
|
||||
color: ${token.colorTextTertiary};
|
||||
}
|
||||
|
||||
.workflow-clipboard-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25em;
|
||||
padding: 0.5em;
|
||||
width: 14em;
|
||||
border-radius: ${token.borderRadiusSM}px;
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadowTertiary};
|
||||
opacity: 0.75;
|
||||
|
||||
&.dragging {
|
||||
opacity: 0.75;
|
||||
outline: 2px dashed ${token.colorPrimaryBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.workflow-clipboard-type {
|
||||
font-size: 0.8em;
|
||||
color: ${token.colorTextSecondary};
|
||||
}
|
||||
|
||||
.workflow-clipboard-title {
|
||||
font-weight: 600;
|
||||
color: ${token.colorText};
|
||||
}
|
||||
`,
|
||||
|
||||
conditionClass: css`
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
|
||||
> span {
|
||||
position: absolute;
|
||||
top: calc(1.5em - 1px);
|
||||
line-height: 1em;
|
||||
color: ${token.colorTextSecondary};
|
||||
background-color: ${token.colorBgLayout};
|
||||
padding: 1px;
|
||||
}
|
||||
`,
|
||||
|
||||
loopLineClass: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2em;
|
||||
height: 6em;
|
||||
flex-shrink: 0;
|
||||
padding: 2em 0;
|
||||
font-size: 14px;
|
||||
`,
|
||||
|
||||
terminalClass: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 4em;
|
||||
height: 4em;
|
||||
border-radius: 50%;
|
||||
background-color: ${token.colorText};
|
||||
color: ${token.colorBgContainer};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
export default useStyles;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runtime-neutral resolution of the workflow client plugin and its node
|
||||
* instructions, shared by both canvases (ADR-0003).
|
||||
*
|
||||
* The canvas node card must look up `instructions.get(type)` to know how to
|
||||
* render. Resolving the plugin by its concrete class (`pm.get(PluginWorkflowClientV2)`)
|
||||
* only hits the v2 runtime; the legacy canvas registers the v1 `PluginWorkflowClient`
|
||||
* instead. Both register under the neutral `'workflow'` package alias and expose
|
||||
* the same `instructions` registry, so `pm.get('workflow')` feeds the shared
|
||||
* `Node` card in either runtime — the same trick the variable aggregator already
|
||||
* uses. Kept structural (not a concrete plugin class) so client-v2 never imports
|
||||
* the v1 client and the back-imported card stays runtime-agnostic.
|
||||
*/
|
||||
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import type { Instruction } from './Instruction';
|
||||
|
||||
/** The slice of the workflow client plugin the canvas card needs — the
|
||||
* `instructions` registry shared by v1 `PluginWorkflowClient` and v2
|
||||
* `PluginWorkflowClientV2`. */
|
||||
export type WorkflowInstructionPlugin = {
|
||||
instructions: { get(type: string): Instruction | undefined };
|
||||
};
|
||||
|
||||
/** Resolve the current runtime's workflow client plugin via the neutral
|
||||
* `'workflow'` alias — v1's or v2's, whichever this runtime loaded. */
|
||||
export function useWorkflowPlugin(): WorkflowInstructionPlugin | undefined {
|
||||
const flowEngine = useFlowEngine();
|
||||
return flowEngine.context.app.pm.get('workflow') as WorkflowInstructionPlugin | undefined;
|
||||
}
|
||||
|
||||
/** Resolve a node type's instruction in a runtime-neutral way. Uses the shared
|
||||
* `instructions` registry (present on both plugins), not v2-only `getInstruction`. */
|
||||
export function useInstruction(type?: string): Instruction | undefined {
|
||||
const plugin = useWorkflowPlugin();
|
||||
return type ? plugin?.instructions.get(type) : undefined;
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v2 workflow variable aggregator (doc §5/§6/§9.7). Same multi-scope shape as
|
||||
* v1's `useWorkflowVariableOptions` (`client/variable.tsx`): each scope
|
||||
* contributes one top-level `MetaTreeNode` and the results are concatenated and
|
||||
* filtered. The modern variable inputs (`WorkflowVariableInput` for the
|
||||
* expression field, `TypedVariableInput` for calculation operands) consume the
|
||||
* produced `MetaTreeNode[]` directly.
|
||||
*
|
||||
* Scopes, mirroring the v1 panel (局域变量 / 节点数据 / 触发器变量 / 系统变量 /
|
||||
* 变量和密钥):
|
||||
* - `$jobsMapByNodeKey` (Node result) — **live**: upstream node outputs, via
|
||||
* each upstream instruction's `useVariables` + the core `VariableOption →
|
||||
* MetaTreeNode` adapter. Round-trips to `{{$jobsMapByNodeKey.<nodeKey>.…}}`.
|
||||
* - `$env` (Variables and secrets) — **live**: global, registered by the
|
||||
* environment-variables plugin via `flowEngine.context.defineProperty`,
|
||||
* read from `getPropertyMetaTree()`. Independent of any node/trigger
|
||||
* migration. Serialized as `{{$env.x.y}}` (no inner spaces, workflow style).
|
||||
* - `$context` (Trigger variables) — **stub**: lit when v2 triggers
|
||||
* implement `useVariables`.
|
||||
* - `$system` (System variables) — **stub**: lit when the v2 plugin
|
||||
* gains a `systemVariables` registry.
|
||||
* - `$scopes` (Scope variables) — **stub**: lit when branch nodes
|
||||
* (loop / parallel) migrate and implement `useScopeVariables`.
|
||||
*
|
||||
* The mature legacy field-tree logic (`getCollectionFieldOptions`) is reused
|
||||
* unchanged through `useVariables`; only the final `VariableOption → MetaTreeNode`
|
||||
* adaptation is v2-specific. A node author never touches `useVariables`.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { useCurrentWorkflowContext, useNodeContext } from './contexts';
|
||||
import { useAvailableUpstreams, useUpstreamScopes, type Instruction } from './Instruction';
|
||||
import { adaptVariableOptionToMetaTree, adaptVariableOptionsToMetaTree } from './adaptVariableOptionToMetaTree';
|
||||
import { NAMESPACE } from '../locale';
|
||||
|
||||
const NODE_RESULT_ROOT = '$jobsMapByNodeKey';
|
||||
const ENV_ROOT = '$env';
|
||||
const SYSTEM_ROOT = '$system';
|
||||
const TRIGGER_ROOT = '$context';
|
||||
const SCOPES_ROOT = '$scopes';
|
||||
|
||||
/**
|
||||
* A system variable as held by either runtime's `systemVariables` registry.
|
||||
* v2 stores `{ key, label(string template) }`; v1 stores
|
||||
* `{ key, label(string OR already-rendered JSX), value }` (the JSX bakes in a
|
||||
* tooltip icon). `useSystemScope` reduces either to a plain string title.
|
||||
*/
|
||||
type SystemVariableLike = { key: string; label: React.ReactNode };
|
||||
|
||||
/**
|
||||
* Coerce a React node to plain text for use as a `MetaTreeNode.title` (which is a
|
||||
* string). v1 system-variable labels are JSX (`<span>Instance ID</span><Tooltip/>`);
|
||||
* recursively collect their text so the picker shows "Instance ID" rather than
|
||||
* `[object Object]`. Strings/numbers pass through; non-text nodes (the tooltip
|
||||
* icon) contribute nothing.
|
||||
*/
|
||||
function reactNodeToPlainText(node: React.ReactNode): string {
|
||||
if (node == null || typeof node === 'boolean') {
|
||||
return '';
|
||||
}
|
||||
if (typeof node === 'string' || typeof node === 'number') {
|
||||
return String(node);
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(reactNodeToPlainText).join('');
|
||||
}
|
||||
if (React.isValidElement(node)) {
|
||||
return reactNodeToPlainText((node.props as { children?: React.ReactNode })?.children);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A trigger as held by either runtime's `triggers` registry. v1 stores a
|
||||
* `Trigger` instance carrying a `useVariables(config, options)` hook; v2 stores
|
||||
* a plain options object with no `useVariables` (so its trigger scope is empty —
|
||||
* the trigger variable migration hasn't happened in v2 yet).
|
||||
*/
|
||||
type TriggerLike = { useVariables?(config: any, options?: any): any[] | null | undefined };
|
||||
|
||||
/**
|
||||
* The minimal slice of the workflow client plugin that this aggregator needs —
|
||||
* the registries shared by BOTH runtimes (v1 `PluginWorkflowClient` and v2
|
||||
* `PluginWorkflowClientV2`). Resolved at runtime via the neutral `'workflow'`
|
||||
* package alias (`pm.get('workflow')`), so the same hook feeds the variable tree
|
||||
* in either client. Kept structural (not a concrete plugin class) so client-v2
|
||||
* never imports from the v1 client and the back-imported v1 canvas stays
|
||||
* runtime-agnostic.
|
||||
*/
|
||||
type WorkflowVariablePlugin = {
|
||||
instructions: { get(type: string): Instruction | undefined };
|
||||
systemVariables: { getValues(): Iterable<SystemVariableLike> };
|
||||
triggers: { get(type: string): TriggerLike | undefined };
|
||||
};
|
||||
|
||||
/** Resolve the current runtime's workflow client plugin via the neutral
|
||||
* `'workflow'` alias — v1's `PluginWorkflowClient` or v2's
|
||||
* `PluginWorkflowClientV2`, whichever this runtime loaded. */
|
||||
function useWorkflowPlugin(): WorkflowVariablePlugin | undefined {
|
||||
const flowEngine = useFlowEngine();
|
||||
return flowEngine.context.app.pm.get('workflow') as WorkflowVariablePlugin | undefined;
|
||||
}
|
||||
|
||||
export type UseWorkflowVariableOptions = {
|
||||
types?: any[];
|
||||
fieldNames?: { label?: string; value?: string; children?: string };
|
||||
appends?: string[] | null;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Node result" (`$jobsMapByNodeKey`) — upstream node outputs. Walks the current
|
||||
* node's upstream chain, calls each upstream instruction's `useVariables`, and
|
||||
* adapts the aggregated tree. Returns null when no upstream contributes.
|
||||
*/
|
||||
function useNodeResultScope(options: UseWorkflowVariableOptions): MetaTreeNode | null {
|
||||
const flowEngine = useFlowEngine();
|
||||
const plugin = useWorkflowPlugin();
|
||||
const current = useNodeContext();
|
||||
const upstreams = useAvailableUpstreams(current);
|
||||
|
||||
const children: MetaTreeNode[] = [];
|
||||
upstreams.forEach((node: any) => {
|
||||
const instruction = plugin?.instructions?.get(node.type);
|
||||
const option = instruction?.useVariables?.(node, options);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
// Each upstream node hangs under $jobsMapByNodeKey.<nodeKey>.
|
||||
children.push(adaptVariableOptionToMetaTree(option, [NODE_RESULT_ROOT]));
|
||||
});
|
||||
|
||||
if (!children.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: NODE_RESULT_ROOT,
|
||||
title: flowEngine.context.t('Node result', { ns: 'workflow' }),
|
||||
type: '',
|
||||
paths: [NODE_RESULT_ROOT],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Variables and secrets" (`$env`) — global environment variables registered by
|
||||
* the environment-variables plugin. Read from the global property meta tree and
|
||||
* re-rooted so paths serialize as `{{$env.x.y}}`. Returns null when no env
|
||||
* variables are defined (the env plugin is absent / empty).
|
||||
*/
|
||||
function useEnvScope(): MetaTreeNode | null {
|
||||
const flowEngine = useFlowEngine();
|
||||
// The environment-variables plugin registers `$env` on the flow-engine context (`defineProperty('$env', …)`) in BOTH
|
||||
// runtimes — v2 in `client-v2/plugin.tsx`, v1 in `client/index.tsx` via the shared `registerEnvProperty` (so it
|
||||
// resolves from the config drawer, which mounts detached from v1's React tree). It shows up in the property meta tree
|
||||
// with an **async** `children` thunk; return that node as-is — the downstream pickers resolve the thunk on expand
|
||||
// (the result is cached by the flow-engine context, so it is fetched only once). Returns null when the env plugin is
|
||||
// absent.
|
||||
const tree = flowEngine.context.getPropertyMetaTree?.() ?? [];
|
||||
const env = tree.find((node) => node.name === ENV_ROOT);
|
||||
return env ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Trigger variables" (`$context`) — the workflow trigger's output. Resolves the
|
||||
* current workflow (threaded into the config drawer via `CurrentWorkflowContext`,
|
||||
* since the drawer renders at the React root, outside the canvas `FlowContext`),
|
||||
* looks up its trigger, and calls the trigger's `useVariables(config, options)`.
|
||||
*
|
||||
* Runtime-neutral, mirroring `useNodeResultScope`: a v1 trigger
|
||||
* (`PluginWorkflowClient.triggers`) implements `useVariables` so the scope lights
|
||||
* up; a v2 trigger (a plain options object) has none, so it stays empty until the
|
||||
* trigger-variable migration reaches v2. Returns null when no trigger contributes.
|
||||
*/
|
||||
function useTriggerScope(options: UseWorkflowVariableOptions): MetaTreeNode | null {
|
||||
const flowEngine = useFlowEngine();
|
||||
const plugin = useWorkflowPlugin();
|
||||
const workflow = useCurrentWorkflowContext();
|
||||
const trigger = workflow?.type ? plugin?.triggers?.get(workflow.type) : undefined;
|
||||
const subOptions = trigger?.useVariables?.(workflow?.config, options);
|
||||
const list = Array.isArray(subOptions) ? subOptions.filter(Boolean) : [];
|
||||
if (!list.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: TRIGGER_ROOT,
|
||||
title: flowEngine.context.t('Trigger variables', { ns: NAMESPACE }),
|
||||
type: '',
|
||||
paths: [TRIGGER_ROOT],
|
||||
children: adaptVariableOptionsToMetaTree(list, [TRIGGER_ROOT]),
|
||||
};
|
||||
}
|
||||
|
||||
function useSystemScope(): MetaTreeNode | null {
|
||||
const flowEngine = useFlowEngine();
|
||||
const plugin = useWorkflowPlugin();
|
||||
const t = (key: string) => flowEngine.context.t(key, { ns: NAMESPACE });
|
||||
const vars: SystemVariableLike[] = plugin ? Array.from(plugin.systemVariables.getValues()) : [];
|
||||
if (!vars.length) {
|
||||
return null;
|
||||
}
|
||||
const children: MetaTreeNode[] = vars.map((item) => {
|
||||
// `MetaTreeNode.title` is a plain string (the cascader label). v2 labels are `{{t("…")}}` templates → translate
|
||||
// them; v1 labels may be already-rendered JSX — coerce to a plain string for the title (the picker renders
|
||||
// strings).
|
||||
const label = typeof item.label === 'string' ? t(item.label) : reactNodeToPlainText(item.label);
|
||||
return {
|
||||
name: item.key,
|
||||
title: label,
|
||||
type: '',
|
||||
paths: [SYSTEM_ROOT, item.key],
|
||||
};
|
||||
});
|
||||
return {
|
||||
name: SYSTEM_ROOT,
|
||||
title: t('System variables'),
|
||||
type: '',
|
||||
paths: [SYSTEM_ROOT],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* "Scope variables" (`$scopes`) — variables contributed by the branch nodes the
|
||||
* current node is nested inside (loop / parallel). Walks the upstream branching
|
||||
* scopes (`useUpstreamScopes`) and calls each scope node's `useScopeVariables`.
|
||||
* Mirrors v1's `scopeOptions` (`client/variable.tsx`): one child per scope node,
|
||||
* whose own children are that node's scope variables.
|
||||
*
|
||||
* No node implements `useScopeVariables` yet (v1 or v2), so this is wiring-only
|
||||
* today — it produces nothing until a branch node (loop / parallel) is migrated
|
||||
* and implements it. Returns null when no scope contributes.
|
||||
*/
|
||||
function useScopeVariablesScope(options: UseWorkflowVariableOptions): MetaTreeNode | null {
|
||||
const flowEngine = useFlowEngine();
|
||||
const plugin = useWorkflowPlugin();
|
||||
const current = useNodeContext();
|
||||
const scopes = useUpstreamScopes(current);
|
||||
|
||||
const children: MetaTreeNode[] = [];
|
||||
scopes.forEach((node: any) => {
|
||||
const instruction = plugin?.instructions?.get(node.type);
|
||||
const subOptions = instruction?.useScopeVariables?.(node, options);
|
||||
if (!subOptions) {
|
||||
return;
|
||||
}
|
||||
// Each scope node hangs under $scopes.<nodeKey>, its variables beneath it.
|
||||
children.push(
|
||||
adaptVariableOptionToMetaTree(
|
||||
{
|
||||
key: node.key,
|
||||
value: node.key,
|
||||
label: node.title ?? `#${node.id}`,
|
||||
children: subOptions,
|
||||
},
|
||||
[SCOPES_ROOT],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (!children.length) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: SCOPES_ROOT,
|
||||
title: flowEngine.context.t('Scope variables', { ns: NAMESPACE }),
|
||||
type: '',
|
||||
paths: [SCOPES_ROOT],
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the workflow variable MetaTree for the current node's config form.
|
||||
* Aggregates every scope in v1's display order, dropping the ones that
|
||||
* contribute nothing.
|
||||
*
|
||||
* The result is **referentially stable** across re-renders for the same inputs
|
||||
* (memoized on the current node + upstream/scope node keys + workflow id). This
|
||||
* is load-bearing for lazy relation expansion: `TypedVariableInput.loadData`
|
||||
* resolves a node's children and mutates them onto the meta node in place, then
|
||||
* forces a re-render — if this hook returned a brand-new tree each render, that
|
||||
* mutated (resolved) child list would be discarded and the cascader column would
|
||||
* spin forever. Memoizing keeps the same tree objects alive so the resolved
|
||||
* children survive the re-render.
|
||||
*/
|
||||
export function useWorkflowVariableOptions(options: UseWorkflowVariableOptions = {}): MetaTreeNode[] {
|
||||
const scopeVars = useScopeVariablesScope(options);
|
||||
const nodeResult = useNodeResultScope(options);
|
||||
const trigger = useTriggerScope(options);
|
||||
const system = useSystemScope();
|
||||
const env = useEnvScope();
|
||||
|
||||
const current = useNodeContext();
|
||||
const workflow = useCurrentWorkflowContext();
|
||||
// A signature that changes only when the variable tree's *structure* could change — the current node, its upstream
|
||||
// chain (node-result), its branching scopes, and the workflow (trigger). Lazy children resolved into the tree by the
|
||||
// picker are NOT part of this key, so they persist until the structure itself changes.
|
||||
const upstreamKeys = useAvailableUpstreams(current)
|
||||
.map((n: any) => n.key)
|
||||
.join(',');
|
||||
const scopeKeys = useUpstreamScopes(current)
|
||||
.map((n: any) => n.key)
|
||||
.join(',');
|
||||
const signature = `${current?.key ?? ''}|${upstreamKeys}|${scopeKeys}|${workflow?.id ?? ''}|${workflow?.type ?? ''}`;
|
||||
|
||||
/* eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally keyed
|
||||
on the structural `signature`, not the scope objects (which are fresh each
|
||||
render); see the doc comment above. Including them would defeat the memo and
|
||||
reintroduce the lazy-load spinner bug. */
|
||||
return useMemo(() => [scopeVars, nodeResult, trigger, system, env].filter(Boolean) as MetaTreeNode[], [signature]);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v2-native copy of the v1 `CalculationConfig` (mirrors `client/components/
|
||||
* Calculation.tsx`). Same recursive boolean-condition builder, same
|
||||
* `calculators` registry and stored shape — but Formily-free:
|
||||
* - `css`/`cx` from `@emotion/css` (not `@nocobase/client`)
|
||||
* - label compilation through the v2 `useT()` (not `useCompile`)
|
||||
* - operands use the core `TypedVariableInput` (constant-or-variable) fed the
|
||||
* workflow variable MetaTree, replacing v1's `Variable.Input` +
|
||||
* `useTypedConstant` + `scope`
|
||||
*
|
||||
* The `useVariableHook` injection point is preserved (v1 parity) so a caller can
|
||||
* supply a different workflow-variable source; it now returns `MetaTreeNode[]`.
|
||||
*/
|
||||
|
||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { TypedVariableInput, type TypedConstantSpec } from '@nocobase/client-v2';
|
||||
import type { MetaTreeNode } from '@nocobase/flow-engine';
|
||||
import { Registry } from '@nocobase/utils/client';
|
||||
import { Button, Select } from 'antd';
|
||||
import React, { createContext, useCallback, useContext } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useT, useWorkflowTranslation, NAMESPACE } from '../locale';
|
||||
import { useWorkflowVariableOptions } from '../canvas/useWorkflowVariableOptions';
|
||||
|
||||
// Constant types a calculation operand accepts. v1 uses bare `useTypedConstant` (= all types), whose constant submenu
|
||||
// includes JSON — so include `object`.
|
||||
const OPERAND_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date', 'object'];
|
||||
|
||||
// v1 relied on a global FormItem `.auto-width` rule to shrink the operator Select to its content; v2 has no such global
|
||||
// rule, so scope it locally (same pattern as the core `FileSizeInput`). Without this the antd Select defaults to
|
||||
// `width: 100%` and swallows the whole row, collapsing the operands.
|
||||
const operatorWidthClassName = css`
|
||||
&.ant-select {
|
||||
width: auto;
|
||||
min-width: 6em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Calculator {
|
||||
name: string;
|
||||
type: 'boolean' | 'number' | 'string' | 'date' | 'unknown' | 'null' | 'array';
|
||||
group: string;
|
||||
}
|
||||
|
||||
export const calculators = new Registry<Calculator>();
|
||||
|
||||
calculators.register('equal', { name: '=', type: 'boolean', group: 'boolean' });
|
||||
calculators.register('notEqual', { name: '≠', type: 'boolean', group: 'boolean' });
|
||||
calculators.register('gt', { name: '>', type: 'boolean', group: 'boolean' });
|
||||
calculators.register('gte', { name: '≥', type: 'boolean', group: 'boolean' });
|
||||
calculators.register('lt', { name: '<', type: 'boolean', group: 'boolean' });
|
||||
calculators.register('lte', { name: '≤', type: 'boolean', group: 'boolean' });
|
||||
|
||||
calculators.register('add', { name: '+', type: 'number', group: 'number' });
|
||||
calculators.register('minus', { name: '-', type: 'number', group: 'number' });
|
||||
calculators.register('multiple', { name: '*', type: 'number', group: 'number' });
|
||||
calculators.register('divide', { name: '/', type: 'number', group: 'number' });
|
||||
calculators.register('mod', { name: '%', type: 'number', group: 'number' });
|
||||
|
||||
calculators.register('includes', { name: '{{t("contains")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('notIncludes', { name: '{{t("does not contain")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('startsWith', { name: '{{t("starts with")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('notStartsWith', { name: '{{t("not starts with")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('endsWith', { name: '{{t("ends with")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('notEndsWith', { name: '{{t("not ends with")}}', type: 'boolean', group: 'string' });
|
||||
calculators.register('concat', {
|
||||
name: `{{t("Concatenate", { ns: "${NAMESPACE}" })}}`,
|
||||
type: 'string',
|
||||
group: 'string',
|
||||
});
|
||||
|
||||
const calculatorGroups = [
|
||||
{ value: 'boolean', title: '{{t("Comparision")}}' },
|
||||
{ value: 'number', title: `{{t("Arithmetic calculation", { ns: "${NAMESPACE}" })}}` },
|
||||
{ value: 'string', title: `{{t("String operation", { ns: "${NAMESPACE}" })}}` },
|
||||
{ value: 'date', title: `{{t("Date", { ns: "${NAMESPACE}" })}}` },
|
||||
];
|
||||
|
||||
function getGroupCalculators(group: string) {
|
||||
return Array.from(calculators.getEntities()).filter(([, value]) => value.group === group);
|
||||
}
|
||||
|
||||
const VariableHookContext = createContext<() => MetaTreeNode[]>(useWorkflowVariableOptions);
|
||||
|
||||
function useOperandMetaTree(): MetaTreeNode[] {
|
||||
const useVariableHook = useContext(VariableHookContext);
|
||||
return useVariableHook();
|
||||
}
|
||||
|
||||
function Calculation({ calculator, operands = [], onChange }: any) {
|
||||
const compile = useT();
|
||||
const metaTree = useOperandMetaTree();
|
||||
const leftOperandOnChange = useCallback(
|
||||
(v: unknown) => onChange({ calculator, operands: [v, operands[1]] }),
|
||||
[calculator, onChange, operands],
|
||||
);
|
||||
const rightOperandOnChange = useCallback(
|
||||
(v: unknown) => onChange({ calculator, operands: [operands[0], v] }),
|
||||
[calculator, onChange, operands],
|
||||
);
|
||||
const operatorOnChange = useCallback((v: string) => onChange({ operands, calculator: v }), [onChange, operands]);
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
className={css`
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
min-width: 0;
|
||||
`}
|
||||
>
|
||||
{/* Operands flex to fill; the operator select stays narrow (auto-width) —
|
||||
matches v1's single-row [operand · operator · operand] layout. */}
|
||||
<TypedVariableInput
|
||||
types={OPERAND_TYPES}
|
||||
metaTree={metaTree}
|
||||
value={operands[0]}
|
||||
onChange={leftOperandOnChange}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<Select
|
||||
// antd's Select prop type doesn't surface DOM passthrough props (`role`), though it forwards them — same as the
|
||||
// core `CollectionSelectorFieldModel`.
|
||||
// @ts-expect-error -- role is forwarded to the DOM node
|
||||
role="button"
|
||||
aria-label="select-operator-calc"
|
||||
value={calculator}
|
||||
onChange={operatorOnChange}
|
||||
placeholder={compile('Operator')}
|
||||
popupMatchSelectWidth={false}
|
||||
className={operatorWidthClassName}
|
||||
>
|
||||
{calculatorGroups
|
||||
.filter((group) => Boolean(getGroupCalculators(group.value).length))
|
||||
.map((group) => (
|
||||
<Select.OptGroup key={group.value} label={compile(group.title)}>
|
||||
{getGroupCalculators(group.value).map(([value, { name }]) => (
|
||||
<Select.Option key={value} value={value}>
|
||||
{compile(name)}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select.OptGroup>
|
||||
))}
|
||||
</Select>
|
||||
<TypedVariableInput
|
||||
types={OPERAND_TYPES}
|
||||
metaTree={metaTree}
|
||||
value={operands[1]}
|
||||
onChange={rightOperandOnChange}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function CalculationItem({ value, onChange, onRemove }: any) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { calculator, operands = [] } = value;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 0.5em 0;
|
||||
`}
|
||||
>
|
||||
{value.group ? (
|
||||
<CalculationGroup value={value.group} onChange={(group: any) => onChange({ ...value, group })} />
|
||||
) : (
|
||||
<Calculation operands={operands} calculator={calculator} onChange={onChange} />
|
||||
)}
|
||||
<Button aria-label="icon-close" onClick={onRemove} type="link" icon={<CloseCircleOutlined />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalculationGroup({ value, onChange }: any) {
|
||||
const t = useT();
|
||||
// The "Meet [All/Any] conditions in the group" sentence is a composite `<Trans>` key that lives in the core `client`
|
||||
// namespace (not workflow's), so it needs the fallback-aware translator — same as v1's bare `useTranslation()`.
|
||||
const { t: tt } = useWorkflowTranslation();
|
||||
const { type = 'and', calculations = [] } = value;
|
||||
|
||||
const onAddSingle = useCallback(() => {
|
||||
onChange({
|
||||
...value,
|
||||
calculations: [...calculations, { not: false, calculator: 'equal' }],
|
||||
});
|
||||
}, [value, calculations, onChange]);
|
||||
|
||||
const onAddGroup = useCallback(() => {
|
||||
onChange({
|
||||
...value,
|
||||
calculations: [...calculations, { not: false, group: { type: 'and', calculations: [] } }],
|
||||
});
|
||||
}, [value, calculations, onChange]);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(i: number) => {
|
||||
calculations.splice(i, 1);
|
||||
onChange({ ...value, calculations: [...calculations] });
|
||||
},
|
||||
[value, calculations, onChange],
|
||||
);
|
||||
|
||||
const onItemChange = useCallback(
|
||||
(i: number, v: any) => {
|
||||
calculations.splice(i, 1, v);
|
||||
onChange({ ...value, calculations: [...calculations] });
|
||||
},
|
||||
[value, calculations, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'node-type-condition-group',
|
||||
css`
|
||||
position: relative;
|
||||
width: 100%;
|
||||
.node-type-condition-group {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px dashed #ddd;
|
||||
}
|
||||
+ button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
.ant-select {
|
||||
width: auto;
|
||||
min-width: 6em;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Trans t={tt}>
|
||||
{'Meet '}
|
||||
<Select
|
||||
// antd's Select prop type doesn't surface DOM passthrough props (`role`/`data-testid`), though it forwards
|
||||
// them — same as the core `CollectionSelectorFieldModel`.
|
||||
// @ts-expect-error -- role/data-testid are forwarded to the DOM node
|
||||
role="button"
|
||||
data-testid="filter-select-all-or-any"
|
||||
value={type}
|
||||
onChange={(t) => onChange({ ...value, type: t })}
|
||||
>
|
||||
<Select.Option value="and">All</Select.Option>
|
||||
<Select.Option value="or">Any</Select.Option>
|
||||
</Select>
|
||||
{' conditions in the group'}
|
||||
</Trans>
|
||||
</div>
|
||||
<div className="calculation-items">
|
||||
{calculations.map((calculation: any, i: number) => (
|
||||
<CalculationItem
|
||||
key={`${calculation.calculator}_${i}`}
|
||||
value={calculation}
|
||||
onChange={onItemChange.bind(null, i)}
|
||||
onRemove={() => onRemove(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={css`
|
||||
button {
|
||||
padding: 0;
|
||||
&:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Button type="link" onClick={onAddSingle}>
|
||||
{t('Add condition')}
|
||||
</Button>
|
||||
<Button type="link" onClick={onAddGroup}>
|
||||
{t('Add condition group')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CalculationConfigProps {
|
||||
value?: any;
|
||||
onChange?: (value: any) => void;
|
||||
useVariableHook?: () => MetaTreeNode[];
|
||||
}
|
||||
|
||||
export function CalculationConfig({
|
||||
value,
|
||||
onChange,
|
||||
useVariableHook = useWorkflowVariableOptions,
|
||||
}: CalculationConfigProps) {
|
||||
const rule = value && Object.keys(value).length ? value : { group: { type: 'and', calculations: [] } };
|
||||
return (
|
||||
<VariableHookContext.Provider value={useVariableHook}>
|
||||
<CalculationGroup value={rule.group} onChange={(group: any) => onChange?.({ ...rule, group })} />
|
||||
</VariableHookContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalculationConfig;
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import {
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseOutlined,
|
||||
ExclamationOutlined,
|
||||
HourglassOutlined,
|
||||
LoadingOutlined,
|
||||
MinusOutlined,
|
||||
RedoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Tag, theme, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import { EXECUTION_STATUS, EXECUTION_STATUS_OPTIONS_MAP } from '../../common/executionStatus';
|
||||
import { useT } from '../locale';
|
||||
|
||||
// The framework-neutral `EXECUTION_STATUS_OPTIONS` only carries plain data, so the React icon nodes (a v1-client
|
||||
// concern) live here in the v2 client lane.
|
||||
const STATUS_ICON: Record<string, React.ReactNode> = {
|
||||
[EXECUTION_STATUS.QUEUEING as number]: <HourglassOutlined />,
|
||||
[EXECUTION_STATUS.STARTED]: <LoadingOutlined />,
|
||||
[EXECUTION_STATUS.RESOLVED]: <CheckOutlined />,
|
||||
[EXECUTION_STATUS.FAILED]: <ExclamationOutlined />,
|
||||
[EXECUTION_STATUS.ERROR]: <CloseOutlined />,
|
||||
[EXECUTION_STATUS.ABORTED]: <MinusOutlined rotate={90} />,
|
||||
[EXECUTION_STATUS.CANCELED]: <MinusOutlined rotate={45} />,
|
||||
[EXECUTION_STATUS.REJECTED]: <MinusOutlined />,
|
||||
[EXECUTION_STATUS.RETRY_NEEDED]: <RedoOutlined />,
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact, circular status indicator (icon-only Tag) used where a full status
|
||||
* label would be too wide — e.g. the execution-switcher dropdown rows. Mirrors
|
||||
* v1's `StatusButton`.
|
||||
*/
|
||||
export function ExecutionStatusIcon({ value }: { value: number | null }) {
|
||||
const compile = useT();
|
||||
const { token } = theme.useToken();
|
||||
const option = EXECUTION_STATUS_OPTIONS_MAP[value as number];
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const icon = STATUS_ICON[value as number] ?? <ClockCircleOutlined />;
|
||||
const size = token.controlHeightSM;
|
||||
return (
|
||||
<Tooltip title={compile(option.label)}>
|
||||
<Tag
|
||||
color={option.color}
|
||||
style={{
|
||||
marginInlineEnd: token.marginXS,
|
||||
borderRadius: '50%',
|
||||
padding: 0,
|
||||
width: size,
|
||||
height: size,
|
||||
lineHeight: `${size}px`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionStatusIcon;
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { EXECUTION_STATUS_OPTIONS_MAP } from '../../common/executionStatus';
|
||||
import { useT } from '../locale';
|
||||
|
||||
export function ExecutionStatusTag({ value }: { value: number | null }) {
|
||||
const compile = useT();
|
||||
const option = EXECUTION_STATUS_OPTIONS_MAP[value as number];
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return <Tag color={option.color}>{compile(option.label)}</Tag>;
|
||||
}
|
||||
|
||||
export default ExecutionStatusTag;
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { QuestionCircleOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { App, Breadcrumb, Button, Space, Tag, Tooltip, theme } from 'antd';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
EXECUTION_REASON_OPTIONS_MAP,
|
||||
EXECUTION_STATUS,
|
||||
EXECUTION_STATUS_OPTIONS_MAP,
|
||||
} from '../../common/executionStatus';
|
||||
import { useWorkflowRuntimePaths } from '../hooks/useWorkflowRuntimePaths';
|
||||
import { useT, useWorkflowTranslation } from '../locale';
|
||||
import { ExecutionsDropdown } from './ExecutionsDropdown';
|
||||
import { formatTime } from './workflowCanvas';
|
||||
|
||||
const WORKFLOW_HOMEPAGE = '/admin/settings/workflow';
|
||||
|
||||
type ExecutionWorkflow = {
|
||||
id?: number | string;
|
||||
key?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ExecutionViewRecord = {
|
||||
// Optional to accept the loosely-typed page record (a `WorkflowCanvasRecord`,
|
||||
// whose `id` is itself optional) without a cast. The header only reads `id`
|
||||
// after the page has guarded `data?.id != null`, so it is present at runtime.
|
||||
id?: number | string;
|
||||
key?: string;
|
||||
status?: number | null;
|
||||
reason?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
workflow?: ExecutionWorkflow;
|
||||
};
|
||||
|
||||
function ExecutionStatus({ execution }: { execution: ExecutionViewRecord }) {
|
||||
const compile = useT();
|
||||
const option = EXECUTION_STATUS_OPTIONS_MAP[execution.status as number];
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
const reasonOption = execution.reason ? EXECUTION_REASON_OPTIONS_MAP[execution.reason] : undefined;
|
||||
return (
|
||||
<Tag color={option.color}>
|
||||
<Space size={4}>
|
||||
{compile(option.label)}
|
||||
{execution.reason ? (
|
||||
<Tooltip title={compile(reasonOption?.label ?? execution.reason)} placement="bottom">
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExecutionViewHeader({
|
||||
execution,
|
||||
resource,
|
||||
refresh,
|
||||
}: {
|
||||
execution: ExecutionViewRecord;
|
||||
resource: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const compile = useT();
|
||||
const { getWorkflowCanvasPath } = useWorkflowRuntimePaths();
|
||||
const { token } = theme.useToken();
|
||||
const { modal, message } = App.useApp();
|
||||
const workflow = execution.workflow;
|
||||
// STARTED (0) / QUEUEING (null) are the in-progress states that can be canceled.
|
||||
const cancelable = execution.status === EXECUTION_STATUS.STARTED || execution.status === EXECUTION_STATUS.QUEUEING;
|
||||
|
||||
const onCancel = useMemoizedFn(() => {
|
||||
modal.confirm({
|
||||
title: t('Cancel the execution'),
|
||||
content: t('Are you sure you want to cancel the execution?'),
|
||||
async onOk() {
|
||||
await resource.cancel({ filterByTk: execution.id });
|
||||
message.success(t('Operation succeeded'));
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: token.margin,
|
||||
flexWrap: 'wrap',
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: <Link to={WORKFLOW_HOMEPAGE}>{t('Workflow')}</Link>,
|
||||
},
|
||||
{
|
||||
title:
|
||||
workflow?.id != null ? (
|
||||
<Tooltip title={`Key: ${workflow.key}`}>
|
||||
<Link to={getWorkflowCanvasPath(workflow.id)}>{compile(workflow.title || '')}</Link>
|
||||
</Tooltip>
|
||||
) : (
|
||||
compile(workflow?.title || '')
|
||||
),
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<ExecutionsDropdown
|
||||
execution={{
|
||||
id: execution.id,
|
||||
key: execution.key,
|
||||
status: execution.status,
|
||||
createdAt: execution.createdAt,
|
||||
}}
|
||||
refresh={refresh}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: token.marginXS }}>
|
||||
<ExecutionStatus execution={execution} />
|
||||
{cancelable ? (
|
||||
<Tooltip title={t('Cancel the execution')}>
|
||||
<Button type="link" danger shape="circle" size="small" icon={<StopOutlined />} onClick={onCancel} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<time style={{ opacity: 0.65 }}>{formatTime(execution.updatedAt)}</time>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionViewHeader;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DownOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useFlowContext } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn, useRequest } from 'ahooks';
|
||||
import { Button, Dropdown, Space, theme } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getWorkflowExecutionPath } from '../constants';
|
||||
import { ExecutionStatusIcon } from './ExecutionStatusIcon';
|
||||
import { formatTime } from './workflowCanvas';
|
||||
|
||||
export type ExecutionRecord = {
|
||||
// Optional to chain from the loosely-typed page record (`WorkflowCanvasRecord`
|
||||
// → `ExecutionViewRecord` → here), all of which keep `id` optional. The
|
||||
// dropdown only renders once the parent has a concrete execution, so `id` is
|
||||
// present at runtime.
|
||||
id?: number | string;
|
||||
key?: string;
|
||||
status?: number | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Switch between executions of the same workflow. Loads a window of the
|
||||
* executions immediately before and after the current one (by id), mirroring
|
||||
* v1's prev/next loading.
|
||||
*/
|
||||
export function ExecutionsDropdown({ execution, refresh }: { execution: ExecutionRecord; refresh?: () => void }) {
|
||||
const ctx = useFlowContext();
|
||||
const { token } = theme.useToken();
|
||||
const resource = ctx.api.resource('executions');
|
||||
|
||||
const { data, run } = useRequest(
|
||||
async () => {
|
||||
const [before, after] = await Promise.all([
|
||||
resource.list({
|
||||
filter: { key: execution.key, id: { $lt: execution.id } },
|
||||
sort: '-id',
|
||||
pageSize: 10,
|
||||
fields: ['id', 'status', 'createdAt'],
|
||||
}),
|
||||
resource.list({
|
||||
filter: { key: execution.key, id: { $gt: execution.id } },
|
||||
sort: 'id',
|
||||
pageSize: 10,
|
||||
fields: ['id', 'status', 'createdAt'],
|
||||
}),
|
||||
]);
|
||||
const beforeList = (before?.data?.data ?? []) as ExecutionRecord[];
|
||||
const afterList = ((after?.data?.data ?? []) as ExecutionRecord[]).slice().reverse();
|
||||
return [...afterList, execution, ...beforeList];
|
||||
},
|
||||
{ manual: true },
|
||||
);
|
||||
|
||||
const onClick = useMemoizedFn(({ key }: { key: string }) => {
|
||||
if (String(key) !== String(execution.id)) {
|
||||
ctx.router.navigate(getWorkflowExecutionPath(key));
|
||||
}
|
||||
});
|
||||
|
||||
const items = useMemo(
|
||||
() =>
|
||||
(data ?? [execution]).map((item) => ({
|
||||
key: `${item.id}`,
|
||||
icon: <ExecutionStatusIcon value={item.status ?? null} />,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: token.marginXL,
|
||||
minWidth: token.sizeXXL * 5,
|
||||
}}
|
||||
>
|
||||
<span>{`#${item.id}`}</span>
|
||||
<time style={{ fontSize: token.fontSizeSM, color: token.colorTextTertiary }}>
|
||||
{formatTime(item.createdAt)}
|
||||
</time>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
[data, execution, token],
|
||||
);
|
||||
|
||||
return (
|
||||
<Space size={token.marginXXS}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
onOpenChange={(open) => open && run()}
|
||||
menu={{ onClick, selectedKeys: [`${execution.id}`], items }}
|
||||
>
|
||||
<Space style={{ cursor: 'pointer' }}>
|
||||
<strong>{`#${execution.id}`}</strong>
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
{refresh ? <Button type="link" size="small" icon={<ReloadOutlined />} onClick={refresh} /> : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionsDropdown;
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { FilterGroup, CollectionFilterItem, type CollectionFilterItemValue } from '@nocobase/client-v2';
|
||||
import { observable, reaction, toJS, useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { removeInvalidFilterItems, type FilterGroupType } from '@nocobase/utils/client';
|
||||
import { useT } from '../locale';
|
||||
import { getCollection } from './collection/utils';
|
||||
|
||||
type FilterCondition = CollectionFilterItemValue;
|
||||
|
||||
type FilterGroupValue = {
|
||||
logic: '$and' | '$or';
|
||||
items: Array<FilterCondition | FilterGroupValue>;
|
||||
};
|
||||
|
||||
function createEmptyGroup(): FilterGroupType {
|
||||
return { logic: '$and', items: [] };
|
||||
}
|
||||
|
||||
function isFilterGroupValue(item: unknown): item is FilterGroupValue {
|
||||
return Boolean(item && typeof item === 'object' && 'logic' in (item as object) && 'items' in (item as object));
|
||||
}
|
||||
|
||||
function isConditionItem(item: FilterCondition | FilterGroupValue): item is FilterCondition {
|
||||
return typeof (item as FilterCondition).path === 'string' && typeof (item as FilterCondition).operator === 'string';
|
||||
}
|
||||
|
||||
function nestPath(path: string, leaf: unknown): Record<string, unknown> {
|
||||
const segments = path.split('.');
|
||||
let result: unknown = leaf;
|
||||
for (let i = segments.length - 1; i >= 0; i--) {
|
||||
result = { [segments[i]]: result };
|
||||
}
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function compileFilterGroup(group: FilterGroupType | undefined): Record<string, unknown> | undefined {
|
||||
if (!group?.items?.length) {
|
||||
return undefined;
|
||||
}
|
||||
const compiled = group.items
|
||||
.map((item) => {
|
||||
if (isFilterGroupValue(item)) {
|
||||
return compileFilterGroup(item as FilterGroupType);
|
||||
}
|
||||
if (!isConditionItem(item)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!item.path || !item.operator) {
|
||||
return undefined;
|
||||
}
|
||||
return nestPath(item.path, { [item.operator]: item.value });
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
||||
if (!compiled.length) {
|
||||
return undefined;
|
||||
}
|
||||
return { [group.logic]: compiled };
|
||||
}
|
||||
|
||||
function decompileConditions(value: unknown, path: string[] = []): FilterCondition[] {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return path.length ? [{ path: path.join('.'), operator: '$eq', value }] : [];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
const operatorEntries = entries.filter(([key]) => key.startsWith('$'));
|
||||
if (operatorEntries.length) {
|
||||
if (!path.length) {
|
||||
return [];
|
||||
}
|
||||
return operatorEntries.map(([operator, operatorValue]) => ({
|
||||
path: path.join('.'),
|
||||
operator,
|
||||
value: operatorValue,
|
||||
}));
|
||||
}
|
||||
|
||||
return entries.flatMap(([fieldName, nextValue]) => decompileConditions(nextValue, [...path, fieldName]));
|
||||
}
|
||||
|
||||
function getGroupLogic(record: Record<string, unknown>): FilterGroupType['logic'] | undefined {
|
||||
if (Array.isArray(record.$or)) {
|
||||
return '$or';
|
||||
}
|
||||
if (Array.isArray(record.$and)) {
|
||||
return '$and';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function decompileFilterItem(item: unknown): Array<FilterCondition | FilterGroupValue> {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return [];
|
||||
}
|
||||
const record = item as Record<string, unknown>;
|
||||
const logic = getGroupLogic(record);
|
||||
if (logic) {
|
||||
const group = decompileFilterGroup(record);
|
||||
return group ? [group] : [];
|
||||
}
|
||||
return decompileConditions(record);
|
||||
}
|
||||
|
||||
function decompileFilterGroup(filter: unknown): FilterGroupValue | undefined {
|
||||
if (!filter || typeof filter !== 'object' || Array.isArray(filter)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = filter as Record<string, unknown>;
|
||||
const logic = getGroupLogic(record);
|
||||
if (!logic) {
|
||||
const items = decompileConditions(record);
|
||||
return items.length ? { logic: '$and', items } : undefined;
|
||||
}
|
||||
const sourceItems = record[logic] as unknown[];
|
||||
const items = sourceItems
|
||||
.flatMap((item) => decompileFilterItem(item))
|
||||
.filter((item): item is FilterCondition | FilterGroupValue => Boolean(item));
|
||||
return items.length ? { logic, items } : undefined;
|
||||
}
|
||||
|
||||
function toFilterGroup(value: unknown): FilterGroupType {
|
||||
if (isFilterGroupValue(value)) {
|
||||
return value as FilterGroupType;
|
||||
}
|
||||
return decompileFilterGroup(value) ?? createEmptyGroup();
|
||||
}
|
||||
|
||||
export function FilterDynamicComponent({
|
||||
collection,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
collection?: string;
|
||||
value?: Record<string, unknown> | null;
|
||||
onChange?: (value: Record<string, unknown> | null) => void;
|
||||
}) {
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
const stableT = useMemoizedFn((key: string, options?: Record<string, unknown>) => t(key, options));
|
||||
const currentCollection = useMemo(
|
||||
() => getCollection(flowEngine.context.dataSourceManager, collection),
|
||||
[flowEngine, collection],
|
||||
);
|
||||
|
||||
const filterRef = useRef<FilterGroupType>();
|
||||
if (!filterRef.current) {
|
||||
filterRef.current = observable(toFilterGroup(value)) as FilterGroupType;
|
||||
}
|
||||
|
||||
const lastExternalSignatureRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextSignature = JSON.stringify(value ?? {});
|
||||
if (lastExternalSignatureRef.current === nextSignature) {
|
||||
return;
|
||||
}
|
||||
const next = toFilterGroup(value);
|
||||
if (filterRef.current) {
|
||||
filterRef.current.logic = next.logic;
|
||||
filterRef.current.items = next.items;
|
||||
lastExternalSignatureRef.current = nextSignature;
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
return reaction(
|
||||
() => JSON.stringify(toJS(filterRef.current)),
|
||||
(serialized) => {
|
||||
const current = JSON.parse(serialized) as FilterGroupType;
|
||||
const draftItemsLength = Array.isArray(current.items) ? current.items.length : 0;
|
||||
const filtered = removeInvalidFilterItems(current);
|
||||
if (!filtered.items.length) {
|
||||
if (!draftItemsLength) {
|
||||
onChange?.({});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nextValue = compileFilterGroup(filtered) ?? {};
|
||||
lastExternalSignatureRef.current = JSON.stringify(nextValue);
|
||||
onChange?.(nextValue);
|
||||
},
|
||||
{ fireImmediately: false },
|
||||
);
|
||||
}, [onChange]);
|
||||
|
||||
const FilterItemComponent = useMemo(() => {
|
||||
if (!currentCollection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = ({ value }: { value: CollectionFilterItemValue }) => (
|
||||
<CollectionFilterItem
|
||||
value={value}
|
||||
collection={currentCollection}
|
||||
t={stableT}
|
||||
fieldPlaceholder={stableT('Select field')}
|
||||
operatorPlaceholder={stableT('Comparision')}
|
||||
valuePlaceholder={null}
|
||||
fieldWidth={160}
|
||||
operatorMinWidth={110}
|
||||
/>
|
||||
);
|
||||
Component.displayName = 'WorkflowCollectionFilterItem';
|
||||
return Component;
|
||||
}, [currentCollection, stableT]);
|
||||
|
||||
return <FilterGroup value={filterRef.current} FilterItem={FilterItemComponent ?? undefined} />;
|
||||
}
|
||||
|
||||
export const ConditionField = FilterDynamicComponent;
|
||||
|
||||
export default FilterDynamicComponent;
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine';
|
||||
import { useRequest } from 'ahooks';
|
||||
import { App, Input, Modal, Space, Spin, Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { useT } from '../locale';
|
||||
import { useInstruction } from '../canvas/useWorkflowInstruction';
|
||||
import { useFlowContext } from '../canvas/contexts';
|
||||
import { formatTime } from './workflowCanvas';
|
||||
import { JobStatusTag } from './jobStatus';
|
||||
import useStyles from '../canvas/style';
|
||||
|
||||
function JobResult({ jobId }: { jobId: string | number }) {
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
const { styles } = useStyles();
|
||||
const { data, loading } = useRequest(async () => {
|
||||
const response = await ctx.api.resource('jobs').get({ filterByTk: jobId });
|
||||
return response?.data?.data ?? null;
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>{t('Status')}:</div>
|
||||
<JobStatusTag value={data?.status ?? null} />
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>{t('Executed at')}:</div>
|
||||
<div>{formatTime(data?.updatedAt)}</div>
|
||||
</div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>{t('Node result')}:</div>
|
||||
<Input.TextArea
|
||||
value={JSON.stringify(data?.result ?? null, null, 2)}
|
||||
disabled
|
||||
autoSize={{ minRows: 4, maxRows: 20 }}
|
||||
className={styles.nodeJobResultClass}
|
||||
style={{ whiteSpace: 'pre', fontFamily: 'monospace', fontSize: '80%' }}
|
||||
/>
|
||||
{data?.log ? (
|
||||
<>
|
||||
<div style={{ marginTop: 16, marginBottom: 8, fontWeight: 500 }}>{t('Log')}</div>
|
||||
<Input.TextArea
|
||||
value={data.log}
|
||||
disabled
|
||||
autoSize={{ minRows: 4, maxRows: 20 }}
|
||||
style={{ whiteSpace: 'pre', fontFamily: 'monospace', fontSize: '80%' }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function JobResultModal() {
|
||||
const { viewJob, setViewJob } = useFlowContext() ?? {};
|
||||
const t = useT();
|
||||
const instruction = useInstruction(viewJob?.node?.type);
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={Boolean(viewJob)}
|
||||
onCancel={() => setViewJob?.(null)}
|
||||
footer={null}
|
||||
width={980}
|
||||
title={
|
||||
<div className={styles.nodeTitleClass}>
|
||||
<Tag>{instruction ? t(instruction.title as string) : viewJob?.node?.type}</Tag>
|
||||
<strong>{viewJob?.node?.title}</strong>
|
||||
</div>
|
||||
}
|
||||
destroyOnClose
|
||||
modalRender={(node) => (
|
||||
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}>
|
||||
{node}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{viewJob?.id != null ? <JobResult jobId={viewJob.id} /> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default JobResultModal;
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v2-native copy of the v1 `RadioWithTooltip` (mirrors `client/components/
|
||||
* RadioWithTooltip.tsx`). Same DOM/behaviour, but Formily-free: `css` comes from
|
||||
* `@emotion/css` and label compilation goes through the v2 `useT()` instead of
|
||||
* v1's `useCompile`. Used by the condition node's "Calculation engine" field.
|
||||
*/
|
||||
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { css } from '@emotion/css';
|
||||
import { Radio, Space, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import { useT } from '../locale';
|
||||
|
||||
export interface RadioWithTooltipOption {
|
||||
value: any;
|
||||
label: string;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface RadioWithTooltipProps {
|
||||
options?: RadioWithTooltipOption[];
|
||||
direction?: 'horizontal' | 'vertical';
|
||||
value?: any;
|
||||
onChange?: (value: any) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function RadioWithTooltip(props: RadioWithTooltipProps) {
|
||||
const { options = [], direction, ...other } = props;
|
||||
const compile = useT();
|
||||
|
||||
return (
|
||||
<Radio.Group {...other}>
|
||||
<Space direction={direction}>
|
||||
{options.map((option) => (
|
||||
<Radio key={option.value} value={option.value}>
|
||||
<span
|
||||
className={css`
|
||||
& + .anticon {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{compile(option.label)}
|
||||
</span>
|
||||
{option.tooltip && (
|
||||
<Tooltip title={compile(option.tooltip)}>
|
||||
<QuestionCircleOutlined style={{ color: '#666' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default RadioWithTooltip;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { Radio, Space, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
|
||||
export type SyncModeSelectProps = {
|
||||
value?: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function SyncModeSelect({ value, onChange, disabled }: SyncModeSelectProps) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const options = [
|
||||
{
|
||||
value: false,
|
||||
label: t('Asynchronously'),
|
||||
tooltip: t('Will be executed in the background as a queued task.'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: t('Synchronously'),
|
||||
tooltip: t(
|
||||
'For user actions that require immediate feedback. Can not use asynchronous nodes in such mode, and it is not recommended to perform time-consuming operations under synchronous mode.',
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Radio.Group value={value} disabled={disabled} onChange={(e) => onChange?.(e.target.value)}>
|
||||
{options.map((option) => (
|
||||
<Radio key={String(option.value)} value={option.value}>
|
||||
<Space size="small">
|
||||
{option.label}
|
||||
<Tooltip title={option.tooltip}>
|
||||
<QuestionCircleOutlined />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default SyncModeSelect;
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* "Test run" action — native-antd rewrite of v1's Formily `TestButton`
|
||||
* (`client/nodes/index.tsx`). v1 is deeply Formily-coupled (createForm /
|
||||
* SchemaComponent / Action.Modal / observer), so per ADR-0003 it is rebuilt
|
||||
* here rather than ported.
|
||||
*
|
||||
* Click opens a centered `ctx.viewer.dialog` (X-to-close only, no footer) that:
|
||||
* - warns that a test run hits real data / APIs;
|
||||
* - lists every `{{ ... }}` variable reference in the node config (extracted
|
||||
* with `parse(...).parameters`) as a row: left = the disabled variable pill,
|
||||
* right = a pure-constant `TypedVariableInput` (no variable tree) to supply a
|
||||
* literal test value — exactly v1's "replace variables" form;
|
||||
* - on Run, substitutes the supplied values into the config via the
|
||||
* `template(context)` returned by `parse`, POSTs `flow_nodes.test`, and shows
|
||||
* the status (Resolved / Failed), the result JSON, and a collapsible log.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { CaretRightOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Collapse, Empty, Input, Space } from 'antd';
|
||||
import { css } from '@emotion/css';
|
||||
import { TypedVariableInput, type TypedConstantSpec } from '@nocobase/client-v2';
|
||||
import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine';
|
||||
import { parse } from '@nocobase/utils/client';
|
||||
import { useT } from '../locale';
|
||||
import { NodeContext } from '../canvas/contexts';
|
||||
import { WorkflowVariableInput } from '../canvas/WorkflowVariableInput';
|
||||
|
||||
// Replacement values accept any literal, matching v1's
|
||||
// `useTypedConstant={['string','number','boolean','date','object']}`.
|
||||
const REPLACE_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date', 'object'];
|
||||
|
||||
/** One variable row: disabled pill (which variable) + a constant input (test value). */
|
||||
function VariableReplacer({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}) {
|
||||
// A plain flex row (not antd `Space`): `Space` wraps each child in a shrink-to-content `.ant-space-item`, which
|
||||
// collapses `TypedVariableInput`'s internal `width:100%` and squashes the boolean Select. Giving each side an
|
||||
// explicit flex basis keeps both at a usable width (mirrors v1's layout).
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<WorkflowVariableInput value={`{{${name}}}`} disabled />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* metaTree={[]} → no variable branch, pure constant (mirrors v1). */}
|
||||
<TypedVariableInput metaTree={[]} types={REPLACE_TYPES} value={value} onChange={onChange} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsible log panel — mirrors v1's `LogCollapse`. */
|
||||
function LogCollapse({ value }: { value?: string }) {
|
||||
const t = useT();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'log',
|
||||
label: t('Log'),
|
||||
children: (
|
||||
<Input.TextArea
|
||||
value={value}
|
||||
autoSize={{ minRows: 5, maxRows: 20 }}
|
||||
style={{ whiteSpace: 'pre', cursor: 'text', fontFamily: 'monospace', fontSize: '80%' }}
|
||||
disabled
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
className={css`
|
||||
.ant-collapse-item > .ant-collapse-header {
|
||||
padding: 0;
|
||||
}
|
||||
.ant-collapse-content > .ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TestResult = { status: number; result?: unknown; log?: string };
|
||||
|
||||
function TestRunDialog({ data }: { data: any }) {
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
const [replaceValues, setReplaceValues] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Variable keys referenced by the node config (e.g. `$jobsMapByNodeKey.x.y`).
|
||||
const template = useMemo(() => parse(data.config ?? {}), [data.config]);
|
||||
const keys = useMemo(() => template.parameters.map((p: { key: string }) => p.key), [template]);
|
||||
|
||||
const onRun = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const config = template(replaceValues);
|
||||
const {
|
||||
data: { data: res },
|
||||
} = await ctx.api.resource('flow_nodes').test({
|
||||
values: { config, type: data.type },
|
||||
});
|
||||
setResult(res as TestResult);
|
||||
} catch (err) {
|
||||
setError((err as Error)?.message ?? t('Failed to run'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const succeeded = result != null && result.status > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('Test run will do the actual data manipulating or API calling, please use with caution.')}
|
||||
style={{ marginBottom: '1em' }}
|
||||
/>
|
||||
|
||||
<div style={{ marginBottom: '1em' }}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '0.5em' }}>{t('Replace variables')}</div>
|
||||
{keys.length ? (
|
||||
<Space direction="vertical" style={{ display: 'flex', width: '100%' }}>
|
||||
{keys.map((key: string) => (
|
||||
<VariableReplacer
|
||||
key={key}
|
||||
name={key}
|
||||
value={replaceValues[key]}
|
||||
onChange={(v) => setReplaceValues((prev) => ({ ...prev, [key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t('No variable')} style={{ margin: '1em' }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1em' }}>
|
||||
<Button type="primary" loading={loading} onClick={onRun}>
|
||||
{t('Run')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontWeight: 'bold', marginBottom: '0.5em' }}>{t('Result')}</div>
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: '0.5em' }} /> : null}
|
||||
{result != null ? (
|
||||
<Alert
|
||||
type={succeeded ? 'success' : 'error'}
|
||||
showIcon
|
||||
message={succeeded ? t('Resolved') : t('Failed')}
|
||||
style={{ marginBottom: '0.5em' }}
|
||||
/>
|
||||
) : null}
|
||||
<Input.TextArea
|
||||
value={result == null ? '' : JSON.stringify(result.result ?? null, null, 2)}
|
||||
readOnly
|
||||
autoSize={{ minRows: 5, maxRows: 20 }}
|
||||
style={{ whiteSpace: 'pre', cursor: 'text', fontFamily: 'monospace', fontSize: '80%' }}
|
||||
/>
|
||||
<LogCollapse value={result?.log} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer "Test run" button shown in the node config drawer for testable
|
||||
* nodes. `form` is the drawer's antd form — its live `config` value is snapshot
|
||||
* into the dialog when opened.
|
||||
*/
|
||||
export function TestRunButton({ data, form }: { data: any; form: any }) {
|
||||
const ctx = useFlowEngineContext();
|
||||
const t = useT();
|
||||
|
||||
const onOpen = () => {
|
||||
const config = form.getFieldsValue()?.config ?? data.config ?? {};
|
||||
// The dialog renders in a detached portal, so React contexts from the drawer (NodeContext) don't cross into it.
|
||||
// Re-provide it from the same `data` node object — its live `.upstream` linked-list lets the variable pills resolve
|
||||
// labels (Node result / field names), exactly as the config drawer does.
|
||||
ctx.viewer.dialog({
|
||||
width: 800,
|
||||
closable: true,
|
||||
title: t('Test run'),
|
||||
content: () => (
|
||||
<NodeContext.Provider value={{ ...data, config }}>
|
||||
<TestRunDialog data={{ ...data, config }} />
|
||||
</NodeContext.Provider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button icon={<CaretRightOutlined />} onClick={onOpen}>
|
||||
{t('Test run')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestRunButton;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { InputNumber, Select, Space } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
|
||||
const UNIT_OPTIONS = [
|
||||
{ value: 1000, label: 'Seconds' },
|
||||
{ value: 60_000, label: 'Minutes' },
|
||||
{ value: 3600_000, label: 'Hours' },
|
||||
{ value: 86_400_000, label: 'Days' },
|
||||
];
|
||||
|
||||
const DEFAULT_UNIT = 60_000;
|
||||
const MAX_TIMEOUT = 180 * 86_400_000;
|
||||
|
||||
function clampTimeout(value: number, max = MAX_TIMEOUT) {
|
||||
return Math.min(Math.max(value, 0), max);
|
||||
}
|
||||
|
||||
function normalizeUnit(value?: number, fallback = DEFAULT_UNIT) {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
return UNIT_OPTIONS.findLast((item) => value % item.value === 0)?.value ?? fallback;
|
||||
}
|
||||
|
||||
export type TimeoutInputProps = {
|
||||
value?: number;
|
||||
onChange?: (value: number) => void;
|
||||
};
|
||||
|
||||
export function TimeoutInput({ value: rawValue, onChange }: TimeoutInputProps) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const value = clampTimeout(Number(rawValue ?? 0));
|
||||
const [unit, setUnit] = useState(() => normalizeUnit(value));
|
||||
|
||||
useEffect(() => {
|
||||
if (value === 0) {
|
||||
return;
|
||||
}
|
||||
setUnit((current) => normalizeUnit(value, current));
|
||||
}, [value]);
|
||||
|
||||
const displayValue = value === 0 ? 0 : value / unit;
|
||||
const max = MAX_TIMEOUT / unit;
|
||||
|
||||
return (
|
||||
<Space.Compact style={{ width: '50%' }}>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={max}
|
||||
precision={0}
|
||||
value={displayValue}
|
||||
onChange={(next) => onChange?.(clampTimeout(Number(next) || 0, max) * unit)}
|
||||
style={{ width: '70%' }}
|
||||
/>
|
||||
<Select
|
||||
value={unit}
|
||||
style={{ width: '30%' }}
|
||||
options={UNIT_OPTIONS.map((item) => ({ value: item.value, label: t(item.label) }))}
|
||||
onChange={(nextUnit) => {
|
||||
const base = clampTimeout(Number(rawValue ?? 0));
|
||||
const current = base === 0 ? 0 : base / unit;
|
||||
setUnit(nextUnit);
|
||||
onChange?.(clampTimeout(current * nextUnit));
|
||||
}}
|
||||
/>
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimeoutInput;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Breadcrumb, Tag, Tooltip, theme } from 'antd';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useT, useWorkflowTranslation } from '../locale';
|
||||
import type { WorkflowCanvasRecord, WorkflowRevision } from './workflowCanvas';
|
||||
import { WorkflowEnabledSwitch } from './WorkflowEnabledSwitch';
|
||||
import { WorkflowMenu } from './WorkflowMenu';
|
||||
import { WorkflowRevisionsDropdown } from './WorkflowRevisionsDropdown';
|
||||
import { ExecuteWorkflowButton } from '../triggers/ExecuteWorkflowButton';
|
||||
|
||||
const WORKFLOW_HOMEPAGE = '/admin/settings/workflow';
|
||||
|
||||
export function WorkflowCanvasHeader({
|
||||
record,
|
||||
revisions,
|
||||
resource,
|
||||
refresh,
|
||||
}: {
|
||||
record: WorkflowCanvasRecord;
|
||||
revisions: WorkflowRevision[];
|
||||
resource: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const compile = useT();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: token.margin,
|
||||
flexWrap: 'wrap',
|
||||
padding: `${token.paddingSM}px ${token.padding}px`,
|
||||
background: token.colorBgContainer,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: token.marginSM }}>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{
|
||||
title: <Link to={WORKFLOW_HOMEPAGE}>{t('Workflow')}</Link>,
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<Tooltip title={`Key: ${record.key}`}>
|
||||
<strong>{compile(record.title || '')}</strong>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{record.sync ? <Tag color="orange">{t('Synchronously')}</Tag> : <Tag color="cyan">{t('Asynchronously')}</Tag>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: token.marginXS }}>
|
||||
<ExecuteWorkflowButton record={record} refresh={refresh} />
|
||||
<WorkflowRevisionsDropdown record={record} resource={resource} />
|
||||
<WorkflowEnabledSwitch record={record} resource={resource} onChanged={() => refresh()} />
|
||||
<WorkflowMenu record={record} revisions={revisions} resource={resource} refresh={refresh} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowCanvasHeader;
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Descriptions, Modal } from 'antd';
|
||||
import React from 'react';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
import { formatTime, formatUser, type WorkflowCanvasRecord } from './workflowCanvas';
|
||||
|
||||
export function WorkflowDetailsModal({
|
||||
record,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
record: WorkflowCanvasRecord;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
return (
|
||||
<Modal open={open} title={t('Details')} width={640} footer={null} onCancel={onClose}>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="Key" span={2}>
|
||||
{record.key || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Created by')}>{formatUser(record.createdBy)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Created at')}>{formatTime(record.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Last updated by')}>{formatUser(record.updatedBy)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Last updated at')}>{formatTime(record.updatedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Description')} span={2}>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{record.description || '-'}</div>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowDetailsModal;
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { App, Switch } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
import type { WorkflowCanvasRecord } from './workflowCanvas';
|
||||
|
||||
export function WorkflowEnabledSwitch({
|
||||
record,
|
||||
resource,
|
||||
onChanged,
|
||||
}: {
|
||||
record: WorkflowCanvasRecord;
|
||||
resource: any;
|
||||
onChanged: (enabled: boolean) => void;
|
||||
}) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const onChange = useMemoizedFn(async (checked: boolean) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await resource.update({ filterByTk: record.id, values: { enabled: checked } });
|
||||
onChanged(checked);
|
||||
} catch (error) {
|
||||
message.error(t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Switch
|
||||
checked={Boolean(record.enabled)}
|
||||
loading={loading}
|
||||
onChange={onChange}
|
||||
checkedChildren={t('On')}
|
||||
unCheckedChildren={t('Off')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowEnabledSwitch;
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { EllipsisOutlined } from '@ant-design/icons';
|
||||
import { useFlowContext } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { App, Button, Dropdown } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useWorkflowRuntimePaths } from '../hooks/useWorkflowRuntimePaths';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
import { ExecutionHistoryDrawer } from '../pages/ExecutionHistoryDrawer';
|
||||
import { WorkflowDetailsModal } from './WorkflowDetailsModal';
|
||||
import { normalizeRecordResponse, type WorkflowCanvasRecord, type WorkflowRevision } from './workflowCanvas';
|
||||
|
||||
const WORKFLOW_HOMEPAGE = '/admin/settings/workflow';
|
||||
|
||||
export function WorkflowMenu({
|
||||
record,
|
||||
revisions,
|
||||
resource,
|
||||
refresh,
|
||||
}: {
|
||||
record: WorkflowCanvasRecord;
|
||||
revisions: WorkflowRevision[];
|
||||
resource: any;
|
||||
refresh: () => void;
|
||||
}) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const ctx = useFlowContext();
|
||||
const { getWorkflowCanvasPath } = useWorkflowRuntimePaths();
|
||||
const { modal, message } = App.useApp();
|
||||
const [detailsVisible, setDetailsVisible] = useState(false);
|
||||
|
||||
const anyExecuted = Number(record.stats?.executed || 0) > 0;
|
||||
|
||||
const openExecutions = useMemoizedFn(() => {
|
||||
ctx.viewer.drawer({
|
||||
width: '60%',
|
||||
closable: true,
|
||||
title: t('Execution history'),
|
||||
content: () => <ExecutionHistoryDrawer workflowKey={record.key} />,
|
||||
});
|
||||
});
|
||||
|
||||
const onRevision = useMemoizedFn(async () => {
|
||||
const response = await resource.revision({ filterByTk: record.id, filter: { key: record.key } });
|
||||
const revision = normalizeRecordResponse(response);
|
||||
message.success(t('Operation succeeded'));
|
||||
if (revision?.id != null) {
|
||||
ctx.router.navigate(getWorkflowCanvasPath(revision.id));
|
||||
}
|
||||
});
|
||||
|
||||
const onDelete = useMemoizedFn(() => {
|
||||
const content = record.current
|
||||
? t('Delete a main version will cause all other revisions to be deleted too.')
|
||||
: t('Current version will be deleted (without affecting other versions).');
|
||||
modal.confirm({
|
||||
title: t('Are you sure you want to delete it?'),
|
||||
content,
|
||||
async onOk() {
|
||||
await resource.destroy({ filterByTk: record.id });
|
||||
message.success(t('Operation succeeded'));
|
||||
if (record.current) {
|
||||
ctx.router.navigate(WORKFLOW_HOMEPAGE);
|
||||
return;
|
||||
}
|
||||
const fallback = revisions.find((item) => item.current);
|
||||
ctx.router.navigate(fallback?.id != null ? getWorkflowCanvasPath(fallback.id) : WORKFLOW_HOMEPAGE);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const onMenuClick = useMemoizedFn(({ key }: { key: string }) => {
|
||||
switch (key) {
|
||||
case 'details':
|
||||
setDetailsVisible(true);
|
||||
return;
|
||||
case 'refresh':
|
||||
refresh();
|
||||
return;
|
||||
case 'history':
|
||||
openExecutions();
|
||||
return;
|
||||
case 'revision':
|
||||
onRevision();
|
||||
return;
|
||||
case 'delete':
|
||||
onDelete();
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
menu={{
|
||||
onClick: onMenuClick,
|
||||
items: [
|
||||
{ key: 'details', label: t('Details') },
|
||||
{ type: 'divider' },
|
||||
{ key: 'refresh', label: t('Refresh') },
|
||||
{ key: 'history', label: t('Execution history'), disabled: !anyExecuted },
|
||||
{ key: 'revision', label: t('Copy to new version') },
|
||||
{ type: 'divider' },
|
||||
{ key: 'delete', label: t('Delete'), danger: true },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button aria-label="more" type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
<WorkflowDetailsModal record={record} open={detailsVisible} onClose={() => setDetailsVisible(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowMenu;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DownOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useFlowContext } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn, useRequest } from 'ahooks';
|
||||
import { Button, Dropdown, theme } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useWorkflowRuntimePaths } from '../hooks/useWorkflowRuntimePaths';
|
||||
import { useWorkflowTranslation } from '../locale';
|
||||
import type { WorkflowCanvasRecord, WorkflowRevision } from './workflowCanvas';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function WorkflowRevisionsDropdown({ record, resource }: { record: WorkflowCanvasRecord; resource: any }) {
|
||||
const { t } = useWorkflowTranslation();
|
||||
const ctx = useFlowContext();
|
||||
const { getWorkflowCanvasPath } = useWorkflowRuntimePaths();
|
||||
const { token } = theme.useToken();
|
||||
const { data, run } = useRequest(
|
||||
async () => {
|
||||
const response = await resource.list({
|
||||
filter: { key: record.key },
|
||||
fields: ['id', 'createdAt', 'current', 'enabled', 'versionStats.executed'],
|
||||
sort: '-id',
|
||||
});
|
||||
return (response?.data?.data ?? []) as WorkflowRevision[];
|
||||
},
|
||||
{ manual: true },
|
||||
);
|
||||
|
||||
const onSwitchVersion = useMemoizedFn(({ key }: { key: string }) => {
|
||||
if (String(key) !== String(record.id)) {
|
||||
ctx.router.navigate(getWorkflowCanvasPath(key));
|
||||
}
|
||||
});
|
||||
|
||||
const revisions = useMemo(() => data ?? [], [data]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
onOpenChange={(open) => open && run()}
|
||||
menu={{
|
||||
onClick: onSwitchVersion,
|
||||
selectedKeys: [`${record.id}`],
|
||||
items: revisions
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.id) - Number(a.id))
|
||||
.map((item) => ({
|
||||
key: `${item.id}`,
|
||||
icon: item.current ? <RightOutlined /> : null,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
gap: token.marginXL,
|
||||
minWidth: token.sizeXXL * 5,
|
||||
}}
|
||||
>
|
||||
<strong style={{ fontWeight: item.enabled ? 'bold' : 'normal' }}>{`#${item.id}`}</strong>
|
||||
<time style={{ fontSize: token.fontSizeSM, color: token.colorTextTertiary }}>
|
||||
{item.createdAt ? dayjs(item.createdAt).fromNow() : ''}
|
||||
</time>
|
||||
</span>
|
||||
),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<Button type="text" aria-label="version">
|
||||
<span style={{ opacity: 0.65, marginRight: 4 }}>{t('Version')}</span>
|
||||
<span>{record.id != null ? `#${record.id}` : null}</span>
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowRevisionsDropdown;
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppendsSelect } from '../collection/AppendsSelect';
|
||||
|
||||
const treeSelectState = vi.hoisted(() => ({
|
||||
props: null as null | { treeData?: Array<{ title: string; value: string }> },
|
||||
}));
|
||||
|
||||
vi.mock('antd', () => {
|
||||
const TreeSelect = (props: any) => {
|
||||
treeSelectState.props = props;
|
||||
return <div data-testid="tree-select" />;
|
||||
};
|
||||
TreeSelect.SHOW_PARENT = 'SHOW_PARENT';
|
||||
return { TreeSelect };
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/flow-engine', () => ({
|
||||
useFlowEngine: () => ({
|
||||
context: {
|
||||
dataSourceManager: {
|
||||
getDataSource: () => ({
|
||||
collectionManager: {
|
||||
getCollection: () => ({
|
||||
getFields: () => [
|
||||
{
|
||||
options: {
|
||||
name: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
target: 'users',
|
||||
uiSchema: { title: '{{t("Created by")}}' },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../locale', () => ({
|
||||
NAMESPACE: 'workflow',
|
||||
useT: () => (key: string) => {
|
||||
const matched = key.match(/^{{t\("(.+)"(?:,\s*\{.*\})?\)}}$/);
|
||||
return matched?.[1] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('AppendsSelect', () => {
|
||||
it('compiles association field titles before passing them to TreeSelect', () => {
|
||||
render(<AppendsSelect collection="users" />);
|
||||
|
||||
expect(screen.getByTestId('tree-select')).toBeInTheDocument();
|
||||
expect(treeSelectState.props?.treeData).toEqual([
|
||||
expect.objectContaining({
|
||||
title: 'Created by',
|
||||
value: 'createdBy',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { FlowEngine, FlowEngineProvider } from '@nocobase/flow-engine';
|
||||
import { FilterDynamicComponent } from '../FilterDynamicComponent';
|
||||
|
||||
class TestCollectionFieldInterface {
|
||||
name = '';
|
||||
group = '';
|
||||
filterable?: {
|
||||
operators?: any[];
|
||||
children?: any[];
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFlowApp() {
|
||||
const components: Record<string, any> = {};
|
||||
const fieldInterfaceMap = new Map<string, TestCollectionFieldInterface>();
|
||||
const collectionFieldInterfaceManager = {
|
||||
getFieldInterface(name: string) {
|
||||
return fieldInterfaceMap.get(name);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
dataSourceManager: {
|
||||
collectionFieldInterfaceManager,
|
||||
},
|
||||
addFieldInterfaces(fieldInterfaceClasses: Array<new (...args: any[]) => TestCollectionFieldInterface> = []) {
|
||||
fieldInterfaceClasses.forEach((FieldInterfaceClass) => {
|
||||
const instance = new FieldInterfaceClass(collectionFieldInterfaceManager);
|
||||
if (instance?.name) {
|
||||
fieldInterfaceMap.set(instance.name, instance);
|
||||
}
|
||||
});
|
||||
},
|
||||
addComponents(nextComponents: Record<string, any>) {
|
||||
Object.assign(components, nextComponents);
|
||||
},
|
||||
getComponent(name: string) {
|
||||
return components[name];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('../../locale', () => ({
|
||||
NAMESPACE: 'workflow',
|
||||
useT: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
function setupEngine() {
|
||||
const engine = new FlowEngine();
|
||||
const app = createMockFlowApp();
|
||||
|
||||
class InputInterface extends TestCollectionFieldInterface {
|
||||
name = 'input';
|
||||
group = 'basic';
|
||||
filterable = {
|
||||
operators: [
|
||||
{ value: '$eq', label: 'Equals' },
|
||||
{ value: '$null', label: 'Is null', noValue: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
app.addFieldInterfaces([InputInterface]);
|
||||
engine.context.defineProperty('app', { value: app });
|
||||
|
||||
const ds = engine.dataSourceManager.getDataSource('main');
|
||||
ds.addCollection({
|
||||
name: 'posts',
|
||||
fields: [{ name: 'title', type: 'string', interface: 'input', uiSchema: { 'x-component': 'Input' } }],
|
||||
});
|
||||
|
||||
return { engine };
|
||||
}
|
||||
|
||||
describe('FilterDynamicComponent', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('renders as a React component instead of throwing from the resource FilterGroup class', () => {
|
||||
const { engine } = setupEngine();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlowEngineProvider engine={engine}>
|
||||
<FilterDynamicComponent collection="posts" value={{}} onChange={onChange} />
|
||||
</FlowEngineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/conditions in the group/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-select-all-or-any')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add condition')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Select field')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an existing legacy query object as an editable condition row', () => {
|
||||
const { engine } = setupEngine();
|
||||
|
||||
render(
|
||||
<FlowEngineProvider engine={engine}>
|
||||
<FilterDynamicComponent
|
||||
collection="posts"
|
||||
value={{ $and: [{ title: { $eq: 'foo' } }] }}
|
||||
onChange={() => undefined}
|
||||
/>
|
||||
</FlowEngineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps an empty draft condition row visible after clicking Add condition', () => {
|
||||
const { engine } = setupEngine();
|
||||
function Wrapper() {
|
||||
const [value, setValue] = React.useState<Record<string, unknown>>({});
|
||||
return <FilterDynamicComponent collection="posts" value={value} onChange={(next) => setValue(next ?? {})} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<FlowEngineProvider engine={engine}>
|
||||
<Wrapper />
|
||||
</FlowEngineProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Add condition'));
|
||||
|
||||
expect(screen.getByText('Select field')).toBeInTheDocument();
|
||||
expect(screen.getByText('Comparision')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps an empty draft condition group visible after clicking Add condition group', async () => {
|
||||
const { engine } = setupEngine();
|
||||
function Wrapper() {
|
||||
const [value, setValue] = React.useState<Record<string, unknown>>({});
|
||||
return <FilterDynamicComponent collection="posts" value={value} onChange={(next) => setValue(next ?? {})} />;
|
||||
}
|
||||
|
||||
render(
|
||||
<FlowEngineProvider engine={engine}>
|
||||
<Wrapper />
|
||||
</FlowEngineProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Add condition group'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('filter-select-all-or-any').length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Form } from 'antd';
|
||||
import { TriggerCollectionRecordSelect } from '../collection';
|
||||
|
||||
const remoteSelectState = vi.hoisted(() => ({
|
||||
props: null as null | {
|
||||
value?: unknown;
|
||||
onChange?: (value?: unknown) => void;
|
||||
request: () => Promise<any[]>;
|
||||
onLoaded?: (items: any[]) => void;
|
||||
mapOptions: (item: any, index: number) => { label: React.ReactNode; value: any };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/client-v2', () => ({
|
||||
RemoteSelect: (props: any) => {
|
||||
remoteSelectState.props = props;
|
||||
return <div data-testid="remote-select" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/flow-engine', () => ({
|
||||
useFlowEngine: () => ({
|
||||
context: {
|
||||
dataSourceManager: {
|
||||
getDataSource: () => ({
|
||||
collectionManager: {
|
||||
getCollection: () => ({
|
||||
filterTargetKey: 'name',
|
||||
titleCollectionField: { name: 'title' },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
api: {
|
||||
resource: () => ({
|
||||
list: async () => ({
|
||||
data: {
|
||||
data: [
|
||||
{ name: 'admin', title: '{{t("Admin")}}' },
|
||||
{ name: 'root', title: '{{t("Root")}}' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../canvas/contexts', () => ({
|
||||
useCurrentWorkflowContext: () => ({
|
||||
config: { collection: 'roles' },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../locale', () => ({
|
||||
NAMESPACE: 'workflow',
|
||||
useT: () => (key: string) => {
|
||||
const matched = key.match(/^{{t\("(.+)"\)}}$/);
|
||||
return matched?.[1] ?? key;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('TriggerCollectionRecordSelect', () => {
|
||||
it('compiles server-returned title templates before rendering remote select options', async () => {
|
||||
render(<TriggerCollectionRecordSelect />);
|
||||
|
||||
expect(screen.getByTestId('remote-select')).toBeInTheDocument();
|
||||
|
||||
const items = await remoteSelectState.props?.request();
|
||||
const options = items?.map((item, index) => remoteSelectState.props?.mapOptions(item, index));
|
||||
|
||||
expect(options).toEqual([
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Root', value: 'root' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores the full selected record instead of only its primary key', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TriggerCollectionRecordSelect onChange={onChange} />);
|
||||
|
||||
const items = await remoteSelectState.props?.request();
|
||||
remoteSelectState.props?.onLoaded?.(items ?? []);
|
||||
remoteSelectState.props?.onChange?.('admin');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
name: 'admin',
|
||||
title: '{{t("Admin")}}',
|
||||
});
|
||||
});
|
||||
|
||||
it('converts an object-form value back to the primary key for select display', () => {
|
||||
render(<TriggerCollectionRecordSelect value={{ name: 'admin', title: '{{t("Admin")}}' }} />);
|
||||
|
||||
expect(remoteSelectState.props?.value).toBe('admin');
|
||||
});
|
||||
|
||||
it('writes the full selected record into the parent form field value', async () => {
|
||||
let formInstance: ReturnType<typeof Form.useForm>[0] | null = null;
|
||||
|
||||
function Wrapper() {
|
||||
const [form] = Form.useForm();
|
||||
formInstance = form;
|
||||
|
||||
return (
|
||||
<Form form={form} initialValues={{ data: null }}>
|
||||
<Form.Item name="data">
|
||||
<TriggerCollectionRecordSelect />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Wrapper />);
|
||||
|
||||
const items = await remoteSelectState.props?.request();
|
||||
await act(async () => {
|
||||
remoteSelectState.props?.onLoaded?.(items ?? []);
|
||||
remoteSelectState.props?.onChange?.('admin');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(formInstance?.getFieldValue('data')).toEqual({
|
||||
name: 'admin',
|
||||
title: '{{t("Admin")}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { TreeSelect } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useT } from '../../locale';
|
||||
import {
|
||||
getCollectionFields,
|
||||
hasFieldName,
|
||||
isAssociationField,
|
||||
parseCollectionName,
|
||||
type CollectionTriggerField,
|
||||
} from './utils';
|
||||
|
||||
type AppendsTreeNode = { title: string; value: string; key: string; children?: AppendsTreeNode[] };
|
||||
type CollectionDataSourceManager = Parameters<typeof getCollectionFields>[0];
|
||||
|
||||
function buildAssociationTree(
|
||||
dataSourceManager: CollectionDataSourceManager,
|
||||
compile: (value: string) => string,
|
||||
collectionValue?: string,
|
||||
prefix = '',
|
||||
depth = 2,
|
||||
): AppendsTreeNode[] {
|
||||
const fields = getCollectionFields(dataSourceManager, collectionValue);
|
||||
return fields
|
||||
.filter(hasFieldName)
|
||||
.filter(isAssociationField)
|
||||
.map((field: CollectionTriggerField & { name: string }) => {
|
||||
const value = prefix ? `${prefix}.${field.name}` : field.name;
|
||||
const [dataSourceKey] = parseCollectionName(collectionValue) as [string, string];
|
||||
const targetCollection = field.target
|
||||
? `${dataSourceKey && dataSourceKey !== 'main' ? `${dataSourceKey}:` : ''}${field.target}`
|
||||
: undefined;
|
||||
const children =
|
||||
depth > 1 && targetCollection
|
||||
? buildAssociationTree(dataSourceManager, compile, targetCollection, value, depth - 1)
|
||||
: [];
|
||||
return {
|
||||
title: field.uiSchema?.title ? compile(field.uiSchema.title) : field.name,
|
||||
value,
|
||||
key: value,
|
||||
children: children.length ? children : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function AppendsSelect({
|
||||
collection,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
collection?: string;
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
}) {
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
const treeData = useMemo(
|
||||
() => buildAssociationTree(flowEngine.context.dataSourceManager, t, collection),
|
||||
[flowEngine, t, collection],
|
||||
);
|
||||
|
||||
return (
|
||||
<TreeSelect
|
||||
treeData={treeData}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
treeCheckable
|
||||
showCheckedStrategy={TreeSelect.SHOW_PARENT}
|
||||
placeholder={t('Preload associations')}
|
||||
treeNodeFilterProp="title"
|
||||
allowClear
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppendsSelect;
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { Cascader } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useT } from '../../locale';
|
||||
import { getCollectionOptions, joinCollectionName, parseCollectionName } from './utils';
|
||||
|
||||
export function CollectionCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: (value?: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
|
||||
const options = useMemo(() => getCollectionOptions(flowEngine.context.dataSourceManager), [flowEngine]);
|
||||
const pathValue = useMemo(() => {
|
||||
const parsed = parseCollectionName(value);
|
||||
return parsed.length ? parsed : undefined;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Cascader
|
||||
options={options}
|
||||
value={pathValue}
|
||||
disabled={disabled}
|
||||
placeholder={t('Select collection')}
|
||||
showSearch
|
||||
onChange={(path) => {
|
||||
if (!path?.length) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
const [dataSourceKey, collectionName] = path as string[];
|
||||
onChange?.(joinCollectionName(dataSourceKey, collectionName));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollectionCascader;
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Form, Select, Space, Tag, type SelectProps } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { getCollectionFields, type CollectionTriggerField } from './utils';
|
||||
|
||||
function defaultFilter() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function FieldOption({ label, value }: { label?: React.ReactNode; value?: string }) {
|
||||
return (
|
||||
<Space>
|
||||
<span>{label}</span>
|
||||
<Tag bordered={false}>{value}</Tag>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldsSelect({
|
||||
collection,
|
||||
filter = defaultFilter,
|
||||
...others
|
||||
}: SelectProps & {
|
||||
collection?: string;
|
||||
filter?: (field: CollectionTriggerField) => boolean;
|
||||
}) {
|
||||
const flowEngine = useFlowEngine();
|
||||
const formCollection = Form.useWatch(['config', 'collection']);
|
||||
const fields = getCollectionFields(flowEngine.context.dataSourceManager, collection ?? formCollection);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
fields.filter(filter).map((field) => ({
|
||||
label: field.uiSchema?.title ? flowEngine.context.t(field.uiSchema.title) : undefined,
|
||||
value: field.name as string,
|
||||
})),
|
||||
[fields, filter, flowEngine],
|
||||
);
|
||||
const onSearch = useMemoizedFn((value: string, option?: { label?: string; value?: string }) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
option?.label?.toLowerCase().includes(value.toLowerCase()) ||
|
||||
option?.value?.toLowerCase().includes(value.toLowerCase()) ||
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
popupMatchSelectWidth={false}
|
||||
{...others}
|
||||
options={options}
|
||||
filterOption={onSearch}
|
||||
optionRender={(option) => <FieldOption label={option.data.label} value={option.data.value as string} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldsSelect;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { Col, Form, Row } from 'antd';
|
||||
import React from 'react';
|
||||
import { WorkflowVariableInput } from '../../canvas/WorkflowVariableInput';
|
||||
import { useT } from '../../locale';
|
||||
|
||||
export function PaginationFields({
|
||||
pageName = 'page',
|
||||
pageSizeName = 'pageSize',
|
||||
}: {
|
||||
pageName?: string | number | (string | number)[];
|
||||
pageSizeName?: string | number | (string | number)[];
|
||||
}) {
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name={pageName as any} label={t('Page number')} initialValue={1}>
|
||||
<WorkflowVariableInput variableOptions={{ types: ['number'] }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name={pageSizeName as any} label={t('Page size')} initialValue={20}>
|
||||
<WorkflowVariableInput variableOptions={{ types: ['number'] }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaginationFields;
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { DeleteOutlined, HolderOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { DndContext, PointerSensor, useSensor, useSensors, closestCenter, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn } from 'ahooks';
|
||||
import { Button, Form, Radio, Select, Space } from 'antd';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useT } from '../../locale';
|
||||
import { getCollectionFields, type CollectionTriggerField } from './utils';
|
||||
|
||||
type SortItem = {
|
||||
field?: string;
|
||||
direction?: 'asc' | 'desc';
|
||||
};
|
||||
|
||||
type SortableField = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
function useSortableFields(collection?: string): SortableField[] {
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
const app = flowEngine.context.app;
|
||||
const fields = getCollectionFields(flowEngine.context.dataSourceManager, collection);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
fields
|
||||
.filter((field: CollectionTriggerField) => {
|
||||
if (!field.interface) {
|
||||
return false;
|
||||
}
|
||||
const fieldInterface = app?.dataSourceManager?.collectionFieldInterfaceManager?.getFieldInterface?.(
|
||||
field.interface,
|
||||
);
|
||||
return Boolean(fieldInterface?.sortable);
|
||||
})
|
||||
.map((field) => ({
|
||||
value: field.name as string,
|
||||
label: field.uiSchema?.title ? t(field.uiSchema.title) : (field.name as string),
|
||||
})),
|
||||
[app, fields, t],
|
||||
);
|
||||
}
|
||||
|
||||
function SortRow({
|
||||
id,
|
||||
item,
|
||||
options,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
id: string;
|
||||
item: SortItem;
|
||||
options: SortableField[];
|
||||
onChange: (item: SortItem) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id });
|
||||
|
||||
return (
|
||||
<Space
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
align="start"
|
||||
>
|
||||
<Button type="text" icon={<HolderOutlined />} {...attributes} {...listeners} />
|
||||
<Select
|
||||
value={item.field}
|
||||
options={options}
|
||||
style={{ width: 260 }}
|
||||
placeholder=""
|
||||
onChange={(field) => onChange({ ...item, field })}
|
||||
/>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
value={item.direction}
|
||||
options={[
|
||||
{ label: 'ASC', value: 'asc' },
|
||||
{ label: 'DESC', value: 'desc' },
|
||||
]}
|
||||
onChange={(event) => onChange({ ...item, direction: event.target.value })}
|
||||
/>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} onClick={onRemove} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortFieldsInput({
|
||||
collection,
|
||||
value = [],
|
||||
onChange,
|
||||
}: {
|
||||
collection?: string;
|
||||
value?: SortItem[];
|
||||
onChange?: (value: SortItem[]) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const options = useSortableFields(collection);
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const items = value.map((item, index) => ({
|
||||
key: `${item.field ?? 'field'}-${item.direction ?? 'asc'}-${index}`,
|
||||
item,
|
||||
}));
|
||||
|
||||
const handleAdd = useMemoizedFn(() => {
|
||||
onChange?.([...(value ?? []), { field: options[0]?.value, direction: 'asc' }]);
|
||||
});
|
||||
|
||||
const handleRemove = useMemoizedFn((index: number) => {
|
||||
onChange?.((value ?? []).filter((_, currentIndex) => currentIndex !== index));
|
||||
});
|
||||
|
||||
const handleItemChange = useMemoizedFn((index: number, item: SortItem) => {
|
||||
const next = [...(value ?? [])];
|
||||
next[index] = item;
|
||||
onChange?.(next);
|
||||
});
|
||||
|
||||
const handleDragEnd = useMemoizedFn((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
if (activeId === overId) {
|
||||
return;
|
||||
}
|
||||
const oldIndex = items.findIndex((item) => item.key === activeId);
|
||||
const newIndex = items.findIndex((item) => item.key === overId);
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return;
|
||||
}
|
||||
onChange?.(arrayMove(value ?? [], oldIndex, newIndex));
|
||||
});
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{items.length ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items.map((item) => item.key)} strategy={verticalListSortingStrategy}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{items.map(({ key, item }, index) => (
|
||||
<SortRow
|
||||
key={key}
|
||||
id={key}
|
||||
item={item}
|
||||
options={options}
|
||||
onChange={(nextItem) => handleItemChange(index, nextItem)}
|
||||
onRemove={() => handleRemove(index)}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : null}
|
||||
<Button icon={<PlusOutlined />} onClick={handleAdd}>
|
||||
{t('Add sort field')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortFieldsInput;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { RemoteSelect } from '@nocobase/client-v2';
|
||||
import { useFlowEngine } from '@nocobase/flow-engine';
|
||||
import { Alert } from 'antd';
|
||||
import React, { useRef } from 'react';
|
||||
import { useCurrentWorkflowContext } from '../../canvas/contexts';
|
||||
import { useT } from '../../locale';
|
||||
import { parseCollectionName } from './utils';
|
||||
|
||||
type RecordValue = Record<string, unknown>;
|
||||
|
||||
function getPrimaryValue(item: RecordValue | string | number | null | undefined, filterTargetKey: string | string[]) {
|
||||
if (item == null || typeof item !== 'object') {
|
||||
return item;
|
||||
}
|
||||
if (Array.isArray(filterTargetKey)) {
|
||||
return JSON.stringify(
|
||||
filterTargetKey.reduce(
|
||||
(result, key) => {
|
||||
result[key] = item[key];
|
||||
return result;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
}
|
||||
return item[filterTargetKey] as string | number | undefined;
|
||||
}
|
||||
|
||||
export function TriggerCollectionRecordSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: RecordValue | string | number | null;
|
||||
onChange?: (value?: RecordValue | string | number | null) => void;
|
||||
}) {
|
||||
const workflow = useCurrentWorkflowContext();
|
||||
const flowEngine = useFlowEngine();
|
||||
const t = useT();
|
||||
const loadedItemsRef = useRef<RecordValue[]>([]);
|
||||
|
||||
const [dataSourceKey, collectionName] = parseCollectionName(workflow?.config?.collection as string) as [
|
||||
string,
|
||||
string,
|
||||
];
|
||||
const dataSource = dataSourceKey ? flowEngine.context.dataSourceManager?.getDataSource?.(dataSourceKey) : null;
|
||||
const collection = dataSource?.collectionManager?.getCollection?.(collectionName);
|
||||
|
||||
if (!dataSource) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('Data source "{{dataSourceName}}" not found.', { dataSourceName: dataSourceKey })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return (
|
||||
<Alert type="warning" showIcon message={t('Collection "{{collectionName}}" not found.', { collectionName })} />
|
||||
);
|
||||
}
|
||||
|
||||
const filterTargetKey = collection.filterTargetKey;
|
||||
const labelKey = collection.titleCollectionField?.name || filterTargetKey;
|
||||
const selectValue = getPrimaryValue(value, filterTargetKey);
|
||||
|
||||
if (!filterTargetKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RemoteSelect
|
||||
value={selectValue}
|
||||
onChange={(nextValue) => {
|
||||
const matched = loadedItemsRef.current.find((item) => getPrimaryValue(item, filterTargetKey) === nextValue);
|
||||
onChange?.(matched ?? nextValue);
|
||||
}}
|
||||
request={async () => {
|
||||
const response = await flowEngine.context.api
|
||||
.resource(collectionName, null, { 'x-data-source': dataSourceKey })
|
||||
.list({ pageSize: 50 });
|
||||
return response?.data?.data ?? [];
|
||||
}}
|
||||
onLoaded={(items) => {
|
||||
loadedItemsRef.current = items as RecordValue[];
|
||||
}}
|
||||
mapOptions={(item) => {
|
||||
const rawLabel = item?.[labelKey] ?? item?.[filterTargetKey];
|
||||
return {
|
||||
label: typeof rawLabel === 'string' ? t(rawLabel) : rawLabel ?? t('Untitled'),
|
||||
value: getPrimaryValue(item as RecordValue, filterTargetKey),
|
||||
};
|
||||
}}
|
||||
cacheKey={`workflow:collection-trigger-records:${dataSourceKey}:${collectionName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TriggerCollectionRecordSelect;
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
export * from './utils';
|
||||
export * from './CollectionCascader';
|
||||
export * from './FieldsSelect';
|
||||
export * from './AppendsSelect';
|
||||
export * from './SortFieldsInput';
|
||||
export * from './PaginationFields';
|
||||
export * from './TriggerCollectionRecordSelect';
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import type { Collection, CollectionField, DataSourceManager } from '@nocobase/flow-engine';
|
||||
import type { FieldTreeCollectionManager } from '../../canvas/collectionFieldOptions';
|
||||
|
||||
export type CollectionTriggerField = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
target?: string;
|
||||
hidden?: boolean;
|
||||
interface?: string;
|
||||
collectionName?: string;
|
||||
foreignKey?: string;
|
||||
targetKey?: string;
|
||||
primaryKey?: boolean;
|
||||
isForeignKey?: boolean;
|
||||
uiSchema?: { title?: string; ['x-read-pretty']?: boolean };
|
||||
};
|
||||
|
||||
function normalizeField(field: CollectionField): CollectionTriggerField {
|
||||
return (field.options ?? field) as CollectionTriggerField;
|
||||
}
|
||||
|
||||
export function joinCollectionName(dataSourceKey: string, collectionName: string) {
|
||||
if (!dataSourceKey || dataSourceKey === 'main') {
|
||||
return collectionName;
|
||||
}
|
||||
return `${dataSourceKey}:${collectionName}`;
|
||||
}
|
||||
|
||||
export function parseCollectionName(value?: string): [string, string] | [] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const parts = value.split(':');
|
||||
const collectionName = parts.pop();
|
||||
const dataSourceKey = parts[0] ?? 'main';
|
||||
return collectionName ? [dataSourceKey, collectionName] : [];
|
||||
}
|
||||
|
||||
export type CollectionOption = { value: string; label: string; children?: CollectionOption[] };
|
||||
|
||||
export function getCollectionOptions(dataSourceManager: DataSourceManager | undefined): CollectionOption[] {
|
||||
const dataSources = dataSourceManager?.getDataSources?.() ?? [];
|
||||
return dataSources
|
||||
.filter((ds) => ds.key === 'main' || ds.options?.isDBInstance)
|
||||
.map((ds) => ({
|
||||
value: ds.key,
|
||||
label: ds.displayName,
|
||||
children: ds
|
||||
.getCollections()
|
||||
.filter((collection) => !collection.hidden)
|
||||
.map((collection) => ({
|
||||
value: collection.name,
|
||||
label: collection.title,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function getCollectionFields(
|
||||
dataSourceManager: DataSourceManager | undefined,
|
||||
collectionValue?: string,
|
||||
): CollectionTriggerField[] {
|
||||
const [dataSourceKey, collectionName] = parseCollectionName(collectionValue) as [string, string];
|
||||
if (!dataSourceKey || !collectionName) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
dataSourceManager
|
||||
?.getDataSource?.(dataSourceKey)
|
||||
?.collectionManager?.getCollection?.(collectionName)
|
||||
?.getFields?.()
|
||||
?.map(normalizeField) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
export function getCollection(
|
||||
dataSourceManager: DataSourceManager | undefined,
|
||||
collectionValue?: string,
|
||||
): Collection | undefined {
|
||||
const [dataSourceKey, collectionName] = parseCollectionName(collectionValue) as [string, string];
|
||||
if (!dataSourceKey || !collectionName) {
|
||||
return undefined;
|
||||
}
|
||||
return dataSourceManager?.getDataSource?.(dataSourceKey)?.collectionManager?.getCollection?.(collectionName);
|
||||
}
|
||||
|
||||
export function getCollectionManagerAdapter(
|
||||
dataSourceManager: DataSourceManager | undefined,
|
||||
dataSourceKey = 'main',
|
||||
): FieldTreeCollectionManager {
|
||||
return {
|
||||
getCollectionAllFields(collectionName: string) {
|
||||
return (
|
||||
dataSourceManager
|
||||
?.getDataSource?.(dataSourceKey)
|
||||
?.collectionManager?.getCollection?.(collectionName)
|
||||
?.getFields?.()
|
||||
?.map(normalizeField) ?? []
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function isAssociationField(field: CollectionTriggerField) {
|
||||
return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'].includes(field.type || '');
|
||||
}
|
||||
|
||||
export function hasFieldName(field: CollectionTriggerField): field is CollectionTriggerField & { name: string } {
|
||||
return typeof field.name === 'string' && field.name.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
CheckOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseOutlined,
|
||||
ExclamationOutlined,
|
||||
MinusOutlined,
|
||||
RedoOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Tag } from 'antd';
|
||||
import { useT } from '../locale';
|
||||
|
||||
export const JOB_STATUS = {
|
||||
PENDING: 0,
|
||||
RESOLVED: 1,
|
||||
FAILED: -1,
|
||||
ERROR: -2,
|
||||
ABORTED: -3,
|
||||
CANCELED: -4,
|
||||
REJECTED: -5,
|
||||
RETRY_NEEDED: -6,
|
||||
} as const;
|
||||
|
||||
export const JOB_STATUS_OPTIONS_MAP: Record<number, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
[JOB_STATUS.PENDING]: { label: 'Pending', color: 'gold', icon: <ClockCircleOutlined /> },
|
||||
[JOB_STATUS.RESOLVED]: { label: 'Resolved', color: 'green', icon: <CheckOutlined /> },
|
||||
[JOB_STATUS.FAILED]: { label: 'Failed', color: 'red', icon: <ExclamationOutlined /> },
|
||||
[JOB_STATUS.ERROR]: { label: 'Error', color: 'red', icon: <CloseOutlined /> },
|
||||
[JOB_STATUS.ABORTED]: { label: 'Aborted', color: 'red', icon: <MinusOutlined rotate={90} /> },
|
||||
[JOB_STATUS.CANCELED]: { label: 'Canceled', color: 'volcano', icon: <MinusOutlined rotate={45} /> },
|
||||
[JOB_STATUS.REJECTED]: { label: 'Rejected', color: 'volcano', icon: <MinusOutlined /> },
|
||||
[JOB_STATUS.RETRY_NEEDED]: { label: 'Retry needed', color: 'volcano', icon: <RedoOutlined /> },
|
||||
};
|
||||
|
||||
export function JobStatusTag({ value }: { value: number | null }) {
|
||||
const t = useT();
|
||||
const option = JOB_STATUS_OPTIONS_MAP[value as number];
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
return <Tag color={option.color}>{t(option.label)}</Tag>;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* v2-native copy of the v1 `renderEngineReference` (mirrors `client/components/
|
||||
* renderEngineReference.tsx`). Renders the "Syntax references: <engine link>"
|
||||
* hint shown under the condition node's expression field.
|
||||
*
|
||||
* Unlike v1 (which read the module-level `i18n.t` from `@nocobase/client`), the
|
||||
* translate function is **injected** so this stays Formily-free and pure —
|
||||
* callers pass the v2 `useT()` result. `css` comes from `@emotion/css`.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
import { evaluators } from '@nocobase/evaluators/client';
|
||||
import React from 'react';
|
||||
|
||||
export const renderEngineReference = (key: string, t: (text: string) => string) => {
|
||||
const engine = evaluators.get(key);
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return engine.link ? (
|
||||
<>
|
||||
<span
|
||||
className={css`
|
||||
&:after {
|
||||
content: ':';
|
||||
}
|
||||
& + a {
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t('Syntax references')}
|
||||
</span>
|
||||
<a href={t(engine.link)} target="_blank" rel="noreferrer">
|
||||
{engine.label}
|
||||
</a>
|
||||
</>
|
||||
) : null;
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export type WorkflowRevision = {
|
||||
id: number | string;
|
||||
createdAt?: string;
|
||||
current?: boolean;
|
||||
enabled?: boolean;
|
||||
versionStats?: { executed?: number };
|
||||
};
|
||||
|
||||
export type WorkflowCanvasRecord = {
|
||||
// Optional because the canvas context (`WorkflowCanvasFlowContextValue.workflow`) is itself `WorkflowCanvasRecord |
|
||||
// null`, and downstream consumers (e.g. the approval pro-plugin's association `approval.workflow`) may hold a
|
||||
// partially loaded record without `id`. Every internal canvas read already guards with `workflow?.id`, so the type
|
||||
// was over-promising — this aligns it with usage.
|
||||
id?: number | string;
|
||||
key?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
sync?: boolean;
|
||||
enabled?: boolean;
|
||||
current?: boolean;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
createdBy?: any;
|
||||
updatedBy?: any;
|
||||
stats?: { executed?: number };
|
||||
versionStats?: { executed?: number };
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export function normalizeRecordResponse(response: any): WorkflowCanvasRecord | null {
|
||||
return response?.data?.data || response?.data || null;
|
||||
}
|
||||
|
||||
export function formatUser(user: any) {
|
||||
return user?.nickname || user?.username || user?.email || user?.id || '-';
|
||||
}
|
||||
|
||||
export function formatTime(value?: string) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-';
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
// The canvas page is registered directly under `admin` (not `admin.settings`), so it renders in the admin content area
|
||||
// without the settings left menu. `admin.workflow.*` is a shared namespace so sibling pages (e.g. executions at
|
||||
// `/admin/workflow/executions/:id`) can line up under the same prefix, mirroring v1's `admin.workflow.workflows.id`
|
||||
// route.
|
||||
export const WORKFLOW_CANVAS_ROUTE_NAME = 'admin.workflow.workflows.id';
|
||||
export const WORKFLOW_CANVAS_ROUTE_PATH = '/admin/workflow/workflows/:id';
|
||||
|
||||
export function getWorkflowCanvasPath(id: string | number) {
|
||||
return `/admin/workflow/workflows/${id}`;
|
||||
}
|
||||
|
||||
// Execution detail page, a sibling of the canvas under the same `admin.workflow` namespace — mirrors v1's
|
||||
// `admin.workflow.executions.id` route.
|
||||
export const WORKFLOW_EXECUTION_ROUTE_NAME = 'admin.workflow.executions.id';
|
||||
export const WORKFLOW_EXECUTION_ROUTE_PATH = '/admin/workflow/executions/:id';
|
||||
|
||||
export function getWorkflowExecutionPath(id: string | number) {
|
||||
return `/admin/workflow/executions/${id}`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user