Skip to content
Merged
11 changes: 11 additions & 0 deletions studio/docs/specs/react-studio-preview/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,14 @@ selection preserves the complete definition. Unit fixtures cover each editor and
extension registration. A browser fixture loads the actual ESM entry and exercises
editing, local-time conversion, read-only behavior and an extension email hint.
The current frontend suite has 110 unit cases and seven browser cases.


## Activity settings and retries

ActivityEditors now includes commit strategy, log persistence, resilience, task and
workflow-activity version tabs. WorkflowInstances includes paged retry history per
activity execution, with details and permission checks. The [Blazor parity checklist](../../../react/docs/activity-settings.md)
records the wire contracts, visibility and verification boundaries. Browser fixtures
exercise the actual production modules; a local reference-backend check verifies
save/reload of each setting and a published reusable activity's paired version IDs.
This does not close the broader partial-parity or release acceptance gates.
1 change: 1 addition & 0 deletions studio/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ New to the project? Start with the [Getting started guide](docs/getting-started.
| [Getting started](docs/getting-started.md) | Install, run, connect to a server, first sign-in. |
| [Configuration](docs/configuration.md) | **Adding servers**, authentication providers, feature flags, theming, environment variables. |
| [Modules](docs/modules.md) | What every module does, how to enable it, and how to build a new one. |
| [Activity settings](docs/activity-settings.md) | Commit, log persistence, resilience, task and version settings; instance retry history. |
| [Designer](docs/designer.md) | Containers, the quick-add palette, data flow, call context, keyboard shortcuts. |
| [Deployment](docs/deployment.md) | Docker image, standalone output, runtime configuration. |
| [Troubleshooting](docs/troubleshooting.md) | CORS, hidden modules, sign-in failures, workflows from older Elsa versions. |
Expand Down
57 changes: 57 additions & 0 deletions studio/react/docs/activity-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Activity settings and retry history

The activity properties panel includes the following Blazor-compatible settings.
All controls honor the designer's read-only state. Changes use the designer store,
so normal save, undo and redo retain the complete activity and unrelated plugin data.
Merely opening a tab does not modify a definition. Strategy catalog failures show an
error while preserving local Inherit/Default, clear and expression editing actions;
only available catalog options are offered, and read-only mode still disables edits.

| Blazor tab | React tab and visibility | Persisted server contract | Verification |
| --- | --- | --- | --- |
| Commit Strategy | Commit strategy; all activities | `customProperties.commitStrategyName`; catalog uses `name`, not `typeName`; clear also removes the legacy PascalCase alias | Browser selection, immutable-update test, real Core save/reload |
| Log | Log; all activities | `customProperties.logPersistenceConfig`: default, internal state, and camelCase input/output rules | Browser strategy/expression changes, legacy migration tests, real Core save/reload |
| Task | Task; `kind: Task` | `customProperties.runAsynchronously` | Browser checkbox/read-only checks, real Core save/reload |
| Resilience | Resilience; `Resilient` descriptor and installed backend feature (catalog failures retain compatibility) | `customProperties.resilienceStrategy`: Identifier/strategyId or Expression/expression | Browser strategy selection, real Core expression save/reload |
| Version | Version; `RootType: WorkflowDefinitionActivity` | Activity `version` and `workflowDefinitionVersionId` change together | Browser catalog eligibility/read-only checks, paired-version unit test and real Core save/reload |
| Instance Retries | Retries in the selected activity's details; resilient descriptor or saved policy, and `resilience/retries:view` permission | `GET resilience/retries/{activityInstanceId}?skip=…&take=…` | Actual viewer browser test with multiple executions, two pages, attempt details and empty history; encoded ID/auth/tenant/cancellation API test |

The pre-existing asynchronous control remains in Common for other activity kinds.
Additional module-provided activity tabs continue to render through the SDK.

Log rules select a catalog strategy or an expression returning `LogPersistenceMode`
(`Inherit`, `Include`, `Exclude`; numeric enum values are accepted when reading).
They do not use booleans. Strategy identities come from the server catalog.
Legacy `logPersistenceMode` default/input/output modes are read without mutation and
upgraded to assembly-qualified built-in strategy identities when settings change.
Non-null current rules take precedence. Clearing writes null and removes only the
corresponding legacy entry, because the runtime falls back to legacy values for a
null rule. Unknown properties and unrelated historical rules are retained.

Workflow activity versions use the `AllVersions` query. A version can be selected
only when its type and version are present in the activity catalog; the current
activity inputs and custom properties survive the change. The list is paginated. Toolbox creation normalizes the server catalog's CLR-named
workflow definition construction fields to the camelCase activity JSON fields,
without retaining conflicting aliases.

Retry history uses the execution record ID, rather than the graph activity/node ID.
Repeated executions of one node can therefore be inspected separately. Switching
execution resets the page and details; changing page clears the selected attempt.
Queries share the host's authentication, tenant and query scope, cancel on unmount,
and are invalidated with their workflow instance. Loading, API errors, missing
server support, no executions and empty history have explicit states. Opening other
tabs does not fetch retries.

The browser tests in `tests/activity-settings.spec.ts` load the production ESM
modules through the real ASP.NET host and substitute deterministic API responses.
They cover rendering and HTTP contracts; they do not claim to exercise server retry
execution. The separate reference-backend check creates and publishes two reusable
workflow versions, saves a parent with commit/log/resilience/task settings, reloads
and verifies every setting and version ID, then removes its temporary definitions.


To repeat the reference-backend check, set `ELSA_TEST_API_URL`,
`ELSA_TEST_USERNAME` and `ELSA_TEST_PASSWORD` for a disposable development server,
then run `pnpm test:settings-server`. The test needs workflow create, publish, view
and delete permissions and creates temporary published definitions. Credentials
remain in memory; temporary definitions are deleted in its cleanup handler.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createDesignerStore } from "@elsa-workflows/react-studio-sdk/designer/store";
import type { ActivityDescriptor } from "@elsa-workflows/react-studio-api-client/types/descriptors";
import { describe, expect, it } from "vitest";
import type { Activity } from "@elsa-workflows/react-studio-api-client/types/activities";
import type { WorkflowDefinitionSummary } from "@elsa-workflows/react-studio-api-client/types/workflow-definitions";
import { persistenceConfiguration, setCustomProperty, setPersistenceRule, applyActivityVersion } from "../settings/activity-settings";

const activity = (customProperties: Record<string, unknown> = {}): Activity => ({ id: "child", type: "Example.Child", version: 1, customProperties });

describe("per-activity settings", () => {
it("preserves inputs and unrelated custom properties while updating or clearing one setting", () => {
const original = { ...activity({ pluginData: { keep: true }, commitStrategyName: "Existing" }), input: { expression: { type: "Literal", value: "keep" } } };
const updated = setCustomProperty(original, "commitStrategyName", "Always");
expect(original.customProperties?.commitStrategyName).toBe("Existing");
expect(updated.input).toBe(original.input);
expect(updated.customProperties?.pluginData).toBe(original.customProperties?.pluginData);
expect(setCustomProperty(updated, "commitStrategyName", undefined).customProperties).toEqual({ pluginData: { keep: true } });
});

it("reads string and numeric legacy modes, prefers current settings, and does not mutate on read", () => {
const original = activity({ logPersistenceMode: { default: "Exclude", inputs: { old: 1, skip: "Exclude" }, outputs: { result: 2 } },
logPersistenceConfig: { inputs: { old: { evaluationMode: 1, expression: { type: "JavaScript", value: "true" } } }, future: "keep" } });
const snapshot = JSON.stringify(original);
expect(persistenceConfiguration(original)).toEqual({ default: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" }, future: "keep", inputs: {
old: { evaluationMode: 1, expression: { type: "JavaScript", value: "true" } }, skip: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" },
}, outputs: { result: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } } });
expect(JSON.stringify(original)).toBe(snapshot);
});

it("clears a legacy property without resurrecting its old mode and preserves adjacent rules", () => {
const original = activity({ logPersistenceMode: { inputs: { secret: "Include", message: "Exclude" } }, other: 42 });
const updated = setPersistenceRule(original, "inputs", "Secret", undefined);
expect(persistenceConfiguration(updated).inputs).toEqual({ secret: null, message: { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } });
expect(updated.customProperties?.logPersistenceMode).toEqual({ inputs: { message: "Exclude" } });
expect(original.customProperties?.logPersistenceMode).toEqual({ inputs: { secret: "Include", message: "Exclude" } });
expect(updated.customProperties?.other).toBe(42);
expect(() => setPersistenceRule(original, "outputs", undefined, undefined)).toThrow("property name");
});

it("matches the runtime's null fallback and clears legacy defaults explicitly", () => {
const original = activity({ logPersistenceMode: { default: "Exclude", inputs: { message: "Include" } },
logPersistenceConfig: { default: null, inputs: { message: null } } });
expect(persistenceConfiguration(original).default?.strategyType).toContain("Strategies.Exclude,");
expect(persistenceConfiguration(original).inputs?.message?.strategyType).toContain("Strategies.Include,");
const cleared = setPersistenceRule(original, "default", undefined, undefined);
expect(persistenceConfiguration(cleared).default).toBeNull();
expect(cleared.customProperties?.logPersistenceMode).toEqual({ inputs: { message: "Include" } });
});

it("retains expression metadata when setting another log section", () => {
const expression = { type: "JavaScript", value: "getVariable('Persist')" };
const original = activity({ logPersistenceConfig: { default: { evaluationMode: "Expression", expression }, inputs: { password: null } } });
const updated = setPersistenceRule(original, "internalState", undefined, { evaluationMode: "Strategy", strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" });
expect(persistenceConfiguration(updated)).toMatchObject({ default: { expression }, inputs: { password: null }, internalState: { strategyType: "Elsa.Workflows.LogPersistence.Strategies.Exclude, Elsa.Workflows.Core" } });
});

it("updates the activity version and definition-version ID together and rejects another definition", () => {
const original = { ...activity(), workflowDefinitionId: "reusable", workflowDefinitionVersionId: "version-one", input: "keep" };
const version = { id: "version-two", definitionId: "reusable", version: 2 } as WorkflowDefinitionSummary;
expect(applyActivityVersion(original, version)).toMatchObject({ version: 2, workflowDefinitionVersionId: "version-two", input: "keep" });
expect(original.version).toBe(1);
expect(() => applyActivityVersion(original, { ...version, definitionId: "another" })).toThrow("another workflow");
});
});


it("creates toolbox workflow activities with the server's camelCase version fields", () => {
const store = createDesignerStore({ id: "root", type: "Elsa.Flowchart", version: 1, activities: [], connections: [] });
const descriptor = { typeName: "Example.Reusable", name: "Reusable", version: 2, kind: "Action", inputs: [], outputs: [], ports: [],
customProperties: { RootType: "WorkflowDefinitionActivity" },
constructionProperties: { WorkflowDefinitionId: "reusable", WorkflowDefinitionVersionId: "version-two", Version: 2, plugin: "keep" } } as unknown as ActivityDescriptor;
const created = store.getState().addActivity(descriptor, { x: 0, y: 0 });
expect(created).toMatchObject({ version: 2, workflowDefinitionId: "reusable", workflowDefinitionVersionId: "version-two", plugin: "keep" });
expect(created).not.toHaveProperty("WorkflowDefinitionId");
expect(created).not.toHaveProperty("WorkflowDefinitionVersionId");
expect(created).not.toHaveProperty("Version");
expect(descriptor.constructionProperties).toHaveProperty("WorkflowDefinitionVersionId", "version-two");
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { activityDisplayText } from "@elsa-workflows/react-studio-sdk/designer/m
import { selectableSyntaxes } from "@elsa-workflows/react-studio-sdk/activity-expressions";
import { useUiHintEditor } from "./registry";
import type { UiHintContext } from "./types";
import { useRemoteFeatures } from "@elsa-workflows/react-studio-ui/runtime/remote-features";
import { CommitStrategySettings, LogPersistenceSettings, ResilienceSettings, TaskSettings, VersionSettings } from "./settings/settings-tabs";

export interface ActivityPropertiesPanelProps {
workflowDefinitionId?: string;
Expand Down Expand Up @@ -51,6 +53,7 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf
const readOnly = useDesignerState((s) => s.readOnly);
const updateActivity = useDesignerState((s) => s.updateActivity);
const extraTabs = useExtensions("activityTabs");
const features = useRemoteFeatures();
const { items: expressionDescriptors } = useExpressionDescriptors();
const syntaxes = useMemo(() => selectableSyntaxes(expressionDescriptors), [expressionDescriptors]);
const [tab, setTab] = useState("input");
Expand All @@ -74,6 +77,11 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf
activities: siblingActivities,
};
const dataSummary = dataFlow?.byActivity.get(activity.id);
const isTask = descriptor?.kind === "Task";
const isWorkflowActivity = descriptor?.customProperties?.RootType === "WorkflowDefinitionActivity";
const isResilient = descriptor?.customProperties?.Resilient === true && (features.catalog.has("Elsa.Resilience") || features.isError);
const settingsProps = { activity, readOnly, onChange: update };
const visibleTab = (tab === "task" && !isTask) || (tab === "version" && !isWorkflowActivity) || (tab === "resilience" && !isResilient) ? "input" : tab;

const inputs = [...(descriptor?.inputs ?? [])].filter((i) => i.isBrowsable !== false).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
const outputs = (descriptor?.outputs ?? []).filter((o) => o.isBrowsable !== false);
Expand All @@ -86,11 +94,16 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf
<div className="truncate text-xs text-muted-foreground">{descriptor?.displayName ?? activity.type}</div>
</div>
</div>
<Tabs value={tab} onValueChange={(v) => setTab(String(v))} className="flex min-h-0 flex-1 flex-col">
<Tabs value={visibleTab} onValueChange={(v) => setTab(String(v))} className="flex min-h-0 flex-1 flex-col">
<TabsList className="mx-3 mt-2 w-[calc(100%-1.5rem)] justify-start overflow-x-auto [&>*]:shrink-0">
<TabsTrigger value="input">Input</TabsTrigger>
<TabsTrigger value="output">Output</TabsTrigger>
<TabsTrigger value="common">Common</TabsTrigger>
<TabsTrigger value="commit">Commit strategy</TabsTrigger>
<TabsTrigger value="log">Log</TabsTrigger>
{isTask && <TabsTrigger value="task">Task</TabsTrigger>}
{isResilient && <TabsTrigger value="resilience">Resilience</TabsTrigger>}
{isWorkflowActivity && <TabsTrigger value="version">Version</TabsTrigger>}
{extraTabs.map((t) => (
<TabsTrigger key={t.id} value={t.id}>
{t.label}
Expand Down Expand Up @@ -133,14 +146,13 @@ export function ActivityPropertiesPanel({ workflowDefinitionId, variables, workf
/>
</Field>
)}
<Field inline label="Run asynchronously" hint="Schedule the activity on a background worker instead of running it inline.">
<Checkbox
checked={activity.customProperties?.runAsynchronously === true}
disabled={readOnly}
onCheckedChange={(c) => update((a) => ({ ...a, customProperties: { ...a.customProperties, runAsynchronously: c === true } }))}
/>
</Field>
{!isTask && <TaskSettings {...settingsProps} />}
</TabsContent>
<TabsContent value="commit" className="flex flex-col gap-4 p-3"><CommitStrategySettings {...settingsProps} /></TabsContent>
<TabsContent value="log" className="flex flex-col gap-4 p-3"><LogPersistenceSettings {...settingsProps} descriptor={descriptor} context={context} syntaxes={syntaxes} /></TabsContent>
{isTask && <TabsContent value="task" className="p-3"><TaskSettings {...settingsProps} /></TabsContent>}
{isResilient && <TabsContent value="resilience" className="flex flex-col gap-4 p-3"><ResilienceSettings {...settingsProps} context={context} syntaxes={syntaxes} /></TabsContent>}
{isWorkflowActivity && <TabsContent value="version" className="flex flex-col gap-4 p-3"><VersionSettings key={String(activity.workflowDefinitionId)} {...settingsProps} /></TabsContent>}
{extraTabs.map((t) => (
<TabsContent key={t.id} value={t.id} className="p-3">
<t.component activity={activity} context={{ root, selected: [activity], readOnly,
Expand Down
Loading
Loading