mirror of https://github.com/Squidex/squidex.git
195 changed files with 4716 additions and 1468 deletions
@ -0,0 +1,37 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using Newtonsoft.Json; |
||||
|
using Squidex.Infrastructure.Json.Newtonsoft; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents.Json |
||||
|
{ |
||||
|
public sealed class WorkflowConverter : JsonClassConverter<Workflows> |
||||
|
{ |
||||
|
protected override void WriteValue(JsonWriter writer, Workflows value, JsonSerializer serializer) |
||||
|
{ |
||||
|
var json = new Dictionary<Guid, Workflow>(value.Count); |
||||
|
|
||||
|
foreach (var workflow in value) |
||||
|
{ |
||||
|
json.Add(workflow.Key, workflow.Value); |
||||
|
} |
||||
|
|
||||
|
serializer.Serialize(writer, json); |
||||
|
} |
||||
|
|
||||
|
protected override Workflows ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer) |
||||
|
{ |
||||
|
var json = serializer.Deserialize<Dictionary<Guid, Workflow>>(reader); |
||||
|
|
||||
|
return new Workflows(json.ToArray()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.ComponentModel; |
||||
|
using System.Globalization; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents |
||||
|
{ |
||||
|
public sealed class StatusConverter : TypeConverter |
||||
|
{ |
||||
|
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) |
||||
|
{ |
||||
|
return sourceType == typeof(string); |
||||
|
} |
||||
|
|
||||
|
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType) |
||||
|
{ |
||||
|
return destinationType == typeof(string); |
||||
|
} |
||||
|
|
||||
|
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) |
||||
|
{ |
||||
|
return new Status(value?.ToString()); |
||||
|
} |
||||
|
|
||||
|
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType) |
||||
|
{ |
||||
|
return value.ToString(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,88 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents |
||||
|
{ |
||||
|
public sealed class Workflow |
||||
|
{ |
||||
|
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>(); |
||||
|
|
||||
|
public static readonly Workflow Default = new Workflow( |
||||
|
new Dictionary<Status, WorkflowStep> |
||||
|
{ |
||||
|
[Status.Archived] = |
||||
|
new WorkflowStep( |
||||
|
new Dictionary<Status, WorkflowTransition> |
||||
|
{ |
||||
|
[Status.Draft] = new WorkflowTransition() |
||||
|
}, |
||||
|
StatusColors.Archived, true), |
||||
|
[Status.Draft] = |
||||
|
new WorkflowStep( |
||||
|
new Dictionary<Status, WorkflowTransition> |
||||
|
{ |
||||
|
[Status.Archived] = new WorkflowTransition(), |
||||
|
[Status.Published] = new WorkflowTransition() |
||||
|
}, |
||||
|
StatusColors.Draft), |
||||
|
[Status.Published] = |
||||
|
new WorkflowStep( |
||||
|
new Dictionary<Status, WorkflowTransition> |
||||
|
{ |
||||
|
[Status.Archived] = new WorkflowTransition(), |
||||
|
[Status.Draft] = new WorkflowTransition() |
||||
|
}, |
||||
|
StatusColors.Published) |
||||
|
}, Status.Draft); |
||||
|
|
||||
|
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; } |
||||
|
|
||||
|
public Status Initial { get; } |
||||
|
|
||||
|
public Workflow(IReadOnlyDictionary<Status, WorkflowStep> steps, Status initial) |
||||
|
{ |
||||
|
Steps = steps ?? EmptySteps; |
||||
|
|
||||
|
Initial = initial; |
||||
|
} |
||||
|
|
||||
|
public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status) |
||||
|
{ |
||||
|
if (TryGetStep(status, out var step)) |
||||
|
{ |
||||
|
foreach (var transition in step.Transitions) |
||||
|
{ |
||||
|
yield return (transition.Key, Steps[transition.Key], transition.Value); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public bool TryGetTransition(Status from, Status to, out WorkflowTransition transition) |
||||
|
{ |
||||
|
if (TryGetStep(from, out var step) && step.Transitions.TryGetValue(to, out transition)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
transition = null; |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public bool TryGetStep(Status status, out WorkflowStep step) |
||||
|
{ |
||||
|
return Steps.TryGetValue(status, out step); |
||||
|
} |
||||
|
|
||||
|
public (Status Key, WorkflowStep) GetInitialStep() |
||||
|
{ |
||||
|
return (Initial, Steps[Initial]); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents |
||||
|
{ |
||||
|
public sealed class WorkflowStep |
||||
|
{ |
||||
|
private static readonly IReadOnlyDictionary<Status, WorkflowTransition> EmptyTransitions = new Dictionary<Status, WorkflowTransition>(); |
||||
|
|
||||
|
public IReadOnlyDictionary<Status, WorkflowTransition> Transitions { get; } |
||||
|
|
||||
|
public string Color { get; } |
||||
|
|
||||
|
public bool NoUpdate { get; } |
||||
|
|
||||
|
public WorkflowStep(IReadOnlyDictionary<Status, WorkflowTransition> transitions = null, string color = null, bool noUpdate = false) |
||||
|
{ |
||||
|
Transitions = transitions ?? EmptyTransitions; |
||||
|
|
||||
|
Color = color; |
||||
|
|
||||
|
NoUpdate = noUpdate; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents |
||||
|
{ |
||||
|
public sealed class WorkflowTransition |
||||
|
{ |
||||
|
public string Expression { get; } |
||||
|
|
||||
|
public string Role { get; } |
||||
|
|
||||
|
public WorkflowTransition(string expression = null, string role = null) |
||||
|
{ |
||||
|
Expression = expression; |
||||
|
|
||||
|
Role = role; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,43 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Diagnostics.Contracts; |
||||
|
using System.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Collections; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Contents |
||||
|
{ |
||||
|
public sealed class Workflows : ArrayDictionary<Guid, Workflow> |
||||
|
{ |
||||
|
public static readonly Workflows Empty = new Workflows(); |
||||
|
|
||||
|
private Workflows() |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public Workflows(KeyValuePair<Guid, Workflow>[] items) |
||||
|
: base(items) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
[Pure] |
||||
|
public Workflows Set(Workflow workflow) |
||||
|
{ |
||||
|
Guard.NotNull(workflow, nameof(workflow)); |
||||
|
|
||||
|
return new Workflows(With(Guid.Empty, workflow)); |
||||
|
} |
||||
|
|
||||
|
public Workflow GetFirst() |
||||
|
{ |
||||
|
return Values.FirstOrDefault() ?? Workflow.Default; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,16 +1,16 @@ |
|||||
// ==========================================================================
|
// ==========================================================================
|
||||
// Squidex Headless CMS
|
// Squidex Headless CMS
|
||||
// ==========================================================================
|
// ==========================================================================
|
||||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
// All rights reserved. Licensed under the MIT license.
|
// All rights reserved. Licensed under the MIT license.
|
||||
// ==========================================================================
|
// ==========================================================================
|
||||
|
|
||||
using Squidex.Domain.Apps.Entities.Apps; |
using Squidex.Domain.Apps.Core.Contents; |
||||
|
|
||||
namespace Squidex.Web |
namespace Squidex.Domain.Apps.Entities.Apps.Commands |
||||
{ |
{ |
||||
public interface IAppFeature |
public sealed class ConfigureWorkflow : AppCommand |
||||
{ |
{ |
||||
IAppEntity App { get; } |
public Workflow Workflow { get; set; } |
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Apps.Commands; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Apps.Guards |
||||
|
{ |
||||
|
public static class GuardAppWorkflows |
||||
|
{ |
||||
|
public static void CanConfigure(ConfigureWorkflow command) |
||||
|
{ |
||||
|
Guard.NotNull(command, nameof(command)); |
||||
|
|
||||
|
Validate.It(() => "Cannot configure workflow.", e => |
||||
|
{ |
||||
|
if (command.Workflow == null) |
||||
|
{ |
||||
|
e(Not.Defined("Workflow"), nameof(command.Workflow)); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
var workflow = command.Workflow; |
||||
|
|
||||
|
if (!workflow.Steps.ContainsKey(workflow.Initial)) |
||||
|
{ |
||||
|
e(Not.Defined("Initial step"), $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); |
||||
|
} |
||||
|
|
||||
|
if (workflow.Initial == Status.Published) |
||||
|
{ |
||||
|
e("Initial step cannot be published step.", $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}"); |
||||
|
} |
||||
|
|
||||
|
var stepsPrefix = $"{nameof(command.Workflow)}.{nameof(workflow.Steps)}"; |
||||
|
|
||||
|
if (!workflow.Steps.ContainsKey(Status.Published)) |
||||
|
{ |
||||
|
e("Workflow must have a published step.", stepsPrefix); |
||||
|
} |
||||
|
|
||||
|
foreach (var step in workflow.Steps) |
||||
|
{ |
||||
|
var stepPrefix = $"{stepsPrefix}.{step.Key}"; |
||||
|
|
||||
|
if (step.Value == null) |
||||
|
{ |
||||
|
e(Not.Defined("Step"), stepPrefix); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
foreach (var transition in step.Value.Transitions) |
||||
|
{ |
||||
|
var transitionPrefix = $"{stepPrefix}.{nameof(step.Value.Transitions)}.{transition.Key}"; |
||||
|
|
||||
|
if (!workflow.Steps.ContainsKey(transition.Key)) |
||||
|
{ |
||||
|
e("Transition has an invalid target.", transitionPrefix); |
||||
|
} |
||||
|
|
||||
|
if (transition.Value == null) |
||||
|
{ |
||||
|
e(Not.Defined("Transition"), transitionPrefix); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,139 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents |
||||
|
{ |
||||
|
public static class ContextExtensions |
||||
|
{ |
||||
|
private const string HeaderUnpublished = "X-Unpublished"; |
||||
|
private const string HeaderFlatten = "X-Flatten"; |
||||
|
private const string HeaderLanguages = "X-Languages"; |
||||
|
private const string HeaderResolveFlow = "X-ResolveFlow"; |
||||
|
private const string HeaderResolveAssetUrls = "X-Resolve-Urls"; |
||||
|
private static readonly char[] Separators = { ',', ';' }; |
||||
|
|
||||
|
public static bool IsUnpublished(this Context context) |
||||
|
{ |
||||
|
return context.Headers.ContainsKey(HeaderUnpublished); |
||||
|
} |
||||
|
|
||||
|
public static Context WithUnpublished(this Context context, bool value = true) |
||||
|
{ |
||||
|
if (value) |
||||
|
{ |
||||
|
context.Headers[HeaderUnpublished] = "1"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
context.Headers.Remove(HeaderUnpublished); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
|
||||
|
public static bool IsFlatten(this Context context) |
||||
|
{ |
||||
|
return context.Headers.ContainsKey(HeaderFlatten); |
||||
|
} |
||||
|
|
||||
|
public static Context WithFlatten(this Context context, bool value = true) |
||||
|
{ |
||||
|
if (value) |
||||
|
{ |
||||
|
context.Headers[HeaderFlatten] = "1"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
context.Headers.Remove(HeaderFlatten); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
|
||||
|
public static bool IsResolveFlow(this Context context) |
||||
|
{ |
||||
|
return context.Headers.ContainsKey(HeaderResolveFlow); |
||||
|
} |
||||
|
|
||||
|
public static Context WithResolveFlow(this Context context, bool value = true) |
||||
|
{ |
||||
|
if (value) |
||||
|
{ |
||||
|
context.Headers[HeaderResolveFlow] = "1"; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
context.Headers.Remove(HeaderResolveFlow); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
|
||||
|
public static IEnumerable<string> AssetUrls(this Context context) |
||||
|
{ |
||||
|
if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value)) |
||||
|
{ |
||||
|
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToHashSet(); |
||||
|
} |
||||
|
|
||||
|
return Enumerable.Empty<string>(); |
||||
|
} |
||||
|
|
||||
|
public static Context WithAssetUrlsToResolve(this Context context, IEnumerable<string> fieldNames) |
||||
|
{ |
||||
|
if (fieldNames?.Any() == true) |
||||
|
{ |
||||
|
context.Headers[HeaderResolveAssetUrls] = string.Join(",", fieldNames); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
context.Headers.Remove(HeaderResolveAssetUrls); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
|
||||
|
public static IEnumerable<Language> Languages(this Context context) |
||||
|
{ |
||||
|
if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value)) |
||||
|
{ |
||||
|
var languages = new HashSet<Language>(); |
||||
|
|
||||
|
foreach (var iso2Code in value.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) |
||||
|
{ |
||||
|
if (Language.TryGetLanguage(iso2Code.Trim(), out var language)) |
||||
|
{ |
||||
|
languages.Add(language); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return languages; |
||||
|
} |
||||
|
|
||||
|
return Enumerable.Empty<Language>(); |
||||
|
} |
||||
|
|
||||
|
public static Context WithLanguages(this Context context, IEnumerable<string> fieldNames) |
||||
|
{ |
||||
|
if (fieldNames?.Any() == true) |
||||
|
{ |
||||
|
context.Headers[HeaderLanguages] = string.Join(",", fieldNames); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
context.Headers.Remove(HeaderLanguages); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,129 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Security.Claims; |
||||
|
using System.Threading.Tasks; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.Scripting; |
||||
|
using Squidex.Domain.Apps.Entities.Schemas; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents |
||||
|
{ |
||||
|
public sealed class DynamicContentWorkflow : IContentWorkflow |
||||
|
{ |
||||
|
private readonly IScriptEngine scriptEngine; |
||||
|
private readonly IAppProvider appProvider; |
||||
|
|
||||
|
public DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider) |
||||
|
{ |
||||
|
Guard.NotNull(scriptEngine, nameof(scriptEngine)); |
||||
|
Guard.NotNull(appProvider, nameof(appProvider)); |
||||
|
|
||||
|
this.scriptEngine = scriptEngine; |
||||
|
|
||||
|
this.appProvider = appProvider; |
||||
|
} |
||||
|
|
||||
|
public async Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema) |
||||
|
{ |
||||
|
var workflow = await GetWorkflowAsync(schema.AppId.Id); |
||||
|
|
||||
|
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); |
||||
|
} |
||||
|
|
||||
|
public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user) |
||||
|
{ |
||||
|
var workflow = await GetWorkflowAsync(content.AppId.Id); |
||||
|
|
||||
|
return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user); |
||||
|
} |
||||
|
|
||||
|
public async Task<bool> CanUpdateAsync(IContentEntity content) |
||||
|
{ |
||||
|
var workflow = await GetWorkflowAsync(content.AppId.Id); |
||||
|
|
||||
|
if (workflow.TryGetStep(content.Status, out var step)) |
||||
|
{ |
||||
|
return !step.NoUpdate; |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
public async Task<StatusInfo> GetInfoAsync(IContentEntity content) |
||||
|
{ |
||||
|
var workflow = await GetWorkflowAsync(content.AppId.Id); |
||||
|
|
||||
|
if (workflow.TryGetStep(content.Status, out var step)) |
||||
|
{ |
||||
|
return new StatusInfo(content.Status, GetColor(step)); |
||||
|
} |
||||
|
|
||||
|
return new StatusInfo(content.Status, StatusColors.Draft); |
||||
|
} |
||||
|
|
||||
|
public async Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema) |
||||
|
{ |
||||
|
var workflow = await GetWorkflowAsync(schema.AppId.Id); |
||||
|
|
||||
|
var (status, step) = workflow.GetInitialStep(); |
||||
|
|
||||
|
return new StatusInfo(status, GetColor(step)); |
||||
|
} |
||||
|
|
||||
|
public async Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user) |
||||
|
{ |
||||
|
var result = new List<StatusInfo>(); |
||||
|
|
||||
|
var workflow = await GetWorkflowAsync(content.AppId.Id); |
||||
|
|
||||
|
foreach (var (to, step, transition) in workflow.GetTransitions(content.Status)) |
||||
|
{ |
||||
|
if (CanUse(transition, content, user)) |
||||
|
{ |
||||
|
result.Add(new StatusInfo(to, GetColor(step))); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return result.ToArray(); |
||||
|
} |
||||
|
|
||||
|
private bool CanUse(WorkflowTransition transition, IContentEntity content, ClaimsPrincipal user) |
||||
|
{ |
||||
|
if (!string.IsNullOrWhiteSpace(transition.Role)) |
||||
|
{ |
||||
|
if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && x.Value == transition.Role)) |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!string.IsNullOrWhiteSpace(transition.Expression)) |
||||
|
{ |
||||
|
return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private async Task<Workflow> GetWorkflowAsync(Guid appId) |
||||
|
{ |
||||
|
var app = await appProvider.GetAppAsync(appId); |
||||
|
|
||||
|
return app?.Workflows.GetFirst(); |
||||
|
} |
||||
|
|
||||
|
private static string GetColor(WorkflowStep step) |
||||
|
{ |
||||
|
return step.Color ?? StatusColors.Draft; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using System.Security.Claims; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Infrastructure.Security; |
||||
|
using Squidex.Shared.Identity; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities |
||||
|
{ |
||||
|
public sealed class Context |
||||
|
{ |
||||
|
public IDictionary<string, string> Headers { get; } = new Dictionary<string, string>(); |
||||
|
|
||||
|
public IAppEntity App { get; set; } |
||||
|
|
||||
|
public ClaimsPrincipal User { get; set; } |
||||
|
|
||||
|
public PermissionSet Permissions |
||||
|
{ |
||||
|
get { return User?.Permissions() ?? PermissionSet.Empty; } |
||||
|
} |
||||
|
|
||||
|
public Context() |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public Context(ClaimsPrincipal user, IAppEntity app) |
||||
|
{ |
||||
|
User = user; |
||||
|
|
||||
|
App = app; |
||||
|
} |
||||
|
|
||||
|
public bool IsFrontendClient |
||||
|
{ |
||||
|
get { return User != null && User.IsInClient("squidex-frontend"); } |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,116 +0,0 @@ |
|||||
// ==========================================================================
|
|
||||
// Squidex Headless CMS
|
|
||||
// ==========================================================================
|
|
||||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|
||||
// All rights reserved. Licensed under the MIT license.
|
|
||||
// ==========================================================================
|
|
||||
|
|
||||
using System; |
|
||||
using System.Collections.Generic; |
|
||||
using System.Security.Claims; |
|
||||
using Squidex.Domain.Apps.Entities.Apps; |
|
||||
using Squidex.Domain.Apps.Entities.Contents; |
|
||||
using Squidex.Infrastructure; |
|
||||
using Squidex.Infrastructure.Security; |
|
||||
|
|
||||
namespace Squidex.Domain.Apps.Entities |
|
||||
{ |
|
||||
public sealed class QueryContext : Cloneable<QueryContext> |
|
||||
{ |
|
||||
private static readonly char[] Separators = { ',', ';' }; |
|
||||
|
|
||||
public ClaimsPrincipal User { get; private set; } |
|
||||
|
|
||||
public IAppEntity App { get; private set; } |
|
||||
|
|
||||
public bool Flatten { get; set; } |
|
||||
|
|
||||
public StatusForApi ApiStatus { get; private set; } |
|
||||
|
|
||||
public IReadOnlyCollection<string> AssetUrlsToResolve { get; private set; } |
|
||||
|
|
||||
public IReadOnlyCollection<Language> Languages { get; private set; } |
|
||||
|
|
||||
private QueryContext() |
|
||||
{ |
|
||||
} |
|
||||
|
|
||||
public static QueryContext Create(IAppEntity app, ClaimsPrincipal user) |
|
||||
{ |
|
||||
return new QueryContext { App = app, User = user }; |
|
||||
} |
|
||||
|
|
||||
public QueryContext WithFlatten(bool flatten) |
|
||||
{ |
|
||||
return Clone(c => c.Flatten = flatten); |
|
||||
} |
|
||||
|
|
||||
public QueryContext WithUnpublished(bool unpublished) |
|
||||
{ |
|
||||
return WithApiStatus(unpublished ? StatusForApi.All : StatusForApi.PublishedOnly); |
|
||||
} |
|
||||
|
|
||||
public QueryContext WithApiStatus(StatusForApi status) |
|
||||
{ |
|
||||
return Clone(c => c.ApiStatus = status); |
|
||||
} |
|
||||
|
|
||||
public QueryContext WithAssetUrlsToResolve(IEnumerable<string> fieldNames) |
|
||||
{ |
|
||||
if (fieldNames != null) |
|
||||
{ |
|
||||
return Clone(c => |
|
||||
{ |
|
||||
var fields = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
|
||||
|
|
||||
c.AssetUrlsToResolve?.Foreach(x => fields.Add(x)); |
|
||||
|
|
||||
foreach (var part in fieldNames) |
|
||||
{ |
|
||||
foreach (var fieldName in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) |
|
||||
{ |
|
||||
fields.Add(fieldName.Trim()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
c.AssetUrlsToResolve = fields; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
return this; |
|
||||
} |
|
||||
|
|
||||
public QueryContext WithLanguages(IEnumerable<string> languageCodes) |
|
||||
{ |
|
||||
if (languageCodes != null) |
|
||||
{ |
|
||||
return Clone(c => |
|
||||
{ |
|
||||
var languages = new HashSet<Language>(); |
|
||||
|
|
||||
c.Languages?.Foreach(x => languages.Add(x)); |
|
||||
|
|
||||
foreach (var part in languageCodes) |
|
||||
{ |
|
||||
foreach (var iso2Code in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) |
|
||||
{ |
|
||||
if (Language.TryGetLanguage(iso2Code.Trim(), out var language)) |
|
||||
{ |
|
||||
languages.Add(language); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
c.Languages = languages; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
return this; |
|
||||
} |
|
||||
|
|
||||
public bool IsFrontendClient |
|
||||
{ |
|
||||
get { return User.IsInClient("squidex-frontend"); } |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -0,0 +1,18 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure.EventSourcing; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Events.Apps |
||||
|
{ |
||||
|
[EventType(nameof(AppWorkflowConfigured))] |
||||
|
public sealed class AppWorkflowConfigured : AppEvent |
||||
|
{ |
||||
|
public Workflow Workflow { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Microsoft.AspNetCore.Http; |
||||
|
using Squidex.Domain.Apps.Entities; |
||||
|
|
||||
|
namespace Squidex.Web |
||||
|
{ |
||||
|
public static class ContextExtensions |
||||
|
{ |
||||
|
public static Context Context(this HttpContext httpContext) |
||||
|
{ |
||||
|
var context = httpContext.Features.Get<Context>(); |
||||
|
|
||||
|
if (context == null) |
||||
|
{ |
||||
|
context = new Context { User = httpContext.User }; |
||||
|
|
||||
|
foreach (var header in httpContext.Request.Headers) |
||||
|
{ |
||||
|
if (header.Key.StartsWith("X-", System.StringComparison.Ordinal)) |
||||
|
{ |
||||
|
context.Headers.Add(header.Key, header.Value.ToString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
httpContext.Features.Set(context); |
||||
|
} |
||||
|
|
||||
|
return context; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Microsoft.AspNetCore.Http; |
||||
|
using Squidex.Domain.Apps.Entities; |
||||
|
using Squidex.Infrastructure; |
||||
|
|
||||
|
namespace Squidex.Web |
||||
|
{ |
||||
|
public sealed class ContextProvider : IContextProvider |
||||
|
{ |
||||
|
private readonly IHttpContextAccessor httpContextAccessor; |
||||
|
|
||||
|
public Context Context |
||||
|
{ |
||||
|
get { return httpContextAccessor.HttpContext.Context(); } |
||||
|
} |
||||
|
|
||||
|
public ContextProvider(IHttpContextAccessor httpContextAccessor) |
||||
|
{ |
||||
|
Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); |
||||
|
|
||||
|
this.httpContextAccessor = httpContextAccessor; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,86 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using Microsoft.AspNetCore.Mvc; |
||||
|
using Microsoft.Net.Http.Headers; |
||||
|
using Squidex.Areas.Api.Controllers.Apps.Models; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Infrastructure.Commands; |
||||
|
using Squidex.Shared; |
||||
|
using Squidex.Web; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Manages and configures apps.
|
||||
|
/// </summary>
|
||||
|
[ApiExplorerSettings(GroupName = nameof(Apps))] |
||||
|
public sealed class AppWorkflowsController : ApiController |
||||
|
{ |
||||
|
public AppWorkflowsController(ICommandBus commandBus) |
||||
|
: base(commandBus) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Get app workflow.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <returns>
|
||||
|
/// 200 => App workflows returned.
|
||||
|
/// 404 => App not found.
|
||||
|
/// </returns>
|
||||
|
[HttpGet] |
||||
|
[Route("apps/{app}/workflow/")] |
||||
|
[ProducesResponseType(typeof(WorkflowResponseDto), 200)] |
||||
|
[ApiPermission(Permissions.AppWorkflowsRead)] |
||||
|
[ApiCosts(0)] |
||||
|
public IActionResult GetWorkflow(string app) |
||||
|
{ |
||||
|
var response = WorkflowResponseDto.FromApp(App, this); |
||||
|
|
||||
|
Response.Headers[HeaderNames.ETag] = App.Version.ToString(); |
||||
|
|
||||
|
return Ok(response); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Configure workflow of the app.
|
||||
|
/// </summary>
|
||||
|
/// <param name="app">The name of the app.</param>
|
||||
|
/// <param name="request">The new workflow.</param>
|
||||
|
/// <returns>
|
||||
|
/// 200 => Workflow configured.
|
||||
|
/// 400 => Workflow is not valid.
|
||||
|
/// 404 => App not found.
|
||||
|
/// </returns>
|
||||
|
[HttpPut] |
||||
|
[Route("apps/{app}/workflow/")] |
||||
|
[ProducesResponseType(typeof(WorkflowResponseDto), 200)] |
||||
|
[ApiPermission(Permissions.AppWorkflowsUpdate)] |
||||
|
[ApiCosts(1)] |
||||
|
public async Task<IActionResult> PutWorkflow(string app, [FromBody] UpsertWorkflowDto request) |
||||
|
{ |
||||
|
var command = request.ToCommand(); |
||||
|
|
||||
|
var response = await InvokeCommandAsync(command); |
||||
|
|
||||
|
return Ok(response); |
||||
|
} |
||||
|
|
||||
|
private async Task<WorkflowResponseDto> InvokeCommandAsync(ICommand command) |
||||
|
{ |
||||
|
var context = await CommandBus.PublishAsync(command); |
||||
|
|
||||
|
var result = context.Result<IAppEntity>(); |
||||
|
var response = WorkflowResponseDto.FromApp(result, this); |
||||
|
|
||||
|
return response; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,45 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using System.Linq; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Entities.Apps.Commands; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps.Models |
||||
|
{ |
||||
|
public sealed class UpsertWorkflowDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The workflow steps.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The initial step.
|
||||
|
/// </summary>
|
||||
|
public Status Initial { get; set; } |
||||
|
|
||||
|
public ConfigureWorkflow ToCommand() |
||||
|
{ |
||||
|
var workflow = new Workflow( |
||||
|
Steps?.ToDictionary( |
||||
|
x => x.Key, |
||||
|
x => new WorkflowStep( |
||||
|
x.Value?.Transitions.ToDictionary( |
||||
|
y => y.Key, |
||||
|
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)), |
||||
|
x.Value.Color, |
||||
|
x.Value.NoUpdate)), |
||||
|
Initial); |
||||
|
|
||||
|
return new ConfigureWorkflow { Workflow = workflow }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,61 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using System.Linq; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure.Reflection; |
||||
|
using Squidex.Shared; |
||||
|
using Squidex.Web; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps.Models |
||||
|
{ |
||||
|
public sealed class WorkflowDto : Resource |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The workflow steps.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public Dictionary<Status, WorkflowStepDto> Steps { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The initial step.
|
||||
|
/// </summary>
|
||||
|
public Status Initial { get; set; } |
||||
|
|
||||
|
public static WorkflowDto FromWorkflow(Workflow workflow, ApiController controller, string app) |
||||
|
{ |
||||
|
var result = new WorkflowDto |
||||
|
{ |
||||
|
Steps = workflow.Steps.ToDictionary( |
||||
|
x => x.Key, |
||||
|
x => SimpleMapper.Map(x.Value, new WorkflowStepDto |
||||
|
{ |
||||
|
Transitions = x.Value.Transitions.ToDictionary( |
||||
|
y => y.Key, |
||||
|
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role }) |
||||
|
})), |
||||
|
Initial = workflow.Initial |
||||
|
}; |
||||
|
|
||||
|
return result.CreateLinks(controller, app); |
||||
|
} |
||||
|
|
||||
|
private WorkflowDto CreateLinks(ApiController controller, string app) |
||||
|
{ |
||||
|
var values = new { app }; |
||||
|
|
||||
|
if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) |
||||
|
{ |
||||
|
AddPutLink("update", controller.Url<AppWorkflowsController>(x => nameof(x.PutWorkflow), values)); |
||||
|
} |
||||
|
|
||||
|
return this; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using Squidex.Domain.Apps.Entities.Apps; |
||||
|
using Squidex.Web; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps.Models |
||||
|
{ |
||||
|
public sealed class WorkflowResponseDto : Resource |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The workflow.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public WorkflowDto Workflow { get; set; } |
||||
|
|
||||
|
public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller) |
||||
|
{ |
||||
|
var result = new WorkflowResponseDto |
||||
|
{ |
||||
|
Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name) |
||||
|
}; |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,32 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Collections.Generic; |
||||
|
using System.ComponentModel.DataAnnotations; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps.Models |
||||
|
{ |
||||
|
public sealed class WorkflowStepDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The transitions.
|
||||
|
/// </summary>
|
||||
|
[Required] |
||||
|
public Dictionary<Status, WorkflowTransitionDto> Transitions { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The optional color.
|
||||
|
/// </summary>
|
||||
|
public string Color { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Indicates if updates should not be allowed.
|
||||
|
/// </summary>
|
||||
|
public bool NoUpdate { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.Apps.Models |
||||
|
{ |
||||
|
public sealed class WorkflowTransitionDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The optional expression.
|
||||
|
/// </summary>
|
||||
|
public string Expression { get; set; } |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// The optional restricted role.
|
||||
|
/// </summary>
|
||||
|
public string Role { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,76 @@ |
|||||
|
<div class="step"> |
||||
|
<div class="row no-gutters step-header"> |
||||
|
<div class="col-auto"> |
||||
|
<button class="btn btn-initial mr-1" (click)="makeInitial.emit()" |
||||
|
[class.enabled]="step.name !== workflow.initial && !step.isLocked" |
||||
|
[class.active]="step.name === workflow.initial" |
||||
|
[disabled]="step.name === workflow.initial || step.isLocked || disabled"> |
||||
|
<i class="icon-arrow-right text-decent" *ngIf="!step.isLocked"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
<div class="col-auto color pr-2"> |
||||
|
<sqx-color-picker mode="Circle" |
||||
|
[ngModelOptions]="onBlur" |
||||
|
[ngModel]="step.color" |
||||
|
(ngModelChange)="changeColor($event)" |
||||
|
[disabled]="step.isLocked || disabled"> |
||||
|
</sqx-color-picker> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
<sqx-editable-title |
||||
|
[name]="step.name" |
||||
|
(nameChanged)="changeName($event)" |
||||
|
[disabled]="step.isLocked || disabled"> |
||||
|
</sqx-editable-title> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
</div> |
||||
|
<div class="col-auto" *ngIf="step.isLocked"> |
||||
|
<small class="text-decent">(Cannot be removed)</small> |
||||
|
</div> |
||||
|
<div class="col-auto"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked" [disabled]="disabled"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="step-inner"> |
||||
|
<sqx-workflow-transition *ngFor="let transition of transitions; trackBy: trackByTransition" |
||||
|
[transition]="transition" |
||||
|
[disabled]="disabled" |
||||
|
[roles]="roles" |
||||
|
(remove)="transitionRemove.emit(transition)" |
||||
|
(update)="changeTransition(transition, $event)"> |
||||
|
</sqx-workflow-transition> |
||||
|
|
||||
|
<div class="row transition no-gutters" *ngIf="openSteps.length > 0 && !disabled"> |
||||
|
<div class="col-auto"> |
||||
|
<i class="icon-corner-down-right text-decent"></i> |
||||
|
</div> |
||||
|
<div class="col col-step"> |
||||
|
<sqx-dropdown [items]="openSteps" [(ngModel)]="openStep"> |
||||
|
<ng-template let-target="$implicit"> |
||||
|
<div class="color-circle" [style.background]="target.color"></div> {{target.name}} |
||||
|
</ng-template> |
||||
|
</sqx-dropdown> |
||||
|
</div> |
||||
|
<div class="col pl-2"> |
||||
|
<button class="btn btn-outline-secondary" (click)="transitionAdd.emit(openStep)"> |
||||
|
<i class="icon-plus"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-check"> |
||||
|
<input class="form-check-input" type="checkbox" id="preventUpdates_{{step.name}}" |
||||
|
[disabled]="disabled" |
||||
|
[ngModel]="step.noUpdate" |
||||
|
(ngModelChange)="changeNoUpdate($event)" /> |
||||
|
|
||||
|
<label class="form-check-label" for="preventUpdates_{{step.name}}"> |
||||
|
Prevent updates |
||||
|
</label> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,76 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
:host ::ng-deep { |
||||
|
.color { |
||||
|
line-height: 2.8rem; |
||||
|
} |
||||
|
|
||||
|
.color-circle { |
||||
|
@include circle(12px); |
||||
|
border: 1px solid $color-border-dark; |
||||
|
background: $color-border; |
||||
|
display: inline-block; |
||||
|
} |
||||
|
|
||||
|
.col-label { |
||||
|
padding: 0 .5rem; |
||||
|
} |
||||
|
|
||||
|
.col-step { |
||||
|
min-width: 10rem; |
||||
|
max-width: 10rem; |
||||
|
padding-left: .5rem; |
||||
|
} |
||||
|
|
||||
|
.transition { |
||||
|
& { |
||||
|
margin-top: .25rem; |
||||
|
margin-bottom: .5rem; |
||||
|
line-height: 2.2rem; |
||||
|
} |
||||
|
|
||||
|
&-to { |
||||
|
padding: .5rem .75rem; |
||||
|
padding-right: 0; |
||||
|
line-height: 1.2rem; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.step { |
||||
|
& { |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
&-inner { |
||||
|
padding-left: 2.4rem; |
||||
|
} |
||||
|
|
||||
|
&-header { |
||||
|
&:hover { |
||||
|
.enabled { |
||||
|
visibility: visible; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
.btn-initial { |
||||
|
& { |
||||
|
visibility: hidden; |
||||
|
line-height: 1.6rem; |
||||
|
padding-left: 0; |
||||
|
padding-right: 0; |
||||
|
width: 2rem; |
||||
|
text-align: center; |
||||
|
} |
||||
|
|
||||
|
&:disabled { |
||||
|
@include opacity(1); |
||||
|
} |
||||
|
|
||||
|
&.active { |
||||
|
visibility: visible; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
RoleDto, |
||||
|
WorkflowDto, |
||||
|
WorkflowStep, |
||||
|
WorkflowStepValues, |
||||
|
WorkflowTransition, |
||||
|
WorkflowTransitionValues, |
||||
|
WorkflowTransitionView |
||||
|
} from '@app/shared'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-workflow-step', |
||||
|
styleUrls: ['./workflow-step.component.scss'], |
||||
|
templateUrl: './workflow-step.component.html' |
||||
|
}) |
||||
|
export class WorkflowStepComponent implements OnChanges { |
||||
|
@Input() |
||||
|
public workflow: WorkflowDto; |
||||
|
|
||||
|
@Input() |
||||
|
public step: WorkflowStep; |
||||
|
|
||||
|
@Input() |
||||
|
public roles: RoleDto[]; |
||||
|
|
||||
|
@Input() |
||||
|
public disabled: boolean; |
||||
|
|
||||
|
@Output() |
||||
|
public makeInitial = new EventEmitter(); |
||||
|
|
||||
|
@Output() |
||||
|
public transitionAdd = new EventEmitter<WorkflowStep>(); |
||||
|
|
||||
|
@Output() |
||||
|
public transitionRemove = new EventEmitter<WorkflowTransition>(); |
||||
|
|
||||
|
@Output() |
||||
|
public transitionUpdate = new EventEmitter<{ transition: WorkflowTransition, values: WorkflowTransitionValues }>(); |
||||
|
|
||||
|
@Output() |
||||
|
public update = new EventEmitter<WorkflowStepValues>(); |
||||
|
|
||||
|
@Output() |
||||
|
public rename = new EventEmitter<string>(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
public onBlur = { updateOn: 'blur' }; |
||||
|
|
||||
|
public openSteps: WorkflowStep[]; |
||||
|
public openStep: WorkflowStep; |
||||
|
|
||||
|
public transitions: WorkflowTransitionView[]; |
||||
|
|
||||
|
public ngOnChanges(changes: SimpleChanges) { |
||||
|
if (changes['workflow'] || changes['step'] || false) { |
||||
|
this.openSteps = this.workflow.getOpenSteps(this.step); |
||||
|
this.openStep = this.openSteps[0]; |
||||
|
|
||||
|
this.transitions = this.workflow.getTransitions(this.step); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public changeTransition(transition: WorkflowTransition, values: WorkflowTransitionValues) { |
||||
|
this.transitionUpdate.emit({ transition, values }); |
||||
|
} |
||||
|
|
||||
|
public changeName(name: string) { |
||||
|
this.rename.emit(name); |
||||
|
} |
||||
|
|
||||
|
public changeColor(color: string) { |
||||
|
this.update.emit({ color }); |
||||
|
} |
||||
|
|
||||
|
public changeNoUpdate(noUpdate: boolean) { |
||||
|
this.update.emit({ noUpdate }); |
||||
|
} |
||||
|
|
||||
|
public trackByTransition(index: number, transition: WorkflowTransition) { |
||||
|
return transition.to; |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,40 @@ |
|||||
|
<div class="row transition no-gutters"> |
||||
|
<div class="col-auto"> |
||||
|
<i class="icon-corner-down-right text-decent"></i> |
||||
|
</div> |
||||
|
<div class="col col-step"> |
||||
|
<div class="transition-to"> |
||||
|
<div class="color-circle" [style.background]="transition.step.color"></div> {{transition.to}} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="col-auto col-label"> |
||||
|
<span class="text-decent">when</span> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
<input class="form-control" [class.dashed]="!transition.expression" spellcheck="false" |
||||
|
[disabled]="disabled" |
||||
|
[ngModelOptions]="onBlur" |
||||
|
[ngModel]="transition.expression" |
||||
|
(ngModelChange)="changeExpression($event)" |
||||
|
placeholder="Add Expression" /> |
||||
|
</div> |
||||
|
<div class="col-auto col-label"> |
||||
|
<span class="text-decent">for</span> |
||||
|
</div> |
||||
|
<div class="col"> |
||||
|
<select class="form-control" [class.dashed]="!transition.role || transition.role === ''" |
||||
|
[disabled]="disabled" |
||||
|
[ngModel]="transition.role" |
||||
|
(ngModelChange)="changeRole($event)"> |
||||
|
<option></option> |
||||
|
<option *ngFor="let role of roles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option> |
||||
|
</select> |
||||
|
|
||||
|
<span class="select-placeholder" *ngIf="!transition.role || transition.role === ''">Add Role</span> |
||||
|
</div> |
||||
|
<div class="col-auto pl-2"> |
||||
|
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" [disabled]="disabled"> |
||||
|
<i class="icon-bin2"></i> |
||||
|
</button> |
||||
|
</div> |
||||
|
</div> |
||||
@ -0,0 +1,24 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
|
|
||||
|
.dashed { |
||||
|
@include placeholder-color($color-theme-secondary); |
||||
|
border-style: dashed; |
||||
|
border-width: 1px; |
||||
|
} |
||||
|
|
||||
|
.select-placeholder { |
||||
|
@include absolute(0, 0, 0, 0); |
||||
|
color: $color-theme-secondary; |
||||
|
padding: .5rem .75rem; |
||||
|
pointer-events: none; |
||||
|
line-height: 1.2rem; |
||||
|
border: 1px solid transparent; |
||||
|
} |
||||
|
|
||||
|
.form-control { |
||||
|
&:disabled, |
||||
|
&.disabled { |
||||
|
background: $panel-light-background; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
RoleDto, |
||||
|
WorkflowTransitionValues, |
||||
|
WorkflowTransitionView |
||||
|
} from '@app/shared'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-workflow-transition', |
||||
|
styleUrls: ['./workflow-transition.component.scss'], |
||||
|
templateUrl: './workflow-transition.component.html' |
||||
|
}) |
||||
|
export class WorkflowTransitionComponent { |
||||
|
@Input() |
||||
|
public transition: WorkflowTransitionView; |
||||
|
|
||||
|
@Input() |
||||
|
public roles: RoleDto[]; |
||||
|
|
||||
|
@Input() |
||||
|
public disabled: boolean; |
||||
|
|
||||
|
@Output() |
||||
|
public update = new EventEmitter<WorkflowTransitionValues>(); |
||||
|
|
||||
|
@Output() |
||||
|
public remove = new EventEmitter(); |
||||
|
|
||||
|
public onBlur = { updateOn: 'blur' }; |
||||
|
|
||||
|
public changeExpression(expression: string) { |
||||
|
this.update.emit({ expression }); |
||||
|
} |
||||
|
|
||||
|
public changeRole(role: string) { |
||||
|
this.update.emit({ role: role || '' }); |
||||
|
} |
||||
|
|
||||
|
public trackByRole(index: number, role: RoleDto) { |
||||
|
return role.name; |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,57 @@ |
|||||
|
<sqx-title message="{app} | Workflows | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> |
||||
|
|
||||
|
<sqx-panel desiredWidth="50rem" isBlank="true" [showSidebar]="true" [isLazyLoaded]="false"> |
||||
|
<ng-container title> |
||||
|
Workflow |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container menu> |
||||
|
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh roles (CTRL + SHIFT + R)"> |
||||
|
<i class="icon-reset"></i> Refresh |
||||
|
</button> |
||||
|
|
||||
|
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> |
||||
|
|
||||
|
<ng-container *ngIf="workflow && workflow.canUpdate"> |
||||
|
<button type="button" class="btn btn-primary" (click)="save()" title="Save (CTRL + S)"> |
||||
|
Save |
||||
|
</button> |
||||
|
|
||||
|
<sqx-shortcut keys="ctrl+s" (trigger)="save()"></sqx-shortcut> |
||||
|
</ng-container> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container content> |
||||
|
<ng-container *ngIf="workflow"> |
||||
|
<ng-container *ngIf="rolesState.roles | async; let roles"> |
||||
|
<sqx-workflow-step *ngFor="let step of workflow.steps; trackBy: trackByStep" |
||||
|
[workflow]="workflow" |
||||
|
[disabled]="!workflow.canUpdate" |
||||
|
[roles]="roles" |
||||
|
[step]="step" |
||||
|
(makeInitial)="setInitial(step)" |
||||
|
(rename)="renameStep(step, $event)" |
||||
|
(remove)="removeStep(step)" |
||||
|
(transitionAdd)="addTransiton(step, $event)" |
||||
|
(transitionRemove)="removeTransition(step, $event)" |
||||
|
(transitionUpdate)="updateTransition($event)" |
||||
|
(update)="updateStep(step, $event)"> |
||||
|
</sqx-workflow-step> |
||||
|
</ng-container> |
||||
|
|
||||
|
<button class="btn btn-success" (click)="addStep()" *ngIf="workflow.canUpdate"> |
||||
|
Add Step |
||||
|
</button> |
||||
|
</ng-container> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container sidebar> |
||||
|
<div class="panel-nav"> |
||||
|
<a class="panel-link" routerLink="help" routerLinkActive="active" title="Help" titlePosition="left"> |
||||
|
<i class="icon-help"></i> |
||||
|
</a> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
</sqx-panel> |
||||
|
|
||||
|
<router-outlet></router-outlet> |
||||
@ -0,0 +1,2 @@ |
|||||
|
@import '_vars'; |
||||
|
@import '_mixins'; |
||||
@ -0,0 +1,102 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Component, OnInit } from '@angular/core'; |
||||
|
|
||||
|
import { |
||||
|
AppsState, |
||||
|
MathHelper, |
||||
|
RolesState, |
||||
|
WorkflowDto, |
||||
|
WorkflowsState, |
||||
|
WorkflowStep, |
||||
|
WorkflowStepValues, |
||||
|
WorkflowTransition, |
||||
|
WorkflowTransitionValues |
||||
|
} from '@app/shared'; |
||||
|
|
||||
|
@Component({ |
||||
|
selector: 'sqx-workflows-page', |
||||
|
styleUrls: ['./workflows-page.component.scss'], |
||||
|
templateUrl: './workflows-page.component.html' |
||||
|
}) |
||||
|
export class WorkflowsPageComponent implements OnInit { |
||||
|
public workflow: WorkflowDto; |
||||
|
|
||||
|
constructor( |
||||
|
public readonly appsState: AppsState, |
||||
|
public readonly rolesState: RolesState, |
||||
|
public readonly workflowsState: WorkflowsState |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.workflowsState.load() |
||||
|
.subscribe(workflow => { |
||||
|
this.workflow = workflow; |
||||
|
}); |
||||
|
|
||||
|
this.rolesState.load(); |
||||
|
} |
||||
|
|
||||
|
public reload() { |
||||
|
this.workflowsState.load(true) |
||||
|
.subscribe(workflow => { |
||||
|
this.workflow = workflow; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public save() { |
||||
|
this.workflowsState.save(this.workflow); |
||||
|
} |
||||
|
|
||||
|
public addStep() { |
||||
|
let index = this.workflow.steps.length; |
||||
|
|
||||
|
for (let i = index; i < index + 100; i++) { |
||||
|
const name = `Step${i}`; |
||||
|
|
||||
|
if (!this.workflow.getStep(name)) { |
||||
|
this.workflow = this.workflow.setStep(name, { color: MathHelper.randomColor() }); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public setInitial(step: WorkflowStep) { |
||||
|
this.workflow = this.workflow.setInitial(step.name); |
||||
|
} |
||||
|
|
||||
|
public addTransiton(from: WorkflowStep, to: WorkflowStep) { |
||||
|
this.workflow = this.workflow.setTransition(from.name, to.name, {}); |
||||
|
} |
||||
|
|
||||
|
public removeTransition(from: WorkflowStep, transition: WorkflowTransition) { |
||||
|
this.workflow = this.workflow.removeTransition(from.name, transition.to); |
||||
|
} |
||||
|
|
||||
|
public updateTransition(update: { transition: WorkflowTransition, values: WorkflowTransitionValues }) { |
||||
|
this.workflow = this.workflow.setTransition(update.transition.from, update.transition.to, update.values); |
||||
|
} |
||||
|
|
||||
|
public updateStep(step: WorkflowStep, values: WorkflowStepValues) { |
||||
|
this.workflow = this.workflow.setStep(step.name, values); |
||||
|
} |
||||
|
|
||||
|
public renameStep(step: WorkflowStep, newName: string) { |
||||
|
this.workflow = this.workflow.renameStep(step.name, newName); |
||||
|
} |
||||
|
|
||||
|
public removeStep(step: WorkflowStep) { |
||||
|
this.workflow = this.workflow.removeStep(step.name); |
||||
|
} |
||||
|
|
||||
|
public trackByStep(index: number, step: WorkflowStep) { |
||||
|
return step.name; |
||||
|
} |
||||
|
} |
||||
|
|
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue