Implemented Cron activity (#23)

This commit is contained in:
Sipke Schoorstra 2019-02-16 20:57:59 +01:00 committed by GitHub
parent d957d67d9b
commit a167799cd6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 407 additions and 11 deletions

View file

@ -67,6 +67,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Email", "sr
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Web.Activities.Email", "src\web\modules\Elsa.Web.Activities.Email\Elsa.Web.Activities.Email.csproj", "{6B5E8D2F-3C4A-407A-B78C-DF4C3B32FF92}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Activities.Cron", "src\activities\Elsa.Activities.Cron\Elsa.Activities.Cron.csproj", "{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Web.Activities.Cron", "src\web\modules\Elsa.Web.Activities.Cron\Elsa.Web.Activities.Cron.csproj", "{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Runtime.Hosting", "src\core\Elsa.Runtime.Hosting\Elsa.Runtime.Hosting.csproj", "{4728D287-E4F4-4219-8066-924B53E6E17C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -161,6 +167,18 @@ Global
{6B5E8D2F-3C4A-407A-B78C-DF4C3B32FF92}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6B5E8D2F-3C4A-407A-B78C-DF4C3B32FF92}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6B5E8D2F-3C4A-407A-B78C-DF4C3B32FF92}.Release|Any CPU.Build.0 = Release|Any CPU
{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E4B71DC4-3E73-49C3-9B4E-CA13909F222D}.Release|Any CPU.Build.0 = Release|Any CPU
{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8}.Release|Any CPU.Build.0 = Release|Any CPU
{4728D287-E4F4-4219-8066-924B53E6E17C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4728D287-E4F4-4219-8066-924B53E6E17C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4728D287-E4F4-4219-8066-924B53E6E17C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4728D287-E4F4-4219-8066-924B53E6E17C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -192,6 +210,9 @@ Global
{089E23E7-0B8D-4BB6-949D-EB669C116D9E} = {35F44BE9-13D0-417C-A01A-F0787BEE6DC3}
{D20FCB88-9DCA-49EA-9CC2-5B94DD935D30} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180}
{6B5E8D2F-3C4A-407A-B78C-DF4C3B32FF92} = {B33F1927-FF0A-4826-80C6-F480E9C43892}
{E4B71DC4-3E73-49C3-9B4E-CA13909F222D} = {B43B546E-23F3-46E8-ACB7-D04F05CDA180}
{FE6ECF63-D3FC-44EA-8405-C2A73F7C00C8} = {B33F1927-FF0A-4826-80C6-F480E9C43892}
{4728D287-E4F4-4219-8066-924B53E6E17C} = {35F44BE9-13D0-417C-A01A-F0787BEE6DC3}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8B0975FD-7050-48B0-88C5-48C33378E158}

View file

@ -0,0 +1,12 @@
using System;
using Elsa.Expressions;
using Elsa.Models;
namespace Elsa.Activities.Cron.Activities
{
public class CronTrigger : Activity
{
public WorkflowExpression<string> CronExpression { get; set; }
public DateTime? StartTimestamp { get; set; }
}
}

View file

@ -0,0 +1,27 @@
using System.Collections.Generic;
using Elsa.Activities.Cron.Activities;
using Elsa.Models;
using Microsoft.Extensions.Localization;
namespace Elsa.Activities.Cron
{
public class ActivityDescriptors : ActivityDescriptorProviderBase
{
public ActivityDescriptors(IStringLocalizer<ActivityDescriptors> localizer)
{
T = localizer;
}
private IStringLocalizer<ActivityDescriptors> T { get; }
private LocalizedString Category => T["Triggers"];
protected override IEnumerable<ActivityDescriptor> Describe()
{
yield return ActivityDescriptor.ForTrigger<CronTrigger>(
Category,
T["Cron Trigger"],
T["Triggers at specified intervals using CRON expressions."],
T["Done"]);
}
}
}

View file

@ -0,0 +1,55 @@
using System.Threading;
using System.Threading.Tasks;
using Elsa.Activities.Cron.Activities;
using Elsa.Handlers;
using Elsa.Models;
using Elsa.Results;
using NCrontab;
using NodaTime;
using NodaTime.Extensions;
namespace Elsa.Activities.Cron.Drivers
{
public class CronTriggerDriver : ActivityDriver<CronTrigger>
{
private readonly IWorkflowExpressionEvaluator expressionEvaluator;
private readonly IClock clock;
public CronTriggerDriver(IWorkflowExpressionEvaluator expressionEvaluator, IClock clock)
{
this.expressionEvaluator = expressionEvaluator;
this.clock = clock;
}
protected override async Task<bool> OnCanExecuteAsync(CronTrigger activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
return activity.StartTimestamp == null || await IsExpiredAsync(activity, workflowContext, cancellationToken);
}
protected override ActivityExecutionResult OnExecute(CronTrigger activity, WorkflowExecutionContext workflowContext)
{
if (activity.StartTimestamp == null)
activity.StartTimestamp = clock.GetCurrentInstant().ToDateTimeUtc();
return Halt();
}
protected override async Task<ActivityExecutionResult> OnResumeAsync(CronTrigger activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
var isExpired = await IsExpiredAsync(activity, workflowContext, cancellationToken);
return isExpired ? (ActivityExecutionResult) Endpoint("Done") : Halt();
}
private async Task<bool> IsExpiredAsync(CronTrigger activity, WorkflowExecutionContext workflowContext, CancellationToken cancellationToken)
{
var cronExpression = await expressionEvaluator.EvaluateAsync(activity.CronExpression, workflowContext, cancellationToken);
var schedule = CrontabSchedule.Parse(cronExpression);
var now = clock.GetCurrentInstant().ToDateTimeUtc();
var startTimestamp = activity.StartTimestamp ?? now;
var nextOccurrence = schedule.GetNextOccurrence(startTimestamp);
return now >= nextOccurrence;
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageVersion>1.0.0</PackageVersion>
<Authors>Sipke Schoorstra</Authors>
<Description>Elsa is a set of workflowing libraries and tools to enable super-fast workflowing capabilities in any .NET Core application.</Description>
<Copyright>2019</Copyright>
<PackageProjectUrl>https://github.com/sfmskywalker/Elsa</PackageProjectUrl>
<RepositoryUrl>https://github.com/sfmskywalker/Elsa</RepositoryUrl>
<RepositoryType>GitHub</RepositoryType>
<PackageTags>elsa, workflows, orchard</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\Elsa.Core\Elsa.Core.csproj" />
<ProjectReference Include="..\..\core\Elsa.Runtime.Abstractions\Elsa.Runtime.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="2.2.0" />
<PackageReference Include="NCrontab" Version="3.3.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,27 @@
using Elsa.Activities.Cron.Drivers;
using Elsa.Activities.Cron.HostedServices;
using Elsa.Activities.Cron.Options;
using Elsa.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Activities.Cron.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCronDescriptors(this IServiceCollection services)
{
return services.AddActivityDescriptors<ActivityDescriptors>();
}
public static IServiceCollection AddCronDrivers(this IServiceCollection services, IConfiguration configuration)
{
return services
.AddCronDescriptors()
.AddOptions()
.Configure<CronOptions>(configuration)
.AddHostedService<CronHostedService>()
.AddActivityDriver<CronTriggerDriver>();
}
}
}

View file

@ -0,0 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Elsa.Activities.Cron.Activities;
using Elsa.Activities.Cron.Options;
using Elsa.Models;
using Elsa.Runtime;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Elsa.Activities.Cron.HostedServices
{
public class CronHostedService : BackgroundService
{
private readonly IWorkflowHost workflowHost;
private readonly IOptions<CronOptions> options;
private readonly ILogger<CronHostedService> logger;
public CronHostedService(IWorkflowHost workflowHost, IOptions<CronOptions> options, ILogger<CronHostedService> logger)
{
this.workflowHost = workflowHost;
this.options = options;
this.logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await workflowHost.TriggerWorkflowAsync(nameof(CronTrigger), Variables.Empty, stoppingToken);
}
catch (Exception ex)
{
logger.LogError(ex, $"Exception occurred while invoking workflows.");
}
await Task.Delay(options.Value.SweepInterval.ToDuration().ToTimeSpan(), stoppingToken);
}
}
}
}

View file

@ -0,0 +1,14 @@
using NodaTime;
namespace Elsa.Activities.Cron.Options
{
public class CronOptions
{
public CronOptions()
{
SweepInterval = Period.FromMinutes(1);
}
public Period SweepInterval { get; set; }
}
}

View file

@ -34,7 +34,7 @@ namespace Elsa.Activities.Email.Drivers
};
mailMessage.To.Add(to);
smtpClient.SendAsync(mailMessage, null);
await smtpClient.SendMailAsync(mailMessage);
return Endpoint("Done");
}

View file

@ -21,7 +21,7 @@ namespace Elsa.Serialization.Tokenizers
private readonly ITokenizerInvoker tokenizerInvoker;
private readonly IActivityLibrary activityLibrary;
private readonly IClock clock;
private readonly JsonSerializerSettings _serializerSettings;
private readonly JsonSerializerSettings serializerSettings;
private readonly JsonSerializer jsonSerializer;
public WorkflowTokenizer(ITokenizerInvoker tokenizerInvoker, IActivityLibrary activityLibrary, IClock clock)
@ -30,14 +30,14 @@ namespace Elsa.Serialization.Tokenizers
this.activityLibrary = activityLibrary;
this.clock = clock;
_serializerSettings = new JsonSerializerSettings
serializerSettings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
}
.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_serializerSettings.Converters.Add(new StringEnumConverter());
jsonSerializer = JsonSerializer.Create(_serializerSettings);
serializerSettings.Converters.Add(new StringEnumConverter());
jsonSerializer = JsonSerializer.Create(serializerSettings);
}
public async Task<JToken> TokenizeWorkflowAsync(Workflow value, CancellationToken cancellationToken)
@ -120,7 +120,7 @@ namespace Elsa.Serialization.Tokenizers
var name = token["name"].Value<string>();
var descriptor = await GetActivityDescriptorAsync(name, cancellationToken);
var activityType = descriptor?.ActivityType ?? typeof(UnknownActivity);
var activity = (IActivity) token.ToObject(activityType);
var activity = (IActivity) jsonSerializer.Deserialize(token.CreateReader(), activityType);
activity.Descriptor = descriptor;
return activity;
@ -267,7 +267,7 @@ namespace Elsa.Serialization.Tokenizers
{
var models = (JArray) token["executionLog"] ?? new JArray();
return JsonConvert.DeserializeObject<IList<LogEntry>>(models.ToString(), _serializerSettings);
return JsonConvert.DeserializeObject<IList<LogEntry>>(models.ToString(), serializerSettings);
}
private IEnumerable<Connection> DeserializeConnections(JToken token, IDictionary<string, IActivity> activityDictionary)

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NodaTime" Version="2.4.4" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,16 @@
using System.ComponentModel;
using Elsa.Runtime.Hosting.TypeConverters;
using NodaTime;
namespace Elsa.Runtime.Hosting.Extensions
{
public static class ProgramExtensions
{
public static void ConfigureTypeConverters()
{
TypeDescriptor.AddAttributes(
typeof(Period),
new TypeConverterAttribute(typeof(PeriodTypeConverter)));
}
}
}

View file

@ -0,0 +1,25 @@
using System;
using System.ComponentModel;
using System.Globalization;
using NodaTime.Text;
namespace Elsa.Runtime.Hosting.TypeConverters
{
public class PeriodTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
if (value is string s)
{
return PeriodPattern.Roundtrip.Parse(s).Value;
}
return base.ConvertFrom(context, culture, value);
}
}
}

View file

@ -10,7 +10,7 @@ namespace Elsa.Runtime.Extensions
return services
.AddWorkflowsCore()
.AddWorkflowsInvoker()
.AddScoped<IWorkflowHost, WorkflowHost>();
.AddSingleton<IWorkflowHost, WorkflowHost>();
}
}
}

View file

@ -31,6 +31,7 @@ namespace Elsa.Runtime
public async Task<WorkflowExecutionContext> StartWorkflowAsync(Workflow workflow, IActivity startActivity, Variables arguments, CancellationToken cancellationToken)
{
var workflowInstance = await workflowSerializer.DeriveAsync(workflow, cancellationToken);
startActivity = workflowInstance.Activities.Single(x => x.Id == startActivity.Id);
return await invoker.InvokeAsync(workflowInstance, startActivity, arguments, cancellationToken);
}

View file

@ -24,6 +24,7 @@
<ItemGroup>
<ProjectReference Include="..\..\web\modules\Elsa.Web.Activities.Console\Elsa.Web.Activities.Console.csproj" />
<ProjectReference Include="..\..\web\modules\Elsa.Web.Activities.Cron\Elsa.Web.Activities.Cron.csproj" />
<ProjectReference Include="..\..\web\modules\Elsa.Web.Activities.Email\Elsa.Web.Activities.Email.csproj" />
<ProjectReference Include="..\..\web\modules\Elsa.Web.Activities.Http\Elsa.Web.Activities.Http.csproj" />
<ProjectReference Include="..\..\web\modules\Elsa.Web.Activities.Primitives\Elsa.Web.Activities.Primitives.csproj" />

View file

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using Elsa.Runtime.Hosting.Extensions;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
@ -13,7 +14,10 @@ namespace SampleHost.Web
await host.RunAsync();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder<Startup>(args);
public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
ProgramExtensions.ConfigureTypeConverters();
return WebHost.CreateDefaultBuilder<Startup>(args);
}
}
}

View file

@ -20,11 +20,13 @@
<ItemGroup>
<ProjectReference Include="..\..\activities\Elsa.Activities.Console\Elsa.Activities.Console.csproj" />
<ProjectReference Include="..\..\activities\Elsa.Activities.Cron\Elsa.Activities.Cron.csproj" />
<ProjectReference Include="..\..\activities\Elsa.Activities.Email\Elsa.Activities.Email.csproj" />
<ProjectReference Include="..\..\activities\Elsa.Activities.Http\Elsa.Activities.Http.csproj" />
<ProjectReference Include="..\..\activities\Elsa.Activities.Primitives\Elsa.Activities.Primitives.csproj" />
<ProjectReference Include="..\..\core\Elsa.Core\Elsa.Core.csproj" />
<ProjectReference Include="..\..\core\Elsa.Persistence.FileSystem\Elsa.Persistence.FileSystem.csproj" />
<ProjectReference Include="..\..\core\Elsa.Runtime.Hosting\Elsa.Runtime.Hosting.csproj" />
<ProjectReference Include="..\..\core\Elsa.Runtime\Elsa.Runtime.csproj" />
</ItemGroup>

View file

@ -1,4 +1,5 @@
using Elsa.Activities.Console.Extensions;
using Elsa.Activities.Cron.Extensions;
using Elsa.Activities.Email.Extensions;
using Elsa.Activities.Http.Extensions;
using Elsa.Activities.Primitives.Extensions;
@ -31,7 +32,8 @@ namespace SampleHost.Web
.AddPrimitiveWorkflowDrivers()
.AddConsoleWorkflowDrivers()
.AddHttpWorkflowDrivers()
.AddEmailDrivers(Configuration.GetSection("Smtp"));
.AddEmailDrivers(Configuration.GetSection("Smtp"))
.AddCronDrivers(Configuration.GetSection("Cron"));
services
.AddMvc()

View file

@ -15,5 +15,8 @@
"Credentials": {
"Type": "None"
}
},
"Cron": {
"SweepInterval": "PT10S"
}
}

View file

@ -0,0 +1,20 @@
using Elsa.Activities.Cron.Activities;
using Elsa.Web.Activities.Cron.ViewModels;
using Elsa.Web.Components.ViewModels;
using Elsa.Web.Drivers;
namespace Elsa.Web.Activities.Cron.Display
{
public class CronTriggerDisplay : ActivityDisplayDriver<CronTrigger, CronTriggerViewModel>
{
protected override void EditActivity(CronTrigger activity, CronTriggerViewModel model)
{
model.CronExpression = new ExpressionViewModel(activity.CronExpression);
}
protected override void UpdateActivity(CronTriggerViewModel model, CronTrigger activity)
{
activity.CronExpression = model.CronExpression.ToWorkflowExpression<string>();
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageVersion>1.0.0</PackageVersion>
<Authors>Sipke Schoorstra</Authors>
<Description>Elsa is a set of workflowing libraries and tools to enable super-fast workflowing capabilities in any .NET Core application.</Description>
<Copyright>2019</Copyright>
<PackageProjectUrl>https://github.com/sfmskywalker/Elsa</PackageProjectUrl>
<RepositoryUrl>https://github.com/sfmskywalker/Elsa</RepositoryUrl>
<RepositoryType>GitHub</RepositoryType>
<PackageTags>elsa, workflows, orchard</PackageTags>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\activities\Elsa.Activities.Cron\Elsa.Activities.Cron.csproj" />
<ProjectReference Include="..\Elsa.Web.Abstractions\Elsa.Web.Abstractions.csproj" />
<ProjectReference Include="..\Elsa.Web.Components\Elsa.Web.Components.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OrchardCore.Module.Targets" Version="1.0.0-beta3-70705" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,7 @@
using OrchardCore.Modules.Manifest;
[assembly: Module(
Name = "Cron Activities",
Category = "Elsa Workflows",
Description = "Provides a Cron Trigger activity."
)]

View file

@ -0,0 +1,18 @@
using Elsa.Activities.Cron.Extensions;
using Elsa.Web.Activities.Cron.Display;
using Elsa.Web.Extensions;
using Microsoft.Extensions.DependencyInjection;
using OrchardCore.Modules;
namespace Elsa.Web.Activities.Cron
{
public class Startup : StartupBase
{
public override void ConfigureServices(IServiceCollection services)
{
services
.AddCronDescriptors()
.AddActivityDisplay<CronTriggerDisplay>();
}
}
}

View file

@ -0,0 +1,9 @@
using Elsa.Web.Components.ViewModels;
namespace Elsa.Web.Activities.Cron.ViewModels
{
public class CronTriggerViewModel
{
public ExpressionViewModel CronExpression { get; set; }
}
}

View file

@ -0,0 +1,6 @@
@model ActivityShapeModel<CronTrigger>
@{
var activityDescriptor = Model.Activity.Descriptor;
}
<h4 class="card-title"><i class="fas fa-globe"></i>@activityDescriptor.DisplayText</h4>
<p>@activityDescriptor.Description</p>

View file

@ -0,0 +1,6 @@
@model ActivityShapeModel<CronTrigger>
<header>
<h4><i class="fas fa-globe"></i>@Model.DisplayText</h4>
<i>&quot;@($"{Model.Activity.CronExpression}")&quot;</i>
</header>

View file

@ -0,0 +1,7 @@
@model CronTriggerViewModel
<div class="form-group">
<label asp-for="CronExpression">@T["Cron Expression"]</label>
<vc:expression-editor model="@Model.CronExpression" prefix="@Html.NameFor(m => m.CronExpression)"></vc:expression-editor>
<span asp-validation-for="CronExpression" class="text-danger"></span>
<small class="form-text text-muted">@T["Enter a crontab expression. See https://crontab.guru/."]</small>
</div>

View file

@ -0,0 +1,7 @@
@using Elsa.Web.ViewModels
@using Elsa.Web.Activities.Cron.ViewModels
@using Elsa.Activities.Cron.Activities
@using OrchardCore.Mvc.Core.Utilities
@inherits OrchardCore.DisplayManagement.Razor.RazorPage<TModel>
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Elsa.Web.Components