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.
Cross-references:
- ViewModel fundamentals
- Activity code and CacheMetadata
- Architecture overview
- FilterBuilder widget (another advanced topic)
- SDK framework (alternative base classes)
When the ViewModel needs a user-friendly property that maps inversely to the Activity property:
// Activity has: public bool FailWhenFaulted { get; set; }
// ViewModel exposes the opposite for better UX:
[NotMappedProperty]
public DesignProperty<bool> ContinueWhenFaulted { get; set; }
private ModelProperty _failWhenFaultedModelProperty;
protected override async ValueTask InitializeModelAsync()
{
await base.InitializeModelAsync();
_failWhenFaultedModelProperty = ModelItem.Properties[nameof(RunJob.FailWhenFaulted)];
ContinueWhenFaulted.Value = !(bool)_failWhenFaultedModelProperty.ComputedValue;
}
// Sync back on change
public override async ValueTask<UpdateViewModelResult> UpdateAsync(string propertyName, object value)
{
if (propertyName == nameof(ContinueWhenFaulted))
_failWhenFaultedModelProperty.SetValue(!(bool)value);
return await base.UpdateAsync(propertyName, value);
}Key points:
- Mark the ViewModel property with
[NotMappedProperty]so the framework does not attempt automatic mapping. - Read the activity's
ModelPropertydirectly inInitializeModelAsync. - Write back through
ModelProperty.SetValue()inUpdateAsync.
For activities supporting fundamentally different input strategies (e.g., RunJob supports Object, Dictionary, and DataMapper modes):
private readonly MenuAction _objectInputSwitch = new();
private readonly MenuAction _dictionaryInputSwitch = new();
private readonly MenuAction _dataMapperInputSwitch = new();
private async Task SwitchToObjectHandler(MenuAction _)
{
// Clear other modes
_inputModelProperty.SetValue(null);
// Build new InArgument<object>
var argument = Argument.Create(typeof(object), ArgumentDirection.In);
_inputDesignProperty = BuildInputDesignProperty(argument);
// Optionally import output schema
await ImportArgument(processName, ArgumentDirection.Out);
}
private Task SwitchToDictionaryHandler(MenuAction _)
{
_inputModelProperty.SetValue(null);
_outputModelProperty.SetValue(null);
var args = new Dictionary<string, Argument>();
_argumentsModelProperty.SetValue(args);
return Task.CompletedTask;
}
private async Task SwitchToDataMapperHandler(MenuAction _)
{
// Fetch schema and generate JIT types
var metadata = await _schemaProvider.GetArguments(processName);
await ImportArgument(processName, ArgumentDirection.In, metadata?.Input);
}Each MenuAction is wired to a handler that:
- Clears properties belonging to other modes (set to
null). - Creates and configures the new mode's properties.
- Optionally fetches external metadata (schema, arguments).
Enum values can carry localization attributes for display in the designer:
public enum JobExecutionMode
{
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_None_DisplayName))]
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_None_Description))]
None,
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_Busy_DisplayName))]
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_Busy_Description))]
Busy,
[LocalizedDisplayName(nameof(Resources.RunJob_ExecutionMode_Suspend_DisplayName))]
[LocalizedDescription(nameof(Resources.RunJob_ExecutionMode_Suspend_Description))]
Suspend
}Both [LocalizedDisplayName] and [LocalizedDescription] reference keys in the .resx resource file. The Studio designer resolves these at design time for the current locale.
Activities can define alias keys so users can find them by searching different terms in the Studio activities panel:
{
"fullName": "UiPath.Activities.System.Jobs.RunJob",
"displayNameKey": "RunJob_DisplayName",
"displayNameAliasKeys": [
"RunJob_Synonym_Agent",
"RunJob_Synonym_API",
"RunJob_Synonym_RPA",
"RunJob_Synonym_RunAgent",
"RunJob_Synonym_RunAPI",
"RunJob_Synonym_CaseManagement"
]
}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.
Activities can update their display name based on runtime data (e.g., process type fetched from Orchestrator):
private async Task ProcessNameChangedRule()
{
if (!ProcessName.TryGetLiteralOfType(out var processName)) return;
// Fetch process type from Orchestrator metadata
var suffix = await _dataSourceBuilder.GetSuffixByProcessType(processName);
// e.g., suffix = "RPA", "API", "Agent", "App"
if (!string.IsNullOrWhiteSpace(suffix))
{
var newDisplayName = $"Run {suffix}: {processName}";
ModelItem.Properties[nameof(RunJob.DisplayName)]?.SetValue(newDisplayName);
Dispatcher.Invoke(() => DisplayName.Value = newDisplayName);
}
}Important: update both the ModelItem property (persisted to XAML) and the DesignProperty.Value (displayed in the UI). Use Dispatcher.Invoke for UI thread safety.
DataSource builders can raise events to notify the ViewModel when data changes:
internal class ProcessesDataSourceBuilder : FolderPathDynamicDataSourceBuilder
{
public event EventHandler<DataSource<ReleaseDto>> DataSourceGenerated;
public override async ValueTask<IDataSource> GetDynamicDataSourceAsyncInternal(
string searchText, int limit, CancellationToken ct)
{
var data = await GetProcesses(searchText, ct, limit);
DataSource.Data = data ?? Array.Empty<ReleaseDto>();
DataSourceGenerated?.Invoke(this, DataSource);
return DataSource;
}
}
// In ViewModel:
_dataSourceBuilder.DataSourceGenerated += (sender, ds) =>
{
// React to data changes, e.g., update dependent properties
};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.
Wrap rules with optional services (like BusyService) without duplicating logic:
// Helper that conditionally wraps with busy indicator
private Func<Task> ResolveRule(Func<Task> rule)
=> _busyService is not null
? () => RunWithBusyService(() => rule())
: rule;
protected override void InitializeRules()
{
base.InitializeRules();
Rule(nameof(ProcessName), ResolveRule(ProcessNameChangedRule), false);
Rule(nameof(ExecutionMode), ResolveRule(ExecutionModeChangedRule), false);
}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.
For activities that dynamically compose child activities at design time:
public class RunJob : NativeActivity
{
private readonly Sequence _implementationSequence = new();
private readonly WaitForJob _waitForJobActivity = new();
private readonly WaitForJobAndResume _waitForJobAndResumeActivity = new();
protected override void CacheMetadata(NativeActivityMetadata metadata)
{
_implementationSequence.Activities.Clear();
// Dynamically add the right child activity based on configuration
Activity waitActivity = ExecutionMode switch
{
JobExecutionMode.Busy => _waitForJobActivity,
JobExecutionMode.Suspend => _waitForJobAndResumeActivity,
_ => throw new InvalidOperationException()
};
_implementationSequence.Activities.Add(waitActivity);
metadata.AddImplementationChild(_implementationSequence);
// Bind dynamic arguments to metadata
foreach (var (name, argument) in Arguments)
{
var runtimeArgument = new RuntimeArgument(name, argument.ArgumentType, argument.Direction);
metadata.Bind(argument, runtimeArgument);
metadata.AddArgument(runtimeArgument);
}
}
}Key points:
AddImplementationChildregisters the composed sequence as an implementation detail (not visible to the user).- Dynamic arguments (e.g., from a dictionary) must be individually registered with
Bind+AddArgument. - The implementation sequence is rebuilt on every
CacheMetadatacall, so it always reflects the current configuration.
For long-running activities that survive process restarts:
[PersistentActivity]
[ValidatePersistenceDependsOn(nameof(ExecutionMode), nameof(JobExecutionMode.Suspend))]
public class RunJob : NativeActivity
{
// Dynamically add/remove persistence constraints
private void UpdateNoPersistScopeConstraint(bool shouldHaveConstraint)
{
if (shouldHaveConstraint && !Constraints.Contains(_constraint))
Constraints.Add(_constraint);
else if (!shouldHaveConstraint && Constraints.Contains(_constraint))
Constraints.Remove(_constraint);
}
}
// In the child activity:
protected void Persist(NativeActivityContext context, object resumeTrigger)
{
var bookmark = context.CreateBookmark(Guid.NewGuid().ToString(), OnWaitResume);
var persistenceBookmarks = context.GetExtension<IPersistenceBookmarks>();
persistenceBookmarks.RegisterBookmark(
new PersistenceBookmark(bookmark.Name, resumeTrigger));
}[PersistentActivity]marks the activity as persistence-aware.[ValidatePersistenceDependsOn]conditionally validates persistence requirements based on property values.CreateBookmarkpauses execution; the workflow can be persisted and later resumed.IPersistenceBookmarks.RegisterBookmarkassociates the bookmark with a resume trigger for the orchestrator.
Activities implement interfaces that the bindings system uses for polymorphic resource resolution:
// Contract interfaces
internal interface IOrchestratorActivity
{
InArgument<string> FolderPath { get; }
}
internal interface IProcessNameActivity : IOrchestratorActivity
{
InArgument<string> ProcessName { get; }
}
// Activity implements the interface
public class RunJob : NativeActivity, IProcessNameActivity
{
public InArgument<string> FolderPath { get; set; }
public InArgument<string> ProcessName { get; set; }
// Bindings key generated from interface contract
public string BindingsKey => BindingsKeyFactory.GenerateBindingsKey(this);
}
// At runtime, check for binding overrides before using property value
private string GetProcessNameValue(ActivityContext context)
=> _bindingsService.GetBindingsOverride(
SystemBindingTypes.Process, BindingsKey, PropertyContracts.Name, context)
?? ProcessName.Get(context);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.
Change property editability dynamically based on another property's value:
Rule(nameof(ExecutionMode), () =>
{
var mode = ExecutionMode.Value;
TimeoutMS.IsReadOnly = mode is not JobExecutionMode.Busy;
ContinueOnError.IsReadOnly = mode is JobExecutionMode.None;
ContinueWhenFaulted.IsReadOnly = mode is JobExecutionMode.None;
});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.