diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj b/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj index 09d1bbb2447..eca4cea5b46 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj +++ b/src/OrchardCore.Modules/OrchardCore.Contents/OrchardCore.Contents.csproj @@ -41,6 +41,7 @@ + diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Design.cshtml b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Design.cshtml new file mode 100644 index 00000000000..ef9e2da5275 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Design.cshtml @@ -0,0 +1,6 @@ +@model OrchardCore.Workflows.ViewModels.ActivityViewModel + +
+

@Model.Activity.GetTitleOrDefault(() => T["Content For Each"])

+
+@(Model.Activity.UseQuery ? $"Query: {Model.Activity.Query}" : $"ContentType: {Model.Activity.ContentType}") diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Edit.cshtml b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Edit.cshtml new file mode 100644 index 00000000000..b5a8ea5ba76 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Edit.cshtml @@ -0,0 +1,107 @@ +@model ContentForEachTaskViewModel +@if (Model.QueriesEnabled) +{ +
+
+ + + @T["Check to use (I.e. lucene, elastic or SQL) query instead of selecting all of one content type."] +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + + @T[@"Enter the query parameters as a JSON object. I.e. {{ 'example' : 'parameter' }} - allows liquid expressions."] +
+
+
+} + +
+
+
+ + + + @T["Select the type of content to loop."] +
+
+
+ + + @T["Check if you only want to return published items. Leave unchecked to return latest (Includes published and draft)."] +
+
+
+
+ +
+ + + + @T["How many to take per call. Automatically overrides size and from parameters in query if they exist, 0 to disable"] +
+ + \ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Thumbnail.cshtml b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Thumbnail.cshtml new file mode 100644 index 00000000000..26209de98e5 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Views/Items/ContentForEachTask.Fields.Thumbnail.cshtml @@ -0,0 +1,2 @@ +

@T["Content For Each"]

+

@T["Content for each task"]

\ No newline at end of file diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentForEachTask.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentForEachTask.cs new file mode 100644 index 00000000000..af7b403c257 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Activities/ContentForEachTask.cs @@ -0,0 +1,228 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using OrchardCore.ContentManagement.Workflows; +using OrchardCore.Environment.Shell.Descriptor.Models; +using OrchardCore.Queries; +using OrchardCore.Workflows.Abstractions.Models; +using OrchardCore.Workflows.Activities; +using OrchardCore.Workflows.Models; +using OrchardCore.Workflows.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using YesSql; + +namespace OrchardCore.Contents.Workflows.Activities +{ + public class ContentForEachTask : TaskActivity + { + readonly IStringLocalizer S; + private readonly ISession _session; + private readonly IWorkflowScriptEvaluator _scriptEvaluator; + private readonly ShellDescriptor _shellDescriptor; + private readonly IServiceProvider _serviceProvider; + private int _currentPage; + + public ContentForEachTask(IWorkflowScriptEvaluator scriptEvaluator, IStringLocalizer localizer, ISession session, ShellDescriptor shellDescriptor, IServiceProvider serviceProvider) + { + _scriptEvaluator = scriptEvaluator; + _session = session; + S = localizer; + _shellDescriptor = shellDescriptor; + _serviceProvider = serviceProvider; + } + + public override string Name => nameof(ContentForEachTask); + + public override LocalizedString DisplayText => S["Content For Each Task"]; + + public override LocalizedString Category => S["Content"]; + + public override IEnumerable GetPossibleOutcomes(WorkflowExecutionContext workflowContext, ActivityContext activityContext) + { + return Outcomes(S["Iterate"], S["Done"]); + } + public override bool CanExecute(WorkflowExecutionContext workflowContext, ActivityContext activityContext) + { + return true; + } + public override async Task ExecuteAsync(WorkflowExecutionContext workflowContext, ActivityContext activityContext) + { + + if (UseQuery && !_shellDescriptor.Features.Any(feature => feature.Id == "OrchardCore.Queries")) + { + throw new InvalidOperationException($"The '{nameof(ContentForEachTask)}' can't process the query as the feature OrchardCore.Queries is not enabled"); + } + + if (Index >= ContentItems.Count && !await FetchNextBatchAsync(workflowContext)) + { + return Outcomes("Done"); + } + + ProcessContentItem(workflowContext); + return Outcomes("Iterate"); + } + + private async Task FetchNextBatchAsync(WorkflowExecutionContext workflowContext) + { + //Have already looped once and there is no PageSize parameter so all results would have come with first query. + if (_currentPage > 0 && PageSize == 0) + { + return false; + } + if (UseQuery) + { + ContentItems = await ExecContentQueryAsync(workflowContext); + } + else + { + await ExecuteContentTypeQueryAsync(); + } + _currentPage++; + Index = 0; + return ContentItems.Count > 0; + } + + private async Task> ExecContentQueryAsync(WorkflowExecutionContext workflowContext) + { + var _queryManager = (IQueryManager)_serviceProvider.GetService(typeof(IQueryManager)); + var contentItems = new List(); + Query query = await _queryManager.GetQueryAsync(Query); + if (query == null) + { + throw new InvalidOperationException(S[$"Failed to retrieve the query {Query} (Have you changed, deleted the query or disabled the feature?)"]); + } + + string queryParameters = await _scriptEvaluator.EvaluateAsync(Parameters, workflowContext, null); + + var parameters = !string.IsNullOrEmpty(queryParameters) ? + JsonSerializer.Deserialize>(queryParameters) + : new Dictionary(); + + if (PageSize > 0) + { + parameters["from"] = _currentPage * PageSize; + parameters["size"] = PageSize; + } + try + { + IQueryResults results = await _queryManager.ExecuteQueryAsync(query, parameters); + foreach (ContentItem item in results.Items) + { + contentItems.Add(item); + } + } + catch (Exception e) + { + throw new InvalidOperationException(S[$"Failed to run the query {Query}, failed with message: {e.Message}."]); + } + return contentItems; + } + + private async Task ExecuteContentTypeQueryAsync() + { + ContentItems = await _session + .Query(index => index.ContentType == ContentType) + .Where(w => (w.Published || w.Published == PublishedOnly) && (w.Latest || w.Latest == !PublishedOnly)) + .Skip(_currentPage * PageSize) + .Take(PageSize) + .ListAsync() as List; + } + private void ProcessContentItem(WorkflowExecutionContext workflowContext) + { + var contentItem = ContentItems[Index]; + Current = contentItem; + workflowContext.CorrelationId = contentItem.ContentItemId; + workflowContext.Properties[ContentEventConstants.ContentItemInputKey] = contentItem; + workflowContext.LastResult = Current; + Index++; + } + + /// + /// How many to take each db call. + /// + public int PageSize + { + get => GetProperty(() => 10); + set => SetProperty(value); + } + /// + /// The current number of iterations executed. + /// + public int Index + { + get => GetProperty(() => 0); + set => SetProperty(value); + } + + /// + /// The current iteration value. + /// + public object Current + { + get => GetProperty(); + set => SetProperty(value); + } + + /// + /// The collection of contentItems. + /// + public List ContentItems + { + get => GetProperty(() => new List()); + set => SetProperty(value); + } + /// + /// The name of the content type to select. + /// + public string ContentType + { + get => GetProperty(() => string.Empty); + set => SetProperty(value); + } + /// + /// Toggles between using a query (I.e. Lucene or raw YesSql query). + /// + public bool UseQuery + { + get => GetProperty(() => false); + set => SetProperty(value); + } + /// + /// The selected query source, if any. + /// + public string QuerySource + { + get => GetProperty(() => string.Empty); + set => SetProperty(value); + } + /// + /// The name of the query to run. + /// + public string Query + { + get => GetProperty(() => string.Empty); + set => SetProperty(value); + } + /// + /// Parameters to pass into the query. + /// + public WorkflowExpression Parameters + { + get => GetProperty(() => new WorkflowExpression()); + set => SetProperty(value); + } + /// + /// Only return published items. + /// + public bool PublishedOnly + { + get => GetProperty(() => false); + set => SetProperty(value); + } + } + +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Drivers/ContentForEachTaskDisplayDriver.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Drivers/ContentForEachTaskDisplayDriver.cs new file mode 100644 index 00000000000..93f4c9e0855 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Drivers/ContentForEachTaskDisplayDriver.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Mvc.Rendering; +using OrchardCore.ContentManagement.Metadata; +using OrchardCore.Contents.Workflows.Activities; +using OrchardCore.Contents.Workflows.ViewModels; +using OrchardCore.Environment.Shell.Descriptor.Models; +using OrchardCore.Queries; +using OrchardCore.Workflows.Display; +using OrchardCore.Workflows.Models; +using System; +using System.Linq; +using YesSql; + + +namespace OrchardCore.Contents.Workflows.Drivers +{ + public class ContentForEachTaskDisplayDriver : ActivityDisplayDriver + { + private readonly IContentDefinitionManager _contentDefinitionManager; + private readonly ShellDescriptor _shellDescriptor; + private readonly IServiceProvider _serviceProvider; + public ContentForEachTaskDisplayDriver(IContentDefinitionManager contentDefinitionManager, ISession session, ShellDescriptor shellDescriptor, IServiceProvider serviceProvider) + { + _contentDefinitionManager = contentDefinitionManager; + _shellDescriptor = shellDescriptor; + _serviceProvider = serviceProvider; + } + protected override async void EditActivity(ContentForEachTask activity, ContentForEachTaskViewModel model) + { + model.QueriesEnabled = _shellDescriptor.Features.Any(feature => feature.Id == "OrchardCore.Queries"); + if (model.QueriesEnabled) + { + var _queryManager = (IQueryManager)_serviceProvider.GetService(typeof(IQueryManager)); + model.UseQuery = activity.UseQuery; + model.QuerySource = activity.QuerySource; + model.Query = activity.Query; + model.Parameters = activity.Parameters?.Expression ?? ""; + var queries = await _queryManager.ListQueriesAsync(); + + model.QuerySources = queries.Select(x => x.Source).Distinct() + .Select(x => new SelectListItem { Text = x, Value = x }) + .ToList(); + + model.QueriesBySource = queries.GroupBy(x => x.Source) + .ToDictionary( + g => g.Key, + g => g.Select(q => new SelectListItem { Text = q.Name, Value = q.Name }).ToList()); + + } + else + { + model.UseQuery = false; + } + + model.AvailableContentTypes = (await _contentDefinitionManager.ListTypeDefinitionsAsync()) + .Select(x => new SelectListItem { Text = x.DisplayName, Value = x.Name }) + .ToList(); + model.ContentType = activity.ContentType; + model.PageSize = activity.PageSize; + model.PublishedOnly = activity.PublishedOnly; + } + + protected override void UpdateActivity(ContentForEachTaskViewModel model, ContentForEachTask activity) + { + activity.UseQuery = model.UseQuery; + activity.ContentType = model.ContentType; + activity.Query = model.Query ?? string.Empty; + activity.QuerySource = model.QuerySource; + activity.Parameters = new WorkflowExpression(model.Parameters); + activity.PageSize = model.PageSize; + activity.PublishedOnly = model.PublishedOnly; + } + } + +} diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Startup.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Startup.cs index 4bd3a50d67f..00a114c7661 100644 --- a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Startup.cs +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/Startup.cs @@ -27,7 +27,7 @@ public override void ConfigureServices(IServiceCollection services) services.AddActivity(); services.AddActivity(); services.AddActivity(); - + services.AddActivity(); services.AddScoped(); services.AddScoped(); } diff --git a/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/ViewModels/ContentForEachTaskViewModel.cs b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/ViewModels/ContentForEachTaskViewModel.cs new file mode 100644 index 00000000000..cb1c96bbd64 --- /dev/null +++ b/src/OrchardCore.Modules/OrchardCore.Contents/Workflows/ViewModels/ContentForEachTaskViewModel.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using System.Collections.Generic; + +namespace OrchardCore.Contents.Workflows.ViewModels +{ + + public class ContentForEachTaskViewModel + { + public bool QueriesEnabled { get; set; } + public bool UseQuery { get; set; } + public string ContentType { get; set; } + public string QuerySource { get; set; } + public string Query { get; set; } + public string Parameters { get; set; } + public bool PublishedOnly { get; set; } + public int PageSize { get; set; } + + [BindNever] + public IList AvailableContentTypes { get; set; } + + [BindNever] + public List QuerySources { get; set; } + + [BindNever] + public Dictionary> QueriesBySource { get; set; } + } + +} diff --git a/src/docs/reference/modules/Workflows/README.md b/src/docs/reference/modules/Workflows/README.md index 415ab84566c..59f4f986763 100644 --- a/src/docs/reference/modules/Workflows/README.md +++ b/src/docs/reference/modules/Workflows/README.md @@ -85,6 +85,11 @@ Although many activities support multiple outcomes, they typically return only o For example, the *Send Email* activity has two possible outcomes: "Done" and "Failed". When the email was sent successfully, it yields "Done" as the outcome, and "Failed" otherwise. +For activities that have an iterator outcome e.g _Content For Each_, return the outcome of the next activity back to the iterate task for the iteration to continue either through the done outcome or via a fork. +If forking make sure that the loop output is after the action output. + +![Sample content for each ](docs/sample-content-for-each.png) + ### Transition A transition is the connection between the outcome of one activity to another activity. Transitions are created using drag & drop operations in the workflow editor. @@ -245,6 +250,7 @@ The following activities are available with any default Orchard installation: | Create Content | Task | Create a content item. | | Delete Content | Task | Delete a content item. | | Publish Content | Task | Publish a content item. | +| For Content Type | Task | Loop through a content type. | **User** | * | * | * | | ValidateUser | Task | Used to check if the user is logged in and has the specified role(s). | diff --git a/src/docs/reference/modules/Workflows/docs/sample-content-for-each.png b/src/docs/reference/modules/Workflows/docs/sample-content-for-each.png new file mode 100644 index 00000000000..ef265ce60e3 Binary files /dev/null and b/src/docs/reference/modules/Workflows/docs/sample-content-for-each.png differ