|
| 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