diff --git a/AGENTS.md b/AGENTS.md index 8110cbd78bd..14738968929 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 6517d986ac3..9e4d1b476e1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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` 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`) diff --git a/docs/adr/0002-workflow-instruction-progressive-migration.md b/docs/adr/0002-workflow-instruction-progressive-migration.md new file mode 100644 index 00000000000..4765f58c063 --- /dev/null +++ b/docs/adr/0002-workflow-instruction-progressive-migration.md @@ -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` 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. diff --git a/docs/adr/0003-workflow-canvas-progressive-migration.md b/docs/adr/0003-workflow-canvas-progressive-migration.md new file mode 100644 index 00000000000..43afe48cd34 --- /dev/null +++ b/docs/adr/0003-workflow-canvas-progressive-migration.md @@ -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 `` (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 ``; when an instruction has a `ComponentLoader`, `NodeCard` renders the loader instead, and the loader re-wraps `{subtree}` (the condition node appends its Yes/No `` 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 ``, structurally matching v1's one-line ``. 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. diff --git a/docs/docs/ru/data-sources/_meta.json b/docs/docs/ru/data-sources/_meta.json index 10799d68de2..47f5753c379 100644 --- a/docs/docs/ru/data-sources/_meta.json +++ b/docs/docs/ru/data-sources/_meta.json @@ -68,7 +68,7 @@ "type": "custom-link", "label": "Внешний NocoBase", "link": "/data-sources/data-source-external-nocobase/" - }, + }, { "type": "custom-link", "label": "Источник данных KingbaseES", diff --git a/packages/core/client-v2/src/components/README.md b/packages/core/client-v2/src/components/README.md index e190e3becc3..cd6d7008040 100644 --- a/packages/core/client-v2/src/components/README.md +++ b/packages/core/client-v2/src/components/README.md @@ -190,6 +190,10 @@ import { TypedVariableInput } from '@nocobase/client-v2'; + +// Inject a custom variable tree (e.g. a workflow node's upstream outputs, +// which are not in the global registry) + ``` 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`) 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 diff --git a/packages/core/client-v2/src/components/README.zh-CN.md b/packages/core/client-v2/src/components/README.zh-CN.md index 17302aaa2aa..b82758e0113 100644 --- a/packages/core/client-v2/src/components/README.zh-CN.md +++ b/packages/core/client-v2/src/components/README.zh-CN.md @@ -190,6 +190,9 @@ import { TypedVariableInput } from '@nocobase/client-v2'; + +// 注入自定义变量树(如工作流节点的上游输出,不在全局注册表里) + ``` 主要属性: @@ -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`)的节点会在用户展开 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 diff --git a/packages/core/client-v2/src/components/category-tabs/SortableCategoryTabs.tsx b/packages/core/client-v2/src/components/category-tabs/SortableCategoryTabs.tsx new file mode 100644 index 00000000000..ee341998859 --- /dev/null +++ b/packages/core/client-v2/src/components/category-tabs/SortableCategoryTabs.tsx @@ -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; + /** 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 ( +
+ {props.children} +
+ ); +} + +function DroppableTab(props: { id: string; children: React.ReactNode }) { + const { isOver, setNodeRef } = useDroppable({ id: props.id }); + return ( +
+ {props.children} +
+ ); +} + +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 ( + + + {item.label} + {hasMenu ? ( + + - - + + + + {jsonError ? ( +
+ {jsonError} +
+ ) : null} + ); } diff --git a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx index 3f318c7f2fe..f2396449888 100644 --- a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx +++ b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx @@ -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, + 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, + , + ); + 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, + , + ); + 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, + , + ); + 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[] { + 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, 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, 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((resolve) => { + resolveChildren = resolve; + }); + const loadChildren = vi.fn(() => childrenPromise); + const metaTree = makeLazyTree(loadChildren); + renderWithCtx(ctx, 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, ); + 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, + 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(); diff --git a/packages/core/client-v2/src/components/form/filter/CollectionFilterItem.tsx b/packages/core/client-v2/src/components/form/filter/CollectionFilterItem.tsx index 86f759858ec..a73f57ef50c 100644 --- a/packages/core/client-v2/src/components/form/filter/CollectionFilterItem.tsx +++ b/packages/core/client-v2/src/components/form/filter/CollectionFilterItem.tsx @@ -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 | 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 = 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 = observer( return ( = observer( popupClassName={cascaderPopupClass} /> @@ -162,7 +178,7 @@ export const CollectionFilterItem: FC = 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) => ( diff --git a/packages/core/client-v2/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx b/packages/core/client-v2/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx index f69603dcbfc..5b169394e86 100644 --- a/packages/core/client-v2/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx +++ b/packages/core/client-v2/src/components/form/filter/__tests__/CollectionFilterItem.test.tsx @@ -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(); + + let selects = container.querySelectorAll('.ant-select'); + expect(selects[0]).toHaveStyle({ width: '200px' }); + expect(selects[1]).toHaveStyle({ minWidth: '120px' }); + + rerender( + , + ); + + 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(); + expect(screen.getByPlaceholderText('Enter value')).toBeInTheDocument(); + + rerender(); + expect(screen.queryByPlaceholderText('Enter value')).not.toBeInTheDocument(); + }); }); diff --git a/packages/core/client-v2/src/components/form/filter/index.ts b/packages/core/client-v2/src/components/form/filter/index.ts index b5043547672..867e373575e 100644 --- a/packages/core/client-v2/src/components/form/filter/index.ts +++ b/packages/core/client-v2/src/components/form/filter/index.ts @@ -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'; diff --git a/packages/core/client-v2/src/components/index.ts b/packages/core/client-v2/src/components/index.ts index 29638b1da32..b220975af4b 100644 --- a/packages/core/client-v2/src/components/index.ts +++ b/packages/core/client-v2/src/components/index.ts @@ -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'; diff --git a/packages/core/flow-engine/src/__tests__/flowI18n.test.ts b/packages/core/flow-engine/src/__tests__/flowI18n.test.ts index 254d0757932..1242baa12ff 100644 --- a/packages/core/flow-engine/src/__tests__/flowI18n.test.ts +++ b/packages/core/flow-engine/src/__tests__/flowI18n.test.ts @@ -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 = { [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 } }); diff --git a/packages/core/flow-engine/src/components/FlowContextSelector.tsx b/packages/core/flow-engine/src/components/FlowContextSelector.tsx index 1f14610e778..e08d86863a5 100644 --- a/packages/core/flow-engine/src/components/FlowContextSelector.tsx +++ b/packages/core/flow-engine/src/components/FlowContextSelector.tsx @@ -92,6 +92,7 @@ const FlowContextSelectorComponent: React.FC = ({ open, onlyLeafSelectable = false, ignoreFieldNames, + dropdownFooter, ...cascaderProps }) => { const { token } = theme.useToken(); @@ -360,12 +361,41 @@ const FlowContextSelectorComponent: React.FC = ({ [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 ( +
+ {flowCtx.t('Double click to choose entire object')} +
+ ); + }, [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 = ({ /> {cascaderMenu} + {footerNode} ); }, - [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText], + [cascaderDropdownRender, cascaderSearchInputClassName, children, flowCtx, isSearchEnabled, searchText, footerNode], ); const inlinePlaceholder = diff --git a/packages/core/flow-engine/src/components/variables/VariableHybridInput.tsx b/packages/core/flow-engine/src/components/variables/VariableHybridInput.tsx index be781c3c65a..5446da4b299 100644 --- a/packages/core/flow-engine/src/components/variables/VariableHybridInput.tsx +++ b/packages/core/flow-engine/src/components/variables/VariableHybridInput.tsx @@ -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, 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 { + 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 = (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(null); const [isComposing, setIsComposing] = useState(false); const [changed, setChanged] = useState(false); @@ -233,13 +310,63 @@ const VariableHybridInputComponent: React.FC = (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.`) 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 = (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 = (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 = (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 = (props) converters?.formatPathToValue?.(item) || defaultFormatPathToValue(item)} onChange={handleSelectorChange} /> diff --git a/packages/core/flow-engine/src/components/variables/__tests__/VariableHybridInput.test.tsx b/packages/core/flow-engine/src/components/variables/__tests__/VariableHybridInput.test.tsx new file mode 100644 index 00000000000..1c3f6987cde --- /dev/null +++ b/packages/core/flow-engine/src/components/variables/__tests__/VariableHybridInput.test.tsx @@ -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.`). `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( + + + , + ); + + 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.` shape produced by the workflow adapter. + const loadChildren = vi.fn( + async (): Promise => [ + { 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( + + + , + ); + + // 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( + + + , + ); + + // 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + const tag = document.querySelector(TAG_SELECTOR); + expect(tag?.textContent).toBe('{{$missing.field}}'); + }); + }); +}); diff --git a/packages/core/flow-engine/src/components/variables/types.ts b/packages/core/flow-engine/src/components/variables/types.ts index 6435ba48b83..0f499eeaeee 100644 --- a/packages/core/flow-engine/src/components/variables/types.ts +++ b/packages/core/flow-engine/src/components/variables/types.ts @@ -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 { diff --git a/packages/core/flow-engine/src/flowI18n.ts b/packages/core/flow-engine/src/flowI18n.ts index 6f5a9b653c9..5aac29b9101 100644 --- a/packages/core/flow-engine/src/flowI18n.ts +++ b/packages/core/flow-engine/src/flowI18n.ts @@ -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) { diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/CollectionsPage.tsx b/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/CollectionsPage.tsx index a093c3d9002..63bb50c6afb 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/CollectionsPage.tsx +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/CollectionsPage.tsx @@ -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 = { purple: 'Purple', }; -function DraggableCategoryTab(props: { children: React.ReactNode; item: CollectionCategoryRecord }) { - const { attributes, listeners, setNodeRef } = useDraggable({ - id: String(props.item.id), - data: props.item, - }); - - return ( -
- {props.children} -
- ); -} - -function DroppableCategoryTab(props: { children: React.ReactNode; item: CollectionCategoryRecord }) { - const { isOver, setNodeRef } = useDroppable({ - id: String(props.item.id), - data: props.item, - }); - - return ( -
- {props.children} -
- ); -} - -function CategoryTabContent(props: { - item: CollectionCategoryRecord; - onDelete: (category: CollectionCategoryRecord) => void; - onEdit: (category: CollectionCategoryRecord) => void; -}) { - const t = useT(); - - return ( - - - {compileLegacyTemplate(props.item.name || props.item.id, t)} - - } + /> + ); + } + + return ( + + + + + + ); +} + +export default ExecutionCanvas; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/README.md b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/README.md new file mode 100644 index 00000000000..76287abc225 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/README.md @@ -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/`.** diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.shared.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.shared.tsx new file mode 100644 index 00000000000..cfe6e3b7383 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.shared.tsx @@ -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; + presetting?: any; + setPresetting?: (value: any) => void; + setCreating?: (value: any) => void; +}; + +export const AddNodeContext = createContext(null); + +export function useAddNodeContext() { + return useContext(AddNodeContext); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.tsx new file mode 100644 index 00000000000..ba8749e3f62 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeContext.tsx @@ -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 }) { + const view = useFlowView(); + return ( + { + 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; +}) { + 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 ( + +
+ {Preset ? ( + }> + + + ) : null} + + +
+ ); +} + +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: () => ( + 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: () => onCreate(anchor, type)} />, + }); + }); + + const value = useMemo( + () => ({ + creating, + anchor: null, + onMenuOpen, + }), + [creating, onMenuOpen], + ); + + return {props.children}; +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeSlot.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeSlot.tsx new file mode 100644 index 00000000000..eb2cdafc66b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/AddNodeSlot.tsx @@ -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 ( +
+ +
+ ); +} + +/** 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(null); + + useEffect(() => { + if (!registerDropZone || !zoneRef.current || disabled) { + return; + } + return registerDropZone(target, zoneRef.current); + }, [registerDropZone, disabled, target]); + + return ( +
+
+
+ ); +} + +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 ( +
+
+ ); +} + +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 ; + } + + if (executed) { + return ; + } + + // While dragging, every slot is a drop zone (v1 behavior, takes precedence). + if (dragContext?.dragging) { + return ; + } + + // A copied node turns every add-slot into a paste zone (v1 behavior). + if (clipboard?.clipboard) { + return ; + } + + return ( +
+
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Branch.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Branch.tsx new file mode 100644 index 00000000000..5c69f42b3fc --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Branch.tsx @@ -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 `` and using + * the v2 contexts. Branch nodes recurse by self-rendering nested `` 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 = ( +
+ +
+ ); + return title ? {content} : 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 ( + +
+
+ {controller ?
{controller}
: null} +
+ {start ? : null} + {addable ? : null} + {list.map((item) => ( + + ))} +
+ {end === true ? : end} +
+ + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchContext.tsx new file mode 100644 index 00000000000..6881d02739e --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchContext.tsx @@ -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(null); + +export function useBranchContext() { + return useContext(BranchContext); +} + +export function useBranchIndex() { + return useBranchContext()?.branchIndex ?? null; +} + +export function useBranchSyncOnly() { + return useBranchContext()?.syncOnly ?? false; +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchRenderContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchRenderContext.tsx new file mode 100644 index 00000000000..1bfce32be39 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/BranchRenderContext.tsx @@ -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(null); + +export function useBranchNodeRenderer() { + return useContext(BranchRenderContext); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/CanvasContent.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/CanvasContent.tsx new file mode 100644 index 00000000000..ac0f576bde5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/CanvasContent.tsx @@ -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 ``. + * + * 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 ( +
+
+ {t('Copied node')} +
+
+
{typeTitle}
+
{copied.title ?? copied.type}
+
+
+ ); +} + +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. +
+
+
+
+
+ {executed ? ( + + ) : null} + + + +
+ +
+
{t('End')}
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/DownstreamBranchIndex.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/DownstreamBranchIndex.tsx new file mode 100644 index 00000000000..2397506b0fa --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/DownstreamBranchIndex.tsx @@ -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; + hasDownstream: boolean; + t: (key: string, options?: Record) => 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 ( + + + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Instruction.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Instruction.ts new file mode 100644 index 00000000000..edaf688a2b9 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Instruction.ts @@ -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

= () => Promise<{ default: ComponentType

}>; + +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; + +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; + /** + * @experimental + */ + presetFieldset?: Record; + /** + * @experimental + */ + view?: ISchema; + scope?: Record; + components?: Record; + /** 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 ``). + * 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'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/JobButton.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/JobButton.tsx new file mode 100644 index 00000000000..16830ec1f64 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/JobButton.tsx @@ -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; + 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 ? {option.icon} : null; + const button = ( + + ); + + return option ? {button} : 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 ( + + + + + + ); + } + + if (jobs.length === 1) { + return ( + + + + ); + } + + const latestJob = jobs[jobs.length - 1]; + + return ( + + ({ + key: `${job.id}`, + label: ( + + + + + ), + })), + onClick: onOpenJobInList, + className: styles.dropdownClass, + }} + > + + + + + + ); +} + +export default JobButton; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Node.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Node.tsx new file mode 100644 index 00000000000..1ebd5c355b7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/Node.tsx @@ -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 ( + , + }, + { type: 'divider' }, + { key: 'delete', label: t('Delete'), icon: , 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); + } + }, + }} + > + + + +

+ ); + + return ( + + + + {data.title ?? typeTitle} + + + {data.key} + + +
+ } + footer={footer} + > + + {instruction ? : null} + {Fieldset ? ( + }> +
+ + ) : ( +
+ {t("This node's configuration has not been migrated to the new canvas yet.")} +
+ )} + + + + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeDragContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeDragContext.tsx new file mode 100644 index 00000000000..4da851cf507 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/NodeDragContext.tsx @@ -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; + /** 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(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(null); + const [activeDropKey, setActiveDropKey] = useState(null); + + const dragNodeRef = useRef(null); + const dragSubtreeRef = useRef>(new Set()); + const activeDropRef = useRef(null); + const activeDropKeyRef = useRef(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(null); + const onMouseMoveRef = useRef<(event: MouseEvent) => void>(() => {}); + const onMouseUpRef = useRef<() => void>(() => {}); + const previewRef = useRef(null); + const previewRafRef = useRef(null); + const previewOffsetRef = useRef({ x: 0, y: 0 }); + const previewSizeRef = useRef({ width: 0, height: 0 }); + const dropZonesRef = useRef>(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(); + 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(); + 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>(); + const dependents = new Map>(); + 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(); + 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(); + const targetDownstream = getTargetDownstream(upstream, branchIndex, node); + const downstreamSet = targetDownstream + ? collectDownstreams(targetDownstream, branchChildrenMap) + : new Set(); + + const deps = nodeDepsMap.get(node.id) ?? new Set(); + 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(); + 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 }[]) => { + 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 }[] = []; + 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: ( +
+
+ {lang( + 'This action will remove invalid variable references, otherwise the workflow cannot run correctly.', + )} +
+ {impactedSelfTitles ? ( +
{lang('Impacted current node variables') + ': ' + impactedSelfTitles}
+ ) : null} + {impactedDependentTitles ? ( +
{lang('Impacted dependent node variables') + ': ' + impactedDependentTitles}
+ ) : null} +
+ (keepVariablesRef.current = ev.target.checked)}> + {lang('Keep variable references')} + +
+
+ ), + 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 {props.children}; +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/RemoveNodeContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/RemoveNodeContext.tsx new file mode 100644 index 00000000000..135283cab94 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/canvas/RemoveNodeContext.tsx @@ -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(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(null); + const [keepBranch, setKeepBranch] = useState(null); + + const deletingBranches = useMemo( + () => (nodes ?? []).filter((item: any) => item.upstream === deletingNode && item.branchIndex != null), + [nodes, deletingNode], + ); + + const destroy = useCallback( + async (nodeId: any, values?: Record) => { + 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(() => ({ requestRemove }), [requestRemove]); + + return ( + + {props.children} + setDeletingNode(null)} + onOk={onConfirmKeepBranch} + okButtonProps={{ danger: true }} + okText={t('Delete')} + > + setKeepBranch(e.target.value === 0 ? null : deletingBranches[0]?.branchIndex ?? null)} + > + + {t('Delete all')} + + {t('Keep')} + + {calculatorGroups + .filter((group) => Boolean(getGroupCalculators(group.value).length)) + .map((group) => ( + + {getGroupCalculators(group.value).map(([value, { name }]) => ( + + {compile(name)} + + ))} + + ))} + + +
+ ); +} + +function CalculationItem({ value, onChange, onRemove }: any) { + if (!value) { + return null; + } + + const { calculator, operands = [] } = value; + + return ( +
+ {value.group ? ( + onChange({ ...value, group })} /> + ) : ( + + )} +
+ ); +} + +function CalculationGroup({ value, onChange }: any) { + const t = useT(); + // The "Meet [All/Any] conditions in the group" sentence is a composite `` 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 ( +
+
+ + {'Meet '} + + {' conditions in the group'} + +
+
+ {calculations.map((calculation: any, i: number) => ( + onRemove(i)} + /> + ))} +
+
+ + +
+
+ ); +} + +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 ( + + onChange?.({ ...rule, group })} /> + + ); +} + +export default CalculationConfig; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusIcon.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusIcon.tsx new file mode 100644 index 00000000000..c5403e7fc6b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusIcon.tsx @@ -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 = { + [EXECUTION_STATUS.QUEUEING as number]: , + [EXECUTION_STATUS.STARTED]: , + [EXECUTION_STATUS.RESOLVED]: , + [EXECUTION_STATUS.FAILED]: , + [EXECUTION_STATUS.ERROR]: , + [EXECUTION_STATUS.ABORTED]: , + [EXECUTION_STATUS.CANCELED]: , + [EXECUTION_STATUS.REJECTED]: , + [EXECUTION_STATUS.RETRY_NEEDED]: , +}; + +/** + * 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] ?? ; + const size = token.controlHeightSM; + return ( + + + {icon} + + + ); +} + +export default ExecutionStatusIcon; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusTag.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusTag.tsx new file mode 100644 index 00000000000..1878e5c325b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionStatusTag.tsx @@ -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 {compile(option.label)}; +} + +export default ExecutionStatusTag; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionViewHeader.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionViewHeader.tsx new file mode 100644 index 00000000000..feb2fbf2520 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionViewHeader.tsx @@ -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 ( + + + {compile(option.label)} + {execution.reason ? ( + + + + ) : null} + + + ); +} + +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 ( +
+ {t('Workflow')}, + }, + { + title: + workflow?.id != null ? ( + + {compile(workflow.title || '')} + + ) : ( + compile(workflow?.title || '') + ), + }, + { + title: ( + + ), + }, + ]} + /> +
+ + {cancelable ? ( + +
+
+ ); +} + +export default ExecutionViewHeader; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionsDropdown.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionsDropdown.tsx new file mode 100644 index 00000000000..82f58c7d852 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/ExecutionsDropdown.tsx @@ -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: , + label: ( + + {`#${item.id}`} + + + ), + })), + [data, execution, token], + ); + + return ( + + open && run()} + menu={{ onClick, selectedKeys: [`${execution.id}`], items }} + > + + {`#${execution.id}`} + + + + {refresh ? + + +
{t('Result')}
+ {error ? : null} + {result != null ? ( + + ) : null} + + + + ); +} + +/** + * 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: () => ( + + + + ), + }); + }; + + return ( + + ); +} + +export default TestRunButton; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/TimeoutInput.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/TimeoutInput.tsx new file mode 100644 index 00000000000..31542c8080d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/TimeoutInput.tsx @@ -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 ( + + onChange?.(clampTimeout(Number(next) || 0, max) * unit)} + style={{ width: '70%' }} + /> + } + /> + ); +} + +export default FieldsSelect; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/PaginationFields.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/PaginationFields.tsx new file mode 100644 index 00000000000..08cda0aa904 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/PaginationFields.tsx @@ -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 ( + + + + + + + + + + + + + ); +} + +export default PaginationFields; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/SortFieldsInput.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/SortFieldsInput.tsx new file mode 100644 index 00000000000..3bda79cf95d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/components/collection/SortFieldsInput.tsx @@ -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 ( + + + ) : null} + + ), + }, + { + title: t('Actions'), + width: 160, + render: (_, record) => ( + + handleView(record)}>{t('View')} + {record.status !== EXECUTION_STATUS.STARTED ? ( + handleDelete(record.id)}>{t('Delete')} + ) : null} + + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleCancel, handleDelete, handleView, t], + ); + + return ( +
+ + + + + + + + + + rowKey="id" + loading={loading} + columns={columns} + dataSource={data?.records || []} + rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} + pagination={{ current: page, pageSize, total: data?.total || 0, onChange: handlePaginationChange }} + /> +
+ ); +} + +export function ExecutionHistoryDrawer({ workflowKey }: { workflowKey: string | number }) { + const compile = useT(); + // The shared `executions` collection is `schema-only`, so it isn't published to the v2 data source — register a + // client-only copy so `CollectionFilter` can resolve its fields. Its `status` field carries the v1 Formily template + // `enum: '{{ExecutionStatusOptions}}'`, which the v2 filter value renderer can't compile; replace it with the + // resolved `{ label, value }` options up front so the Status condition renders as a Select (matching v1). + const collections = useMemo(() => { + const statusOptions = EXECUTION_STATUS_OPTIONS.map((option) => ({ + value: option.value, + label: compile(option.label), + })); + return [ + { + ...executionCollection, + fields: executionCollection.fields.map((field) => + field?.name === 'status' && field.uiSchema + ? { ...field, uiSchema: { ...field.uiSchema, enum: statusOptions } } + : field, + ), + }, + ]; + }, [compile]); + + return ( + + + + ); +} + +export default ExecutionHistoryDrawer; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx new file mode 100644 index 00000000000..c84d8789153 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/ExecutionViewPage.tsx @@ -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 { useFlowContext } from '@nocobase/flow-engine'; +import { useRequest } from 'ahooks'; +import { Spin, theme } from 'antd'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import ExecutionCanvas from '../ExecutionCanvas'; +import { normalizeRecordResponse } from '../components/workflowCanvas'; +import { useWorkflowTranslation } from '../locale'; + +export default function ExecutionViewPage() { + useWorkflowTranslation(); + const ctx = useFlowContext(); + const { token } = theme.useToken(); + const params = useParams<{ id?: string }>(); + const executionId = params.id; + const resource = ctx.api.resource('executions'); + + const { data, refresh } = useRequest( + async () => { + if (!executionId) { + return null; + } + const response = await resource.get({ + filterByTk: executionId, + appends: ['jobs', 'workflow', 'workflow.nodes', 'workflow.versionStats', 'workflow.stats'], + except: ['jobs.result', 'workflow.options'], + }); + return normalizeRecordResponse(response); + }, + { refreshDeps: [executionId] }, + ); + + const record = data?.id != null && String(data.id) === String(executionId) ? data : null; + + if (!executionId) { + return null; + } + + if (!record) { + return ; + } + + return ( +
+
+ +
+
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx new file mode 100644 index 00000000000..e1a005fdccd --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCanvasPage.tsx @@ -0,0 +1,121 @@ +/** + * 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 { Spin, theme } from 'antd'; +import React, { useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { WorkflowCanvasHeader } from '../components/WorkflowCanvasHeader'; +import { normalizeRecordResponse, type WorkflowRevision } from '../components/workflowCanvas'; +import { FlowContext } from '../canvas/contexts'; +import { CanvasContent } from '../canvas/CanvasContent'; +import { linkNodes } from '../canvas/nodeTree'; +import { AddNodeContextProvider } from '../canvas/AddNodeContext'; +import { RemoveNodeContextProvider } from '../canvas/RemoveNodeContext'; +import { NodeClipboardContextProvider } from '../canvas/NodeClipboardContext'; +import { NodeDragContextProvider } from '../canvas/NodeDragContext'; + +export default function WorkflowCanvasPage() { + const ctx = useFlowEngineContext(); + const { token } = theme.useToken(); + const params = useParams<{ id?: string }>(); + const workflowId = params.id; + const resource = ctx.api.resource('workflows'); + + const { data, refresh } = useRequest( + async () => { + if (!workflowId) { + return null; + } + const response = await resource.get({ + filterByTk: workflowId, + appends: ['nodes', 'stats', 'versionStats', 'createdBy', 'updatedBy'], + }); + return normalizeRecordResponse(response); + }, + { refreshDeps: [workflowId] }, + ); + + // Revisions feed the "delete" fallback target (jump to the current version after deleting a non-current one). + const { data: revisionsData } = useRequest( + async () => { + if (!data?.key) { + return [] as WorkflowRevision[]; + } + const response = await resource.list({ + filter: { key: data.key }, + fields: ['id', 'current', 'enabled'], + sort: '-id', + }); + return (response?.data?.data ?? []) as WorkflowRevision[]; + }, + { refreshDeps: [data?.key] }, + ); + + const record = data?.id != null && String(data.id) === String(workflowId) ? data : null; + const revisions = useMemo(() => revisionsData ?? [], [revisionsData]); + + // Build the in-memory node tree (linked list) and find the entry node. + const { nodes, entry } = useMemo(() => { + const list = ((record as any)?.nodes ?? []) as any[]; + linkNodes(list); + return { nodes: list, entry: list.find((item) => !item.upstream) ?? null }; + }, [record]); + + const flowContextValue = useMemo(() => ({ workflow: record, nodes, refresh }), [record, nodes, refresh]); + + if (!workflowId) { + return null; + } + + if (!record) { + return ; + } + + return ( + +
+ +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCategoryTabs.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCategoryTabs.tsx new file mode 100644 index 00000000000..9617d6ac0bf --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowCategoryTabs.tsx @@ -0,0 +1,176 @@ +/** + * 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 { SortableCategoryTabs } from '@nocobase/client-v2'; +import { useFlowContext } from '@nocobase/flow-engine'; +import { useMemoizedFn } from 'ahooks'; +import { App, Form, Input, Modal, Select, Tag } from 'antd'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useT, useWorkflowTranslation } from '../locale'; + +export const ALL_CATEGORY_KEY = 'all'; + +export type WorkflowCategory = { + id: string | number; + title?: string; + color?: string; + sort?: number; +}; + +const COLOR_KEYS = [ + 'red', + 'magenta', + 'volcano', + 'orange', + 'gold', + 'lime', + 'green', + 'cyan', + 'blue', + 'geekblue', + 'purple', + 'default', +]; + +function ColorSelect(props: { value?: string; onChange?: (value: string) => void }) { + const { t } = useTranslation(); + const options = COLOR_KEYS.map((color) => ({ + value: color, + label: ( + {t(color.charAt(0).toUpperCase() + color.slice(1))} + ), + })); + return + + + + + + + + ); +} + +export default WorkflowCategoryTabs; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowFormDrawer.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowFormDrawer.tsx new file mode 100644 index 00000000000..7b7066ff319 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/WorkflowFormDrawer.tsx @@ -0,0 +1,289 @@ +/** + * 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 { DrawerFormLayout } from '@nocobase/client-v2'; +import { useFlowContext } from '@nocobase/flow-engine'; +import { useMemoizedFn } from 'ahooks'; +import { Card, Form, Input, InputNumber, Select, Space, Spin, Tag, Typography, theme } from 'antd'; +import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { EXECUTION_STATUS_OPTIONS, EXECUTION_STATUS_OPTIONS_MAP } from '../../common/executionStatus'; +import { SyncModeSelect } from '../components/SyncModeSelect'; +import { TimeoutInput } from '../components/TimeoutInput'; +import { useT, useWorkflowTranslation } from '../locale'; +import type PluginWorkflowClientV2 from '../plugin'; + +export type WorkflowCategoryOption = { value: string | number; label: string }; + +export type WorkflowRecord = { + id: number | string; + title?: string; + type?: string; + sync?: boolean; + description?: string; + enabled?: boolean; + config?: Record; + options?: Record; + categories?: Array<{ id: string | number }>; + [key: string]: any; +}; + +export type WorkflowFormDrawerProps = { + mode: 'create' | 'edit'; + /** Pre-selected trigger type for create mode (optional). */ + type?: string; + plugin: PluginWorkflowClientV2; + record?: WorkflowRecord; + categoryOptions: WorkflowCategoryOption[]; + onSubmitted: () => void; +}; + +export function WorkflowFormDrawer(props: WorkflowFormDrawerProps) { + const { mode, plugin, record, categoryOptions, onSubmitted } = props; + const { t } = useWorkflowTranslation(); + const compile = useT(); + const ctx = useFlowContext(); + const { token } = theme.useToken(); + const resource = ctx.api.resource('workflows'); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + + const triggerOptions = useMemo(() => { + const options = Array.from(plugin.triggers.getEntities()).map(([value, opt]) => ({ + value, + label: opt?.title ? compile(opt.title) : String(value), + description: opt?.description ? compile(opt.description) : undefined, + })); + // Keep an existing workflow's type selectable in edit mode even if its trigger plugin hasn't exposed a v2 surface + // yet. + if (mode === 'edit' && record?.type && !options.some((item) => item.value === record.type)) { + options.push({ value: record.type, label: record.type, description: undefined }); + } + return options.sort((a, b) => String(a.label).localeCompare(String(b.label))); + }, [plugin, compile, mode, record]); + + const deleteStatusOptions = useMemo( + () => + EXECUTION_STATUS_OPTIONS.filter((option) => Boolean(option.value)).map((option) => ({ + value: option.value, + label: compile(option.label), + color: option.color, + description: option.description ? compile(option.description) : undefined, + })), + [compile], + ); + + const initialValues = useMemo(() => { + if (mode === 'edit') { + return { + title: record?.title, + type: record?.type, + sync: record?.sync ?? false, + description: record?.description, + categories: (record?.categories ?? []).map((category) => category.id), + options: record?.options ?? {}, + }; + } + return { type: props.type, sync: false, options: {}, config: {} }; + }, [mode, record, props.type]); + + useEffect(() => { + form.setFieldsValue(initialValues); + }, [form, initialValues]); + + const watchedType = (Form.useWatch('type', form) as string | undefined) ?? props.type; + const triggerOption = useMemo(() => plugin.getTriggerOptions(watchedType), [plugin, watchedType]); + const syncLocked = triggerOption?.sync != null; + + useEffect(() => { + if (syncLocked) { + form.setFieldValue('sync', triggerOption.sync); + } + }, [syncLocked, triggerOption, form]); + + const ConfigForm = useMemo(() => { + if (mode !== 'create') { + return null; + } + const loader = triggerOption?.PresetFieldsetLoader; + return loader ? lazy(loader) : null; + }, [mode, triggerOption]); + + // Switching trigger type discards the previous type's `config` payload — each trigger owns its own config shape. + const handleTypeChange = useMemoizedFn(() => { + form.setFieldValue('config', {}); + }); + + const handleSubmit = useMemoizedFn(async () => { + const raw = await form.validateFields(); + setSubmitting(true); + try { + if (mode === 'create') { + await resource.create({ + values: { + title: raw.title, + type: raw.type, + sync: Boolean(raw.sync), + current: true, + config: raw.config ?? {}, + description: raw.description, + categories: raw.categories ?? [], + options: raw.options ?? {}, + }, + }); + } else { + // Only send the fields this form owns; `config`/`type` are not edited here, so they are never overwritten. + // `options` renders all of its keys, so the round-trip cannot drop one. + await resource.update({ + filterByTk: record.id, + values: { + title: raw.title, + sync: Boolean(raw.sync), + description: raw.description, + categories: raw.categories ?? [], + options: raw.options ?? {}, + }, + }); + } + onSubmitted(); + } finally { + setSubmitting(false); + } + }); + + return ( + +
+ + + + + + + + + + {/* Title sits above an always-visible bordered group (matches v1's + "Advance options" Fieldset). Not collapsible — the option fields are + always mounted and register, removing any partial-submit risk. */} + + + + + + + + + + + + + + ); +} + +function WorkflowPaneInner() { + const { t } = useWorkflowTranslation(); + const compile = useT(); + const ctx = useFlowContext(); + const { getWorkflowCanvasPath } = useWorkflowRuntimePaths(); + const { token } = theme.useToken(); + const { modal, message } = App.useApp(); + const resource = ctx.api.resource('workflows'); + const plugin = ctx.app.pm.get(PluginWorkflowClientV2); + const filterCollection = useMemo( + () => ctx.dataSourceManager?.getDataSource?.('main')?.getCollection?.('workflows'), + [ctx], + ); + + const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [filterPayload, setFilterPayload] = useState | undefined>(undefined); + + const handleFilterChange = useMemoizedFn((filter: CompiledFilter) => { + setFilterPayload(filter); + setPage(1); + }); + + const { data: categoryData, refresh: refreshCategories } = useRequest(async () => { + const response = await ctx.api.resource('workflowCategories').list({ paginate: false, sort: ['sort'] }); + return (response?.data?.data ?? []) as WorkflowCategory[]; + }); + const categories = useMemo(() => categoryData || [], [categoryData]); + const categoryOptions = useMemo( + () => categories.map((category) => ({ value: category.id, label: compile(category.title ?? '') })), + [categories, compile], + ); + + const { data, loading, refresh } = useRequest( + async () => { + // `{ current: true }` (latest version only) and the active-category scope are mandatory; the user-built filter is + // ANDed onto them so it can only narrow, never widen, the list. + const scope: Record = { current: true }; + if (activeCategory !== ALL_CATEGORY_KEY) { + scope['categories.id'] = activeCategory; + } + const filter = filterPayload ? { $and: [scope, filterPayload] } : scope; + const response = await resource.list({ + page, + pageSize, + sort: ['-createdAt'], + except: ['config'], + appends: ['categories', 'stats'], + filter, + }); + return normalizeListResponse(response); + }, + { refreshDeps: [page, pageSize, activeCategory, filterPayload] }, + ); + + const handlePaginationChange = useMemoizedFn((nextPage: number, nextPageSize: number) => { + if (nextPageSize !== pageSize) { + setPageSize(nextPageSize); + setPage(1); + return; + } + setPage(nextPage); + }); + + const triggerLabel = useMemoizedFn((type?: string) => { + const option = type ? plugin.getTriggerOptions(type) : undefined; + return option?.title ? compile(option.title) : type; + }); + + const openForm = useMemoizedFn((mode: 'create' | 'edit', record?: WorkflowRecord) => { + ctx.viewer.drawer({ + width: '50%', + closable: true, + content: () => ( + refresh()} + /> + ), + }); + }); + + const openDuplicate = useMemoizedFn((record: WorkflowRecord) => { + ctx.viewer.dialog({ + width: 520, + closable: true, + content: () => refresh()} />, + }); + }); + + const openConfigure = useMemoizedFn((record: WorkflowRecord) => { + ctx.router.navigate(getWorkflowCanvasPath(record.id)); + }); + + const openExecutions = useMemoizedFn((record: WorkflowRecord) => { + ctx.viewer.drawer({ + width: '60%', + closable: true, + title: t('Execution history'), + content: () => , + }); + }); + + const handleDelete = useMemoizedFn((filterByTk: React.Key | React.Key[]) => { + modal.confirm({ + title: t('Delete record'), + content: t('Are you sure you want to delete it?'), + async onOk() { + await resource.destroy({ filterByTk }); + setSelectedRowKeys([]); + refresh(); + }, + }); + }); + + const handleSync = useMemoizedFn(async () => { + await resource.sync(); + message.success(t('Operation succeeded')); + refresh(); + }); + + const showSync = Boolean((ctx.app?.name && ctx.app.name !== 'main') || ctx.app.pm.get('multi-app-share-collection')); + + const columns = useMemo>( + () => [ + { title: t('Title'), dataIndex: 'title' }, + { + title: t('Category'), + dataIndex: 'categories', + render: (value: WorkflowRecord['categories']) => ( + + {(value ?? []).map((category: any) => ( + + {compile(category.title)} + + ))} + + ), + }, + { + title: t('Trigger type'), + dataIndex: 'type', + render: (value) => (value ? {triggerLabel(value)} : null), + }, + { + title: t('Execute mode'), + dataIndex: 'sync', + width: 140, + render: (value) => {value ? t('Synchronously') : t('Asynchronously')}, + }, + { + title: t('Enabled'), + dataIndex: 'enabled', + width: 100, + render: (_, record) => , + }, + { + title: t('Executed'), + dataIndex: ['stats', 'executed'], + width: 100, + render: (_, record) => ( + openExecutions(record)}> + {record.stats?.executed ?? 0} + + ), + }, + { + title: t('Actions'), + width: 260, + render: (_, record) => ( + + openConfigure(record)}>{t('Configure')} + openForm('edit', record)}>{t('Edit')} + openDuplicate(record)}>{t('Duplicate')} + handleDelete(record.id)}>{t('Delete')} + + ), + }, + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [handleDelete, openConfigure, openDuplicate, openExecutions, openForm, refresh, resource, t, triggerLabel], + ); + + return ( + + { + setActiveCategory(key); + setPage(1); + }} + categories={categories} + refreshCategories={refreshCategories} + /> + + + + + {showSync ? ( + + + + ) : null} + + + + + + rowKey="id" + loading={loading} + columns={columns} + dataSource={data?.records || []} + rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }} + pagination={{ current: page, pageSize, total: data?.total || 0, onChange: handlePaginationChange }} + /> + + ); +} + +export default function WorkflowPane() { + const compile = useT(); + const ctx = useFlowContext(); + const plugin = ctx.app.pm.get(PluginWorkflowClientV2); + // The shared `workflows` collection is `schema-only`, so it isn't published to the v2 data source — register a + // client-only copy so `CollectionFilter` can resolve its fields. Its `type` field carries the v1 Formily template + // `enum: '{{useTriggersOptions()}}'`, which the v2 filter value renderer can't compile; replace it with the + // registered trigger options up front so the Trigger type condition renders as a Select (matching v1). + const collections = useMemo(() => { + const triggerOptions = Array.from(plugin.triggers.getEntities() as Iterable<[string, { title?: string }]>) + .map(([value, opt]) => ({ value, label: opt?.title ? compile(opt.title) : String(value) })) + .sort((a, b) => String(a.label).localeCompare(String(b.label))); + return [ + { + ...workflowCollection, + fields: workflowCollection.fields.map((field) => + field?.name === 'type' && field.uiSchema + ? { ...field, uiSchema: { ...field.uiSchema, enum: triggerOptions } } + : field, + ), + }, + ]; + }, [plugin, compile]); + + return ( + + + + ); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx new file mode 100644 index 00000000000..66070f7af38 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/ExecutionViewPage.test.tsx @@ -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 { App } from 'antd'; +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const holder = vi.hoisted(() => ({ + ctx: null as any, + executionCanvasProps: null as any, +})); + +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, useFlowContext: () => holder.ctx }; +}); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + useParams: () => ({ id: '369411612409856' }), + }; +}); + +vi.mock('../../locale', () => ({ + useWorkflowTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('../../ExecutionCanvas', () => ({ + __esModule: true, + default: (props: any) => { + holder.executionCanvasProps = props; + return
execution-canvas
; + }, +})); + +import ExecutionViewPage from '../ExecutionViewPage'; + +function renderWithApp(node: React.ReactNode) { + return render({node}); +} + +describe('ExecutionViewPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + holder.executionCanvasProps = null; + }); + + it('loads execution canvas data and renders ExecutionCanvas instead of the empty placeholder', async () => { + const executions = { + get: vi.fn().mockResolvedValue({ + data: { + data: { + id: 369411612409856, + key: 'exec-key', + jobs: [], + workflow: { + id: 11, + key: 'wf-key', + title: 'WF', + type: 'schedule', + nodes: [], + versionStats: { executed: 1 }, + stats: { executed: 1 }, + }, + }, + }, + }), + }; + holder.ctx = { + api: { resource: (name: string) => ({ executions })[name] }, + }; + + renderWithApp(); + + await screen.findByTestId('execution-canvas'); + + await waitFor(() => { + expect(executions.get).toHaveBeenCalledWith({ + filterByTk: '369411612409856', + appends: ['jobs', 'workflow', 'workflow.nodes', 'workflow.versionStats', 'workflow.stats'], + except: ['jobs.result', 'workflow.options'], + }); + }); + + expect(holder.executionCanvasProps?.record?.id).toBe(369411612409856); + expect(screen.queryByText('Workflow canvas editor is being migrated to the new UI.')).toBeNull(); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx new file mode 100644 index 00000000000..01264b63e6c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/pages/__tests__/WorkflowPane.test.tsx @@ -0,0 +1,259 @@ +/** + * 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 { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { App } from 'antd'; +import { get } from 'lodash'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --- Mock the FlowContext so `useFlowContext()` returns our controlled ctx --- +const holder = vi.hoisted(() => ({ ctx: null as any })); +vi.mock('@nocobase/flow-engine', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { ...actual, useFlowContext: () => holder.ctx }; +}); + +// --- Mock plugin's locale so translation is identity (no i18next runtime) --- +vi.mock('../../locale', () => ({ + NAMESPACE: 'workflow', + useT: () => (key: string) => key, + useWorkflowTranslation: () => ({ t: (key: string) => key }), + tExpr: (key: string) => key, +})); + +// --- Mock the client-v2 layout primitives with lightweight test doubles --- +vi.mock('@nocobase/client-v2', () => ({ + DEFAULT_PAGE_SIZE: 20, + Plugin: class Plugin {}, + SortableCategoryTabs: () => null, + CollectionFilter: () => null, + ExtendCollectionsProvider: ({ children }: any) => <>{children}, + DrawerFormLayout: ({ children, onSubmit }: any) => ( +
+ {children} + +
+ ), + DialogFormLayout: ({ children, onSubmit }: any) => ( +
+ {children} + +
+ ), + Table: ({ dataSource = [], columns = [] }: any) => ( + + + {dataSource.map((record: any) => ( + + {columns.map((col: any, index: number) => ( + + ))} + + ))} + +
+ {typeof col.render === 'function' + ? col.render(col.dataIndex ? get(record, col.dataIndex) : undefined, record) + : col.dataIndex + ? get(record, col.dataIndex) + : null} +
+ ), +})); + +import { WorkflowFormDrawer } from '../WorkflowFormDrawer'; +import WorkflowPane from '../WorkflowPane'; + +const mockPlugin = { + triggers: { getEntities: () => [['collection', { title: 'Collection event' }]] }, + getTriggerOptions: (type?: string) => (type === 'collection' ? { title: 'Collection event' } : undefined), +}; + +function makeCtx(resourceMap: Record) { + return { + api: { resource: (name: string) => resourceMap[name] }, + viewer: { drawer: vi.fn(), dialog: vi.fn() }, + app: { name: 'main', pm: { get: () => mockPlugin } }, + }; +} + +function renderWithApp(node: React.ReactNode) { + return render({node}); +} + +describe('WorkflowPane (request layer)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('fires resource.create on submit in create mode', async () => { + const workflows = { create: vi.fn().mockResolvedValue({}), update: vi.fn() }; + holder.ctx = makeCtx({ workflows }); + + renderWithApp( + undefined} + />, + ); + + const title = screen.getAllByRole('textbox')[0]; + fireEvent.change(title, { target: { value: 'My workflow' } }); + fireEvent.click(screen.getByTestId('layout-submit')); + + await waitFor(() => { + expect(workflows.create).toHaveBeenCalledTimes(1); + }); + expect(workflows.create).toHaveBeenCalledWith( + expect.objectContaining({ + values: expect.objectContaining({ title: 'My workflow', type: 'collection' }), + }), + ); + }); + + it('fires resource.update with the correct filterByTk on submit in edit mode', async () => { + const workflows = { create: vi.fn(), update: vi.fn().mockResolvedValue({}) }; + holder.ctx = makeCtx({ workflows }); + + renderWithApp( + undefined} + />, + ); + + fireEvent.click(screen.getByTestId('layout-submit')); + + await waitFor(() => { + expect(workflows.update).toHaveBeenCalledTimes(1); + }); + // Pin filterByTk to the record id so a regression never silently no-ops. + expect(workflows.update).toHaveBeenCalledWith( + expect.objectContaining({ filterByTk: 7, values: expect.objectContaining({ title: 'Existing' }) }), + ); + }); + + it('fires resource.destroy on row-level delete', async () => { + const workflows = { + list: vi.fn().mockResolvedValue({ + data: { + data: [ + { + id: 9, + title: 'Row', + type: 'collection', + sync: false, + enabled: false, + categories: [], + stats: { executed: 0 }, + }, + ], + meta: { count: 1 }, + }, + }), + destroy: vi.fn().mockResolvedValue({}), + }; + const workflowCategories = { list: vi.fn().mockResolvedValue({ data: { data: [] } }) }; + holder.ctx = makeCtx({ workflows, workflowCategories }); + + renderWithApp(); + + await screen.findByText('Row'); + + const rowDelete = screen.getAllByText('Delete').find((el) => el.tagName === 'A'); + expect(rowDelete).toBeTruthy(); + fireEvent.click(rowDelete as HTMLElement); + + const okButton = await screen.findByText('OK'); + fireEvent.click(okButton.closest('button') as HTMLButtonElement); + + await waitFor(() => { + expect(workflows.destroy).toHaveBeenCalledWith({ filterByTk: 9 }); + }); + }); + + it('edit drawer survives a v1-style (title-less) trigger registration', async () => { + // Regression: a downstream plugin registers via the v1 signature `registerTrigger(type, TriggerClass)`, so the v2 + // registry holds an entry whose `title` is undefined. The trigger-options sort must not crash on + // `undefined.localeCompare`. + class LegacyTrigger {} + const pluginWithLegacy = { + triggers: { + getEntities: () => [ + ['collection', { title: 'Collection event' }], + ['custom-action', LegacyTrigger], + ], + }, + getTriggerOptions: (type?: string) => + type === 'collection' ? { title: 'Collection event' } : type === 'custom-action' ? LegacyTrigger : undefined, + }; + const workflows = { create: vi.fn(), update: vi.fn().mockResolvedValue({}) }; + holder.ctx = makeCtx({ workflows }); + + renderWithApp( + undefined} + />, + ); + + fireEvent.click(screen.getByTestId('layout-submit')); + + await waitFor(() => { + expect(workflows.update).toHaveBeenCalledWith(expect.objectContaining({ filterByTk: 11 })); + }); + }); + + it('renders the trigger configuration label and bordered group around preset loader content', async () => { + const workflows = { create: vi.fn().mockResolvedValue({}), update: vi.fn() }; + holder.ctx = makeCtx({ workflows }); + + const pluginWithPreset = { + triggers: { getEntities: () => [['collection', { title: 'Collection event' }]] }, + getTriggerOptions: (type?: string) => + type === 'collection' + ? { + title: 'Collection event', + PresetFieldsetLoader: async () => ({ + default: () =>
preset-field
, + }), + } + : undefined, + }; + + renderWithApp( + undefined} + />, + ); + + const label = await screen.findByText('Trigger configuration:'); + expect(label).toBeInTheDocument(); + expect(label).toHaveStyle({ fontWeight: '600' }); + expect(await screen.findByText('preset-field')).toBeInTheDocument(); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx new file mode 100644 index 00000000000..4deb01905e0 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/plugin.tsx @@ -0,0 +1,224 @@ +/** + * 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 { Plugin } from '@nocobase/client-v2'; +import { Registry } from '@nocobase/utils/client'; +import { NAMESPACE } from './locale'; +import { + WORKFLOW_CANVAS_ROUTE_NAME, + WORKFLOW_CANVAS_ROUTE_PATH, + WORKFLOW_EXECUTION_ROUTE_NAME, + WORKFLOW_EXECUTION_ROUTE_PATH, +} from './constants'; +import type { Instruction } from './canvas/Instruction'; +import type { Trigger } from './triggers'; + +// Core node instructions — one file per node under `nodes/`, mirroring v1's `client/nodes/` layout. Each +// default-exports its Instruction class. +import CalculationInstruction from './nodes/calculation'; +import ConditionInstruction from './nodes/condition'; +import MultiConditionsInstruction from './nodes/multi-conditions'; +import EndInstruction from './nodes/end'; +import OutputInstruction from './nodes/output'; +import QueryInstruction from './nodes/query'; +import CreateInstruction from './nodes/create'; +import UpdateInstruction from './nodes/update'; +import DestroyInstruction from './nodes/destroy'; +import CollectionTrigger from './triggers/collection'; +import ScheduleTrigger from './triggers/schedule'; + +export type InstructionGroup = { key: string; label: string }; + +const tpl = (key: string) => `{{t("${key}", { ns: "${NAMESPACE}" })}}`; + +/** Core instruction groups, in v1 display order. */ +const coreInstructionGroups: InstructionGroup[] = [ + { key: 'control', label: tpl('Control') }, + { key: 'calculation', label: tpl('Calculation') }, + { key: 'collection', label: tpl('Collection operations') }, + { key: 'manual', label: tpl('Manual') }, + { key: 'extended', label: tpl('Extended types') }, +]; + +/** + * A workflow **system variable** (the `$system` scope). Registered by the + * workflow plugin itself — fixed, framework-independent values (current time, + * instance id, snowflake id), not contributed by any other plugin. Mirrors v1's + * `registerSystemVariable` (`client/index.tsx`). + */ +export type SystemVariableOption = { + /** Registry key + the path segment under `$system` (e.g. `now`). */ + key: string; + /** Display label. Accepts a plain string or a `{{t("…")}}` template. */ + label: string; + /** Optional tooltip shown next to the label. */ + tooltip?: string; +}; + +export class PluginWorkflowClientV2 extends Plugin { + triggers = new Registry(); + instructions = new Registry(); + instructionGroups = new Registry(); + systemVariables = new Registry(); + + /** + * Register a `$system` scope variable. Mirrors v1's `registerSystemVariable` + * but writes to *this* (v2) runtime's registry. The workflow plugin registers + * the builtin ones (time / instance id / snowflake id) on load. + */ + registerSystemVariable(option: SystemVariableOption) { + this.systemVariables.register(option.key, option); + } + + /** + * Register a node type's v2 instruction. Mirrors v1's `registerInstruction` + * signature (accepts a class or an instance) but writes to *this* (v2) + * runtime's registry — see ADR-0003 / doc §9.1 (runtime separation). A type + * registered here appears in the v2 add-node menu and renders on the v2 canvas. + */ + registerInstruction(type: string, instruction: Instruction | { new (): Instruction }) { + if (typeof instruction === 'function') { + this.instructions.register(type, new instruction()); + } else { + this.instructions.register(type, instruction); + } + } + + getInstruction(type?: string) { + return type ? this.instructions.get(type) : undefined; + } + + registerInstructionGroup(key: string, group: InstructionGroup) { + this.instructionGroups.register(key, group); + } + + registerTrigger(type: string, trigger: Trigger | { new (): Trigger }) { + if (typeof trigger === 'function') { + this.triggers.register(type, new trigger()); + } else if (trigger) { + this.triggers.register(type, trigger); + } else { + throw new TypeError('invalid trigger type to register'); + } + } + + getTriggerOptions(type?: string) { + return type ? this.triggers.get(type) : undefined; + } + + async load() { + this.registerModelLoaders(); + this.registerBuiltinTriggers(); + this.registerBuiltinInstructions(); + this.registerBuiltinSystemVariables(); + this.registerSettingsPage(); + this.registerCanvasRoute(); + this.registerExecutionRoute(); + } + + // The three fixed `$system` variables (v1 `client/index.tsx`). Self-registered by the workflow plugin — no external + // dependency, so available in v2 now. + private registerBuiltinSystemVariables() { + this.registerSystemVariable({ key: 'now', label: `{{t("System time", { ns: "${NAMESPACE}" })}}` }); + this.registerSystemVariable({ + key: 'instanceId', + label: `{{t("Instance ID", { ns: "${NAMESPACE}" })}}`, + tooltip: `{{t("The ID of current server instance", { ns: "${NAMESPACE}" })}}`, + }); + this.registerSystemVariable({ + key: 'genSnowflakeId', + label: `{{t("Generate snowflake ID", { ns: "${NAMESPACE}" })}}`, + tooltip: `{{t("53 bit (JavaScript safe) unique ID generated by Snowflake algorithm. Will always generate new one each time use this variable.", { ns: "${NAMESPACE}" })}}`, + }); + } + + private registerBuiltinInstructions() { + coreInstructionGroups.forEach((group) => this.registerInstructionGroup(group.key, group)); + // Register each core node, in v1 order (mirrors v1's `client/index.tsx`). + this.registerInstruction('calculation', CalculationInstruction); + this.registerInstruction('condition', ConditionInstruction); + this.registerInstruction('multi-conditions', MultiConditionsInstruction); + this.registerInstruction('end', EndInstruction); + this.registerInstruction('output', OutputInstruction); + this.registerInstruction('query', QueryInstruction); + this.registerInstruction('create', CreateInstruction); + this.registerInstruction('update', UpdateInstruction); + this.registerInstruction('destroy', DestroyInstruction); + } + + private registerModelLoaders() { + // Lazy loaders (not eager `registerModels`). `extends` declares the parent class so the engine can discover these + // as sub-model candidates without loading their chunks first. + this.flowEngine.registerModelLoaders({ + NodeDetailsModel: { + extends: 'CollectionBlockModel', + loader: () => import('./models/NodeDetailsModel'), + }, + NodeDetailsGridModel: { + extends: 'DetailsGridModel', + loader: () => import('./models/NodeDetailsGridModel'), + }, + NodeValueModel: { + extends: 'BlockModel', + loader: () => import('./models/NodeValueModel'), + }, + TaskCardCommonItemModel: { + extends: 'DetailsCustomItemModel', + loader: () => import('./models/TaskCardCommonItemModel'), + }, + }); + } + + private registerBuiltinTriggers() { + this.registerTrigger('collection', CollectionTrigger); + this.registerTrigger('schedule', ScheduleTrigger); + } + + private registerSettingsPage() { + const t = (key: string) => this.app.i18n.t(key, { ns: NAMESPACE }); + this.pluginSettingsManager.addMenuItem({ + key: NAMESPACE, + title: t('Workflow'), + icon: 'PartitionOutlined', + isPinned: true, + sort: 300, + aclSnippet: 'pm.workflow.workflows', + }); + // `index` is the menu's default tab: the settings manager registers it at the menu root (`.../settings/workflow`) + // with no extra path segment, matching v1's route instead of `.../settings/workflow/workflows`. + this.pluginSettingsManager.addPageTabItem({ + menuKey: NAMESPACE, + key: 'index', + title: t('Workflow'), + aclSnippet: 'pm.workflow.workflows', + sort: 1, + componentLoader: () => import('./pages/WorkflowPane'), + }); + } + + // The canvas page is registered directly under `admin` (not `admin.settings`), so it renders in the admin content + // area without the settings left menu — mirroring v1's `router.add('admin.workflow.workflows.id', ...)`. + private registerCanvasRoute() { + this.app.router.add(WORKFLOW_CANVAS_ROUTE_NAME, { + path: WORKFLOW_CANVAS_ROUTE_PATH, + componentLoader: () => import('./pages/WorkflowCanvasPage'), + }); + } + + // The execution detail page, a sibling of the canvas under the same `admin.workflow` namespace — mirrors v1's + // `admin.workflow.executions.id`. + private registerExecutionRoute() { + this.app.router.add(WORKFLOW_EXECUTION_ROUTE_NAME, { + path: WORKFLOW_EXECUTION_ROUTE_PATH, + componentLoader: () => import('./pages/ExecutionViewPage'), + }); + } +} + +export default PluginWorkflowClientV2; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/ExecuteWorkflowButton.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/ExecuteWorkflowButton.tsx new file mode 100644 index 00000000000..e6673c78f1d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/ExecuteWorkflowButton.tsx @@ -0,0 +1,216 @@ +/** + * 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, { lazy, Suspense, useMemo, useState } from 'react'; +import { useMemoizedFn } from 'ahooks'; +import { App, Alert, Button, Checkbox, Form, Skeleton, Space, Tag, Tooltip, theme } from 'antd'; +import { DialogFormLayout } from '@nocobase/client-v2'; +import { useFlowContext as useFlowEngineContext, useFlowView } from '@nocobase/flow-engine'; +import { EXECUTION_STATUS_OPTIONS_MAP } from '../../common/executionStatus'; +import { CurrentWorkflowContext } from '../canvas/contexts'; +import { useWorkflowRuntimePaths } from '../hooks/useWorkflowRuntimePaths'; +import { useT } from '../locale'; +import { PluginWorkflowClientV2 } from '../plugin'; +import type { LoaderOf, Trigger } from '.'; +import { Link } from 'react-router-dom'; +import { Trans } from 'react-i18next'; +import { NAMESPACE } from '../../common/constants'; + +type WorkflowLike = { + id?: string | number; + type?: string; + config?: Record; + versionStats?: { executed?: number }; +}; + +function ExecutedMessage({ execution }: { execution?: { id?: string | number; status?: number | null } }) { + const t = useT(); + const { getWorkflowExecutionPath } = useWorkflowRuntimePaths(); + const option = execution ? EXECUTION_STATUS_OPTIONS_MAP[String(execution.status)] : null; + if (!option) { + return {t('Workflow executed')}; + } + const statusText = t(option.label); + return ( + + {'Workflow executed, the result status is '} + {'{{statusText}}'} + View the execution + + ); +} + +function ExecuteWorkflowForm({ + workflow, + trigger, + TriggerFieldsetLoader, + valid, + onSubmitted, +}: { + workflow: WorkflowLike; + trigger: Trigger; + TriggerFieldsetLoader: LoaderOf; + valid: boolean; + onSubmitted?: () => void; +}) { + const ctx = useFlowEngineContext(); + const view = useFlowView(); + const t = useT(); + const { token } = theme.useToken(); + const { message } = App.useApp(); + const { getWorkflowCanvasPath } = useWorkflowRuntimePaths(); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const executed = Boolean(workflow.versionStats?.executed); + const Fieldset = useMemo(() => lazy(TriggerFieldsetLoader), [TriggerFieldsetLoader]); + const disabled = !valid; + + const onSubmit = useMemoizedFn(async () => { + if (!valid) { + return; + } + const raw = await form.validateFields(); + const { autoRevision, ...values } = raw; + setSubmitting(true); + try { + const response = await ctx.api.resource('workflows').execute({ + filterByTk: workflow.id, + values, + ...(!executed && autoRevision ? { autoRevision: 1 } : {}), + }); + const result = response?.data?.data; + message.open({ type: 'info', content: }); + await view.close(); + if (result?.newVersionId) { + ctx.router.navigate(getWorkflowCanvasPath(result.newVersionId)); + } else { + onSubmitted?.(); + } + } finally { + setSubmitting(false); + } + }); + + const footer = ( + + + + + ); + + return ( + + +
+ + +
+ }> +
+ +
+
+ {executed ? null : ( + + {t('Automatically create a new version after execution')} + + )} + +
+
+ ); +} + +export function ExecuteWorkflowButton({ record, refresh }: { record: WorkflowLike; refresh?: () => void }) { + const ctx = useFlowEngineContext(); + const t = useT(); + const plugin = ctx.app.pm.get(PluginWorkflowClientV2) as PluginWorkflowClientV2; + const trigger = record.type ? plugin.getTriggerOptions(record.type) : undefined; + const valid = trigger?.validate(record.config ?? {}) ?? false; + + const disabledReason = useMemo(() => { + if (!trigger) { + return t('This trigger type is not available in the new canvas yet.'); + } + if (!trigger.TriggerFieldsetLoader) { + return t('This type of trigger has not been supported to be executed manually.'); + } + return ''; + }, [t, trigger]); + + const openExecuteDialog = useMemoizedFn(() => { + if (!trigger || disabledReason || !trigger.TriggerFieldsetLoader) { + return; + } + openExecuteWorkflowDialog({ + ctx, + workflow: record, + trigger, + TriggerFieldsetLoader: trigger.TriggerFieldsetLoader, + valid, + refresh, + }); + }); + + return ( + + + + + + ); +} + +export default ExecuteWorkflowButton; + +export function openExecuteWorkflowDialog(opts: { + ctx: { viewer: { dialog: (options: Record) => void } }; + workflow: WorkflowLike; + trigger: Trigger; + TriggerFieldsetLoader: LoaderOf; + valid: boolean; + refresh?: () => void; +}) { + opts.ctx.viewer.dialog({ + width: 520, + closable: true, + content: () => ( + + ), + }); +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerConfig.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerConfig.tsx new file mode 100644 index 00000000000..ff8aa4c0b7d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerConfig.tsx @@ -0,0 +1,232 @@ +/** + * 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, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { useMemoizedFn } from 'ahooks'; +import { App, Form, Input, Skeleton, Tag, Tooltip, Typography, theme } from 'antd'; +import { ThunderboltOutlined } from '@ant-design/icons'; +import { DrawerFormLayout } from '@nocobase/client-v2'; +import { useFlowContext as useFlowEngineContext } from '@nocobase/flow-engine'; +import { + useFlowContext as useCanvasFlowContext, + useWorkflowCanvasExecuted, + CurrentWorkflowContext, +} from '../canvas/contexts'; +import useStyles from '../canvas/style'; +import { useT } from '../locale'; +import { PluginWorkflowClientV2 } from '../plugin'; +import { TriggerExecutionButton } from './TriggerExecutionButton'; +import type { Trigger } from '.'; + +function TriggerTypeDescription({ + trigger, + t, +}: { + trigger: Trigger; + t: (key: string, options?: Record) => string; +}) { + const { token } = theme.useToken(); + if (!trigger.description) { + return null; + } + return ( +
+ + {t('Trigger type')} + : + }>{t(trigger.title)} + + {t(trigger.description)} +
+ ); +} + +function TriggerConfigForm({ + trigger, + workflow, + onSubmitted, +}: { + trigger?: Trigger; + workflow: Record; + onSubmitted?: () => void; +}) { + const ctx = useFlowEngineContext(); + const t = useT(); + const { message } = App.useApp(); + const executed = Boolean(workflow?.versionStats?.executed); + const [form] = Form.useForm(); + const [submitting, setSubmitting] = useState(false); + const Fieldset = useMemo(() => (trigger?.FieldsetLoader ? lazy(trigger.FieldsetLoader) : null), [trigger]); + const initialValues = useMemo(() => ({ config: workflow.config ?? {} }), [workflow.config]); + + useEffect(() => { + form.setFieldsValue(initialValues); + }, [form, initialValues]); + + const onSubmit = useMemoizedFn(async () => { + const values = await form.validateFields(); + setSubmitting(true); + try { + await ctx.api.resource('workflows').update({ + filterByTk: workflow.id, + values: { + config: values.config ?? {}, + }, + }); + onSubmitted?.(); + } catch (error) { + message.error(t('Failed to save trigger')); + // eslint-disable-next-line no-console + console.error(error); + throw error; + } finally { + setSubmitting(false); + } + }); + + return ( + + : undefined} + > +
+ {trigger ? : null} + {Fieldset ? ( + }> +
+ + ) : ( + + {trigger + ? t("This trigger's configuration has not been migrated to the new canvas yet.") + : t('This trigger type is not available in the new canvas yet.')} + + )} + + + + ); +} + +export function openTriggerConfigDrawer(opts: { + ctx: { viewer: { drawer: (options: Record) => void } }; + trigger?: Trigger; + workflow: Record; + refresh?: () => void; +}) { + opts.ctx.viewer.drawer({ + width: '50%', + closable: true, + content: () => , + }); +} + +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, .ant-modal')); +} + +export function TriggerConfig() { + const flowEngine = useFlowEngineContext(); + const t = useT(); + const { styles, cx } = useStyles(); + const { workflow, refresh } = useCanvasFlowContext() ?? {}; + const executed = Boolean(useWorkflowCanvasExecuted()); + const plugin = flowEngine.app.pm.get(PluginWorkflowClientV2) as PluginWorkflowClientV2; + const trigger = workflow?.type ? plugin.getTriggerOptions(workflow.type) : undefined; + const triggerTitle = trigger ? t(trigger.title) : workflow?.type ?? t('Unknown trigger'); + const [editingTitle, setEditingTitle] = useState(''); + + useEffect(() => { + setEditingTitle(workflow?.triggerTitle ?? workflow?.title ?? triggerTitle); + }, [triggerTitle, workflow?.title, workflow?.triggerTitle]); + + const onSaveTitle = useMemoizedFn(async (nextTitle: string) => { + if (!workflow?.id) { + return; + } + const title = nextTitle || triggerTitle; + setEditingTitle(title); + if (title === workflow.triggerTitle) { + return; + } + await flowEngine.api.resource('workflows').update({ + filterByTk: workflow.id, + values: { + triggerTitle: title, + }, + }); + refresh?.(); + }); + + const openDrawer = useMemoizedFn(() => { + if (!workflow?.id) { + return; + } + openTriggerConfigDrawer({ ctx: flowEngine, trigger, workflow, refresh }); + }); + + if (!workflow) { + return null; + } + + const titleText = t('Trigger'); + + return ( +
{ + if (!event.currentTarget.contains(event.target as Node)) { + return; + } + if (!isInteractiveClickTarget(event.target)) { + openDrawer(); + } + }} + > +
+
+
+ + }> + {triggerTitle} + + +
+
+ +
+
+
+ setEditingTitle(event.target.value)} + onBlur={async (event) => { + await onSaveTitle(event.target.value); + }} + autoSize + disabled={executed} + aria-label={t('Trigger title')} + /> +
+
+
+ ); +} + +export default TriggerConfig; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerExecutionButton.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerExecutionButton.tsx new file mode 100644 index 00000000000..f20c5d36d6b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/TriggerExecutionButton.tsx @@ -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 React from 'react'; +import { Button, Input, Modal, Tag, Tooltip } from 'antd'; +import { InfoOutlined } from '@ant-design/icons'; +import { useT } from '../locale'; +import { useFlowContext } from '../canvas/contexts'; +import { formatTime } from '../components/workflowCanvas'; +import { ExecutionStatusTag } from '../components/ExecutionStatusTag'; +import useStyles from '../canvas/style'; + +export function TriggerExecutionButton({ triggerTitle }: { triggerTitle: string }) { + const t = useT(); + const { execution, workflow } = useFlowContext() ?? {}; + const [open, setOpen] = React.useState(false); + const { styles } = useStyles(); + + if (!execution) { + return null; + } + + return ( + <> + +
); } + +export default EndsByField; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/FieldsSelect.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/FieldsSelect.tsx new file mode 100644 index 00000000000..9ec8782b7e5 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/FieldsSelect.tsx @@ -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 ScheduleCollectionField } from './collectionUtils'; + +function defaultFilter() { + return true; +} + +function FieldOption({ label, value }: { label?: React.ReactNode; value?: string }) { + return ( + + {label} + {value} + + ); +} + +export function FieldsSelect({ + collection, + filter = defaultFilter, + ...others +}: SelectProps & { + collection?: string; + filter?: (field: ScheduleCollectionField) => 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 ( + onChange({ ...value, unit })} + onChange={(unit) => emitChange({ ...value, unit })} options={[ - { value: 86400000, label: lang('Days') }, - { value: 3600000, label: lang('Hours') }, - { value: 60000, label: lang('Minutes') }, - { value: 1000, label: lang('Seconds') }, + { value: 86400000, label: t('Days') }, + { value: 3600000, label: t('Hours') }, + { value: 60000, label: t('Minutes') }, + { value: 1000, label: t('Seconds') }, ]} className="auto-width" /> @@ -75,3 +87,5 @@ export function OnField({ value: propsValue, onChange }) { ); } + +export default OnField; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/RepeatField.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/RepeatField.tsx similarity index 59% rename from packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/RepeatField.tsx rename to packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/RepeatField.tsx index 9024a5a614d..68e0116e31c 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/RepeatField.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/RepeatField.tsx @@ -7,13 +7,23 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { css } from '@nocobase/client'; +import { css } from '@emotion/css'; +import { useMemoizedFn } from 'ahooks'; import { InputNumber, Select } from 'antd'; -import React, { useCallback } from 'react'; -import { Cron } from 'react-js-cron'; +import React from 'react'; +import { Cron, type Locale } from 'react-js-cron'; import { useWorkflowTranslation } from '../../locale'; -const RepeatOptions = [ +declare global { + interface Window { + cronLocale?: Locale; + } +} + +type RepeatValue = number | string | null; +type RepeatOption = { value: 'none' | 'cron' | number; text: string; unitText?: string }; + +const RepeatOptions: RepeatOption[] = [ { value: 'none', text: 'No repeat' }, { value: 60_000, text: 'By minute', unitText: 'Minutes' }, { value: 3600_000, text: 'By hour', unitText: 'Hours' }, @@ -23,13 +33,15 @@ const RepeatOptions = [ { value: 'cron', text: 'Advanced' }, ]; -function getNumberOption(v) { - const opts = RepeatOptions.filter((option) => typeof option.value === 'number').reverse() as any[]; - return opts.find((item) => !(v % item.value)); +function getNumberOption(v?: RepeatValue) { + const opts = RepeatOptions.filter( + (option): option is RepeatOption & { value: number } => typeof option.value === 'number', + ).reverse(); + return opts.find((item) => typeof v === 'number' && !(v % item.value)); } -function getRepeatTypeValue(v) { - let option; +function getRepeatTypeValue(v?: RepeatValue) { + let option: RepeatOption | undefined; switch (typeof v) { case 'number': option = getNumberOption(v); @@ -42,9 +54,17 @@ function getRepeatTypeValue(v) { return 'none'; } -function CommonRepeatField({ value, onChange, disabled }) { +function CommonRepeatField({ + value, + onChange, + disabled, +}: { + value: number; + onChange?: (value: RepeatValue) => void; + disabled?: boolean; +}) { const { t } = useWorkflowTranslation(); - const option = getNumberOption(value); + const option = getNumberOption(value) as RepeatOption & { value: number; unitText: string }; return ( void; + disabled?: boolean; +}) { const { t } = useWorkflowTranslation(); const typeValue = getRepeatTypeValue(value); - const onTypeChange = useCallback( - (v) => { - if (v === 'none') { - onChange(null); - return; - } - if (v === 'cron') { - onChange('0 * * * * *'); - return; - } - onChange(typeof typeValue === 'number' ? Math.round((value / typeValue) * v) : v); - }, - [onChange, typeValue, value], - ); + const onTypeChange = useMemoizedFn((v: RepeatValue | 'none' | 'cron') => { + if (v === 'none') { + onChange?.(null); + return; + } + if (v === 'cron') { + onChange?.('0 * * * * *'); + return; + } + onChange?.(typeof typeValue === 'number' ? Math.round(((value as number) / typeValue) * (v as number)) : v); + }); return (
{typeof typeValue === 'number' ? ( - + ) : null} {typeValue === 'cron' ? ( onChange(`0 ${v}`)} + value={(value as string).trim().split(/\s+/).slice(1).join(' ')} + setValue={(v) => onChange?.(`0 ${v}`)} clearButton={false} locale={window['cronLocale']} disabled={disabled} @@ -137,3 +162,5 @@ export function RepeatField({ value = null, onChange, disabled }) {
); } + +export default RepeatField; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleConfig.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleConfig.tsx new file mode 100644 index 00000000000..df7c02b01d7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleConfig.tsx @@ -0,0 +1,213 @@ +/** + * 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 { css } from '@emotion/css'; +import { DatePicker, Form, InputNumber, Radio } from 'antd'; +import dayjs from 'dayjs'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { useT } from '../../locale'; +import { AppendsSelect } from './AppendsSelect'; +import { CollectionCascader } from './CollectionCascader'; +import { EndsByField } from './EndsByField'; +import { OnField } from './OnField'; +import { RepeatField } from './RepeatField'; +import { ScheduleModeFields } from './ScheduleModes'; +import { SCHEDULE_MODE, scheduleModeOptions } from './constants'; + +type DateTimePickerProps = { + value?: string | Date; + onChange?: (value?: Date | null) => void; + placeholder?: string; +}; + +function DateTimePicker({ value, onChange, placeholder }: DateTimePickerProps) { + return ( + onChange?.(nextValue ? nextValue.toDate() : null)} + /> + ); +} + +export function TriggerModeField({ disabled }: { disabled?: boolean }) { + const t = useT(); + const form = Form.useFormInstance(); + const options = useMemo(() => scheduleModeOptions.map((item) => ({ value: item.value, label: t(item.label) })), [t]); + + return ( + + { + form.setFieldValue(['config', 'mode'], event.target.value); + form.setFieldValue(['config', 'collection'], undefined); + form.setFieldValue(['config', 'startsOn'], undefined); + form.setFieldValue(['config', 'repeat'], undefined); + form.setFieldValue(['config', 'endsOn'], undefined); + form.setFieldValue(['config', 'limit'], undefined); + }} + /> + + ); +} + +function StaticScheduleFields() { + const t = useT(); + const repeat = Form.useWatch(['config', 'repeat']); + const fields = ScheduleModeFields[SCHEDULE_MODE.STATIC]; + return ( + <> + + + + + + + {repeat ? ( + <> + + + + + + + + ) : null} + + ); +} + +function hasSelectedScheduleField(value: unknown) { + return Boolean(value && typeof value === 'object' && 'field' in value && (value as { field?: unknown }).field); +} + +function DateFieldScheduleFields() { + const t = useT(); + const form = Form.useFormInstance(); + const collection = Form.useWatch(['config', 'collection']); + const repeat = Form.useWatch(['config', 'repeat']); + const prevCollectionRef = useRef(collection); + const initializedRef = useRef(false); + const collectionHydratedRef = useRef(false); + const fields = ScheduleModeFields[SCHEDULE_MODE.DATE_FIELD]; + + useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + prevCollectionRef.current = collection; + return; + } + if (!collectionHydratedRef.current) { + prevCollectionRef.current = collection; + if (collection) { + collectionHydratedRef.current = true; + } + return; + } + if (prevCollectionRef.current !== collection && collection) { + form.setFieldValue(['config', 'startsOn'], undefined); + form.validateFields([['config', 'startsOn']]).catch(() => undefined); + } + prevCollectionRef.current = collection; + }, [collection, form]); + + return ( + <> + + + + + + + + + + {repeat ? ( + <> + + + + + + + + ) : null} + + + + + ); +} + +export function ScheduleConfig({ modeDisabled = true }: { modeDisabled?: boolean }) { + const mode = Form.useWatch(['config', 'mode']) ?? SCHEDULE_MODE.STATIC; + + return ( +
+ + {mode === SCHEDULE_MODE.DATE_FIELD ? : } +
+ ); +} + +export function SchedulePresetConfig() { + return ; +} + +export default ScheduleConfig; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleModes.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleModes.tsx new file mode 100644 index 00000000000..5068bd05ee7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/ScheduleModes.tsx @@ -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 { NAMESPACE } from '../../locale'; +import { SCHEDULE_MODE } from './constants'; + +export type ScheduleConfigValue = { + mode?: number; + collection?: string; + startsOn?: unknown; + repeat?: unknown; + endsOn?: unknown; + limit?: number; + appends?: string[]; +}; + +type ScheduleFieldMeta = { + title: string; + description?: string; + placeholder?: string; + min?: number; + required?: boolean; +}; + +type ScheduleExecuteFieldMeta = { + title: string; + description?: string; + placeholder?: string; + required?: boolean; +}; + +export const ScheduleModeFields: Record> = { + [SCHEDULE_MODE.STATIC]: { + startsOn: { + title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`, + required: true, + }, + repeat: { + title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`, + }, + endsOn: { + title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`, + }, + limit: { + title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`, + placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`, + min: 0, + }, + }, + [SCHEDULE_MODE.DATE_FIELD]: { + collection: { + title: '{{t("Collection")}}', + required: true, + }, + startsOn: { + title: `{{t("Starts on", { ns: "${NAMESPACE}" })}}`, + required: true, + }, + repeat: { + title: `{{t("Repeat mode", { ns: "${NAMESPACE}" })}}`, + }, + endsOn: { + title: `{{t("Ends on", { ns: "${NAMESPACE}" })}}`, + }, + limit: { + title: `{{t("Repeat limit", { ns: "${NAMESPACE}" })}}`, + placeholder: `{{t("No limit", { ns: "${NAMESPACE}" })}}`, + min: 0, + }, + appends: { + title: `{{t("Preload associations", { ns: "${NAMESPACE}" })}}`, + description: `{{t("Please select the associated fields that need to be accessed in subsequent nodes. With more than two levels of to-many associations may cause performance issue, please use with caution.", { ns: "${NAMESPACE}" })}}`, + }, + }, +}; + +export const ScheduleModeExecuteFields: Record> = { + [SCHEDULE_MODE.STATIC]: { + date: { + title: `{{t('Execute on', { ns: "${NAMESPACE}" })}}`, + placeholder: `{{t("Current time", { ns: "${NAMESPACE}" })}}`, + required: true, + }, + }, + [SCHEDULE_MODE.DATE_FIELD]: { + data: { + title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, + description: `{{t("Choose a record or primary key of a record in the collection to trigger.", { ns: "${NAMESPACE}" })}}`, + required: true, + }, + }, +}; + +export const ScheduleModes = { + [SCHEDULE_MODE.STATIC]: { + validate(config: ScheduleConfigValue) { + return Boolean(config.startsOn); + }, + }, + [SCHEDULE_MODE.DATE_FIELD]: { + validate(config: ScheduleConfigValue) { + return Boolean(config.collection && config.startsOn); + }, + }, +}; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/TriggerScheduleConfig.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/TriggerScheduleConfig.tsx new file mode 100644 index 00000000000..01b3449059d --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/TriggerScheduleConfig.tsx @@ -0,0 +1,71 @@ +/** + * 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 { Alert, DatePicker, Form } from 'antd'; +import dayjs from 'dayjs'; +import React from 'react'; +import { useCurrentWorkflowContext } from '../../canvas/contexts'; +import { TriggerCollectionRecordSelect } from '../../components/collection'; +import { useT } from '../../locale'; +import { ScheduleModeExecuteFields } from './ScheduleModes'; +import { SCHEDULE_MODE } from './constants'; + +function ScheduleExecuteDatePicker({ + value, + onChange, + placeholder, +}: { + value?: string | Date; + onChange?: (value?: Date | null) => void; + placeholder?: string; +}) { + return ( + onChange?.(nextValue ? nextValue.toDate() : null)} + /> + ); +} + +export function TriggerScheduleConfig() { + const workflow = useCurrentWorkflowContext(); + const t = useT(); + const mode = workflow?.config?.mode; + + if (mode === SCHEDULE_MODE.DATE_FIELD) { + const field = ScheduleModeExecuteFields[SCHEDULE_MODE.DATE_FIELD].data; + return ( + + + + ); + } + + const field = ScheduleModeExecuteFields[SCHEDULE_MODE.STATIC].date; + return ( + + + + ); +} + +export default TriggerScheduleConfig; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/AppendsSelect.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/AppendsSelect.test.tsx new file mode 100644 index 00000000000..909ea614c31 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/AppendsSelect.test.tsx @@ -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 '../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
; + }; + 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('Schedule AppendsSelect', () => { + it('compiles association field titles before passing them to TreeSelect', () => { + render(); + + expect(screen.getByTestId('tree-select')).toBeInTheDocument(); + expect(treeSelectState.props?.treeData).toEqual([ + expect.objectContaining({ + title: 'Created by', + value: 'createdBy', + }), + ]); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/ScheduleConfig.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/ScheduleConfig.test.tsx new file mode 100644 index 00000000000..cae9aa2a65c --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/ScheduleConfig.test.tsx @@ -0,0 +1,56 @@ +/** + * 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 { Form } from 'antd'; +import ScheduleConfig from '../ScheduleConfig'; +import { SCHEDULE_MODE } from '../constants'; + +vi.mock('../../../locale', () => ({ + NAMESPACE: 'workflow', + useT: () => (key: string) => key, +})); + +vi.mock('../CollectionCascader', () => ({ + CollectionCascader: () =>
, +})); + +vi.mock('../OnField', () => ({ + OnField: () =>
, +})); + +vi.mock('../RepeatField', () => ({ + RepeatField: () =>
, +})); + +vi.mock('../EndsByField', () => ({ + EndsByField: () =>
, +})); + +vi.mock('../AppendsSelect', () => ({ + AppendsSelect: () =>
, +})); + +describe('ScheduleConfig', () => { + it('marks starts-on as required in date-field mode', () => { + const { container } = render( +
+ + , + ); + + expect(screen.getByTestId('on-field')).toBeInTheDocument(); + + const startsOnLabel = container.querySelector('label[for="config_startsOn"]'); + expect(startsOnLabel).toBeTruthy(); + expect(startsOnLabel).toHaveClass('ant-form-item-required'); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/TriggerScheduleConfig.test.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/TriggerScheduleConfig.test.tsx new file mode 100644 index 00000000000..7836092bdc6 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/TriggerScheduleConfig.test.tsx @@ -0,0 +1,130 @@ +/** + * 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 TriggerScheduleConfig from '../TriggerScheduleConfig'; +import { SCHEDULE_MODE } from '../constants'; + +const remoteSelectState = vi.hoisted(() => ({ + props: null as null | { + value?: unknown; + onChange?: (value?: unknown) => void; + request: () => Promise; + 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
; + }, +})); + +const flowContextValue = { + 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('@nocobase/flow-engine', () => ({ + useFlowContext: () => flowContextValue, + useFlowEngine: () => ({ + context: flowContextValue, + }), +})); + +vi.mock('../../../canvas/contexts', () => ({ + useCurrentWorkflowContext: () => ({ + config: { mode: SCHEDULE_MODE.DATE_FIELD, collection: 'roles' }, + }), +})); + +vi.mock('../../../locale', () => ({ + NAMESPACE: 'workflow', + useT: () => (key: string) => { + const matched = key.match(/^{{t\("(.+)"(?:,\s*\{.*\})?\)}}$/); + return matched?.[1] ?? key; + }, +})); + +describe('TriggerScheduleConfig', () => { + it('compiles server-returned title templates for trigger record options in date-field mode', async () => { + render( +
+ + , + ); + + 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('writes the full selected record into the parent form field value in date-field mode', async () => { + let formInstance: ReturnType[0] | null = null; + + function Wrapper() { + const [form] = Form.useForm(); + formInstance = form; + + return ( +
+ + + +
+ ); + } + + render(); + + 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")}}', + }); + }); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/scheduleTrigger.test.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/scheduleTrigger.test.ts new file mode 100644 index 00000000000..52b7882c154 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/__tests__/scheduleTrigger.test.ts @@ -0,0 +1,23 @@ +/** + * 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 } from 'vitest'; +import ScheduleTrigger from '../index'; +import ScheduleConfig, { SchedulePresetConfig } from '../ScheduleConfig'; + +describe('schedule trigger progressive migration', () => { + it('loads preset/config fieldsets directly from ScheduleConfig after removing the re-export shim', async () => { + const trigger = new ScheduleTrigger(); + const presetModule = await trigger.PresetFieldsetLoader?.(); + const fieldsetModule = await trigger.FieldsetLoader?.(); + + expect(presetModule?.default).toBe(SchedulePresetConfig); + expect(fieldsetModule?.default).toBe(ScheduleConfig); + }); +}); diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/collectionUtils.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/collectionUtils.ts new file mode 100644 index 00000000000..728ebc2ffb7 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/collectionUtils.ts @@ -0,0 +1,105 @@ +/** + * 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 { CollectionField, DataSourceManager } from '@nocobase/flow-engine'; +import type { FieldTreeCollectionManager } from '../../canvas/collectionFieldOptions'; + +export type ScheduleCollectionField = { + name?: string; + type?: string; + target?: string; + hidden?: boolean; + uiSchema?: { title?: string; ['x-read-pretty']?: boolean }; +}; + +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] : []; +} + +function normalizeField(field: CollectionField): ScheduleCollectionField { + return (field.options ?? field) as ScheduleCollectionField; +} + +export function getCollectionManagerAdapter( + dataSourceManager: DataSourceManager | undefined, + dataSourceKey = 'main', +): FieldTreeCollectionManager { + return { + getCollectionAllFields(collectionName: string) { + return ( + dataSourceManager + ?.getDataSource?.(dataSourceKey) + ?.collectionManager?.getCollection?.(collectionName) + ?.getFields?.() + ?.map(normalizeField) ?? [] + ); + }, + }; +} + +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, +): ScheduleCollectionField[] { + const [dataSourceKey, collectionName] = parseCollectionName(collectionValue) as [string, string]; + if (!dataSourceKey || !collectionName) { + return []; + } + return ( + dataSourceManager + ?.getDataSource?.(dataSourceKey) + ?.collectionManager?.getCollection?.(collectionName) + ?.getFields?.() + ?.map(normalizeField) ?? [] + ); +} + +export function isDateField(field: ScheduleCollectionField) { + return !field.hidden && Boolean(field.uiSchema) && ['date', 'datetimeTz', 'datetimeNoTz'].includes(field.type); +} + +export function isAssociationField(field: ScheduleCollectionField) { + return ['belongsTo', 'hasOne', 'hasMany', 'belongsToMany', 'belongsToArray'].includes(field.type); +} + +export function hasFieldName(field: ScheduleCollectionField): field is ScheduleCollectionField & { name: string } { + return typeof field.name === 'string' && field.name.length > 0; +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/constants.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/constants.ts similarity index 98% rename from packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/constants.ts rename to packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/constants.ts index 910a05e3f6e..0f0816db796 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/constants.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/constants.ts @@ -12,7 +12,7 @@ import { NAMESPACE } from '../../locale'; export const SCHEDULE_MODE = { STATIC: 0, DATE_FIELD: 1, -}; +} as const; export const scheduleModeOptions = [ { value: SCHEDULE_MODE.STATIC, label: `{{t("Based on certain date", { ns: "${NAMESPACE}" })}}` }, diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/index.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/index.tsx new file mode 100644 index 00000000000..ad0f102814f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/index.tsx @@ -0,0 +1,128 @@ +/** + * 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, type SubModelItem } from '@nocobase/flow-engine'; +import { + getCollectionFieldOptions, + type UseVariableOptions, + type VariableOption, +} from '../../canvas/collectionFieldOptions'; +import { NAMESPACE, useT } from '../../locale'; +import { Trigger, type LoaderOf } from '..'; +import { getCollectionManagerAdapter, parseCollectionName } from './collectionUtils'; +import { ScheduleModes, type ScheduleConfigValue } from './ScheduleModes'; +import { SCHEDULE_MODE } from './constants'; + +function useVariables(config: ScheduleConfigValue, opts?: UseVariableOptions): VariableOption[] { + const flowEngine = useFlowEngine(); + const t = useT(); + const [dataSourceName] = parseCollectionName(config.collection) as [string, string]; + const collectionManager = getCollectionManagerAdapter(flowEngine.context.dataSourceManager, dataSourceName); + const options: VariableOption[] = []; + + if (!opts?.types || opts.types.includes('date')) { + options.push({ key: 'date', value: 'date', label: t('Trigger time') }); + } + + if (config.mode === SCHEDULE_MODE.DATE_FIELD && config.collection) { + const [fieldOption] = getCollectionFieldOptions({ + appends: ['data', ...(config.appends?.map((item) => `data.${item}`) || [])], + ...opts, + fields: [ + { + collectionName: config.collection, + name: 'data', + type: 'hasOne', + target: config.collection, + uiSchema: { + title: t('Trigger data'), + }, + }, + ], + compile: t, + collectionManager, + }); + if (fieldOption) { + options.push(fieldOption); + } + } + + return options; +} + +export default class ScheduleTrigger extends Trigger { + sync = false; + title = `{{t("Schedule event", { ns: "${NAMESPACE}" })}}`; + description = `{{t("Triggered according to preset time conditions. Suitable for one-time or periodic tasks, such as sending notifications and cleaning data on a schedule.", { ns: "${NAMESPACE}" })}}`; + + PresetFieldsetLoader: LoaderOf = () => + import('./ScheduleConfig').then((module) => ({ default: module.SchedulePresetConfig })); + FieldsetLoader: LoaderOf<{ modeDisabled?: boolean }> = () => import('./ScheduleConfig'); + TriggerFieldsetLoader: LoaderOf = () => import('./TriggerScheduleConfig'); + + createDefaultConfig() { + return { mode: SCHEDULE_MODE.STATIC }; + } + + validate(config: ScheduleConfigValue) { + if (config.mode == null) { + return false; + } + const { validate } = ScheduleModes[config.mode]; + return validate ? Boolean(validate(config)) : true; + } + + useVariables = useVariables; + + getCreateModelMenuItem({ config }: { config: ScheduleConfigValue }): SubModelItem | null { + if (!config?.collection) { + return null; + } + return { + key: 'triggerData', + label: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, + useModel: 'NodeDetailsModel', + createModelOptions: { + use: 'NodeDetailsModel', + stepParams: { + resourceSettings: { + init: { + dataSourceKey: 'main', + collectionName: config.collection, + dataPath: '$context.data', + }, + }, + cardSettings: { + titleDescription: { + title: `{{t("Trigger data", { ns: "${NAMESPACE}" })}}`, + }, + }, + }, + subModels: { + grid: { + use: 'NodeDetailsGridModel', + subType: 'object', + }, + }, + }, + }; + } + + useTempAssociationSource(config: ScheduleConfigValue, workflow?: { id?: string | number }) { + if (!config?.collection || !workflow?.id) { + return null; + } + return { + collection: config.collection, + nodeId: workflow.id, + nodeKey: 'workflow', + nodeType: 'workflow' as const, + }; + } +} diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/locale/Cron.zh-CN.ts b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/locale/Cron.zh-CN.ts similarity index 67% rename from packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/locale/Cron.zh-CN.ts rename to packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/locale/Cron.zh-CN.ts index d72f9b35589..eab67d20d5f 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/triggers/schedule/locale/Cron.zh-CN.ts +++ b/packages/plugins/@nocobase/plugin-workflow/src/client-v2/triggers/schedule/locale/Cron.zh-CN.ts @@ -7,7 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export default { +import type { Locale } from 'react-js-cron'; + +const zhCN: Locale = { everyText: '每', emptyMonths: '每月', emptyMonthDays: '每日(月)', @@ -36,8 +38,8 @@ export default { errorInvalidCron: '不符合 cron 规则的表达式', clearButtonText: '清空', weekDays: [ - // Order is important, the index will be used as value - '周日', // Sunday must always be first, it's "0" + // Order is important, the index will be used as value. + '周日', '周一', '周二', '周三', @@ -46,32 +48,7 @@ export default { '周六', ], months: [ - // Order is important, the index will be used as value - '一月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '十一月', - '十二月', - ], - altWeekDays: [ - // Order is important, the index will be used as value - '周日', // Sunday must always be first, it's "0" - '周一', - '周二', - '周三', - '周四', - '周五', - '周六', - ], - altMonths: [ - // Order is important, the index will be used as value + // Order is important, the index will be used as value. '一月', '二月', '三月', @@ -85,4 +62,8 @@ export default { '十一月', '十二月', ], + altWeekDays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + altMonths: ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月'], }; + +export default zhCN; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/AddNodeContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/AddNodeContext.tsx index 3be454fb10c..b454d9c85a0 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/AddNodeContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/AddNodeContext.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { createForm } from '@formily/core'; import { observer, useForm } from '@formily/react'; @@ -32,9 +32,12 @@ import { uid } from '@nocobase/utils/client'; import { Button, Dropdown, Menu, Tooltip } from 'antd'; import { SnippetsOutlined, PlusOutlined } from '@ant-design/icons'; import { MenuItemGroupType } from 'antd/es/menu/interface'; -import { useBranchContext } from './BranchContext'; -import { useNodeDragContext } from './NodeDragContext'; -import { useNodeClipboardContext } from './NodeClipboardContext'; +import { useFlowEngine } from '@nocobase/flow-engine'; +import { useMemoizedFn } from 'ahooks'; +export { AddNodeSlot } from '../client-v2/canvas/AddNodeSlot'; +import { PresetDialogForm } from '../client-v2/canvas/AddNodeContext'; +import { AddNodeContext, useAddNodeContext } from '../client-v2/canvas/AddNodeContext.shared'; +import { createNodeAndMaybeReparent, resolveAddNodeDecision } from '../client-v2/canvas/addNodeController'; interface AddButtonProps { upstream; @@ -42,153 +45,6 @@ interface AddButtonProps { [key: string]: any; } -function AddButtonPlaceholder() { - const { styles } = useStyles(); - return ( -
- -
- ); -} - -export function AddButton(props: AddButtonProps) { - const { upstream, branchIndex = null } = props; - const { styles } = useStyles(); - const { workflow } = useFlowContext() ?? {}; - const addNodeContext = useAddNodeContext(); - const executed = useWorkflowExecuted(); - const branchContext = useBranchContext(); - - const onOpen = useCallback( - () => - addNodeContext?.onMenuOpen?.({ - upstream, - branchIndex, - branchContext: { - syncOnly: branchContext?.syncOnly ?? false, - }, - }), - [addNodeContext, upstream, branchIndex, branchContext?.syncOnly], - ); - - if (!workflow || !addNodeContext || branchContext?.addable === false) { - return ; - } - - return ( -
- {executed ? ( - - ) : ( -
- ); -} - -function AddNodeDropZone(props: AddButtonProps) { - const { upstream, branchIndex = null } = props; - const branchContext = useBranchContext(); - const { styles } = useStyles(); - 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 registerDropZone = dragContext?.registerDropZone; - const getDropKey = dragContext?.getDropKey; - const dropKey = getDropKey?.(target); - const isActive = Boolean(dropKey && dragContext?.activeDropKey === dropKey); - const zoneRef = React.useRef(null); - - React.useEffect(() => { - if (!registerDropZone || !zoneRef.current || disabled) { - return; - } - return registerDropZone(target, zoneRef.current); - }, [registerDropZone, disabled, target]); - - return ( -
-
-
- ); -} - -function AddNodePasteZone(props: AddButtonProps) { - const { upstream, branchIndex = null } = props; - const branchContext = useBranchContext(); - const { styles } = useStyles(); - const clipboard = useNodeClipboardContext(); - const target = useMemo(() => ({ upstream, branchIndex }), [upstream, branchIndex]); - const impact = clipboard?.getPasteImpact?.(target); - const status = impact?.status ?? 'disabled'; - const disabled = branchContext?.addable === false || status === 'disabled'; - - const onClick = useCallback(() => { - if (!disabled) { - clipboard?.pasteNode?.(target); - } - }, [clipboard, disabled, target]); - - return ( -
-
- ); -} - -export function AddNodeSlot(props: AddButtonProps) { - const branchContext = useBranchContext(); - const dragContext = useNodeDragContext(); - const clipboard = useNodeClipboardContext(); - const executed = useWorkflowExecuted(); - if (branchContext?.addable === false) { - return ; - } - if (dragContext?.dragging) { - return ; - } - if (clipboard?.clipboard && !executed) { - return ; - } - return ; -} - function useAddNodeSubmitAction() { const form = useForm(); const api = useAPIClient(); @@ -240,12 +96,6 @@ function useAddNodeSubmitAction() { }; } -const AddNodeContext = createContext(null); - -export function useAddNodeContext() { - return useContext(AddNodeContext); -} - const defaultBranchingOptions = [ { value: 0, @@ -416,6 +266,7 @@ function NodeMenu() { export function AddNodeContextProvider(props) { const api = useAPIClient(); const compile = useCompile(); + const flowEngine = useFlowEngine(); const engine = usePlugin(WorkflowPlugin); const [anchor, setAnchor] = useState(null); const [creating, setCreating] = useState(null); @@ -460,49 +311,90 @@ export function AddNodeContextProvider(props) { [api, refresh, workflow.id], ); - const onCreate = useCallback( - async ({ type, upstream, branchIndex, branchContext }) => { - const instruction = engine.instructions.get(type); - if (!instruction) { - console.error(`Instruction "${type}" not found`); - return; - } - - const unavailableMessage = getInstructionAvailable(instruction, { - engine, - workflow, - upstream, - branchIndex, - branchContext, + const createModernNode = useMemoizedFn(async (anchor, instruction, presetValues) => { + const { downstreamBranchIndex, config: presetConfig } = presetValues ?? {}; + const values = { + key: uid(), + type: instruction.type, + upstreamId: anchor.upstream?.id ?? null, + branchIndex: anchor.branchIndex ?? null, + title: flowEngine.context.t(instruction.title), + config: { ...(instruction.createDefaultConfig?.() ?? {}), ...(presetConfig ?? {}) }, + }; + setCreating(values); + try { + await createNodeAndMaybeReparent({ + workflowId: workflow.id, + api, + refresh, + values, + downstreamBranchIndex, }); - if (unavailableMessage) { - return; - } + } catch (err) { + console.error(err); + throw err; + } finally { + setCreating(null); + } + }); - const data = { - key: uid(), - type, - upstreamId: upstream?.id ?? null, - branchIndex, - title: compile(instruction.title), - config: instruction.createDefaultConfig?.() ?? {}, - }; - const downstream = upstream?.id - ? nodes.find((item) => item.upstreamId === data.upstreamId && item.branchIndex === data.branchIndex) - : nodes.find((item) => item.upstreamId === null); - if ( - instruction.presetFieldset || - ((typeof instruction.branching === 'function' ? instruction.branching(data.config) : instruction.branching) && - downstream) - ) { - setPresetting({ data, instruction }); - return; - } + const onCreate = useMemoizedFn(async ({ type, upstream, branchIndex, branchContext }) => { + const decision = resolveAddNodeDecision({ + type, + anchor: { upstream, branchIndex, branchContext }, + runtime: { + workflow, + nodes: nodes ?? [], + getInstruction: (instructionType) => engine.instructions.get(instructionType), + getInstructionAvailable: (instruction, context) => + getInstructionAvailable(instruction, { + ...context, + engine, + }), + translateTitle: (title) => compile(title), + }, + }); - await create(data); - }, - [compile, create, engine.instructions, nodes], - ); + if (decision.kind === 'missing') { + console.error(`Instruction "${type}" not found`); + return; + } + if (decision.kind === 'blocked') { + return; + } + + // Preset dispatch (ADR-0003), v1-first like the card/drawer surfaces: a legacy `presetFieldset` (with entries) + // keeps the Formily preset modal; only a node that dropped it falls through to the inherited `PresetFieldsetLoader` + // and the v2 antd preset dialog (`ctx.viewer.dialog`), maintained once in client-v2. + if (decision.kind === 'legacy-preset') { + setPresetting({ data: decision.draft, instruction: decision.instruction }); + return; + } + + if (decision.kind === 'modern-preset') { + flowEngine.context.viewer.dialog({ + width: 520, + closable: true, + content: () => ( + createModernNode(decision.anchor, decision.instruction, values)} + /> + ), + }); + return; + } + + // No preset form on either side — still show the v1 branch-preservation modal when the node branches into an + // existing downstream. + if (decision.kind === 'branch-fallback') { + setPresetting({ data: decision.draft, instruction: decision.instruction }); + return; + } + + await create(decision.draft); + }); return ( - -
- ); - - return title ? {content} : content; -} - export function Branch({ from = null, entry = null, @@ -56,27 +39,23 @@ export function Branch({ startTitle?: React.ReactNode; dashed?: boolean; }) { - const { styles } = useStyles(); const { getAriaLabel } = useGetAriaLabelOfAddButton(from, branchIndex); - const list: any[] = []; - for (let node = entry; node; node = node.downstream) { - list.push(node); - } return ( - -
-
- {controller ?
{controller}
: null} -
- {start ? : null} - {addable ? : null} - {list.map((item) => ( - - ))} -
- {end === true ? : end} -
- + ); } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/BranchContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/BranchContext.tsx index 22c7ef8fadb..18a91544273 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/BranchContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/BranchContext.tsx @@ -7,24 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useContext } from 'react'; - -export type BranchContextValue = { - branchIndex: number | null; - addable: boolean; - syncOnly: boolean; -}; - -export const BranchContext = createContext(null); - -export function useBranchContext() { - return useContext(BranchContext); -} - -export function useBranchIndex() { - return useBranchContext()?.branchIndex ?? null; -} - -export function useBranchSyncOnly() { - return useBranchContext()?.syncOnly ?? false; -} +// `BranchContext` + its hooks now live in client-v2 and are shared by both canvases (ADR-0003) — a single context +// instance, like `NodeContext`. Re-exported here so existing v1 import sites (`from './BranchContext'`, `from +// '../BranchContext'`) are unchanged. Delete on legacy-canvas retirement. +export { BranchContext, useBranchContext, useBranchIndex, useBranchSyncOnly } from '../client-v2/canvas/BranchContext'; +export type { BranchContextValue } from '../client-v2/canvas/BranchContext'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/CanvasContent.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/CanvasContent.tsx index 55e6c925002..0443bf05a3e 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/CanvasContent.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/CanvasContent.tsx @@ -21,6 +21,8 @@ import { lang } from './locale'; import useStyles from './style'; import { TriggerConfig } from './triggers'; import { useWorkflowExecuted } from './hooks'; +import { BranchRenderContext } from '../client-v2/canvas/BranchRenderContext'; +import { Node } from './nodes'; export function CanvasContent({ entry }) { const { styles } = useStyles(); @@ -36,41 +38,43 @@ export function CanvasContent({ entry }) { return (
-
-
-
- {executed ? ( - - ) : null} - -
- + +
+
+
+ {executed ? ( + + ) : null} + +
+ +
+
{lang('End')}
-
{lang('End')}
-
+ {copiedNode ? (
diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx index 0082fad11c4..76b1c68eeb3 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/FlowContext.tsx @@ -7,16 +7,19 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { useContext } from 'react'; +/** + * The two canvas React contexts now live in client-v2 and are shared by both + * canvases (ADR-0003). v1 re-exports them so existing import sites + * (`from './FlowContext'`) are unchanged. Both contexts are dependency-free + * (`React.createContext`), so this is a plain re-export — no runtime wrapper, no + * injected runtime — the v2 definition models v1's shape exactly (editor + + * execution canvas values). Delete on legacy-canvas retirement. + */ -export const FlowContext = React.createContext({}); - -export function useFlowContext() { - return useContext(FlowContext); -} - -export const CurrentWorkflowContext = React.createContext({}); - -export function useCurrentWorkflowContext() { - return useContext(CurrentWorkflowContext); -} +export { + FlowContext, + useFlowContext, + CurrentWorkflowContext, + useCurrentWorkflowContext, +} from '../client-v2/canvas/contexts'; +export type { WorkflowCanvasFlowContextValue, CanvasNode } from '../client-v2/canvas/contexts'; diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/NodeClipboardContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/NodeClipboardContext.tsx index 04d83cdd52e..328258dd514 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/NodeClipboardContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/NodeClipboardContext.tsx @@ -7,211 +7,37 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; -import { App, Checkbox } from 'antd'; -import { cloneDeep } from 'lodash'; - -import { useAPIClient } from '@nocobase/client'; +/** + * The clipboard context + provider now live in client-v2 and are shared by both + * canvases (ADR-0003). v1 keeps a thin wrapper that injects its own canvas + * runtime source (v1 `FlowContext` + `versionStats.executed`) into the shared + * provider, and re-exports the hook so existing v1 import sites + * (`from './NodeClipboardContext'`) are unchanged. Delete on legacy-canvas + * retirement. + */ +import React from 'react'; +import { + NodeClipboardContextProvider as SharedNodeClipboardContextProvider, + type CanvasClipboardRuntime, +} from '../client-v2/canvas/NodeClipboardContext'; import { useFlowContext } from './FlowContext'; import { useWorkflowExecuted } from './hooks'; -import { lang } from './locale'; -import { collectUpstreams, extractDependencyKeys, stripVariableReferences } from './nodeVariableUtils'; -type ClipboardNode = { - sourceId?: number; - sourceKey?: string; - type: string; - title?: string; - config?: Record; -}; +export { useNodeClipboardContext } from '../client-v2/canvas/NodeClipboardContext'; -type PasteImpactItem = { - key: string; - title: string; -}; - -type PasteImpact = { - status: 'safe' | 'warning' | 'disabled'; - impactedSelf: PasteImpactItem[]; - impactedDependents: PasteImpactItem[]; -}; - -const NodeClipboardContext = createContext(null); - -export function useNodeClipboardContext() { - return useContext(NodeClipboardContext); -} - -export function NodeClipboardContextProvider(props) { - const api = useAPIClient(); +/** Legacy-canvas runtime source: v1's `FlowContext` + `versionStats.executed` + * (a BigInt count) coerced to the `executed` boolean the provider expects. */ +function useLegacyCanvasRuntime(): CanvasClipboardRuntime { const { workflow, nodes, refresh } = useFlowContext() ?? {}; const executed = useWorkflowExecuted(); - const { modal, message } = App.useApp(); - const [clipboard, setClipboard] = useState(null); - - const nodesByKey = useMemo(() => { - const map = new Map(); - if (!nodes) { - return map; - } - nodes.forEach((node) => { - if (node?.key != null) { - map.set(String(node.key), node); - } - }); - return map; - }, [nodes]); - - const copyNode = useCallback( - (node) => { - 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): PasteImpact => { - if (!clipboard || !target) { - return { status: 'disabled', impactedSelf: [], impactedDependents: [] }; - } - const upstream = target.upstream ?? null; - const upstreamSet = upstream ? collectUpstreams(upstream) : new Set(); - 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) => { - if (!workflow?.id) { - return false; - } - try { - await api.resource('flow_nodes').duplicate({ - filterByTk: clipboard.sourceId, - values, - }); - setClipboard(null); - refresh?.(); - return true; - } catch (err) { - console.error(err); - message.error(lang('Failed to paste node')); - return false; - } - }, - [api, clipboard?.sourceId, message, refresh, workflow?.id], - ); - - const pasteNode = useCallback( - async (target) => { - 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 impactedDependentTitles = impact.impactedDependents.map((item) => item.title).join(', '); - const keepVariablesRef = { current: false }; - const keysToRemove = new Set(impact.impactedSelf.map((item) => item.key).filter(Boolean)); - modal.confirm({ - title: lang('Confirm paste'), - content: ( -
-
- {lang( - 'This action will remove invalid variable references, otherwise the workflow cannot run correctly.', - )} -
- {impactedSelfTitles ? ( -
{lang('Impacted current node variables') + ': ' + impactedSelfTitles}
- ) : null} - {impactedDependentTitles ? ( -
{lang('Impacted dependent node variables') + ': ' + impactedDependentTitles}
- ) : null} -
- (keepVariablesRef.current = ev.target.checked)}> - {lang('Keep variable references')} - -
-
- ), - onOk: async () => { - if (keepVariablesRef.current) { - const created = await duplicateNode(baseValues); - if (created) { - message.warning( - lang('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], - ); - - const value = useMemo( - () => ({ - clipboard, - copyNode, - clearClipboard, - getPasteImpact, - pasteNode, - executed, - }), - [clipboard, clearClipboard, copyNode, executed, getPasteImpact, pasteNode], - ); - - return {props.children}; + return { workflow, nodes, refresh, executed: Boolean(executed) }; +} + +export function NodeClipboardContextProvider(props: { children: React.ReactNode }) { + return ( + + {props.children} + + ); } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/NodeDragContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/NodeDragContext.tsx index 24a608d3dd3..598252ace64 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/NodeDragContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/NodeDragContext.tsx @@ -7,766 +7,51 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import { App, Checkbox } from 'antd'; +/** + * The node drag-to-reorder provider now lives in client-v2 and is shared by both + * canvases (ADR-0003). v1 keeps a thin wrapper that injects its own canvas runtime + * (v1 `useAPIClient` / `lang` / `useCompile` / instruction registry / + * `useWorkflowExecuted`) into the shared provider, and re-exports the hook so + * existing v1 import sites (`from './NodeDragContext'`) are unchanged. The pure + * drop-impact graph walks are likewise re-exported from their client-v2 home. + * Delete on legacy-canvas retirement. + */ +import React from 'react'; import { useAPIClient, useCompile, usePlugin } from '@nocobase/client'; - +import { + NodeDragContextProvider as SharedNodeDragContextProvider, + type CanvasDragRuntime, +} from '../client-v2/canvas/NodeDragContext'; import WorkflowPlugin from '.'; -import { useFlowContext } from './FlowContext'; import { useWorkflowExecuted } from './hooks'; import { lang } from './locale'; -import useStyles from './style'; -import { collectUpstreams, extractDependencyKeys, stripVariableReferences } from './nodeVariableUtils'; -const NodeDragContext = createContext(null); +export { useNodeDragContext } from '../client-v2/canvas/NodeDragContext'; +export { collectDownstreams, collectBranchSubtree } from '../client-v2/canvas/dropImpact'; -export function useNodeDragContext() { - return useContext(NodeDragContext); -} - -function collectDownstreams(start, branchChildrenMap: Map, visited = new Set()): Set { - const result = new Set(); - 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; -} - -function collectBranchSubtree(root, branchChildrenMap: Map): Set { - const result = new Set(); - if (!root) { - return result; - } - result.add(root.id); - const branchHeads = branchChildrenMap.get(root.id) ?? []; - branchHeads.forEach((branch) => { - collectDownstreams(branch, branchChildrenMap, result); - }); - return result; -} - -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) { +/** Legacy-canvas runtime source: v1's `useAPIClient`, the two v1 translators + * (`lang` for plain keys, `useCompile()` for `{{t("…")}}` titles), the v1 + * instruction registry, and `versionStats.executed` (a BigInt) coerced to the + * `executed` boolean the provider expects. */ +function useLegacyCanvasRuntime(): CanvasDragRuntime { const api = useAPIClient(); const compile = useCompile(); - const workflowPlugin = usePlugin(WorkflowPlugin); - const { workflow, nodes, refresh } = useFlowContext() ?? {}; + const plugin = usePlugin(WorkflowPlugin) as InstanceType; const executed = useWorkflowExecuted(); - const { modal, message } = App.useApp(); - const { styles } = useStyles(); - - const [dragging, setDragging] = useState(false); - const [dragNode, setDragNode] = useState(null); - const [activeDropKey, setActiveDropKey] = useState(null); - - const dragNodeRef = useRef(null); - const dragSubtreeRef = useRef>(new Set()); - const activeDropRef = useRef(null); - const activeDropKeyRef = useRef(null); - const pendingRef = useRef(null); - const draggingRef = useRef(false); - const pointerRef = useRef({ x: 0, y: 0 }); - const suppressClickRef = useRef(false); - const clearSuppressTimer = useRef(null); - const onMouseMoveRef = useRef<(event: MouseEvent) => void>(() => {}); - const onMouseUpRef = useRef<() => void>(() => {}); - const previewRef = useRef(null); - const previewRafRef = useRef(null); - const previewOffsetRef = useRef({ x: 0, y: 0 }); - const previewSizeRef = useRef({ width: 0, height: 0 }); - const dropZonesRef = useRef>(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(); - 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(); - 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>(); - const dependents = new Map>(); - 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(); - 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) => { - 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: any; 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, branchIndex, currentNode) => { - 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) => { - 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(); - const targetDownstream = getTargetDownstream(upstream, branchIndex, node); - const downstreamSet = targetDownstream - ? collectDownstreams(targetDownstream, branchChildrenMap) - : new Set(); - - const deps = nodeDepsMap.get(node.id) ?? new Set(); - const impactedSelf = [] as any[]; - 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(); - const impactedDependents = [] as any[]; - 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, target, 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, message, refresh], - ); - - const updateNodeConfigs = useCallback( - async (items: { node: any; keys: Set }[]) => { - 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: any; keys: Set }[] = []; - 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: ( -
-
- {lang( - 'This action will remove invalid variable references, otherwise the workflow cannot run correctly.', - )} -
- {impactedSelfTitles ? ( -
{lang('Impacted current node variables') + ': ' + impactedSelfTitles}
- ) : null} - {impactedDependentTitles ? ( -
{lang('Impacted dependent node variables') + ': ' + impactedDependentTitles}
- ) : null} -
- (keepVariablesRef.current = ev.target.checked)}> - {lang('Keep variable references')} - -
-
- ), - 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, 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 = workflowPlugin.instructions.get(dragNode.type); - 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, workflowPlugin.instructions]); - - const onNodeMouseDown = useCallback( - (node, event: React.MouseEvent) => { - if (!workflow || executed > 0n) { - 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) => { - activeDropRef.current = target; - const nextKey = target ? getDropKey(target) : null; - if (nextKey !== activeDropKeyRef.current) { - activeDropKeyRef.current = nextKey; - setActiveDropKey(nextKey); - } - }, - [getDropKey], - ); - - const clearActiveDrop = useCallback((target) => { - if (activeDropRef.current === target) { - activeDropRef.current = null; - if (activeDropKeyRef.current) { - activeDropKeyRef.current = null; - setActiveDropKey(null); - } - } - }, []); - - const registerDropZone = useCallback( - (target, element: HTMLElement) => { - 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 {props.children}; + return { + api, + lang: (key: string, options?: Record) => lang(key, options), + compile: (source: string) => compile(source), + getInstruction: (type: string) => plugin?.instructions.get(type), + executed: Boolean(executed), + }; +} + +export function NodeDragContextProvider(props: { children: React.ReactNode }) { + return ( + + {props.children} + + ); } diff --git a/packages/plugins/@nocobase/plugin-workflow/src/client/RemoveNodeContext.tsx b/packages/plugins/@nocobase/plugin-workflow/src/client/RemoveNodeContext.tsx index f353d081a06..44bbf9ce87d 100644 --- a/packages/plugins/@nocobase/plugin-workflow/src/client/RemoveNodeContext.tsx +++ b/packages/plugins/@nocobase/plugin-workflow/src/client/RemoveNodeContext.tsx @@ -7,234 +7,48 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import React, { createContext, useContext, useMemo, useState } from 'react'; -import { createForm } from '@formily/core'; +/** + * The remove-node context + provider now live in client-v2 and are shared by both + * canvases (ADR-0003). v1 keeps a thin wrapper that injects its own canvas runtime + * (v1 `useAPIClient` / `FlowContext` / instruction registry) into the shared + * provider, and re-exports the hook so existing v1 import sites + * (`from './RemoveNodeContext'`) are unchanged. Delete on legacy-canvas retirement. + * + * This replaces v1's former Formily `Action.Modal` keep-branch dialog with the + * shared antd one; the variable-reference safety check (which the modern canvas + * previously lacked) is now the shared logic, so both canvases block deleting a + * node whose result is still referenced. + */ + +import React from 'react'; +import { useAPIClient, usePlugin } from '@nocobase/client'; import { - ActionContextProvider, - SchemaComponent, - useAPIClient, - useCancelAction, - useCompile, - usePlugin, -} from '@nocobase/client'; - -import { lang, NAMESPACE } from './locale'; -import { App, Radio, Select, Space } from 'antd'; + RemoveNodeContextProvider as SharedRemoveNodeContextProvider, + type CanvasRemoveRuntime, +} from '../client-v2/canvas/RemoveNodeContext'; import { useFlowContext } from './FlowContext'; -import { useForm } from '@formily/react'; import PluginWorkflowClient from '.'; -import { parse } from '@nocobase/utils/client'; -const RemoveNodeContext = createContext({}); +export { useRemoveNodeContext } from '../client-v2/canvas/RemoveNodeContext'; -export function useRemoveNodeContext() { - return useContext(RemoveNodeContext); -} - -function findBranchNodes(nodes, branchHead) { - const result = new Map(); - 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 = findBranchNodes(nodes, subBranch); - for (const [key, value] of subBranchNodes) { - result.set(key, value); - } - } - } - return result; -} - -function KeepBranchRadioGroup(props) { - const { value, onChange } = props; - const { deletingNode, deletingBranches } = useRemoveNodeContext(); - const plugin = usePlugin(PluginWorkflowClient) as PluginWorkflowClient; - const compile = useCompile(); - const branchOptions = useMemo(() => { - if (!deletingNode || deletingBranches?.length === 0) { - return []; - } - const instruction = plugin.instructions.get(deletingNode?.type); - const branching = - typeof instruction.branching === 'function' - ? instruction.branching(deletingNode.config ?? {}) - : instruction.branching; - return branching - ? deletingBranches.map((item, index) => { - const option = Array.isArray(branching) - ? branching.find((branch) => branch.value === item.branchIndex) ?? {} - : {}; - return { - label: option['label'] - ? lang('"{{branchName}}" branch', { branchName: compile(option['label']) }) - : lang('Branch {{index}}', { index: index + 1 }), - value: item.branchIndex, - }; - }) - : []; - }, [deletingNode, deletingBranches, plugin.instructions, compile]); - - return ( - <> - { - if (e.target.value === 0) { - onChange(null); - } else { - onChange(deletingBranches[0].branchIndex); - } - }} - > - - - {lang('Delete all')} - - - - {lang('Keep')} - -