Skip to content

Commit 3ed8129

Browse files
docs: add activity dev guide (#521)
the documentation should be used by AI agents or humans who want to develop activities
1 parent 1dd0b9b commit 3ed8129

41 files changed

Lines changed: 9018 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# Advanced Patterns
2+
3+
> **When to read this:** You are building a complex activity that goes beyond simple property binding — for example, an activity with multiple input modes, dynamic display names, composite child activities, persistence/bookmarks, or inverse property mappings. These patterns are drawn from real implementations like RunJob, InvokeWorkflow, and Orchestrator activities.
4+
5+
**Cross-references:**
6+
- [ViewModel fundamentals](../design/viewmodel.md)
7+
- [Activity code and CacheMetadata](../runtime/activity-code.md)
8+
- [Architecture overview](../core/architecture.md)
9+
- [FilterBuilder widget](./filter-builder.md) (another advanced topic)
10+
- [SDK framework](./sdk-framework.md) (alternative base classes)
11+
12+
---
13+
14+
## Bidirectional Property Mapping (Inverted Boolean)
15+
16+
When the ViewModel needs a user-friendly property that maps inversely to the Activity property:
17+
18+
```csharp
19+
// Activity has: public bool FailWhenFaulted { get; set; }
20+
// ViewModel exposes the opposite for better UX:
21+
22+
[NotMappedProperty]
23+
public DesignProperty<bool> ContinueWhenFaulted { get; set; }
24+
25+
private ModelProperty _failWhenFaultedModelProperty;
26+
27+
protected override async ValueTask InitializeModelAsync()
28+
{
29+
await base.InitializeModelAsync();
30+
_failWhenFaultedModelProperty = ModelItem.Properties[nameof(RunJob.FailWhenFaulted)];
31+
ContinueWhenFaulted.Value = !(bool)_failWhenFaultedModelProperty.ComputedValue;
32+
}
33+
34+
// Sync back on change
35+
public override async ValueTask<UpdateViewModelResult> UpdateAsync(string propertyName, object value)
36+
{
37+
if (propertyName == nameof(ContinueWhenFaulted))
38+
_failWhenFaultedModelProperty.SetValue(!(bool)value);
39+
return await base.UpdateAsync(propertyName, value);
40+
}
41+
```
42+
43+
Key points:
44+
- Mark the ViewModel property with `[NotMappedProperty]` so the framework does not attempt automatic mapping.
45+
- Read the activity's `ModelProperty` directly in `InitializeModelAsync`.
46+
- Write back through `ModelProperty.SetValue()` in `UpdateAsync`.
47+
48+
---
49+
50+
## Multiple Input Mode Switching (3+ Modes)
51+
52+
For activities supporting fundamentally different input strategies (e.g., RunJob supports Object, Dictionary, and DataMapper modes):
53+
54+
```csharp
55+
private readonly MenuAction _objectInputSwitch = new();
56+
private readonly MenuAction _dictionaryInputSwitch = new();
57+
private readonly MenuAction _dataMapperInputSwitch = new();
58+
59+
private async Task SwitchToObjectHandler(MenuAction _)
60+
{
61+
// Clear other modes
62+
_inputModelProperty.SetValue(null);
63+
// Build new InArgument<object>
64+
var argument = Argument.Create(typeof(object), ArgumentDirection.In);
65+
_inputDesignProperty = BuildInputDesignProperty(argument);
66+
// Optionally import output schema
67+
await ImportArgument(processName, ArgumentDirection.Out);
68+
}
69+
70+
private Task SwitchToDictionaryHandler(MenuAction _)
71+
{
72+
_inputModelProperty.SetValue(null);
73+
_outputModelProperty.SetValue(null);
74+
var args = new Dictionary<string, Argument>();
75+
_argumentsModelProperty.SetValue(args);
76+
return Task.CompletedTask;
77+
}
78+
79+
private async Task SwitchToDataMapperHandler(MenuAction _)
80+
{
81+
// Fetch schema and generate JIT types
82+
var metadata = await _schemaProvider.GetArguments(processName);
83+
await ImportArgument(processName, ArgumentDirection.In, metadata?.Input);
84+
}
85+
```
86+
87+
Each `MenuAction` is wired to a handler that:
88+
1. Clears properties belonging to other modes (set to `null`).
89+
2. Creates and configures the new mode's properties.
90+
3. Optionally fetches external metadata (schema, arguments).
91+
92+
---
93+
94+
## Localized Enum Values
95+
96+
Enum values can carry localization attributes for display in the designer:
97+
98+
```csharp
99+
public enum JobExecutionMode
100+
{
101+
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_None_DisplayName))]
102+
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_None_Description))]
103+
None,
104+
105+
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_Busy_DisplayName))]
106+
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_Busy_Description))]
107+
Busy,
108+
109+
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_Suspend_DisplayName))]
110+
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_Suspend_Description))]
111+
Suspend
112+
}
113+
```
114+
115+
Both `[LocalizedDisplayName]` and `[LocalizedDescription]` reference keys in the `.resx` resource file. The Studio designer resolves these at design time for the current locale.
116+
117+
---
118+
119+
## Display Name Alias Keys (Fuzzy Search)
120+
121+
Activities can define alias keys so users can find them by searching different terms in the Studio activities panel:
122+
123+
```json
124+
{
125+
"fullName": "UiPath.Activities.System.Jobs.RunJob",
126+
"displayNameKey": "RunJob_DisplayName",
127+
"displayNameAliasKeys": [
128+
"RunJob_Synonym_Agent",
129+
"RunJob_Synonym_API",
130+
"RunJob_Synonym_RPA",
131+
"RunJob_Synonym_RunAgent",
132+
"RunJob_Synonym_RunAPI",
133+
"RunJob_Synonym_CaseManagement"
134+
]
135+
}
136+
```
137+
138+
Each alias key is a resource key in the `.resx` file. The Studio activities panel searches these aliases when the user types in the search box. This is configured in the activity metadata JSON file.
139+
140+
---
141+
142+
## Dynamic Display Name from External Data
143+
144+
Activities can update their display name based on runtime data (e.g., process type fetched from Orchestrator):
145+
146+
```csharp
147+
private async Task ProcessNameChangedRule()
148+
{
149+
if (!ProcessName.TryGetLiteralOfType(out var processName)) return;
150+
151+
// Fetch process type from Orchestrator metadata
152+
var suffix = await _dataSourceBuilder.GetSuffixByProcessType(processName);
153+
// e.g., suffix = "RPA", "API", "Agent", "App"
154+
155+
if (!string.IsNullOrWhiteSpace(suffix))
156+
{
157+
var newDisplayName = $"Run {suffix}: {processName}";
158+
ModelItem.Properties[nameof(RunJob.DisplayName)]?.SetValue(newDisplayName);
159+
Dispatcher.Invoke(() => DisplayName.Value = newDisplayName);
160+
}
161+
}
162+
```
163+
164+
Important: update both the `ModelItem` property (persisted to XAML) and the `DesignProperty.Value` (displayed in the UI). Use `Dispatcher.Invoke` for UI thread safety.
165+
166+
---
167+
168+
## Event-Driven DataSource
169+
170+
DataSource builders can raise events to notify the ViewModel when data changes:
171+
172+
```csharp
173+
internal class ProcessesDataSourceBuilder : FolderPathDynamicDataSourceBuilder
174+
{
175+
public event EventHandler<DataSource<ReleaseDto>> DataSourceGenerated;
176+
177+
public override async ValueTask<IDataSource> GetDynamicDataSourceAsyncInternal(
178+
string searchText, int limit, CancellationToken ct)
179+
{
180+
var data = await GetProcesses(searchText, ct, limit);
181+
DataSource.Data = data ?? Array.Empty<ReleaseDto>();
182+
DataSourceGenerated?.Invoke(this, DataSource);
183+
return DataSource;
184+
}
185+
}
186+
187+
// In ViewModel:
188+
_dataSourceBuilder.DataSourceGenerated += (sender, ds) =>
189+
{
190+
// React to data changes, e.g., update dependent properties
191+
};
192+
```
193+
194+
This pattern decouples data fetching from the ViewModel reaction logic. The DataSource builder handles the async fetch and notifies subscribers when fresh data is available.
195+
196+
---
197+
198+
## Conditional Rule Wrapping
199+
200+
Wrap rules with optional services (like BusyService) without duplicating logic:
201+
202+
```csharp
203+
// Helper that conditionally wraps with busy indicator
204+
private Func<Task> ResolveRule(Func<Task> rule)
205+
=> _busyService is not null
206+
? () => RunWithBusyService(() => rule())
207+
: rule;
208+
209+
protected override void InitializeRules()
210+
{
211+
base.InitializeRules();
212+
Rule(nameof(ProcessName), ResolveRule(ProcessNameChangedRule), false);
213+
Rule(nameof(ExecutionMode), ResolveRule(ExecutionModeChangedRule), false);
214+
}
215+
```
216+
217+
This avoids duplicating busy-indicator logic in every rule handler. If `_busyService` is not available (e.g., in unit tests), the rule runs without the wrapper.
218+
219+
---
220+
221+
## NativeActivity with Composite Implementation
222+
223+
For activities that dynamically compose child activities at design time:
224+
225+
```csharp
226+
public class RunJob : NativeActivity
227+
{
228+
private readonly Sequence _implementationSequence = new();
229+
private readonly WaitForJob _waitForJobActivity = new();
230+
private readonly WaitForJobAndResume _waitForJobAndResumeActivity = new();
231+
232+
protected override void CacheMetadata(NativeActivityMetadata metadata)
233+
{
234+
_implementationSequence.Activities.Clear();
235+
236+
// Dynamically add the right child activity based on configuration
237+
Activity waitActivity = ExecutionMode switch
238+
{
239+
JobExecutionMode.Busy => _waitForJobActivity,
240+
JobExecutionMode.Suspend => _waitForJobAndResumeActivity,
241+
_ => throw new InvalidOperationException()
242+
};
243+
244+
_implementationSequence.Activities.Add(waitActivity);
245+
metadata.AddImplementationChild(_implementationSequence);
246+
247+
// Bind dynamic arguments to metadata
248+
foreach (var (name, argument) in Arguments)
249+
{
250+
var runtimeArgument = new RuntimeArgument(name, argument.ArgumentType, argument.Direction);
251+
metadata.Bind(argument, runtimeArgument);
252+
metadata.AddArgument(runtimeArgument);
253+
}
254+
}
255+
}
256+
```
257+
258+
Key points:
259+
- `AddImplementationChild` registers the composed sequence as an implementation detail (not visible to the user).
260+
- Dynamic arguments (e.g., from a dictionary) must be individually registered with `Bind` + `AddArgument`.
261+
- The implementation sequence is rebuilt on every `CacheMetadata` call, so it always reflects the current configuration.
262+
263+
---
264+
265+
## Persistent Activity with Bookmarks
266+
267+
For long-running activities that survive process restarts:
268+
269+
```csharp
270+
[PersistentActivity]
271+
[ValidatePersistenceDependsOn(nameof(ExecutionMode), nameof(JobExecutionMode.Suspend))]
272+
public class RunJob : NativeActivity
273+
{
274+
// Dynamically add/remove persistence constraints
275+
private void UpdateNoPersistScopeConstraint(bool shouldHaveConstraint)
276+
{
277+
if (shouldHaveConstraint && !Constraints.Contains(_constraint))
278+
Constraints.Add(_constraint);
279+
else if (!shouldHaveConstraint && Constraints.Contains(_constraint))
280+
Constraints.Remove(_constraint);
281+
}
282+
}
283+
284+
// In the child activity:
285+
protected void Persist(NativeActivityContext context, object resumeTrigger)
286+
{
287+
var bookmark = context.CreateBookmark(Guid.NewGuid().ToString(), OnWaitResume);
288+
var persistenceBookmarks = context.GetExtension<IPersistenceBookmarks>();
289+
persistenceBookmarks.RegisterBookmark(
290+
new PersistenceBookmark(bookmark.Name, resumeTrigger));
291+
}
292+
```
293+
294+
- `[PersistentActivity]` marks the activity as persistence-aware.
295+
- `[ValidatePersistenceDependsOn]` conditionally validates persistence requirements based on property values.
296+
- `CreateBookmark` pauses execution; the workflow can be persisted and later resumed.
297+
- `IPersistenceBookmarks.RegisterBookmark` associates the bookmark with a resume trigger for the orchestrator.
298+
299+
---
300+
301+
## Interface Contracts for Bindings
302+
303+
Activities implement interfaces that the bindings system uses for polymorphic resource resolution:
304+
305+
```csharp
306+
// Contract interfaces
307+
internal interface IOrchestratorActivity
308+
{
309+
InArgument<string> FolderPath { get; }
310+
}
311+
312+
internal interface IProcessNameActivity : IOrchestratorActivity
313+
{
314+
InArgument<string> ProcessName { get; }
315+
}
316+
317+
// Activity implements the interface
318+
public class RunJob : NativeActivity, IProcessNameActivity
319+
{
320+
public InArgument<string> FolderPath { get; set; }
321+
public InArgument<string> ProcessName { get; set; }
322+
323+
// Bindings key generated from interface contract
324+
public string BindingsKey => BindingsKeyFactory.GenerateBindingsKey(this);
325+
}
326+
327+
// At runtime, check for binding overrides before using property value
328+
private string GetProcessNameValue(ActivityContext context)
329+
=> _bindingsService.GetBindingsOverride(
330+
SystemBindingTypes.Process, BindingsKey, PropertyContracts.Name, context)
331+
?? ProcessName.Get(context);
332+
```
333+
334+
The bindings system allows Studio to override property values at runtime based on project-level configuration (e.g., connecting a RunJob activity to a specific Orchestrator folder without hardcoding it). The interface contracts define which properties are bindable.
335+
336+
---
337+
338+
## Rule-Driven Property ReadOnly State
339+
340+
Change property editability dynamically based on another property's value:
341+
342+
```csharp
343+
Rule(nameof(ExecutionMode), () =>
344+
{
345+
var mode = ExecutionMode.Value;
346+
TimeoutMS.IsReadOnly = mode is not JobExecutionMode.Busy;
347+
ContinueOnError.IsReadOnly = mode is JobExecutionMode.None;
348+
ContinueWhenFaulted.IsReadOnly = mode is JobExecutionMode.None;
349+
});
350+
```
351+
352+
When `ExecutionMode` changes, the rule fires and updates `IsReadOnly` on dependent properties. This provides immediate visual feedback in the designer — readonly properties are grayed out and non-editable.
353+
354+
---
355+
356+
## Troubleshooting
357+
358+
<!-- AI agents: add entries here when you encounter issues related to this topic -->

0 commit comments

Comments
 (0)