Browse Source

Merge branch 'next' of github.com:Squidex/squidex into next

pull/383/head
Sebastian Stehle 7 years ago
parent
commit
9d84c69101
  1. 6
      src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs
  2. 8
      src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  3. 4
      src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs
  4. 2
      src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  5. 4
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  6. 37
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs
  7. 2
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  8. 36
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs
  9. 88
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  10. 31
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs
  11. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  12. 43
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  13. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  14. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs
  15. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs
  16. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
  17. 1
      src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
  18. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  19. 2
      src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  20. 32
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  21. 15
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  22. 10
      src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs
  23. 76
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs
  24. 3
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  25. 9
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  26. 8
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  27. 2
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  28. 9
      src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs
  29. 42
      src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs
  30. 72
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  31. 139
      src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs
  32. 9
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  33. 129
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  34. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  35. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  36. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs
  37. 2
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  38. 5
      src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs
  39. 8
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  40. 7
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  41. 4
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  42. 45
      src/Squidex.Domain.Apps.Entities/Context.cs
  43. 2
      src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  44. 7
      src/Squidex.Domain.Apps.Entities/IContextProvider.cs
  45. 116
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  46. 18
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs
  47. 12
      src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs
  48. 6
      src/Squidex.Shared/Permissions.cs
  49. 12
      src/Squidex.Web/ApiController.cs
  50. 7
      src/Squidex.Web/ApiPermissionAttribute.cs
  51. 18
      src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  52. 7
      src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  53. 37
      src/Squidex.Web/ContextExtensions.cs
  54. 30
      src/Squidex.Web/ContextProvider.cs
  55. 22
      src/Squidex.Web/PermissionExtensions.cs
  56. 8
      src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  57. 18
      src/Squidex.Web/Pipeline/AppResolver.cs
  58. 10
      src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs
  59. 4
      src/Squidex.Web/Resource.cs
  60. 2
      src/Squidex.Web/Squidex.Web.csproj
  61. 6
      src/Squidex.Web/UrlHelperExtensions.cs
  62. 86
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  63. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  64. 45
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs
  65. 61
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  66. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs
  67. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs
  68. 22
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs
  69. 9
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  70. 57
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  71. 7
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  72. 2
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  73. 3
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  74. 2
      src/Squidex/Config/Domain/EntitiesServices.cs
  75. 3
      src/Squidex/Config/Domain/SerializationServices.cs
  76. 4
      src/Squidex/Config/Web/WebServices.cs
  77. 4
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  78. 3
      src/Squidex/app/features/settings/declarations.ts
  79. 23
      src/Squidex/app/features/settings/module.ts
  80. 76
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  81. 76
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss
  82. 95
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts
  83. 40
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html
  84. 24
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.scss
  85. 51
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.ts
  86. 57
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  87. 2
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss
  88. 102
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts
  89. 6
      src/Squidex/app/features/settings/settings-area.component.html
  90. 2
      src/Squidex/app/framework/angular/forms/code-editor.component.html
  91. 8
      src/Squidex/app/framework/angular/forms/code-editor.component.scss
  92. 6
      src/Squidex/app/framework/angular/forms/code-editor.component.ts
  93. 3
      src/Squidex/app/framework/angular/forms/dropdown.component.ts
  94. 2
      src/Squidex/app/framework/angular/forms/editable-title.component.html
  95. 8
      src/Squidex/app/framework/angular/forms/focus-on-init.directive.ts
  96. 6
      src/Squidex/app/framework/angular/forms/iframe-editor.component.ts
  97. 8
      src/Squidex/app/framework/angular/forms/json-editor.component.scss
  98. 8
      src/Squidex/app/framework/angular/forms/json-editor.component.ts
  99. 10
      src/Squidex/app/framework/angular/forms/tag-editor.component.ts
  100. 30
      src/Squidex/app/framework/angular/modals/modal-view.directive.ts

6
src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Core.Apps.Json
{

8
src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs

@ -5,11 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using P = Squidex.Shared.Permissions;
namespace Squidex.Domain.Apps.Core.Apps
@ -71,7 +71,8 @@ namespace Squidex.Domain.Apps.Core.Apps
return new Role(Editor,
P.ForApp(P.AppAssets, app),
P.ForApp(P.AppCommon, app),
P.ForApp(P.AppContents, app));
P.ForApp(P.AppContents, app),
P.ForApp(P.AppWorkflowsRead, app));
}
public static Role CreateReader(string app)
@ -90,6 +91,7 @@ namespace Squidex.Domain.Apps.Core.Apps
P.ForApp(P.AppCommon, app),
P.ForApp(P.AppContents, app),
P.ForApp(P.AppPatterns, app),
P.ForApp(P.AppWorkflows, app),
P.ForApp(P.AppRules, app),
P.ForApp(P.AppSchemas, app));
}

4
src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
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.Apps
{

2
src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Comments
{

4
src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
namespace Squidex.Domain.Apps.Core.Contents.Json
{

37
src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs

@ -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());
}
}
}

2
src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs

@ -6,9 +6,11 @@
// ==========================================================================
using System;
using System.ComponentModel;
namespace Squidex.Domain.Apps.Core.Contents
{
[TypeConverter(typeof(StatusConverter))]
public struct Status : IEquatable<Status>
{
public static readonly Status Archived = new Status("Archived");

36
src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs

@ -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();
}
}
}

88
src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -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]);
}
}
}

31
src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs

@ -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;
}
}
}

23
src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs

@ -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;
}
}
}

43
src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs

@ -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;
}
}
}

2
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.ObjectModel;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.Triggers
{

2
src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas
{

2
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
using NamedIdStatic = Squidex.Infrastructure.NamedId;
namespace Squidex.Domain.Apps.Core.Schemas

2
src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System;
using P = Squidex.Domain.Apps.Core.Partitioning;
namespace Squidex.Domain.Apps.Core.Schemas.Json

1
src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Schemas
{
public sealed class JsonFieldProperties : FieldProperties

2
src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas
{

2
src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -14,6 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Freezable.Fody" Version="1.9.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="1.5.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

32
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -65,6 +65,17 @@ namespace Squidex.Domain.Apps.Entities
});
}
public Task<IAppEntity> GetAppAsync(Guid appId)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () =>
{
using (Profiler.TraceMethod<AppProvider>())
{
return await GetAppByIdAsync(appId);
}
});
}
public Task<IAppEntity> GetAppAsync(string appName)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities
return null;
}
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
return await GetAppByIdAsync(appId);
}
});
}
@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities
});
}
private async Task<IAppEntity> GetAppByIdAsync(Guid appId)
{
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
}
private async Task<List<Guid>> GetAppIdsByUserAsync(string userId)
{
using (Profiler.TraceMethod<AppProvider>())

15
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -119,6 +119,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
return Snapshot;
});
case ConfigureWorkflow configureWorkflow:
return UpdateReturn(configureWorkflow, c =>
{
GuardAppWorkflows.CanConfigure(c);
ConfigureWorkflow(c);
return Snapshot;
});
case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c =>
{
@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
}
public void ConfigureWorkflow(ConfigureWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured()));
}
public void AddLanguage(AddLanguage command)
{
RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded()));

10
src/Squidex.Web/IAppFeature.cs → src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs

@ -1,16 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// 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; }
}
}

76
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs

@ -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);
}
}
}
}
});
}
}
}

3
src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Apps
{
@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
LanguagesConfig LanguagesConfig { get; }
Workflows Workflows { get; }
bool IsArchived { get; }
}
}

9
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -7,6 +7,7 @@
using System.Runtime.Serialization;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure.Dispatching;
@ -42,6 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
[DataMember]
public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English;
[DataMember]
public Workflows Workflows { get; set; } = Workflows.Empty;
[DataMember]
public bool IsArchived { get; set; }
@ -92,6 +96,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
Clients = Clients.Revoke(@event.Id);
}
protected void On(AppWorkflowConfigured @event)
{
Workflows = Workflows.Set(@event.Workflow);
}
protected void On(AppPatternAdded @event)
{
Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message);

8
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs

@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return await assetEnricher.EnrichAsync(assets);
}
public async Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext context, Q query)
public async Task<IResultList<IEnrichedAssetEntity>> QueryAsync(Context context, Q query)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query));
@ -91,14 +91,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
return ResultList.Create(assets.Total, enriched);
}
private async Task<IResultList<IAssetEntity>> QueryByQueryAsync(QueryContext context, Q query)
private async Task<IResultList<IAssetEntity>> QueryByQueryAsync(Context context, Q query)
{
var parsedQuery = ParseQuery(context, query.ODataQuery);
return await assetRepository.QueryAsync(context.App.Id, parsedQuery);
}
private async Task<IResultList<IAssetEntity>> QueryByIdsAsync(QueryContext context, Q query)
private async Task<IResultList<IAssetEntity>> QueryByIdsAsync(Context context, Q query)
{
var assets = await assetRepository.QueryAsync(context.App.Id, new HashSet<Guid>(query.Ids));
@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return assets.SortSet(x => x.Id, ids);
}
private Query ParseQuery(QueryContext context, string query)
private Query ParseQuery(Context context, string query)
{
try
{

2
src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task<IReadOnlyList<IEnrichedAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext contex, Q query);
Task<IResultList<IEnrichedAssetEntity>> QueryAsync(Context contex, Q query);
Task<IEnrichedAssetEntity> FindAssetAsync(Guid id);
}

9
src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs

@ -30,12 +30,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
await base.HandleAsync(context, next);
if (context.PlainResult is IContentEntity content && !(context.PlainResult is IEnrichedContentEntity))
if (context.Command is SquidexCommand command && context.PlainResult is IContentEntity content && NotEnriched(context))
{
var enriched = await contentEnricher.EnrichAsync(content);
var enriched = await contentEnricher.EnrichAsync(content, command.User);
context.Complete(enriched);
}
}
private static bool NotEnriched(CommandContext context)
{
return !(context.PlainResult is IEnrichedContentEntity);
}
}
}

42
src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs

@ -8,6 +8,7 @@
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.Infrastructure;
@ -20,24 +21,30 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
private const string DefaultColor = StatusColors.Draft;
private readonly IContentWorkflow contentWorkflow;
private readonly IContextProvider contextProvider;
public ContentEnricher(IContentWorkflow contentWorkflow)
public ContentEnricher(IContentWorkflow contentWorkflow, IContextProvider contextProvider)
{
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(contextProvider, nameof(contextProvider));
this.contentWorkflow = contentWorkflow;
this.contextProvider = contextProvider;
}
public async Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content)
public async Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content, ClaimsPrincipal user)
{
Guard.NotNull(content, nameof(content));
var enriched = await EnrichAsync(Enumerable.Repeat(content, 1));
var enriched = await EnrichAsync(Enumerable.Repeat(content, 1), user);
return enriched[0];
}
public async Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents)
public async Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents, ClaimsPrincipal user)
{
Guard.NotNull(contents, nameof(contents));
Guard.NotNull(user, nameof(user));
using (Profiler.TraceMethod<ContentEnricher>())
{
@ -50,8 +57,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
var result = SimpleMapper.Map(content, new ContentEntity());
await ResolveColorAsync(content, result, cache);
await ResolveNextsAsync(content, result);
if (ShouldEnrichWithStatuses())
{
await ResolveNextsAsync(content, result, user);
await ResolveCanUpdateAsync(content, result);
}
results.Add(result);
}
@ -60,33 +71,38 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private bool ShouldEnrichWithStatuses()
{
return contextProvider.Context.IsFrontendClient || contextProvider.Context.IsResolveFlow();
}
private async Task ResolveCanUpdateAsync(IContentEntity content, ContentEntity result)
{
result.CanUpdate = await contentWorkflow.CanUpdateAsync(content);
}
private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result)
private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result, ClaimsPrincipal user)
{
result.Nexts = await contentWorkflow.GetNextsAsync(content);
result.Nexts = await contentWorkflow.GetNextsAsync(content, user);
}
private async Task ResolveColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache)
{
result.StatusColor = await GetColorAsync(content.SchemaId, content.Status, cache);
result.StatusColor = await GetColorAsync(content, cache);
}
private async Task<string> GetColorAsync(NamedId<Guid> schemaId, Status status, Dictionary<(Guid, Status), StatusInfo> cache)
private async Task<string> GetColorAsync(IContentEntity content, Dictionary<(Guid, Status), StatusInfo> cache)
{
if (!cache.TryGetValue((schemaId.Id, status), out var info))
if (!cache.TryGetValue((content.SchemaId.Id, content.Status), out var info))
{
info = await contentWorkflow.GetInfoAsync(status);
info = await contentWorkflow.GetInfoAsync(content);
if (info == null)
{
info = new StatusInfo(status, DefaultColor);
info = new StatusInfo(content.Status, DefaultColor);
}
cache[(schemaId.Id, status)] = info;
cache[(content.SchemaId.Id, content.Status)] = info;
}
return info.Color;

72
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -78,13 +78,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine;
}
public async Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
public async Task<IEnrichedContentEntity> FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = -1)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
CheckPermission(context, schema);
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -108,13 +108,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query)
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, string schemaIdOrName, Q query)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
CheckPermission(context, schema);
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -133,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, IReadOnlyList<Guid> ids)
{
Guard.NotNull(context, nameof(context));
@ -148,13 +148,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
var contents = await QueryCoreAsync(context, ids);
var permissions = context.User.Permissions();
foreach (var group in contents.GroupBy(x => x.Schema.Id))
{
var schema = group.First().Schema;
if (HasPermission(permissions, schema))
if (HasPermission(context, schema))
{
var enriched = await TransformCoreAsync(context, schema, group.Select(x => x.Content));
@ -166,21 +164,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(QueryContext context, ISchemaEntity schema, IResultList<IContentEntity> contents)
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(Context context, ISchemaEntity schema, IResultList<IContentEntity> contents)
{
var transformed = await TransformCoreAsync(context, schema, contents);
return ResultList.Create(contents.Total, transformed);
}
private async Task<IEnrichedContentEntity> TransformAsync(QueryContext context, ISchemaEntity schema, IContentEntity content)
private async Task<IEnrichedContentEntity> TransformAsync(Context context, ISchemaEntity schema, IContentEntity content)
{
var transformed = await TransformCoreAsync(context, schema, Enumerable.Repeat(content, 1));
return transformed[0];
}
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(QueryContext context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(Context context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
{
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -191,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var scriptText = schema.SchemaDef.Scripts.Query;
var scripting = !string.IsNullOrWhiteSpace(scriptText);
var enriched = await contentEnricher.EnrichAsync(contents);
var enriched = await contentEnricher.EnrichAsync(contents, context.User);
foreach (var content in enriched)
{
@ -209,7 +207,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters);
}
if (result.DataDraft != null && (context.ApiStatus == StatusForApi.All || context.IsFrontendClient))
if (result.DataDraft != null && (context.IsUnpublished() || context.IsFrontendClient))
{
result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters);
}
@ -225,7 +223,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private IEnumerable<FieldConverter> GenerateConverters(QueryContext context)
private IEnumerable<FieldConverter> GenerateConverters(Context context)
{
if (!context.IsFrontendClient)
{
@ -243,19 +241,23 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
yield return FieldConverters.ResolveFallbackLanguages(context.App.LanguagesConfig);
if (context.Languages?.Any() == true)
var languages = context.Languages();
if (languages.Any())
{
yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, context.Languages);
yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, languages);
}
if (context.AssetUrlsToResolve?.Any() == true)
var assetUrls = context.AssetUrls();
if (assetUrls.Any() == true)
{
yield return FieldConverters.ResolveAssetUrls(context.AssetUrlsToResolve, assetUrlGenerator);
yield return FieldConverters.ResolveAssetUrls(assetUrls.ToList(), assetUrlGenerator);
}
}
}
private Query ParseQuery(QueryContext context, string query, ISchemaEntity schema)
private Query ParseQuery(Context context, string query, ISchemaEntity schema)
{
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -292,7 +294,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName)
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName)
{
ISchemaEntity schema = null;
@ -314,29 +316,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
return schema;
}
private static void CheckPermission(ClaimsPrincipal user, params ISchemaEntity[] schemas)
private static void CheckPermission(Context context, params ISchemaEntity[] schemas)
{
var permissions = user.Permissions();
foreach (var schema in schemas)
{
if (!HasPermission(permissions, schema))
if (!HasPermission(context, schema))
{
throw new DomainForbiddenException("You do not have permission for this schema.");
}
}
}
private static bool HasPermission(PermissionSet permissions, ISchemaEntity schema)
private static bool HasPermission(Context context, ISchemaEntity schema)
{
var permission = Permissions.ForApp(Permissions.AppContentsRead, schema.AppId.Name, schema.SchemaDef.Name);
return permissions.Allows(permission);
return context.Permissions.Allows(permission);
}
private static Status[] GetStatus(QueryContext context)
private static Status[] GetStatus(Context context)
{
if (context.IsFrontendClient || context.ApiStatus == StatusForApi.All)
if (context.IsFrontendClient || context.IsUnpublished())
{
return null;
}
@ -346,36 +346,36 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(QueryContext context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(Context context, Q query, ISchemaEntity schema)
{
var parsedQuery = ParseQuery(context, query.ODataQuery, schema);
return await QueryCoreAsync(context, schema, parsedQuery);
}
private async Task<IResultList<IContentEntity>> QueryByIdsAsync(QueryContext context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> QueryByIdsAsync(Context context, Q query, ISchemaEntity schema)
{
var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet());
return contents.SortSet(x => x.Id, query.Ids);
}
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(QueryContext context, IReadOnlyList<Guid> ids)
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(Context context, IReadOnlyList<Guid> ids)
{
return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet<Guid>(ids), WithDraft(context));
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(QueryContext context, ISchemaEntity schema, Query query)
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, Query query)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), context.IsFrontendClient, query, WithDraft(context));
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(QueryContext context, ISchemaEntity schema, HashSet<Guid> ids)
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet<Guid> ids)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context));
}
private Task<IContentEntity> FindCoreAsync(QueryContext context, Guid id, ISchemaEntity schema)
private Task<IContentEntity> FindCoreAsync(Context context, Guid id, ISchemaEntity schema)
{
return contentRepository.FindContentAsync(context.App, schema, GetStatus(context), id, WithDraft(context));
}
@ -385,9 +385,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentVersionLoader.LoadAsync(id, version);
}
private static bool WithDraft(QueryContext context)
private static bool WithDraft(Context context)
{
return context.ApiStatus == StatusForApi.All || context.IsFrontendClient;
return context.IsUnpublished() || context.IsFrontendClient;
}
}
}

139
src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs

@ -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;
}
}
}

9
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -8,6 +8,7 @@
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.Entities.Schemas;
@ -53,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result);
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);
@ -67,14 +68,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result);
}
public Task<StatusInfo> GetInfoAsync(Status status)
public Task<StatusInfo> GetInfoAsync(IContentEntity content)
{
var result = Flow[status].Info;
var result = Flow[content.Status].Info;
return Task.FromResult(result);
}
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content)
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();

129
src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -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;
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
this.resolver = resolver;
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries)
public async Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(queries, nameof(queries));
@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return (result.Any(x => x.HasError), result.ToArray(x => x.Response));
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query)
public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query));

2
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public ISemanticLog Log { get; }
public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver)
public GraphQLExecutionContext(Context context, IDependencyResolver resolver)
: base(context,
resolver.Resolve<IAssetQueryService>(),
resolver.Resolve<IContentQueryService>())

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs

@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public interface IGraphQLService
{
Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries);
Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries);
Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query);
Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query);
}
}

2
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
return Validate.It(() => "Cannot change status.", async e =>
{
if (!await contentWorkflow.CanMoveToAsync(content, command.Status))
if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User))
{
if (content.Status == command.Status && content.Status == Status.Published)
{

5
src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs

@ -6,14 +6,15 @@
// ==========================================================================
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentEnricher
{
Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content);
Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content, ClaimsPrincipal user);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents, ClaimsPrincipal user);
}
}

8
src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -17,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
int DefaultPageSizeGraphQl { get; }
Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, IReadOnlyList<Guid> ids);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, string schemaIdOrName, Q query);
Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task<IEnrichedContentEntity> FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName);
}
}

7
src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
@ -15,13 +16,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content);
Task<StatusInfo> GetInfoAsync(Status status);
Task<StatusInfo> GetInfoAsync(IContentEntity content);
Task<StatusInfo[]> GetNextsAsync(IContentEntity content);
Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user);
Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema);
}

4
src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs

@ -21,9 +21,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly ConcurrentDictionary<Guid, IEnrichedAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IEnrichedAssetEntity>();
private readonly IContentQueryService contentQuery;
private readonly IAssetQueryService assetQuery;
private readonly QueryContext context;
private readonly Context context;
public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
public QueryExecutionContext(Context context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
{
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery));

45
src/Squidex.Domain.Apps.Entities/Context.cs

@ -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"); }
}
}
}

2
src/Squidex.Domain.Apps.Entities/IAppProvider.cs

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities
{
Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id);
Task<IAppEntity> GetAppAsync(Guid appId);
Task<IAppEntity> GetAppAsync(string appName);
Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false);

7
src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs → src/Squidex.Domain.Apps.Entities/IContextProvider.cs

@ -5,11 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents
namespace Squidex.Domain.Apps.Entities
{
public enum StatusForApi
public interface IContextProvider
{
PublishedOnly,
All,
Context Context { get; }
}
}

116
src/Squidex.Domain.Apps.Entities/QueryContext.cs

@ -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"); }
}
}
}

18
src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs

@ -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; }
}
}

12
src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs

@ -36,6 +36,18 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
}
}
protected override JsonDictionaryContract CreateDictionaryContract(Type objectType)
{
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))
{
var implementationType = typeof(Dictionary<,>).MakeGenericType(objectType.GetGenericArguments());
return base.CreateDictionaryContract(implementationType);
}
return base.CreateDictionaryContract(objectType);
}
protected override JsonConverter ResolveContractConverter(Type objectType)
{
var result = base.ResolveContractConverter(objectType);

6
src/Squidex.Shared/Permissions.cs

@ -81,6 +81,12 @@ namespace Squidex.Shared
public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update";
public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete";
public const string AppWorkflows = "squidex.apps.{app}.workflows";
public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read";
public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create";
public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update";
public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete";
public const string AppBackups = "squidex.apps.{app}.backups";
public const string AppBackupsRead = "squidex.apps.{app}.backups.read";
public const string AppBackupsCreate = "squidex.apps.{app}.backups.create";

12
src/Squidex.Web/ApiController.cs

@ -8,6 +8,7 @@
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
@ -26,17 +27,22 @@ namespace Squidex.Web
{
get
{
var appFeature = HttpContext.Features.Get<IAppFeature>();
var app = HttpContext.Context().App;
if (appFeature == null)
if (app == null)
{
throw new InvalidOperationException("Not in a app context.");
}
return appFeature.App;
return app;
}
}
protected Context Context
{
get { return HttpContext.Context(); }
}
protected Guid AppId
{
get { return App.Id; }

7
src/Squidex.Web/ApiPermissionAttribute.cs

@ -36,10 +36,12 @@ namespace Squidex.Web
{
if (permissionIds.Length > 0)
{
var set = context.HttpContext.User.Permissions();
var permissions = context.HttpContext.Context().Permissions;
var hasPermission = false;
if (permissions != null)
{
foreach (var permissionId in permissionIds)
{
var id = permissionId;
@ -49,12 +51,13 @@ namespace Squidex.Web
id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString());
}
if (set.Allows(new Permission(id)))
if (permissions.Allows(new Permission(id)))
{
hasPermission = true;
break;
}
}
}
if (!hasPermission)
{

18
src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs

@ -7,7 +7,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
@ -17,20 +16,15 @@ namespace Squidex.Web.CommandMiddlewares
{
public sealed class EnrichWithAppIdCommandMiddleware : ICommandMiddleware
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IContextProvider contextProvider;
public EnrichWithAppIdCommandMiddleware(IHttpContextAccessor httpContextAccessor)
public EnrichWithAppIdCommandMiddleware(IContextProvider contextProvider)
{
this.httpContextAccessor = httpContextAccessor;
this.contextProvider = contextProvider;
}
public Task HandleAsync(CommandContext context, Func<Task> next)
{
if (httpContextAccessor.HttpContext == null)
{
return next();
}
if (context.Command is IAppCommand appCommand && appCommand.AppId == null)
{
var appId = GetAppId();
@ -50,14 +44,14 @@ namespace Squidex.Web.CommandMiddlewares
private NamedId<Guid> GetAppId()
{
var appFeature = httpContextAccessor.HttpContext.Features.Get<IAppFeature>();
var context = contextProvider.Context;
if (appFeature?.App == null)
if (context.App == null)
{
throw new InvalidOperationException("Cannot resolve app.");
}
return appFeature.App.NamedId();
return context.App.NamedId();
}
}
}

7
src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -65,12 +65,7 @@ namespace Squidex.Web.CommandMiddlewares
if (appId == null)
{
var appFeature = actionContextAccessor.ActionContext.HttpContext.Features.Get<IAppFeature>();
if (appFeature?.App != null)
{
appId = appFeature.App.NamedId();
}
appId = actionContextAccessor.ActionContext.HttpContext.Context().App?.NamedId();
}
if (appId != null)

37
src/Squidex.Web/ContextExtensions.cs

@ -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;
}
}
}

30
src/Squidex.Web/ContextProvider.cs

@ -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;
}
}
}

22
src/Squidex.Web/PermissionExtensions.cs

@ -7,34 +7,14 @@
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Web
{
public static class PermissionExtensions
{
private sealed class PermissionFeature
{
public PermissionSet Permissions { get; }
public PermissionFeature(PermissionSet permissions)
{
Permissions = permissions;
}
}
public static PermissionSet Permissions(this HttpContext httpContext)
{
var feature = httpContext.Features.Get<PermissionFeature>();
if (feature == null)
{
feature = new PermissionFeature(httpContext.User.Permissions());
httpContext.Features.Set(feature);
}
return feature.Permissions;
return httpContext.Context().Permissions;
}
public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null)

8
src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -47,15 +47,15 @@ namespace Squidex.Web.Pipeline
{
context.HttpContext.Features.Set<IApiCostsFeature>(FilterDefinition);
var appFeature = context.HttpContext.Features.Get<IAppFeature>();
var app = context.HttpContext.Context().App;
if (appFeature?.App != null && FilterDefinition.Weight > 0)
if (app != null && FilterDefinition.Weight > 0)
{
var appId = appFeature.App.Id.ToString();
var appId = app.Id.ToString();
using (Profiler.Trace("CheckUsage"))
{
var plan = appPlansProvider.GetPlanForApp(appFeature.App);
var plan = appPlansProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCallsAsync(appId, DateTime.Today);

18
src/Squidex.Web/Pipeline/AppResolver.cs

@ -23,16 +23,6 @@ namespace Squidex.Web.Pipeline
{
private readonly IAppProvider appProvider;
public class AppFeature : IAppFeature
{
public IAppEntity App { get; }
public AppFeature(IAppEntity app)
{
App = app;
}
}
public AppResolver(IAppProvider appProvider)
{
this.appProvider = appProvider;
@ -76,15 +66,15 @@ namespace Squidex.Web.Pipeline
}
}
var set = user.Permissions();
var permissionSet = user.Permissions();
if (!set.Includes(Permissions.ForApp(Permissions.App, appName))&& !AllowAnonymous(context))
context.HttpContext.Context().App = app;
if (!permissionSet.Includes(Permissions.ForApp(Permissions.App, appName)) && !AllowAnonymous(context))
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set<IAppFeature>(new AppFeature(app));
}
await next();

10
src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs

@ -47,9 +47,9 @@ namespace Squidex.Web.Pipeline
}
}
private static void LogFilters(HttpContext context, IObjectWriter c)
private static void LogFilters(HttpContext httpContext, IObjectWriter c)
{
var app = context.Features.Get<IAppFeature>()?.App;
var app = httpContext.Context().App;
if (app != null)
{
@ -57,21 +57,21 @@ namespace Squidex.Web.Pipeline
c.WriteProperty("appName", app.Name);
}
var userId = context.User.OpenIdSubject();
var userId = httpContext.User.OpenIdSubject();
if (!string.IsNullOrWhiteSpace(userId))
{
c.WriteProperty(nameof(userId), userId);
}
var clientId = context.User.OpenIdClientId();
var clientId = httpContext.User.OpenIdClientId();
if (!string.IsNullOrWhiteSpace(clientId))
{
c.WriteProperty(nameof(clientId), clientId);
}
var costs = context.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
var costs = httpContext.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
c.WriteProperty(nameof(costs), costs);
}

4
src/Squidex.Web/Resource.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
using Squidex.Infrastructure;
namespace Squidex.Web
{

2
src/Squidex.Web/Squidex.Web.csproj

@ -9,6 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />

6
src/Squidex.Web/UrlHelperExtensions.cs

@ -5,8 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using System;
using Microsoft.AspNetCore.Mvc;
#pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Web
{
@ -22,7 +24,7 @@ namespace Squidex.Web
var name = typeof(T).Name;
if (name.EndsWith(suffix))
if (name.EndsWith(suffix, StringComparison.Ordinal))
{
name = name.Substring(0, name.Length - suffix.Length);
}

86
src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -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;
}
}
}

5
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -177,6 +177,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddGetLink("schemas", controller.Url<SchemasController>(x => nameof(x.GetSchemas), values));
}
if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions))
{
AddGetLink("workflows", controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflow), values));
}
if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions))
{
AddPostLink("schemas/create", controller.Url<SchemasController>(x => nameof(x.PostSchema), values));

45
src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs

@ -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 };
}
}
}

61
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs

@ -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;
}
}
}

32
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs

@ -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;
}
}
}

32
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs

@ -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; }
}
}

22
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs

@ -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; }
}
}

9
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -101,9 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)]
public async Task<IActionResult> GetAssets(string app, [FromQuery] string ids = null)
{
var context = Context();
var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var response = AssetsDto.FromAssets(assets, this, app);
@ -304,10 +302,5 @@ namespace Squidex.Areas.Api.Controllers.Assets
return assetFile;
}
private QueryContext Context()
{
return QueryContext.Create(App, User);
}
}
}

57
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -62,7 +62,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQL(string app, [FromBody] GraphQLQuery query)
{
var result = await graphQl.QueryAsync(Context(), query);
var result = await graphQl.QueryAsync(Context, query);
if (result.HasError)
{
@ -93,7 +93,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch)
{
var result = await graphQl.QueryAsync(Context(), batch);
var result = await graphQl.QueryAsync(Context, batch);
if (result.HasError)
{
@ -124,17 +124,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetAllContents(string app, [FromQuery] string ids)
{
var context = Context();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids);
var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids);
var response = await ContentsDto.FromContentsAsync(contents, context, this, null, contentWorkflow);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = response.ToEtag();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -159,19 +158,18 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContents(string app, string name, [FromQuery] string ids = null)
{
var context = Context();
var contents = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var schema = await contentQuery.GetSchemaOrThrowAsync(context, name);
var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name);
var response = await ContentsDto.FromContentsAsync(contents, context, this, schema, contentWorkflow);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow);
if (ShouldProvideSurrogateKeys(response))
{
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = response.ToEtag();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -196,17 +194,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContent(string app, string name, Guid id)
{
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id);
var content = await contentQuery.FindContentAsync(Context, name, id);
var response = ContentDto.FromContent(context, content, this);
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.Id.ToString();
}
Response.Headers[HeaderNames.ETag] = content.Version.ToString();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -232,17 +229,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version)
{
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id, version);
var content = await contentQuery.FindContentAsync(Context, name, id, version);
var response = ContentDto.FromContent(context, content, this);
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.Id.ToString();
}
Response.Headers[HeaderNames.ETag] = content.Version.ToString();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response.Data);
}
@ -269,7 +265,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -306,7 +302,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -338,7 +334,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -369,7 +365,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -404,7 +400,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new DiscardChanges { ContentId = id };
@ -432,7 +428,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DeleteContent(string app, string name, Guid id)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new DeleteContent { ContentId = id };
@ -446,20 +442,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IEnrichedContentEntity>();
var response = ContentDto.FromContent(null, result, this);
var response = ContentDto.FromContent(Context, result, this);
return response;
}
private QueryContext Context()
{
return QueryContext.Create(App, User)
.WithAssetUrlsToResolve(Request.Headers["X-Resolve-Urls"])
.WithFlatten(Request.Headers.ContainsKey("X-Flatten"))
.WithLanguages(Request.Headers["X-Languages"])
.WithUnpublished(Request.Headers.ContainsKey("X-Unpublished"));
}
private bool ShouldProvideSurrogateKeys(ContentsDto response)
{
return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys;

7
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -84,11 +84,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary>
public long Version { get; set; }
public static ContentDto FromContent(QueryContext context, IEnrichedContentEntity content, ApiController controller)
public static ContentDto FromContent(Context context, IEnrichedContentEntity content, ApiController controller)
{
var response = SimpleMapper.Map(content, new ContentDto());
if (context?.Flatten == true)
if (context.IsFlatten())
{
response.Data = content.Data?.ToFlatten();
response.DataDraft = content.DataDraft?.ToFlatten();
@ -153,6 +153,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
if (content.Nexts != null)
{
foreach (var next in content.Nexts)
{
if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status)))
@ -160,6 +162,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
}
}
}
return this;
}

2
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
}
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents,
QueryContext context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
{
var result = new ContentsDto
{

3
src/Squidex/Areas/Api/Controllers/UI/UIController.cs

@ -16,7 +16,6 @@ using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.UI
@ -53,7 +52,7 @@ namespace Squidex.Areas.Api.Controllers.UI
MapKey = uiOptions.Map?.GoogleMaps?.Key
};
var canCreateApps = !uiOptions.OnlyAdminsCanCreateApps || User.Permissions().Includes(CreateAppPermission);
var canCreateApps = !uiOptions.OnlyAdminsCanCreateApps || Context.Permissions.Includes(CreateAppPermission);
result.CanCreateApps = canCreateApps;

2
src/Squidex/Config/Domain/EntitiesServices.cs

@ -120,7 +120,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SchemaHistoryEventsCreator>()
.As<IHistoryEventsCreator>();
services.AddSingletonAs<DefaultContentWorkflow>()
services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>();
services.AddSingletonAs<RolePermissionsProvider>()

3
src/Squidex/Config/Domain/SerializationServices.cs

@ -46,7 +46,8 @@ namespace Squidex.Config.Domain
new RuleConverter(),
new SchemaConverter(),
new StatusConverter(),
new StringEnumConverter());
new StringEnumConverter(),
new WorkflowConverter());
settings.NullValueHandling = NullValueHandling.Ignore;

4
src/Squidex/Config/Web/WebServices.cs

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Config.Domain;
using Squidex.Domain.Apps.Entities;
using Squidex.Pipeline.Plugins;
using Squidex.Pipeline.Robots;
using Squidex.Web;
@ -41,6 +42,9 @@ namespace Squidex.Config.Web
services.AddSingletonAs<RequestLogPerformanceMiddleware>()
.AsSelf();
services.AddSingletonAs<ContextProvider>()
.As<IContextProvider>();
services.AddSingletonAs<ApiPermissionUnifier>()
.As<IClaimsTransformation>();

4
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -25,10 +25,10 @@
</button>
<div class="dropdown-menu" *sqxModalView="editOptionsDropdown;closeAlways:true" [sqxModalTarget]="buttonOptions" @fade>
<a class="dropdown-item" (click)="configureScriptsDialog.show()">
Edit Scripts
Configure Scripts
</a>
<a class="dropdown-item" (click)="configurePreviewUrlsDialog.show()">
Edit Preview Urls
Configure Preview Urls
</a>
<ng-container *ngIf="schemasState.canCreate">

3
src/Squidex/app/features/settings/declarations.ts

@ -18,5 +18,8 @@ export * from './pages/patterns/patterns-page.component';
export * from './pages/plans/plans-page.component';
export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component';
export * from './pages/workflows/workflow-step.component';
export * from './pages/workflows/workflow-transition.component';
export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component';

23
src/Squidex/app/features/settings/module.ts

@ -30,7 +30,10 @@ import {
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
SettingsAreaComponent,
WorkflowsPageComponent,
WorkflowStepComponent,
WorkflowTransitionComponent
} from './declarations';
const routes: Routes = [
@ -170,6 +173,19 @@ const routes: Routes = [
}
}
]
},
{
path: 'workflows',
component: WorkflowsPageComponent,
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/workflows'
}
}
]
}
]
}
@ -196,7 +212,10 @@ const routes: Routes = [
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
SettingsAreaComponent,
WorkflowsPageComponent,
WorkflowTransitionComponent,
WorkflowStepComponent
]
})
export class SqxFeatureSettingsModule {}

76
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

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

76
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss

@ -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;
}
}

95
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts

@ -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;
}
}

40
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html

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

24
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.scss

@ -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;
}
}

51
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.ts

@ -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;
}
}

57
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html

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

2
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.scss

@ -0,0 +1,2 @@
@import '_vars';
@import '_mixins';

102
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.ts

@ -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;
}
}

6
src/Squidex/app/features/settings/settings-area.component.html

@ -43,6 +43,12 @@
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="selectedApp.canReadWorkflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active">
Workflow
<i class="icon-angle-right"></i>
</a>
</li>
<li class="nav-item" *ngIf="selectedApp.canReadPlans">
<a class="nav-link" routerLink="plans" routerLinkActive="active">
Subscription

2
src/Squidex/app/framework/angular/forms/code-editor.component.html

@ -1 +1 @@
<div class="editor" #editor></div>
<div class="editor" #editor>Loading editor...</div>

8
src/Squidex/app/framework/angular/forms/code-editor.component.scss

@ -3,11 +3,13 @@
// sass-lint:disable class-name-format
$editor-height: 20rem;
:host ::ng-deep {
.ace_editor {
background: $color-dark-foreground;
border: 1px solid $color-input;
height: 20rem;
height: $editor-height;
}
.ace_active-line,
@ -23,3 +25,7 @@
background: $color-border !important;
}
}
.editor {
height: $editor-height;
}

6
src/Squidex/app/framework/angular/forms/code-editor.component.ts

@ -11,8 +11,8 @@ import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import {
ExternalControlComponent,
ResourceLoaderService,
StatefulControlComponent,
Types
} from '@app/framework/internal';
@ -29,7 +29,7 @@ export const SQX_CODE_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
providers: [SQX_CODE_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CodeEditorComponent extends ExternalControlComponent<string> implements AfterViewInit {
export class CodeEditorComponent extends StatefulControlComponent<any, any> implements AfterViewInit {
private valueChanged = new Subject();
private aceEditor: any;
private value: string;
@ -44,7 +44,7 @@ export class CodeEditorComponent extends ExternalControlComponent<string> implem
constructor(changeDetector: ChangeDetectorRef,
private readonly resourceLoader: ResourceLoaderService
) {
super(changeDetector);
super(changeDetector, {});
}
public writeValue(obj: any) {

3
src/Squidex/app/framework/angular/forms/dropdown.component.ts

@ -118,6 +118,9 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
if (value !== this.snapshot.selectedItem) {
selectedIndex = selectedIndex;
this.callChange(value);
this.callTouched();
this.next(s => ({ ...s, selectedIndex, selectedItem: value }));
}

2
src/Squidex/app/framework/angular/forms/editable-title.component.html

@ -3,7 +3,7 @@
<div class="form-group mr-1">
<sqx-control-errors for="name"></sqx-control-errors>
<input type="text" class="form-control form-underlined" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" />
<input type="text" class="form-control form-underlined" formControlName="name" maxlength="20" sqxFocusOnInit (keydown)="onKeyDown($event.keyCode)" spellcheck="false" />
</div>
<button type="submit" class="btn btn-primary" [disabled]="!renameForm.valid || !renameForm.dirty">Save</button>

8
src/Squidex/app/framework/angular/forms/focus-on-init.directive.ts

@ -17,7 +17,7 @@ export class FocusOnInitDirective implements AfterViewInit {
public select: boolean;
constructor(
private readonly element: ElementRef
private readonly element: ElementRef<HTMLElement>
) {
}
@ -28,8 +28,10 @@ export class FocusOnInitDirective implements AfterViewInit {
}
if (this.select) {
if (Types.isFunction(this.element.nativeElement.select)) {
this.element.nativeElement.select();
const input: HTMLInputElement = <any>this.element.nativeElement;
if (Types.isFunction(input.select)) {
input.select();
}
}
}, 100);

6
src/Squidex/app/framework/angular/forms/iframe-editor.component.ts

@ -8,7 +8,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnChanges, OnInit, Renderer2, SimpleChanges, ViewChild } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { ExternalControlComponent, Types } from '@app/framework/internal';
import { StatefulControlComponent, Types } from '@app/framework/internal';
export const SQX_IFRAME_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => IFrameEditorComponent), multi: true
@ -21,7 +21,7 @@ export const SQX_IFRAME_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
providers: [SQX_IFRAME_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class IFrameEditorComponent extends ExternalControlComponent<any> implements OnChanges, OnInit {
export class IFrameEditorComponent extends StatefulControlComponent<any, any> implements OnChanges, OnInit {
private value: any;
private isDisabled = false;
private isInitialized = false;
@ -41,7 +41,7 @@ export class IFrameEditorComponent extends ExternalControlComponent<any> impleme
constructor(changeDetector: ChangeDetectorRef,
private readonly renderer: Renderer2
) {
super(changeDetector);
super(changeDetector, {});
}
public ngOnChanges(changes: SimpleChanges) {

8
src/Squidex/app/framework/angular/forms/json-editor.component.scss

@ -3,11 +3,13 @@
// sass-lint:disable class-name-format
$editor-height: 20rem;
:host ::ng-deep {
.ace_editor {
background: $color-dark-foreground;
border: 1px solid $color-input;
height: 20rem;
height: $editor-height;
}
.ace_active-line,
@ -23,3 +25,7 @@
background: $color-border !important;
}
}
.editor {
height: $editor-height;
}

8
src/Squidex/app/framework/angular/forms/json-editor.component.ts

@ -10,7 +10,7 @@ import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { Subject } from 'rxjs';
import { debounceTime } from 'rxjs/operators';
import { ExternalControlComponent, ResourceLoaderService } from '@app/framework/internal';
import { ResourceLoaderService, StatefulControlComponent } from '@app/framework/internal';
declare var ace: any;
@ -25,7 +25,7 @@ export const SQX_JSON_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
providers: [SQX_JSON_EDITOR_CONTROL_VALUE_ACCESSOR],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class JsonEditorComponent extends ExternalControlComponent<string> implements AfterViewInit {
export class JsonEditorComponent extends StatefulControlComponent<{}, string> implements AfterViewInit {
private valueChanged = new Subject();
private aceEditor: any;
private value: any;
@ -38,9 +38,7 @@ export class JsonEditorComponent extends ExternalControlComponent<string> implem
constructor(changeDetector: ChangeDetectorRef,
private readonly resourceLoader: ResourceLoaderService
) {
super(changeDetector);
changeDetector.detach();
super(changeDetector, {});
}
public writeValue(obj: any) {

10
src/Squidex/app/framework/angular/forms/tag-editor.component.ts

@ -221,6 +221,10 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
return;
}
if (!this.inputElement.nativeElement) {
return;
}
if (!canvas) {
canvas = document.createElement('canvas');
}
@ -231,13 +235,13 @@ export class TagEditorComponent extends StatefulControlComponent<State, any[]> i
if (ctx) {
ctx.font = CACHED_FONT;
const text = this.inputElement.nativeElement.value;
const textKey = `${text}§${this.placeholder}§${ctx.font}`;
const textValue = this.inputElement.nativeElement.value;
const textKey = `${textValue}§${this.placeholder}§${ctx.font}`;
let width = CACHED_SIZES[textKey];
if (!width) {
const widthText = ctx.measureText(text).width;
const widthText = ctx.measureText(textValue).width;
const widthPlaceholder = ctx.measureText(this.placeholder).width;
width = Math.max(widthText, widthPlaceholder);

30
src/Squidex/app/framework/angular/modals/modal-view.directive.ts

@ -20,9 +20,10 @@ import { RootViewComponent } from './root-view.component';
selector: '[sqxModalView]'
})
export class ModalViewDirective implements OnChanges, OnDestroy {
private subscription: Subscription | null = null;
private modalSubscription: Subscription | null = null;
private documentClickListener: Function | null = null;
private renderedView: EmbeddedViewRef<any> | null = null;
private static clickCounter = 0;
@Input('sqxModalView')
public modalView: DialogModel | ModalModel | any;
@ -43,6 +44,13 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
private readonly templateRef: TemplateRef<any>,
private readonly viewContainer: ViewContainerRef
) {
if (ModalViewDirective.clickCounter === 0) {
this.renderer.listen('document', 'click', () => {
ModalViewDirective.clickCounter++;
});
ModalViewDirective.clickCounter = 1;
}
}
public ngOnDestroy() {
@ -62,7 +70,7 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
this.unsubscribeToModal();
if (Types.is(this.modalView, DialogModel) || Types.is(this.modalView, ModalModel)) {
this.subscription =
this.modalSubscription =
this.modalView.isOpen.subscribe(isOpen => {
this.update(isOpen);
});
@ -76,6 +84,8 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
return;
}
this.unsubscribeToClick();
if (isOpen && !this.renderedView) {
const container = this.getContainer();
@ -85,9 +95,7 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
this.renderer.setStyle(this.renderedView.rootNodes[0], 'display', 'block');
}
setTimeout(() => {
this.startListening();
}, 1000);
this.startListening(ModalViewDirective.clickCounter + 1);
this.changeDetector.detectChanges();
} else if (!isOpen && this.renderedView) {
@ -98,8 +106,6 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
this.renderedView = null;
this.unsubscribeToClick();
this.changeDetector.detectChanges();
}
}
@ -108,14 +114,14 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
return this.placeOnRoot ? this.rootView.viewContainer : this.viewContainer;
}
private startListening() {
private startListening(clickCounter: number) {
if (!this.closeAuto) {
return;
}
this.documentClickListener =
this.renderer.listen('document', 'click', (event: MouseEvent) => {
if (!event.target || this.renderedView === null) {
if (!event.target || this.renderedView === null || ModalViewDirective.clickCounter === clickCounter) {
return;
}
@ -145,9 +151,9 @@ export class ModalViewDirective implements OnChanges, OnDestroy {
}
private unsubscribeToModal() {
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
if (this.modalSubscription) {
this.modalSubscription.unsubscribe();
this.modalSubscription = null;
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save