Browse Source

Merge branch 'master' into feature-twitter

# Conflicts:
#	src/Squidex/Areas/Api/Controllers/UI/UIController.cs
#	src/Squidex/app/theme/icomoon/demo.html
#	src/Squidex/app/theme/icomoon/fonts/icomoon.eot
#	src/Squidex/app/theme/icomoon/fonts/icomoon.svg
#	src/Squidex/app/theme/icomoon/fonts/icomoon.ttf
#	src/Squidex/app/theme/icomoon/fonts/icomoon.woff
#	src/Squidex/app/theme/icomoon/selection.json
#	src/Squidex/app/theme/icomoon/style.css
#	src/Squidex/appsettings.json
pull/314/head
Sebastian Stehle 8 years ago
parent
commit
97caa4582c
  1. 10
      CHANGELOG.md
  2. 2
      Dockerfile
  3. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs
  4. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs
  5. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs
  6. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs
  7. 117
      src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs
  8. 29
      src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  9. 25
      src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs
  10. 2
      src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs
  11. 2
      src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs
  12. 2
      src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs
  13. 5
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryContext.cs
  14. 49
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  15. 4
      src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs
  16. 61
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  17. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  18. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs
  19. 4
      src/Squidex.Domain.Apps.Entities/Contents/SingletonCommandMiddleware.cs
  20. 7
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  21. 16
      src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  22. 44
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  23. 2
      src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs
  24. 19
      src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs
  25. 76
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  26. 50
      src/Squidex/app/features/assets/pages/assets-page.component.html
  27. 21
      src/Squidex/app/features/assets/pages/assets-page.component.scss
  28. 28
      src/Squidex/app/features/assets/pages/assets-page.component.ts
  29. 1
      src/Squidex/app/features/content/declarations.ts
  30. 6
      src/Squidex/app/features/content/module.ts
  31. 38
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  32. 37
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  33. 60
      src/Squidex/app/features/content/pages/contents/search-form.component.html
  34. 4
      src/Squidex/app/features/content/shared/assets-editor.component.ts
  35. 4
      src/Squidex/app/features/content/shared/contents-selector.component.html
  36. 20
      src/Squidex/app/shared/components/assets-selector.component.html
  37. 9
      src/Squidex/app/shared/components/assets-selector.component.ts
  38. 6
      src/Squidex/app/shared/components/geolocation-editor.component.ts
  39. 99
      src/Squidex/app/shared/components/search-form.component.html
  40. 10
      src/Squidex/app/shared/components/search-form.component.scss
  41. 64
      src/Squidex/app/shared/components/search-form.component.ts
  42. 1
      src/Squidex/app/shared/declarations.ts
  43. 2
      src/Squidex/app/shared/internal.ts
  44. 7
      src/Squidex/app/shared/module.ts
  45. 42
      src/Squidex/app/shared/services/ui.service.spec.ts
  46. 39
      src/Squidex/app/shared/services/ui.service.ts
  47. 4
      src/Squidex/app/shared/state/assets.state.ts
  48. 14
      src/Squidex/app/shared/state/contents.forms.ts
  49. 79
      src/Squidex/app/shared/state/queries.spec.ts
  50. 78
      src/Squidex/app/shared/state/queries.ts
  51. 102
      src/Squidex/app/shared/state/ui.state.spec.ts
  52. 139
      src/Squidex/app/shared/state/ui.state.ts
  53. 38
      src/Squidex/app/theme/_panels.scss
  54. 274
      src/Squidex/app/theme/icomoon/demo.html
  55. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.eot
  56. 2
      src/Squidex/app/theme/icomoon/fonts/icomoon.svg
  57. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.ttf
  58. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.woff
  59. 68
      src/Squidex/app/theme/icomoon/selection.json
  60. 61
      src/Squidex/app/theme/icomoon/style.css
  61. 2
      src/Squidex/appsettings.json
  62. 2
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs
  63. 125
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs
  64. 52
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs
  65. 65
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  66. 13
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  67. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs

10
CHANGELOG.md

@ -1,5 +1,15 @@
# Changelog # Changelog
## v1.10.0 - 2018-08-29
### Featues
* **Contents**: Introduce `X-Unpublished` header to also get unpublished content.
* **UI**: General feature to store UI settings.
* **UI**: Save content queries.
* **UI**: Save assets queries.
* **GraphQL**: Endpoint to run multiple queries in parallel with a single request.
## v1.9.0 - 2018-08-19 ## v1.9.0 - 2018-08-19
### Features ### Features

2
Dockerfile

@ -40,7 +40,7 @@ WORKDIR /app
# add libuv # add libuv
RUN apk add --no-cache libuv \ RUN apk add --no-cache libuv \
&& ln -s /usr/lib/libuv.so.1 /usr/lib/libuv.so && ln -s /usr/lib/libuv.so.1 /usr/lib/libuv.so
# Copy from build stage # Copy from build stage
COPY --from=builder /out/alpine . COPY --from=builder /out/alpine .

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs

@ -71,7 +71,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
{ {
QueueConnectionString = action.ConnectionString, QueueConnectionString = action.ConnectionString,
QueueName = queueName, QueueName = queueName,
MessageBodyV2 = body, MessageBodyV2 = body
}; };
return (ruleDescription, ruleJob); return (ruleDescription, ruleJob);

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs

@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
Password = action.Password, Password = action.Password,
ContentId = contentId, ContentId = contentId,
IndexName = formatter.Format(action.IndexName, @event), IndexName = formatter.Format(action.IndexName, @event),
IndexType = formatter.Format(action.IndexType, @event), IndexType = formatter.Format(action.IndexType, @event)
}; };
if (contentEvent.Type == EnrichedContentEventType.Deleted || if (contentEvent.Type == EnrichedContentEventType.Deleted ||

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs

@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
var ruleJob = new SlackJob var ruleJob = new SlackJob
{ {
RequestUrl = action.WebhookUrl.ToString(), RequestUrl = action.WebhookUrl.ToString(),
RequestBodyV2 = body.ToString(Formatting.Indented), RequestBodyV2 = body.ToString(Formatting.Indented)
}; };
return (Description, ruleJob); return (Description, ruleJob);

2
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FindExtensions.cs

@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
{ {
var filters = new List<FilterDefinition<MongoContentEntity>> var filters = new List<FilterDefinition<MongoContentEntity>>
{ {
Filter.Eq(x => x.IndexedSchemaId, schemaId), Filter.Eq(x => x.IndexedSchemaId, schemaId)
}; };
if (status != null) if (status != null)

117
src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs

@ -0,0 +1,117 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain
{
private readonly IStore<Guid> store;
private IPersistence<State> persistence;
private State state = new State();
[CollectionName("UISettings")]
public sealed class State
{
public JObject Settings { get; set; } = new JObject();
}
public AppUISettingsGrain(IStore<Guid> store)
{
Guard.NotNull(store, nameof(store));
this.store = store;
}
public override Task OnActivateAsync(Guid key)
{
persistence = store.WithSnapshots<State, Guid>(GetType(), key, x => state = x);
return persistence.ReadAsync();
}
public Task<J<JObject>> GetAsync()
{
return Task.FromResult(state.Settings.AsJ());
}
public Task SetAsync(J<JObject> settings)
{
state.Settings = settings;
return persistence.WriteSnapshotAsync(state);
}
public Task SetAsync(string path, J<JToken> value)
{
var container = GetContainer(path, out var key);
if (container == null)
{
throw new InvalidOperationException("Path does not lead to an object.");
}
container[key] = value;
return persistence.WriteSnapshotAsync(state);
}
public Task RemoveAsync(string path)
{
var container = GetContainer(path, out var key);
if (container != null)
{
container.Remove(key);
}
return persistence.WriteSnapshotAsync(state);
}
private JObject GetContainer(string path, out string key)
{
Guard.NotNullOrEmpty(path, nameof(path));
var segments = path.Split('.');
key = segments[segments.Length - 1];
var current = state.Settings;
if (segments.Length > 1)
{
foreach (var segment in segments.Take(segments.Length - 1))
{
if (!current.TryGetValue(segment, out var temp))
{
temp = new JObject();
current[segment] = temp;
}
if (temp is JObject next)
{
current = next;
}
else
{
return null;
}
}
}
return current;
}
}
}

29
src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs

@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public sealed class BackupApps : BackupHandlerWithStore public sealed class BackupApps : BackupHandlerWithStore
{ {
private const string UsersFile = "Users.json"; private const string UsersFile = "Users.json";
private const string SettingsFile = "Settings.json";
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly IAppsByNameIndex appsByNameIndex; private readonly IAppsByNameIndex appsByNameIndex;
@ -69,9 +70,10 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
} }
public override Task BackupAsync(Guid appId, BackupWriter writer) public override async Task BackupAsync(Guid appId, BackupWriter writer)
{ {
return WriterUsersAsync(writer); await WriteUsersAsync(writer);
await WriteSettingsAsync(writer, appId);
} }
public async override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor) public async override Task RestoreEventAsync(Envelope<IEvent> @event, Guid appId, BackupReader reader, RefToken actor)
@ -120,6 +122,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
} }
public override Task RestoreAsync(Guid appId, BackupReader reader)
{
return ReadSettingsAsync(reader, appId);
}
private async Task ReserveAppAsync(Guid appId) private async Task ReserveAppAsync(Guid appId)
{ {
if (!(isReserved = await appsByNameIndex.ReserveAppAsync(appId, appName))) if (!(isReserved = await appsByNameIndex.ReserveAppAsync(appId, appName)))
@ -167,11 +174,25 @@ namespace Squidex.Domain.Apps.Entities.Apps
usersWithEmail = json.ToObject<Dictionary<string, string>>(); usersWithEmail = json.ToObject<Dictionary<string, string>>();
} }
private Task WriterUsersAsync(BackupWriter writer) private async Task WriteUsersAsync(BackupWriter writer)
{ {
var json = JObject.FromObject(usersWithEmail); var json = JObject.FromObject(usersWithEmail);
return writer.WriteJsonAsync(UsersFile, json); await writer.WriteJsonAsync(UsersFile, json);
}
private async Task WriteSettingsAsync(BackupWriter writer, Guid appId)
{
var json = await grainFactory.GetGrain<IAppUISettingsGrain>(appId).GetAsync();
await writer.WriteJsonAsync(SettingsFile, json);
}
private async Task ReadSettingsAsync(BackupReader reader, Guid appId)
{
var json = await reader.ReadJsonAttachmentAsync(SettingsFile);
await grainFactory.GetGrain<IAppUISettingsGrain>(appId).SetAsync((JObject)json);
} }
public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader)

25
src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Orleans;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Apps
{
public interface IAppUISettingsGrain : IGrainWithGuidKey
{
Task<J<JObject>> GetAsync();
Task SetAsync(string path, J<JToken> value);
Task SetAsync(J<JObject> settings);
Task RemoveAsync(string path);
}
}

2
src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateBlogCommandMiddleware.cs

@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
.AddField("text", .AddField("text",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", "Just created a blog with Squidex. I love it!")), .AddValue("iv", "Just created a blog with Squidex. I love it!")),
Publish = true, Publish = true
}); });
} }

2
src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateIdentityCommandMiddleware.cs

@ -6,9 +6,7 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Templates.Builders; using Squidex.Domain.Apps.Entities.Apps.Templates.Builders;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;

2
src/Squidex.Domain.Apps.Entities/Apps/Templates/CreateProfileCommandMiddleware.cs

@ -77,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
.AddField("profession", .AddField("profession",
new ContentFieldData() new ContentFieldData()
.AddValue("iv", "Software Developer")), .AddValue("iv", "Software Developer")),
Publish = true, Publish = true
}); });
} }

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

@ -38,6 +38,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Clone(c => c.Base = c.Base.WithFlatten(flatten)); return Clone(c => c.Base = c.Base.WithFlatten(flatten));
} }
public ContentQueryContext WithUnpublished(bool unpublished)
{
return Clone(c => c.Base = c.Base.WithUnpublished(unpublished));
}
public ContentQueryContext WithSchemaId(Guid id) public ContentQueryContext WithSchemaId(Guid id)
{ {
return Clone(c => c.SchemaIdOrName = id.ToString()); return Clone(c => c.SchemaIdOrName = id.ToString());

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

@ -21,6 +21,8 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
#pragma warning disable RECS0147
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentQueryService : IContentQueryService public sealed class ContentQueryService : IContentQueryService
@ -70,12 +72,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var isVersioned = version > EtagVersion.Empty; var isVersioned = version > EtagVersion.Empty;
var parsedStatus = context.Base.IsFrontendClient ? StatusAll : StatusPublished; var status = GetFindStatus(context.Base);
var content = var content =
isVersioned ? isVersioned ?
await FindContentByVersionAsync(id, version) : await FindContentByVersionAsync(id, version) :
await FindContentAsync(context.Base, id, parsedStatus, schema); await FindContentAsync(context.Base, id, status, schema);
if (content == null || (content.Status != Status.Published && !context.Base.IsFrontendClient) || content.SchemaId.Id != schema.Id) if (content == null || (content.Status != Status.Published && !context.Base.IsFrontendClient) || content.SchemaId.Id != schema.Id)
{ {
@ -94,20 +96,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var parsedStatus = ParseStatus(context.Base); var status = GetQueryStatus(context.Base);
IResultList<IContentEntity> contents; IResultList<IContentEntity> contents;
if (query.Ids?.Count > 0) if (query.Ids?.Count > 0)
{ {
contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, new HashSet<Guid>(query.Ids)); contents = await contentRepository.QueryAsync(context.Base.App, schema, status, new HashSet<Guid>(query.Ids));
contents = Sort(contents, query.Ids); contents = Sort(contents, query.Ids);
} }
else else
{ {
var parsedQuery = ParseQuery(context.Base, query.ODataQuery, schema); var parsedQuery = ParseQuery(context.Base, query.ODataQuery, schema);
contents = await contentRepository.QueryAsync(context.Base.App, schema, parsedStatus, parsedQuery); contents = await contentRepository.QueryAsync(context.Base.App, schema, status, parsedQuery);
} }
return Transform(context.Base, schema, true, contents); return Transform(context.Base, schema, true, contents);
@ -234,7 +236,23 @@ namespace Squidex.Domain.Apps.Entities.Contents
return schema; return schema;
} }
private static Status[] ParseStatus(QueryContext context) private static Status[] GetFindStatus(QueryContext context)
{
if (context.IsFrontendClient)
{
return StatusAll;
}
else if (context.Unpublished)
{
return StatusDraftOrPublished;
}
else
{
return StatusPublished;
}
}
private static Status[] GetQueryStatus(QueryContext context)
{ {
if (context.IsFrontendClient) if (context.IsFrontendClient)
{ {
@ -242,11 +260,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
return StatusArchived; return StatusArchived;
} }
else
return StatusDraftOrPublished; {
return StatusDraftOrPublished;
}
}
else
{
if (context.Unpublished)
{
return StatusDraftOrPublished;
}
else
{
return StatusPublished;
}
} }
return StatusPublished;
} }
private Task<IContentEntity> FindContentByVersionAsync(Guid id, long version) private Task<IContentEntity> FindContentByVersionAsync(Guid id, long version)

4
src/Squidex.Domain.Apps.Entities/Contents/Edm/EdmModelBuilder.cs

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm
{ {
public class EdmModelBuilder : CachingProviderBase public class EdmModelBuilder : CachingProviderBase
{ {
private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(60);
public EdmModelBuilder(IMemoryCache cache) public EdmModelBuilder(IMemoryCache cache)
: base(cache) : base(cache)
{ {
@ -32,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Edm
var result = Cache.GetOrCreate<IEdmModel>(cacheKey, entry => var result = Cache.GetOrCreate<IEdmModel>(cacheKey, entry =>
{ {
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(60); entry.AbsoluteExpirationRelativeToNow = CacheTime;
return BuildEdmModel(schema.SchemaDef, app.PartitionResolver()); return BuildEdmModel(schema.SchemaDef, app.PartitionResolver());
}); });

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

@ -23,7 +23,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
public CachingGraphQLService(IMemoryCache cache, public CachingGraphQLService(
IMemoryCache cache,
IAppProvider appProvider, IAppProvider appProvider,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IContentQueryService contentQuery, IContentQueryService contentQuery,
@ -41,39 +42,65 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
this.urlGenerator = urlGenerator; this.urlGenerator = urlGenerator;
} }
public async Task<(object Data, object[] Errors)> QueryAsync(QueryContext context, GraphQLQuery query) public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(queries, nameof(queries));
var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q)));
return (result.Any(x => x.HasError), result.Select(x => x.Response).ToArray());
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query)); Guard.NotNull(query, nameof(query));
var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
var result = await QueryInternalAsync(model, ctx, query);
return result;
}
private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query)
{
if (string.IsNullOrWhiteSpace(query.Query)) if (string.IsNullOrWhiteSpace(query.Query))
{ {
return (new object(), new object[0]); return (false, new { data = new object() });
} }
var modelContext = await GetModelAsync(context.App); var result = await model.ExecuteAsync(ctx, query);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator);
return await modelContext.ExecuteAsync(ctx, query); if (result.Errors?.Any() == true)
{
return (false, new { data = result.Data, errors = result.Errors });
}
else
{
return (false, new { data = result.Data });
}
} }
private async Task<GraphQLModel> GetModelAsync(IAppEntity app) private Task<GraphQLModel> GetModelAsync(IAppEntity app)
{ {
var cacheKey = CreateCacheKey(app.Id, app.Version.ToString()); var cacheKey = CreateCacheKey(app.Id, app.Version.ToString());
var modelContext = Cache.Get<GraphQLModel>(cacheKey); return Cache.GetOrCreateAsync(cacheKey, async entry =>
if (modelContext == null)
{ {
var allSchemas = await appProvider.GetSchemasAsync(app.Id); entry.AbsoluteExpirationRelativeToNow = CacheDuration;
modelContext = new GraphQLModel(app, allSchemas.Where(x => x.IsPublished), urlGenerator); var allSchemas = await appProvider.GetSchemasAsync(app.Id);
Cache.Set(cacheKey, modelContext, CacheDuration);
}
return modelContext; return new GraphQLModel(app, allSchemas, urlGenerator);
});
} }
private static object CreateCacheKey(Guid appId, string etag) private static object CreateCacheKey(Guid appId, string etag)

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

@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
assetType = new AssetGraphType(this); assetType = new AssetGraphType(this);
assetListType = new ListGraphType(new NonNullGraphType(assetType)); assetListType = new ListGraphType(new NonNullGraphType(assetType));
schemasById = schemas.ToDictionary(x => x.Id); schemasById = schemas.Where(x => x.IsPublished).ToDictionary(x => x.Id);
graphQLSchema = BuildSchema(this); graphQLSchema = BuildSchema(this);
graphQLSchema.RegisterValueConverter(JsonConverter.Instance); graphQLSchema.RegisterValueConverter(JsonConverter.Instance);

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

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

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

@ -31,10 +31,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
var data = new NamedContentData(); var data = new NamedContentData();
var contentId = schemaId.Id; var contentId = schemaId.Id;
var content = new CreateContent { Data = data, ContentId = contentId, SchemaId = schemaId, Publish = true }; var content = new CreateContent { Data = data, ContentId = contentId, SchemaId = schemaId };
SimpleMapper.Map(createSchema, content); SimpleMapper.Map(createSchema, content);
content.Publish = true;
await context.CommandBus.PublishAsync(content); await context.CommandBus.PublishAsync(content);
} }
} }

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

@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Entities
public bool Flatten { get; private set; } public bool Flatten { get; private set; }
public bool Unpublished { get; set; }
public IEnumerable<Language> Languages { get; private set; } public IEnumerable<Language> Languages { get; private set; }
private QueryContext() private QueryContext()
@ -34,6 +36,11 @@ namespace Squidex.Domain.Apps.Entities
return new QueryContext { App = app, User = user }; return new QueryContext { App = app, User = user };
} }
public QueryContext WithUnpublished(bool unpublished)
{
return Clone(c => c.Unpublished = unpublished);
}
public QueryContext WithArchived(bool archived) public QueryContext WithArchived(bool archived)
{ {
return Clone(c => c.Archived = archived); return Clone(c => c.Archived = archived);

16
src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.UsageTracking
{ {
public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker
{ {
private static readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private readonly IUsageTracker inner; private readonly IUsageTracker inner;
public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache) public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
@ -35,22 +35,18 @@ namespace Squidex.Infrastructure.UsageTracking
return inner.TrackAsync(key, weight, elapsedMs); return inner.TrackAsync(key, weight, elapsedMs);
} }
public async Task<long> GetMonthlyCallsAsync(string key, DateTime date) public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
{ {
Guard.NotNull(key, nameof(key)); Guard.NotNull(key, nameof(key));
var cacheKey = string.Concat(key, date); var cacheKey = string.Concat(key, date);
if (Cache.TryGetValue<long>(cacheKey, out var result)) return Cache.GetOrCreateAsync(cacheKey, entry =>
{ {
return result; entry.AbsoluteExpirationRelativeToNow = CacheDuration;
}
result = await inner.GetMonthlyCallsAsync(key, date); return inner.GetMonthlyCallsAsync(key, date);
});
Cache.Set(cacheKey, result, CacheTime);
return result;
} }
} }
} }

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

@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
/// GraphQL endpoint. /// GraphQL endpoint.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param> /// <param name="app">The name of the app.</param>
/// <param name="query">The graphql endpoint.</param> /// <param name="query">The graphql query.</param>
/// <returns> /// <returns>
/// 200 => Contents retrieved or mutated. /// 200 => Contents retrieved or mutated.
/// 404 => Schema or app not found. /// 404 => Schema or app not found.
@ -67,13 +67,44 @@ namespace Squidex.Areas.Api.Controllers.Contents
{ {
var result = await graphQl.QueryAsync(Context().Base, query); var result = await graphQl.QueryAsync(Context().Base, query);
if (result.Errors?.Length > 0) if (result.HasError)
{ {
return BadRequest(new { result.Data, result.Errors }); return BadRequest(result.Response);
} }
else else
{ {
return Ok(new { result.Data }); return Ok(result.Response);
}
}
/// <summary>
/// GraphQL endpoint with batch support.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="batch">The graphql queries.</param>
/// <returns>
/// 200 => Contents retrieved or mutated.
/// 404 => Schema or app not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs
/// </remarks>
[MustBeAppReader]
[HttpGet]
[HttpPost]
[Route("content/{app}/graphql/batch")]
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch)
{
var result = await graphQl.QueryAsync(Context().Base, batch);
if (result.HasError)
{
return BadRequest(result.Response);
}
else
{
return Ok(result.Response);
} }
} }
@ -482,7 +513,10 @@ namespace Squidex.Areas.Api.Controllers.Contents
private ContentQueryContext Context() private ContentQueryContext Context()
{ {
return new ContentQueryContext(QueryContext.Create(App, User).WithLanguages(Request.Headers["X-Languages"])).WithFlatten(Request.Headers.ContainsKey("X-Flatten")); return new ContentQueryContext(QueryContext.Create(App, User)
.WithLanguages(Request.Headers["X-Languages"]))
.WithFlatten(Request.Headers.ContainsKey("X-Flatten"))
.WithUnpublished(Request.Headers.ContainsKey("X-Unpublished"));
} }
} }
} }

2
src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs

@ -142,7 +142,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models
new NestedFieldDto new NestedFieldDto
{ {
FieldId = nestedField.Id, FieldId = nestedField.Id,
Properties = nestedFieldPropertiesDto, Properties = nestedFieldPropertiesDto
}); });
fieldDto.Nested.Add(nestedFieldDto); fieldDto.Nested.Add(nestedFieldDto);

19
src/Squidex/Areas/Api/Controllers/UI/Models/UpdateSettingDto.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json.Linq;
namespace Squidex.Areas.Api.Controllers.UI.Models
{
public sealed class UpdateSettingDto
{
/// <summary>
/// The value for the setting.
/// </summary>
public JToken Value { get; set; }
}
}

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

@ -5,12 +5,15 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Newtonsoft.Json.Linq;
using NSwag.Annotations; using NSwag.Annotations;
using Orleans;
using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Areas.Api.Controllers.UI.Models;
using Squidex.Config; using Squidex.Config;
using Squidex.Domain.Apps.Core.Rules.Actions; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Pipeline; using Squidex.Pipeline;
@ -19,38 +22,81 @@ namespace Squidex.Areas.Api.Controllers.UI
/// <summary> /// <summary>
/// Manages ui settings and configs. /// Manages ui settings and configs.
/// </summary> /// </summary>
[ApiAuthorize]
[ApiExceptionFilter] [ApiExceptionFilter]
[AppApi]
[SwaggerTag(nameof(UI))] [SwaggerTag(nameof(UI))]
public sealed class UIController : ApiController public sealed class UIController : ApiController
{ {
private readonly MyUIOptions uiOptions; private readonly MyUIOptions uiOptions;
private readonly TwitterOptions twitterOptions; private readonly IGrainFactory grainFactory;
public UIController(ICommandBus commandBus, IOptions<MyUIOptions> uiOptions, IOptions<TwitterOptions> twitterOptions) public UIController(ICommandBus commandBus, IOptions<MyUIOptions> uiOptions, IGrainFactory grainFactory)
: base(commandBus) : base(commandBus)
{ {
this.twitterOptions = twitterOptions.Value;
this.uiOptions = uiOptions.Value; this.uiOptions = uiOptions.Value;
this.grainFactory = grainFactory;
} }
/// <summary> /// <summary>
/// Get ui settings. /// Get ui settings.
/// </summary> /// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => UI settings returned.
/// 404 => App not found.
/// </returns>
[HttpGet] [HttpGet]
[Route("ui/settings/")] [Route("apps/{app}/ui/settings/")]
[ProducesResponseType(typeof(UISettingsDto), 200)] [ProducesResponseType(typeof(UISettingsDto), 200)]
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetSettings() public async Task<IActionResult> GetSettings(string app)
{
var result = await grainFactory.GetGrain<IAppUISettingsGrain>(App.Id).GetAsync();
result.Value["mapType"] = uiOptions.Map?.Type ?? "OSM";
result.Value["mapKey"] = uiOptions.Map?.GoogleMaps?.Key;
return Ok(result.Value);
}
/// <summary>
/// Set ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <param name="value">The name of the value.</param>
/// <returns>
/// 200 => UI setting set.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/ui/settings/{key}")]
[ApiCosts(0)]
public async Task<IActionResult> PutSetting(string app, string key, [FromBody] UpdateSettingDto request)
{ {
var dto = new UISettingsDto await grainFactory.GetGrain<IAppUISettingsGrain>(App.Id).SetAsync(key, request.Value);
{
MapType = uiOptions.Map?.Type ?? "OSM", return NoContent();
MapKey = uiOptions.Map?.GoogleMaps?.Key, }
SupportsTwitterActions = twitterOptions.IsConfigured()
}; /// <summary>
/// Remove ui settings.
return Ok(dto); /// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <returns>
/// 200 => UI setting removed.
/// 404 => App not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/ui/settings/{key}")]
[ApiCosts(0)]
public async Task<IActionResult> DeleteSetting(string app, string key)
{
await grainFactory.GetGrain<IAppUISettingsGrain>(App.Id).RemoveAsync(key);
return NoContent();
} }
} }
} }

50
src/Squidex/app/features/assets/pages/assets-page.component.html

@ -6,16 +6,25 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button class="btn btn-link btn-secondary mr-1" (click)="reload()" title="Refresh Assets (CTRL + SHIFT + R)"> <div class="row no-gutters pl-1">
<i class="icon-reset"></i> Refresh <div class="col col-auto offset-xl-4">
</button> <sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> <button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)">
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut> <i class="icon-reset"></i> Refresh
</button>
</div>
<div class="col pl-1">
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<form class="form-inline" (ngSubmit)="search()"> <sqx-search-form formClass="form" placeholder="Search for assets" fieldExample="fileSize"
<input class="form-control" #inputFind [formControl]="assetsFilter" placeholder="Search for assets" /> (queryChanged)="search($event)"
</form> [query]="assetsState.assetsQuery | async"
[queries]="queries"
enableShortcut="true">
</sqx-search-form>
</div>
</div>
</ng-container> </ng-container>
<ng-container content> <ng-container content>
@ -23,18 +32,18 @@
</ng-container> </ng-container>
<ng-container sidebar> <ng-container sidebar>
<div class="section"> <div class="sidebar-section">
<a class="row tag" (click)="resetTags()" [class.active]="assetsState.isTagSelectionEmpty()"> <a class="row sidebar-item" (click)="resetTags()" [class.active]="assetsState.isTagSelectionEmpty()">
<div class="col"> <div class="col">
All Assets All tags
</div> </div>
</a> </a>
</div> </div>
<div class="section"> <div class="sidebar-section">
<h3>Tags</h3> <h3>Tags</h3>
<a class="row tag" *ngFor="let tag of assetsState.tags | async" (click)="toggleTag(tag.name)" [class.active]="assetsState.isTagSelected(tag.name)"> <a class="row sidebar-item" *ngFor="let tag of assetsState.tags | async" (click)="toggleTag(tag.name)" [class.active]="assetsState.isTagSelected(tag.name)">
<div class="col"> <div class="col">
{{tag.name}} {{tag.name}}
</div> </div>
@ -43,5 +52,20 @@
</div> </div>
</a> </a>
</div> </div>
<div class="sidebar-section">
<h3>Saved queries</h3>
<a class="row sidebar-item" *ngFor="let query of queries.queries | async" (click)="search(query.filter)" [class.active]="isSelectedQuery(query.filter)">
<div class="col truncate">
{{query.name}}
</div>
<div class="col col-auto">
<a class="sidebar-item-remove" (click)="queries.remove(query.name)">
<i class="icon-close"></i>
</a>
</div>
</a>
</div>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>

21
src/Squidex/app/features/assets/pages/assets-page.component.scss

@ -4,25 +4,4 @@
.section { .section {
border-top: 1px solid $color-border; border-top: 1px solid $color-border;
padding: 1rem; padding: 1rem;
}
.tag {
& {
padding: .25rem 0;
}
&.active {
font-weight: bold;
}
&.active,
&:hover {
background: $color-background;
}
}
a {
&.tag {
cursor: pointer !important;
}
} }

28
src/Squidex/app/features/assets/pages/assets-page.component.ts

@ -11,7 +11,12 @@ import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { AppsState, AssetsState } from '@app/shared'; import {
AppsState,
AssetsState,
Queries,
UIState
} from '@app/shared';
@Component({ @Component({
selector: 'sqx-assets-page', selector: 'sqx-assets-page',
@ -21,9 +26,12 @@ import { AppsState, AssetsState } from '@app/shared';
export class AssetsPageComponent implements OnInit { export class AssetsPageComponent implements OnInit {
public assetsFilter = new FormControl(); public assetsFilter = new FormControl();
public queries = new Queries(this.uiState, 'assets');
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly assetsState: AssetsState public readonly assetsState: AssetsState,
private readonly uiState: UIState
) { ) {
} }
@ -35,18 +43,18 @@ export class AssetsPageComponent implements OnInit {
this.assetsState.load(true).pipe(onErrorResumeNext()).subscribe(); this.assetsState.load(true).pipe(onErrorResumeNext()).subscribe();
} }
public search() { public search(query: string) {
this.assetsState.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); this.assetsState.search(query).pipe(onErrorResumeNext()).subscribe();
}
public resetTags() {
this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe();
} }
public toggleTag(tag: string) { public toggleTag(tag: string) {
this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe(); this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe();
} }
public resetTags() {
this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe();
}
public goNext() { public goNext() {
this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe(); this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe();
} }
@ -54,5 +62,9 @@ export class AssetsPageComponent implements OnInit {
public goPrev() { public goPrev() {
this.assetsState.goPrev().pipe(onErrorResumeNext()).subscribe(); this.assetsState.goPrev().pipe(onErrorResumeNext()).subscribe();
} }
public isSelectedQuery(query: string) {
return query === this.assetsState.snapshot.assetsQuery || (!query && !this.assetsState.assetsQuery);
}
} }

1
src/Squidex/app/features/content/declarations.ts

@ -9,7 +9,6 @@ export * from './pages/content/content-field.component';
export * from './pages/content/content-history.component'; export * from './pages/content/content-history.component';
export * from './pages/content/content-page.component'; export * from './pages/content/content-page.component';
export * from './pages/contents/contents-page.component'; export * from './pages/contents/contents-page.component';
export * from './pages/contents/search-form.component';
export * from './pages/schemas/schemas-page.component'; export * from './pages/schemas/schemas-page.component';
export * from './shared/array-editor.component'; export * from './shared/array-editor.component';

6
src/Squidex/app/features/content/module.ts

@ -32,8 +32,7 @@ import {
DueTimeSelectorComponent, DueTimeSelectorComponent,
FieldEditorComponent, FieldEditorComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
SchemasPageComponent, SchemasPageComponent
SearchFormComponent
} from './declarations'; } from './declarations';
const routes: Routes = [ const routes: Routes = [
@ -100,8 +99,7 @@ const routes: Routes = [
DueTimeSelectorComponent, DueTimeSelectorComponent,
FieldEditorComponent, FieldEditorComponent,
ReferencesEditorComponent, ReferencesEditorComponent,
SchemasPageComponent, SchemasPageComponent
SearchFormComponent
] ]
}) })
export class SqxFeatureContentModule { } export class SqxFeatureContentModule { }

38
src/Squidex/app/features/content/pages/contents/contents-page.component.html

@ -1,6 +1,6 @@
<sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title> <sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title>
<sqx-panel desiredWidth="*" contentClass="grid"> <sqx-panel desiredWidth="*" contentClass="grid" showSidebar="true" sidebarClass="wide">
<ng-container title> <ng-container title>
<ng-container *ngIf="contentsState.isArchive | async; else noArchive"> <ng-container *ngIf="contentsState.isArchive | async; else noArchive">
Archive Archive
@ -14,18 +14,21 @@
<ng-container menu> <ng-container menu>
<div class="row no-gutters pl-1"> <div class="row no-gutters pl-1">
<div class="col col-auto offset-xl-4"> <div class="col col-auto offset-xl-4">
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)"> <button class="btn btn-link btn-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
</div> </div>
<div class="col pl-1"> <div class="col pl-1">
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> <sqx-search-form formClass="form" placeholder="Search for content" fieldExample="data/[MY_FIELD]/iv"
<sqx-search-form formClass="form"
(queryChanged)="search($event)" (queryChanged)="search($event)"
[query]="contentsState.contentsQuery | async" [query]="contentsState.contentsQuery | async"
[queries]="schemaQueries"
(archivedChanged)="goArchive($event)" (archivedChanged)="goArchive($event)"
[archived]="contentsState.isArchive | async" [archived]="contentsState.isArchive | async"
expandable="true"
enableArchive="true"
enableShortcut="true"> enableShortcut="true">
</sqx-search-form> </sqx-search-form>
</div> </div>
@ -118,6 +121,31 @@
<div class="grid-footer"> <div class="grid-footer">
<sqx-pager [pager]="contentsState.contentsPager | async" (prev)="goPrev()" (next)="goNext()"></sqx-pager> <sqx-pager [pager]="contentsState.contentsPager | async" (prev)="goPrev()" (next)="goNext()"></sqx-pager>
</div> </div>
</ng-container>
<ng-container sidebar>
<div class="sidebar-section">
<a class="row sidebar-item" *ngFor="let query of schemaQueries.defaultQueries" (click)="search(query.filter)" [class.active]="isSelectedQuery(query.filter)">
<div class="col truncate">
{{query.name}}
</div>
</a>
</div>
<div class="sidebar-section">
<h3>Saved queries</h3>
<a class="row sidebar-item" *ngFor="let query of schemaQueries.queries | async" (click)="search(query.filter)" [class.active]="isSelectedQuery(query.filter)">
<div class="col truncate">
{{query.name}}
</div>
<div class="col col-auto">
<a class="sidebar-item-remove" (click)="schemaQueries.remove(query.name)">
<i class="icon-close"></i>
</a>
</div>
</a>
</div>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>

37
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -17,8 +17,10 @@ import {
ImmutableArray, ImmutableArray,
LanguagesState, LanguagesState,
ModalModel, ModalModel,
Queries,
SchemaDetailsDto, SchemaDetailsDto,
SchemasState SchemasState,
UIState
} from '@app/shared'; } from '@app/shared';
import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component'; import { DueTimeSelectorComponent } from './../../shared/due-time-selector.component';
@ -34,6 +36,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
private selectedSchemaSubscription: Subscription; private selectedSchemaSubscription: Subscription;
public schema: SchemaDetailsDto; public schema: SchemaDetailsDto;
public schemaQueries: Queries;
public searchModal = new ModalModel(); public searchModal = new ModalModel();
@ -55,7 +58,8 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly contentsState: ContentsState, public readonly contentsState: ContentsState,
private readonly languagesState: LanguagesState, private readonly languagesState: LanguagesState,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState,
private readonly uiState: UIState
) { ) {
} }
@ -72,6 +76,7 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
this.resetSelection(); this.resetSelection();
this.schema = schema!; this.schema = schema!;
this.schemaQueries = new Queries(this.uiState, `schemas.${this.schema.name}`);
this.contentsState.init().pipe(onErrorResumeNext()).subscribe(); this.contentsState.init().pipe(onErrorResumeNext()).subscribe();
}); });
@ -103,38 +108,42 @@ export class ContentsPageComponent implements OnDestroy, OnInit {
} }
public publish(content: ContentDto) { public publish(content: ContentDto) {
this.changeContentItems([content], 'Publish', false); this.changeContentItems([content], 'Publish');
} }
public publishSelected(scheduled: boolean) { public publishSelected() {
this.changeContentItems(this.select(c => c.status !== 'Published'), 'Publish', false); this.changeContentItems(this.select(c => c.status !== 'Published'), 'Publish');
} }
public unpublish(content: ContentDto) { public unpublish(content: ContentDto) {
this.changeContentItems([content], 'Unpublish', false); this.changeContentItems([content], 'Unpublish');
} }
public unpublishSelected(scheduled: boolean) { public unpublishSelected() {
this.changeContentItems(this.select(c => c.status === 'Published'), 'Unpublish', false); this.changeContentItems(this.select(c => c.status === 'Published'), 'Unpublish');
} }
public archive(content: ContentDto) { public archive(content: ContentDto) {
this.changeContentItems([content], 'Archive', true); this.changeContentItems([content], 'Archive');
} }
public archiveSelected(scheduled: boolean) { public archiveSelected() {
this.changeContentItems(this.select(), 'Archive', true); this.changeContentItems(this.select(), 'Archive');
} }
public restore(content: ContentDto) { public restore(content: ContentDto) {
this.changeContentItems([content], 'Restore', true); this.changeContentItems([content], 'Restore');
} }
public restoreSelected(scheduled: boolean) { public restoreSelected(scheduled: boolean) {
this.changeContentItems(this.select(), 'Restore', true); this.changeContentItems(this.select(), 'Restore');
} }
private changeContentItems(contents: ContentDto[], action: string, reload: boolean) { public isSelectedQuery(query: string) {
return query === this.contentsState.snapshot.contentsQuery || (!query && !this.contentsState.contentsQuery);
}
private changeContentItems(contents: ContentDto[], action: string) {
if (contents.length === 0) { if (contents.length === 0) {
return; return;
} }

60
src/Squidex/app/features/content/pages/contents/search-form.component.html

@ -1,60 +0,0 @@
<ng-container *ngIf="enableShortcut">
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut>
</ng-container>
<form [class]="formClass" (ngSubmit)="search()">
<input class="form-control form-control-expandable" #inputFind [formControl]="contentsFilter" placeholder="Search for content" />
<a class="expand-search" (click)="searchModal.toggle()" #archive>
<i class="icon-caret-down"></i>
</a>
</form>
<sqx-onboarding-tooltip id="contentArchive" [for]="archive" position="bottomRight" after="60000">
Click this icon to show the advanced search menu and to show the archive!
</sqx-onboarding-tooltip>
<sqx-onboarding-tooltip id="contentFind" [for]="inputFind" position="bottomRight" after="120000">
Search for content using full text search over all fields and languages!
</sqx-onboarding-tooltip>
<div class="dropdown-menu" *sqxModalView="searchModal" [sqxModalTarget]="inputFind">
<div class="form-horizontal">
<div [formGroup]="searchForm">
<div class="form-group row">
<label class="col col-2 col-form-label" for="odataSearch">Text</label>
<div class="col col-10">
<input type="input" class="form-control" id="search" (blur)="updateQuery()" formControlName="odataSearch" placeholder="Fulltext search" />
</div>
</div>
<div class="form-group row">
<label class="col col-2 col-form-label" for="filter">Filter</label>
<div class="col col-10">
<input type="input" class="form-control" id="filter" (blur)="updateQuery()" formControlName="odataFilter" placeholder="data/[MY_FIELD]/iv eq 100" />
</div>
</div>
<div class="form-group row">
<label class="col col-2 col-form-label" for="orderBy">Order</label>
<div class="col col-10">
<input type="input" class="form-control" id="orderBy" (blur)="updateQuery()" formControlName="odataOrderBy" placeholder="data/[MY_FIELD]/iv desc" />
</div>
</div>
</div>
<div class="form-check" *ngIf="canArchive">
<input class="form-check-input" type="checkbox" id="archivedItems" [ngModel]="archived" (ngModelChange)="archivedChanged.emit($event)" />
<label class="form-check-label" for="archivedItems">
Archived items
</label>
</div>
<div class="link">
Read more about filtering in the <a href="https://docs.squidex.io/04-guides/02-api.html" target="_blank">Documentation</a>.
</div>
</div>
</div>

4
src/Squidex/app/features/content/shared/assets-editor.component.ts

@ -14,8 +14,8 @@ import {
AppsState, AppsState,
AssetDto, AssetDto,
AssetsService, AssetsService,
DialogModel,
ImmutableArray, ImmutableArray,
ModalModel,
Types Types
} from '@app/shared'; } from '@app/shared';
@ -35,7 +35,7 @@ export class AssetsEditorComponent implements ControlValueAccessor {
private callChange = (v: any) => { /* NOOP */ }; private callChange = (v: any) => { /* NOOP */ };
private callTouched = () => { /* NOOP */ }; private callTouched = () => { /* NOOP */ };
public assetsDialog = new ModalModel(); public assetsDialog = new DialogModel();
public newAssets = ImmutableArray.empty<File>(); public newAssets = ImmutableArray.empty<File>();
public oldAssets = ImmutableArray.empty<AssetDto>(); public oldAssets = ImmutableArray.empty<AssetDto>();

4
src/Squidex/app/features/content/shared/contents-selector.component.html

@ -11,10 +11,10 @@
</button> </button>
</div> </div>
<div class="col pl-1"> <div class="col pl-1">
<sqx-search-form formClass="form" <sqx-search-form formClass="form" placeholder="Search for content" fieldExample="data/[MY_FIELD]/iv"
[query]="contentsState.contentsQuery | async" [query]="contentsState.contentsQuery | async"
(queryChanged)="search($event)" (queryChanged)="search($event)"
[canArchive]="false"> expandable="true">
</sqx-search-form> </sqx-search-form>
<ng-container *ngIf="languages.length > 1"> <ng-container *ngIf="languages.length > 1">

20
src/Squidex/app/shared/components/assets-selector.component.html

@ -4,13 +4,19 @@
</ng-container> </ng-container>
<ng-container tabs> <ng-container tabs>
<button class="float-right btn btn-link btn-secondary" (click)="reload()"> <div class="row no-gutters">
<i class="icon-reset"></i> Refresh <div class="col col-auto offset-lg-4">
</button> <button class="btn btn-link btn-secondary" (click)="reload()">
<i class="icon-reset"></i> Refresh
<form class="float-left form-inline" (ngSubmit)="search()"> </button>
<input class="form-control" [formControl]="assetsFilter" placeholder="Search for assets" /> </div>
</form> <div class="col pl-1">
<sqx-search-form formClass="form" placeholder="Search for assets" fieldExample="fileSize"
[query]="state.assetsQuery | async"
(queryChanged)="search($event)">
</sqx-search-form>
</div>
</div>
</ng-container> </ng-container>
<ng-container content> <ng-container content>

9
src/Squidex/app/shared/components/assets-selector.component.ts

@ -8,7 +8,6 @@
// tslint:disable:prefer-for-of // tslint:disable:prefer-for-of
import { Component, EventEmitter, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators'; import { onErrorResumeNext } from 'rxjs/operators';
import { import {
@ -32,8 +31,6 @@ export class AssetsSelectorComponent implements OnInit {
@Output() @Output()
public selected = new EventEmitter<AssetDto[]>(); public selected = new EventEmitter<AssetDto[]>();
public assetsFilter = new FormControl('');
constructor( constructor(
public readonly state: AssetsDialogState public readonly state: AssetsDialogState
) { ) {
@ -41,16 +38,14 @@ export class AssetsSelectorComponent implements OnInit {
public ngOnInit() { public ngOnInit() {
this.state.load().pipe(onErrorResumeNext()).subscribe(); this.state.load().pipe(onErrorResumeNext()).subscribe();
this.assetsFilter.setValue(this.state.snapshot.assetsQuery);
} }
public reload() { public reload() {
this.state.load(true).pipe(onErrorResumeNext()).subscribe(); this.state.load(true).pipe(onErrorResumeNext()).subscribe();
} }
public search() { public search(query: string) {
this.state.search(this.assetsFilter.value).pipe(onErrorResumeNext()).subscribe(); this.state.search(query).pipe(onErrorResumeNext()).subscribe();
} }
public complete() { public complete() {

6
src/Squidex/app/shared/components/geolocation-editor.component.ts

@ -11,7 +11,7 @@ import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR } from '@angular/f
import { import {
ResourceLoaderService, ResourceLoaderService,
Types, Types,
UIService, UIState,
ValidatorsEx ValidatorsEx
} from '@app/shared/internal'; } from '@app/shared/internal';
@ -72,7 +72,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi
constructor( constructor(
private readonly resourceLoader: ResourceLoaderService, private readonly resourceLoader: ResourceLoaderService,
private readonly formBuilder: FormBuilder, private readonly formBuilder: FormBuilder,
private readonly uiService: UIService private readonly uiState: UIState
) { ) {
} }
@ -158,7 +158,7 @@ export class GeolocationEditorComponent implements ControlValueAccessor, AfterVi
} }
public ngAfterViewInit() { public ngAfterViewInit() {
this.uiService.getSettings() this.uiState.settings
.subscribe(settings => { .subscribe(settings => {
this.isGoogleMaps = settings.mapType === 'GoogleMaps'; this.isGoogleMaps = settings.mapType === 'GoogleMaps';

99
src/Squidex/app/shared/components/search-form.component.html

@ -0,0 +1,99 @@
<ng-container *ngIf="enableShortcut">
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut>
</ng-container>
<form [class]="formClass" (ngSubmit)="search()">
<input class="form-control form-control-expandable" #inputFind [formControl]="contentsFilter" [placeholder]="placeholder" />
<ng-container *ngIf="expandable">
<a class="expand-search" (click)="searchModal.toggle()" #expand>
<i class="icon-caret-down"></i>
</a>
<sqx-onboarding-tooltip id="contentArchive" [for]="expand" position="bottomRight" after="60000">
Click this icon to show the advanced search menu and to show the archive!
</sqx-onboarding-tooltip>
</ng-container>
<ng-container *ngIf="queries">
<ng-container *ngIf="saveKey | async; else notBookmarked; let key">
<a class="save-search" (click)="queries.remove(key)">
<i class="icon-star-full"></i>
</a>
</ng-container>
<ng-template #notBookmarked>
<a class="save-search" (click)="saveQuery()" *ngIf="contentsFilterValue | async">
<i class="icon-star-empty"></i>
</a>
</ng-template>
</ng-container>
</form>
<sqx-onboarding-tooltip id="contentFind" [for]="inputFind" position="bottomRight" after="120000">
Search for content using full text search over all fields and languages!
</sqx-onboarding-tooltip>
<div class="dropdown-menu" *sqxModalView="searchModal" [sqxModalTarget]="inputFind">
<div class="form-horizontal">
<div [formGroup]="searchForm">
<div class="form-group row">
<label class="col col-2 col-form-label" for="odataSearch">Text</label>
<div class="col col-10">
<input type="input" class="form-control" id="search" (blur)="updateQuery()" formControlName="odataSearch" placeholder="Fulltext search" />
</div>
</div>
<div class="form-group row">
<label class="col col-2 col-form-label" for="filter">Filter</label>
<div class="col col-10">
<input type="input" class="form-control" id="filter" (blur)="updateQuery()" formControlName="odataFilter" placeholder="{{fieldExample}} eq [VALUE]" />
</div>
</div>
<div class="form-group row">
<label class="col col-2 col-form-label" for="orderBy">Order</label>
<div class="col col-10">
<input type="input" class="form-control" id="orderBy" (blur)="updateQuery()" formControlName="odataOrderBy" placeholder="{{fieldExample}} desc" />
</div>
</div>
</div>
<div class="form-check" *ngIf="enableArchive">
<input class="form-check-input" type="checkbox" id="archivedItems" [ngModel]="archived" (ngModelChange)="archivedChanged.emit($event)" />
<label class="form-check-label" for="archivedItems">
Archived items
</label>
</div>
<div class="link">
Read more about filtering in the <a href="https://docs.squidex.io/04-guides/02-api.html" target="_blank">Documentation</a>.
</div>
</div>
</div>
<ng-container *sqxModalView="saveQueryDialog;onRoot:true">
<form [formGroup]="saveQueryForm.form" (ngSubmit)="saveQueryComplete()">
<sqx-modal-dialog (closed)="saveQueryDialog.hide()">
<ng-container title>
Name your query
</ng-container>
<ng-container content>
<div class="form-group">
<sqx-control-errors for="name" submitOnly="true" [submitted]="saveQueryForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" id="appName" formControlName="name" autocomplete="off" sqxFocusOnInit />
</div>
</ng-container>
<ng-container footer>
<button type="reset" class="float-left btn btn-secondary" (click)="saveQueryComplete()" [disabled]="saveQueryForm.submitted | async">Cancel</button>
<button type="submit" class="float-right btn btn-success">Create</button>
</ng-container>
</sqx-modal-dialog>
</form>
</ng-container>

10
src/Squidex/app/features/content/pages/contents/search-form.component.scss → src/Squidex/app/shared/components/search-form.component.scss

@ -10,7 +10,7 @@
} }
.form-control-expandable { .form-control-expandable {
padding-right: 1.5rem; padding-right: 3rem;
} }
.form-horizontal { .form-horizontal {
@ -26,6 +26,14 @@
text-align: right; text-align: right;
} }
.save-search {
@include absolute(8px, 24px, auto, auto);
color: $color-border-dark !important;
font-size: .9rem;
font-weight: normal;
cursor: pointer !important;
}
.expand-search { .expand-search {
@include absolute(8px, 8px, auto, auto); @include absolute(8px, 8px, auto, auto);
color: $color-border-dark !important; color: $color-border-dark !important;

64
src/Squidex/app/features/content/pages/contents/search-form.component.ts → src/Squidex/app/shared/components/search-form.component.ts

@ -5,10 +5,17 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms'; import { FormBuilder, FormControl } from '@angular/forms';
import { ModalModel } from '@app/shared'; import {
ModalModel,
Queries,
SaveQueryForm
} from '@app/shared/internal';
import { Observable } from 'rxjs';
import { shareReplay } from 'rxjs/operators';
@Component({ @Component({
selector: 'sqx-search-form', selector: 'sqx-search-form',
@ -16,7 +23,19 @@ import { ModalModel } from '@app/shared';
templateUrl: './search-form.component.html', templateUrl: './search-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class SearchFormComponent implements OnChanges { export class SearchFormComponent implements OnChanges, OnInit {
@Input()
public queries: Queries;
@Input()
public placeholder = '';
@Input()
public fieldExample = '[MY_FIELD]';
@Input()
public expandable = false;
@Input() @Input()
public query = ''; public query = '';
@ -30,7 +49,10 @@ export class SearchFormComponent implements OnChanges {
public archivedChanged = new EventEmitter<boolean>(); public archivedChanged = new EventEmitter<boolean>();
@Input() @Input()
public canArchive = true; public schemaName = '';
@Input()
public enableArchive = false;
@Input() @Input()
public enableShortcut = false; public enableShortcut = false;
@ -39,6 +61,9 @@ export class SearchFormComponent implements OnChanges {
public formClass = 'form-inline search-form'; public formClass = 'form-inline search-form';
public contentsFilter = new FormControl(); public contentsFilter = new FormControl();
public contentsFilterValue = this.contentsFilter.valueChanges.pipe(shareReplay(1));
public saveKey: Observable<string | null>;
public searchModal = new ModalModel(); public searchModal = new ModalModel();
public searchForm = public searchForm =
@ -48,21 +73,44 @@ export class SearchFormComponent implements OnChanges {
odataSearch: '' odataSearch: ''
}); });
public saveQueryDialog = new ModalModel();
public saveQueryForm = new SaveQueryForm(this.formBuilder);
constructor( constructor(
private readonly formBuilder: FormBuilder private readonly formBuilder: FormBuilder
) { ) {
} }
public search() { public ngOnInit() {
this.invalidate(this.contentsFilter.value); this.saveKey = this.queries.getSaveKey(this.contentsFilter.valueChanges);
this.queryChanged.emit(this.contentsFilter.value);
} }
public ngOnChanges() { public ngOnChanges() {
this.invalidate(this.query); this.invalidate(this.query);
} }
public saveQuery() {
this.saveQueryDialog.show();
}
public saveQueryComplete() {
const value = this.saveQueryForm.submit();
if (value) {
this.queries.add(value.name, this.contentsFilter.value);
this.saveQueryForm.submitCompleted();
}
this.saveQueryDialog.hide();
}
public search() {
this.invalidate(this.contentsFilter.value);
this.queryChanged.emit(this.contentsFilter.value);
}
private invalidate(query: string) { private invalidate(query: string) {
if (query === this.contentsFilter.value) { if (query === this.contentsFilter.value) {
return; return;

1
src/Squidex/app/shared/declarations.ts

@ -18,5 +18,6 @@ export * from './components/markdown-editor.component';
export * from './components/pipes'; export * from './components/pipes';
export * from './components/rich-editor.component'; export * from './components/rich-editor.component';
export * from './components/schema-category.component'; export * from './components/schema-category.component';
export * from './components/search-form.component';
export * from './internal'; export * from './internal';

2
src/Squidex/app/shared/internal.ts

@ -57,10 +57,12 @@ export * from './state/languages.state';
export * from './state/patterns.forms'; export * from './state/patterns.forms';
export * from './state/patterns.state'; export * from './state/patterns.state';
export * from './state/plans.state'; export * from './state/plans.state';
export * from './state/queries';
export * from './state/rule-events.state'; export * from './state/rule-events.state';
export * from './state/rules.state'; export * from './state/rules.state';
export * from './state/schemas.forms'; export * from './state/schemas.forms';
export * from './state/schemas.state'; export * from './state/schemas.state';
export * from './state/ui.state';
export * from './utils/messages'; export * from './utils/messages';

7
src/Squidex/app/shared/module.ts

@ -66,7 +66,9 @@ import {
SchemaMustExistPublishedGuard, SchemaMustExistPublishedGuard,
SchemasService, SchemasService,
SchemasState, SchemasState,
SearchFormComponent,
UIService, UIService,
UIState,
UnsetAppGuard, UnsetAppGuard,
UnsetContentGuard, UnsetContentGuard,
UsagesService, UsagesService,
@ -107,7 +109,8 @@ import {
UserNameRefPipe, UserNameRefPipe,
UserPicturePipe, UserPicturePipe,
UserPictureRefPipe, UserPictureRefPipe,
RichEditorComponent RichEditorComponent,
SearchFormComponent
], ],
exports: [ exports: [
AppFormComponent, AppFormComponent,
@ -125,6 +128,7 @@ import {
MarkdownEditorComponent, MarkdownEditorComponent,
RouterModule, RouterModule,
SchemaCategoryComponent, SchemaCategoryComponent,
SearchFormComponent,
UserDtoPicture, UserDtoPicture,
UserIdPicturePipe, UserIdPicturePipe,
UserNamePipe, UserNamePipe,
@ -179,6 +183,7 @@ export class SqxSharedModule {
SchemasService, SchemasService,
SchemasState, SchemasState,
UIService, UIService,
UIState,
UnsetAppGuard, UnsetAppGuard,
UnsetContentGuard, UnsetContentGuard,
UsagesService, UsagesService,

42
src/Squidex/app/shared/services/ui.service.spec.ts

@ -34,28 +34,22 @@ describe('UIService', () => {
it('should make get request to get settings', it('should make get request to get settings',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
let settings1: UISettingsDto; let settings: UISettingsDto;
let settings2: UISettingsDto;
uiService.getSettings().subscribe(result => { uiService.getSettings('my-app').subscribe(result => {
settings1 = result; settings = result;
}); });
const response: UISettingsDto = { mapType: 'OSM', mapKey: '' }; const response: UISettingsDto = { mapType: 'OSM', mapKey: '' };
const req = httpMock.expectOne('http://service/p/api/ui/settings'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings');
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(response); req.flush(response);
uiService.getSettings().subscribe(result => { expect(settings!).toEqual(response);
settings2 = result;
});
expect(settings1!).toEqual(response);
expect(settings2!).toEqual(response);
})); }));
it('should return default settings when error occurs', it('should return default settings when error occurs',
@ -63,11 +57,11 @@ describe('UIService', () => {
let settings: UISettingsDto; let settings: UISettingsDto;
uiService.getSettings().subscribe(result => { uiService.getSettings('my-app').subscribe(result => {
settings = result; settings = result;
}); });
const req = httpMock.expectOne('http://service/p/api/ui/settings'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings');
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
@ -76,4 +70,26 @@ describe('UIService', () => {
expect(settings!).toBeDefined(); expect(settings!).toBeDefined();
})); }));
it('should make put request to set value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.putSetting('my-app', 'root.nested', 123).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
}));
it('should make delete request to remove value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.deleteSetting('my-app', 'root.nested').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
}));
}); });

39
src/Squidex/app/shared/services/ui.service.ts

@ -8,38 +8,41 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
import { ApiUrlConfig } from '@app/framework'; import { ApiUrlConfig } from '@app/framework';
export interface UISettingsDto { export interface UISettingsDto {
mapType: string; mapType: string;
mapKey: string; mapKey?: string;
} }
@Injectable() @Injectable()
export class UIService { export class UIService {
private settings: UISettingsDto;
constructor( constructor(
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig private readonly apiUrl: ApiUrlConfig
) { ) {
} }
public getSettings(): Observable<UISettingsDto> { public getSettings(appName: string): Observable<UISettingsDto & object> {
if (this.settings) { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`);
return of(this.settings);
} else { return this.http.get<UISettingsDto>(url).pipe(
const url = this.apiUrl.buildUrl(`api/ui/settings`); catchError(_ => {
return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' });
return this.http.get<UISettingsDto>(url).pipe( }));
catchError(error => { }
return of({ regexSuggestions: [], mapType: 'OSM', mapKey: '' });
}), public putSetting(appName: string, key: string, value: any): Observable<any> {
tap(settings => { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`);
this.settings = settings;
})); return this.http.put(url, { value });
} }
public deleteSetting(appName: string, key: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`);
return this.http.delete(url);
} }
} }

4
src/Squidex/app/shared/state/assets.state.ts

@ -45,6 +45,10 @@ export class AssetsState extends State<Snapshot> {
this.changes.pipe(map(x => x.assets), this.changes.pipe(map(x => x.assets),
distinctUntilChanged()); distinctUntilChanged());
public assetsQuery =
this.changes.pipe(map(x => x.assetsQuery),
distinctUntilChanged());
public assetsPager = public assetsPager =
this.changes.pipe(map(x => x.assetsPager), this.changes.pipe(map(x => x.assetsPager),
distinctUntilChanged()); distinctUntilChanged());

14
src/Squidex/app/shared/state/contents.forms.ts

@ -8,7 +8,7 @@
// tslint:disable:prefer-for-of // tslint:disable:prefer-for-of
import { FormArray, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { import {
DateTime, DateTime,
@ -36,6 +36,18 @@ import {
TagsFieldPropertiesDto TagsFieldPropertiesDto
} from './../services/schemas.types'; } from './../services/schemas.types';
export class SaveQueryForm extends Form<FormGroup> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
name: ['',
[
Validators.required
]
]
}));
}
}
export class FieldFormatter implements FieldPropertiesVisitor<string> { export class FieldFormatter implements FieldPropertiesVisitor<string> {
constructor( constructor(
private readonly value: any private readonly value: any

79
src/Squidex/app/shared/state/queries.spec.ts

@ -0,0 +1,79 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { BehaviorSubject } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { Queries, Query } from './queries';
import { UIState } from './ui.state';
describe('Queries', () => {
const prefix = 'schemas.my-schema';
let uiState: IMock<UIState>;
let filter = new BehaviorSubject('');
let queries$ = new BehaviorSubject({});
let queries: Queries;
beforeEach(() => {
uiState = Mock.ofType<UIState>();
uiState.setup(x => x.get('schemas.my-schema.queries', {}))
.returns(() => queries$);
queries$.next({
key1: 'query1',
key2: 'query2'
});
queries = new Queries(uiState.object, prefix);
});
it('should load queries', () => {
let converted: Query[];
queries.queries.subscribe(x => {
converted = x;
});
expect(converted!).toEqual([
{
name: 'key1',
nameSortable: 'KEY1',
filter: 'query1'
}, {
name: 'key2',
nameSortable: 'KEY2',
filter: 'query2'
}
]);
});
it('should provide key', () => {
let key: string;
queries.getSaveKey(filter).subscribe(x => {
key = x!;
});
filter.next('query2');
expect(key!).toEqual('key2');
});
it('should forward add call to state', () => {
queries.add('key3', 'filter3');
uiState.verify(x => x.set('schemas.my-schema.queries.key3', 'filter3'), Times.once());
});
it('should forward remove call to state', () => {
queries.remove('key3');
uiState.verify(x => x.remove('schemas.my-schema.queries.key3'), Times.once());
});
});

78
src/Squidex/app/shared/state/queries.ts

@ -0,0 +1,78 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { combineLatest, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { UIState } from './ui.state';
export interface Query {
name: string;
nameSortable?: string;
filter: string;
}
export class Queries {
public queries: Observable<Query[]>;
public defaultQueries: Query[] = [{
name: 'All (newest first)', filter: ''
}, {
name: 'All (oldest first)', filter: '$orderby=lastModified asc'
}];
constructor(
private readonly uiState: UIState,
private readonly prefix: string
) {
this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe(
map(x => {
let queries: Query[] = Object.keys(x).map(y => ({ name: y, filter: x[y] }));
for (let query of queries) {
query.nameSortable = query.name.toUpperCase();
}
queries = queries.sort((a, b) => {
if (a.nameSortable! < b.nameSortable!) {
return -1;
}
if (a.nameSortable! > b.nameSortable!) {
return 1;
}
return 0;
});
return queries;
})
);
}
public add(key: string, filter: string) {
this.uiState.set(`${this.prefix}.queries.${key}`, filter);
}
public remove(key: string) {
this.uiState.remove(`${this.prefix}.queries.${key}`);
}
public getSaveKey(filter$: Observable<string>): Observable<string | null> {
return combineLatest(this.queries, filter$).pipe(
map(project => {
const filter = project[1];
if (filter) {
for (let query of project[0]) {
if (query.filter === filter) {
return query.name;
}
}
}
return null;
}));
}
}

102
src/Squidex/app/shared/state/ui.state.spec.ts

@ -0,0 +1,102 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { AppsState } from '@app/shared';
import { UIService } from './../services/ui.service';
import { UIState } from './ui.state';
describe('UIState', () => {
const app = 'my-app';
const oldSettings = {
mapType: 'OSM'
};
let appsState: IMock<AppsState>;
let uiService: IMock<UIService>;
let uiState: UIState;
beforeEach(() => {
appsState = Mock.ofType<AppsState>();
appsState.setup(x => x.appName)
.returns(() => app);
uiService = Mock.ofType<UIService>();
uiService.setup(x => x.getSettings(app))
.returns(() => of(oldSettings));
uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny()))
.returns(() => of({}));
uiService.setup(x => x.deleteSetting(app, It.isAnyString()))
.returns(() => of({}));
uiState = new UIState(appsState.object, uiService.object);
});
it('should load settings', () => {
expect(uiState.snapshot.settings).toEqual(oldSettings);
});
it('should add value to snapshot when set', () => {
uiState.set('root.nested', 123);
expect(uiState.snapshot.settings).toEqual({
mapType: 'OSM',
root: {
nested: 123
}
});
uiState.get('root', {}).subscribe(x => {
expect(x).toEqual({ nested: 123 });
});
uiState.get('root.nested', 0).subscribe(x => {
expect(x).toEqual(123);
});
uiState.get('root.notfound', 1337).subscribe(x => {
expect(x).toEqual(1337);
});
uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once());
});
it('should remove value from snapshot when removed', () => {
uiState.set('root.nested1', 123);
uiState.set('root.nested2', 123);
uiState.remove('root.nested1');
expect(uiState.snapshot.settings).toEqual({
mapType: 'OSM',
root: {
nested2: 123
}
});
uiState.get('root', {}).subscribe(x => {
expect(x).toEqual({ nested2: 123 });
});
uiState.get('root.nested2', 0).subscribe(x => {
expect(x).toEqual(123);
});
uiState.get('root.nested1', 1337).subscribe(x => {
expect(x).toEqual(1337);
});
uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once());
});
});

139
src/Squidex/app/shared/state/ui.state.ts

@ -0,0 +1,139 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { State, Types } from '@app/framework';
import { AppsState } from './apps.state';
import { UIService, UISettingsDto } from './../services/ui.service';
interface Snapshot {
settings: object & any;
}
@Injectable()
export class UIState extends State<Snapshot> {
public settings =
this.changes.pipe(map(x => x.settings),
distinctUntilChanged());
public get<T>(path: string, defaultValue: T) {
return this.settings.pipe(map(x => this.getValue(x, path, defaultValue)),
distinctUntilChanged());
}
constructor(
private readonly appsState: AppsState,
private readonly uiService: UIService
) {
super({ settings: { mapType: 'OSM' } });
if (appsState.selectedApp && Types.isFunction(appsState.selectedApp.subscribe)) {
appsState.selectedApp.subscribe(app => {
if (app) {
this.load(true);
}
});
} else {
this.load(true);
}
}
public load(reset = false) {
if (!reset) {
this.resetState();
}
this.uiService.getSettings(this.appName)
.subscribe(dtos => {
return this.next({ settings: dtos });
});
}
public set(path: string, value: any) {
const { key, current, root } = this.getContainer(path);
if (current && key) {
this.uiService.putSetting(this.appName, path, value).subscribe();
current[key] = value;
this.next({ settings: root });
}
}
public remove(path: string) {
const { key, current, root } = this.getContainer(path);
if (current && key) {
this.uiService.deleteSetting(this.appName, path).subscribe();
delete current[key];
this.next({ settings: root });
}
}
private getContainer(path: string) {
const segments = path.split('.');
let current = { ...this.snapshot.settings };
const root = current;
if (segments.length > 0) {
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
let temp = current[segment];
if (!temp) {
temp = {};
} else {
temp = { ...temp };
}
current[segment] = temp;
if (!Types.isObject(temp)) {
return { key: null, current: null, root: null };
}
current = temp;
}
}
return { key: segments[segments.length - 1], current, root };
}
private getValue<T>(setting: object & UISettingsDto, path: string, defaultValue: T) {
const segments = path.split('.');
let current = setting;
for (let segment of segments) {
let temp = current[segment];
if (temp) {
current[segment] = temp;
} else {
return defaultValue;
}
current = temp;
}
return <T><any>current;
}
private get appName() {
return this.appsState.appName;
}
}

38
src/Squidex/app/theme/_panels.scss

@ -280,6 +280,44 @@
} }
} }
} }
&-section {
border-top: 1px solid $color-border;
padding: 1rem;
}
&-item {
& {
padding: .25rem 0;
font-size: .9rem;
font-weight: normal;
}
&-remove {
visibility: hidden;
}
&.active {
font-weight: bold;
}
&.active,
&:hover {
background: $color-background;
}
&:hover {
.sidebar-item-remove {
visibility: visible;
}
}
}
}
a {
&.sidebar-item {
cursor: pointer !important;
}
} }
// //

274
src/Squidex/app/theme/icomoon/demo.html

@ -9,10 +9,42 @@
<link rel="stylesheet" href="style.css"></head> <link rel="stylesheet" href="style.css"></head>
<body> <body>
<div class="bgc1 clearfix"> <div class="bgc1 clearfix">
<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;96)</small></h1> <h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;98)</small></h1>
</div> </div>
<div class="clearfix mhl ptl"> <div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 16</h1> <h1 class="mvm mtn fgc1">Grid Size: 16</h1>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-star-full">
</span>
<span class="mls"> icon-star-full</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95d" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95d;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-star-empty">
</span>
<span class="mls"> icon-star-empty</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95e" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95e;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1"> <div class="glyph fs1">
<div class="clearfix bshadow0 pbs"> <div class="clearfix bshadow0 pbs">
<span class="icon-twitter"> <span class="icon-twitter">
@ -368,246 +400,6 @@
<input type="text" readonly value="" class="liga unitRight" /> <input type="text" readonly value="" class="liga unitRight" />
</div> </div>
</div> </div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-action-Algolia">
</span>
<span class="mls"> icon-action-Algolia</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e94c" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe94c;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Tags">
</span>
<span class="mls"> icon-type-Tags</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e94a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe94a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-activity">
</span>
<span class="mls"> icon-activity</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e904" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe904;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-history">
</span>
<span class="mls"> icon-history</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e904" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe904;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-time">
</span>
<span class="mls"> icon-time</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e904" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe904;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-add">
</span>
<span class="mls"> icon-add</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e905" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe905;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-plus">
</span>
<span class="mls"> icon-plus</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e905" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe905;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-check-circle">
</span>
<span class="mls"> icon-check-circle</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e906" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe906;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-check-circle-filled">
</span>
<span class="mls"> icon-check-circle-filled</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e907" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe907;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-close">
</span>
<span class="mls"> icon-close</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e908" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe908;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-References">
</span>
<span class="mls"> icon-type-References</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e909" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe909;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Checkbox">
</span>
<span class="mls"> icon-control-Checkbox</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e90a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe90a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Dropdown">
</span>
<span class="mls"> icon-control-Dropdown</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e90b" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe90b;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Input">
</span>
<span class="mls"> icon-control-Input</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e90c" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe90c;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Radio">
</span>
<span class="mls"> icon-control-Radio</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e90d" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe90d;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
</div> </div>
<div class="clearfix mhl ptl"> <div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: Unknown</h1> <h1 class="mvm mtn fgc1">Grid Size: Unknown</h1>

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

2
src/Squidex/app/theme/icomoon/fonts/icomoon.svg

@ -100,6 +100,8 @@
<glyph unicode="&#xe95a;" glyph-name="support" d="M726 426.667c0-24-20-42-44-42h-426l-170-172v598c0 24 18 42 42 42h554c24 0 44-18 44-42v-384zM896 682.667c24 0 42-18 42-42v-640l-170 170h-470c-24 0-42 18-42 42v86h554v384h86z" /> <glyph unicode="&#xe95a;" glyph-name="support" d="M726 426.667c0-24-20-42-44-42h-426l-170-172v598c0 24 18 42 42 42h554c24 0 44-18 44-42v-384zM896 682.667c24 0 42-18 42-42v-640l-170 170h-470c-24 0-42 18-42 42v86h554v384h86z" />
<glyph unicode="&#xe95b;" glyph-name="backup" d="M512 810.667c212 0 384-172 384-384s-172-384-384-384c-88 0-170 30-234 80l60 60c50-34 110-54 174-54 166 0 298 132 298 298s-132 298-298 298-298-132-298-298h128l-172-170-170 170h128c0 212 172 384 384 384zM598 426.667c0-46-40-86-86-86s-86 40-86 86 40 86 86 86 86-40 86-86z" /> <glyph unicode="&#xe95b;" glyph-name="backup" d="M512 810.667c212 0 384-172 384-384s-172-384-384-384c-88 0-170 30-234 80l60 60c50-34 110-54 174-54 166 0 298 132 298 298s-132 298-298 298-298-132-298-298h128l-172-170-170 170h128c0 212 172 384 384 384zM598 426.667c0-46-40-86-86-86s-86 40-86 86 40 86 86 86 86-40 86-86z" />
<glyph unicode="&#xe95c;" glyph-name="twitter, action-Tweet" d="M1024 733.6c-37.6-16.8-78.2-28-120.6-33 43.4 26 76.6 67.2 92.4 116.2-40.6-24-85.6-41.6-133.4-51-38.4 40.8-93 66.2-153.4 66.2-116 0-210-94-210-210 0-16.4 1.8-32.4 5.4-47.8-174.6 8.8-329.4 92.4-433 219.6-18-31-28.4-67.2-28.4-105.6 0-72.8 37-137.2 93.4-174.8-34.4 1-66.8 10.6-95.2 26.2 0-0.8 0-1.8 0-2.6 0-101.8 72.4-186.8 168.6-206-17.6-4.8-36.2-7.4-55.4-7.4-13.6 0-26.6 1.4-39.6 3.8 26.8-83.4 104.4-144.2 196.2-146-72-56.4-162.4-90-261-90-17 0-33.6 1-50.2 3 93.2-59.8 203.6-94.4 322.2-94.4 386.4 0 597.8 320.2 597.8 597.8 0 9.2-0.2 18.2-0.6 27.2 41 29.4 76.6 66.4 104.8 108.6z" /> <glyph unicode="&#xe95c;" glyph-name="twitter, action-Tweet" d="M1024 733.6c-37.6-16.8-78.2-28-120.6-33 43.4 26 76.6 67.2 92.4 116.2-40.6-24-85.6-41.6-133.4-51-38.4 40.8-93 66.2-153.4 66.2-116 0-210-94-210-210 0-16.4 1.8-32.4 5.4-47.8-174.6 8.8-329.4 92.4-433 219.6-18-31-28.4-67.2-28.4-105.6 0-72.8 37-137.2 93.4-174.8-34.4 1-66.8 10.6-95.2 26.2 0-0.8 0-1.8 0-2.6 0-101.8 72.4-186.8 168.6-206-17.6-4.8-36.2-7.4-55.4-7.4-13.6 0-26.6 1.4-39.6 3.8 26.8-83.4 104.4-144.2 196.2-146-72-56.4-162.4-90-261-90-17 0-33.6 1-50.2 3 93.2-59.8 203.6-94.4 322.2-94.4 386.4 0 597.8 320.2 597.8 597.8 0 9.2-0.2 18.2-0.6 27.2 41 29.4 76.6 66.4 104.8 108.6z" />
<glyph unicode="&#xe95d;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe95e;" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" /> <glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" /> <glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" /> <glyph unicode="&#xf0c9;" glyph-name="list" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 80 KiB

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

68
src/Squidex/app/theme/icomoon/selection.json

@ -1,6 +1,72 @@
{ {
"IcoMoonType": "selection", "IcoMoonType": "selection",
"icons": [ "icons": [
{
"icon": {
"paths": [
"M1024 397.050l-353.78-51.408-158.22-320.582-158.216 320.582-353.784 51.408 256 249.538-60.432 352.352 316.432-166.358 316.432 166.358-60.434-352.352 256.002-249.538z"
],
"attrs": [
{}
],
"isMulticolor": false,
"isMulticolor2": false,
"tags": [
"star-full",
"rate",
"star",
"favorite",
"bookmark"
],
"grid": 16
},
"attrs": [
{}
],
"properties": {
"order": 1,
"id": 2,
"prevSize": 32,
"code": 59741,
"name": "star-full"
},
"setIdx": 0,
"setId": 2,
"iconIdx": 0
},
{
"icon": {
"paths": [
"M1024 397.050l-353.78-51.408-158.22-320.582-158.216 320.582-353.784 51.408 256 249.538-60.432 352.352 316.432-166.358 316.432 166.358-60.434-352.352 256.002-249.538zM512 753.498l-223.462 117.48 42.676-248.83-180.786-176.222 249.84-36.304 111.732-226.396 111.736 226.396 249.836 36.304-180.788 176.222 42.678 248.83-223.462-117.48z"
],
"attrs": [
{}
],
"isMulticolor": false,
"isMulticolor2": false,
"tags": [
"star-empty",
"rate",
"star",
"favorite",
"bookmark"
],
"grid": 16
},
"attrs": [
{}
],
"properties": {
"order": 2,
"id": 1,
"prevSize": 32,
"code": 59742,
"name": "star-empty"
},
"setIdx": 0,
"setId": 2,
"iconIdx": 1
},
{ {
"icon": { "icon": {
"paths": [ "paths": [
@ -31,7 +97,7 @@
}, },
"setIdx": 0, "setIdx": 0,
"setId": 2, "setId": 2,
"iconIdx": 0 "iconIdx": 2
}, },
{ {
"icon": { "icon": {

61
src/Squidex/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face { @font-face {
font-family: 'icomoon'; font-family: 'icomoon';
src: url('fonts/icomoon.eot?f9httd'); src: url('fonts/icomoon.eot?jk799');
src: url('fonts/icomoon.eot?f9httd#iefix') format('embedded-opentype'), src: url('fonts/icomoon.eot?jk799#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?f9httd') format('truetype'), url('fonts/icomoon.ttf?jk799') format('truetype'),
url('fonts/icomoon.woff?f9httd') format('woff'), url('fonts/icomoon.woff?jk799') format('woff'),
url('fonts/icomoon.svg?f9httd#icomoon') format('svg'); url('fonts/icomoon.svg?jk799#icomoon') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
} }
@ -24,6 +24,12 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-star-full:before {
content: "\e95d";
}
.icon-star-empty:before {
content: "\e95e";
}
.icon-twitter:before { .icon-twitter:before {
content: "\e95c"; content: "\e95c";
} }
@ -90,51 +96,6 @@
.icon-download:before { .icon-download:before {
content: "\e93e"; content: "\e93e";
} }
.icon-action-Algolia:before {
content: "\e94c";
}
.icon-type-Tags:before {
content: "\e94a";
}
.icon-activity:before {
content: "\e904";
}
.icon-history:before {
content: "\e904";
}
.icon-time:before {
content: "\e904";
}
.icon-add:before {
content: "\e905";
}
.icon-plus:before {
content: "\e905";
}
.icon-check-circle:before {
content: "\e906";
}
.icon-check-circle-filled:before {
content: "\e907";
}
.icon-close:before {
content: "\e908";
}
.icon-type-References:before {
content: "\e909";
}
.icon-control-Checkbox:before {
content: "\e90a";
}
.icon-control-Dropdown:before {
content: "\e90b";
}
.icon-control-Input:before {
content: "\e90c";
}
.icon-control-Radio:before {
content: "\e90d";
}
.icon-action-Medium:before { .icon-action-Medium:before {
content: "\e959"; content: "\e959";
} }

2
src/Squidex/appsettings.json

@ -12,7 +12,7 @@
*/ */
"baseUrl": "http://localhost:5000", "baseUrl": "http://localhost:5000",
/* /*7
* Set it to true to redirect the user from http to https permanently. * Set it to true to redirect the user from http to https permanently.
*/ */
"enforceHttps": false "enforceHttps": false

2
tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ContentValidationTests.cs

@ -340,7 +340,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
new List<ValidationError> new List<ValidationError>
{ {
new ValidationError("my-field[1].my-nested: Field is required.", "my-field[1].my-nested"), new ValidationError("my-field[1].my-nested: Field is required.", "my-field[1].my-nested"),
new ValidationError("my-field[3].my-nested: Field is required.", "my-field[3].my-nested"), new ValidationError("my-field[3].my-nested: Field is required.", "my-field[3].my-nested")
}); });
} }
} }

125
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs

@ -0,0 +1,125 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Newtonsoft.Json.Linq;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppUISettingsGrainTests
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly IPersistence<AppUISettingsGrain.State> persistence = A.Fake<IPersistence<AppUISettingsGrain.State>>();
private readonly AppUISettingsGrain sut;
public AppUISettingsGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<AppUISettingsGrain.State, Task>>.Ignored))
.Returns(persistence);
sut = new AppUISettingsGrain(store);
sut.OnActivateAsync(Guid.Empty).Wait();
}
[Fact]
public async Task Should_set_setting()
{
await sut.SetAsync(new JObject(new JProperty("key", 15)).AsJ());
var actual = await sut.GetAsync();
var expected =
new JObject(
new JProperty("key", 15));
Assert.Equal(expected.ToString(), actual.Value.ToString());
}
[Fact]
public async Task Should_set_root_value()
{
await sut.SetAsync("key", ((JToken)123).AsJ());
var actual = await sut.GetAsync();
var expected =
new JObject(
new JProperty("key", 123));
Assert.Equal(expected.ToString(), actual.Value.ToString());
}
[Fact]
public async Task Should_remove_root_value()
{
await sut.SetAsync("key", ((JToken)123).AsJ());
await sut.RemoveAsync("key");
var actual = await sut.GetAsync();
var expected = new JObject();
Assert.Equal(expected.ToString(), actual.Value.ToString());
}
[Fact]
public async Task Should_set_nested_value()
{
await sut.SetAsync("root.nested", ((JToken)123).AsJ());
var actual = await sut.GetAsync();
var expected =
new JObject(
new JProperty("root",
new JObject(
new JProperty("nested", 123))));
Assert.Equal(expected.ToString(), actual.Value.ToString());
}
[Fact]
public async Task Should_remove_nested_value()
{
await sut.SetAsync("root.nested", ((JToken)123).AsJ());
await sut.RemoveAsync("root.nested");
var actual = await sut.GetAsync();
var expected =
new JObject(
new JProperty("root", new JObject()));
Assert.Equal(expected.ToString(), actual.Value.ToString());
}
[Fact]
public async Task Should_throw_exception_if_nested_not_an_object()
{
await sut.SetAsync("root.nested", ((JToken)123).AsJ());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.SetAsync("root.nested.value", ((JToken)123).AsJ()));
}
[Fact]
public Task Should_do_nothing_if_deleting_and_nested_not_found()
{
return sut.RemoveAsync("root.nested");
}
[Fact]
public Task Should_do_nothing_if_deleting_and_key_not_found()
{
return sut.RemoveAsync("root");
}
}
}

52
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentQueryServiceTests.cs

@ -105,13 +105,15 @@ namespace Squidex.Domain.Apps.Entities.Contents
public static IEnumerable<object[]> SingleRequestData = new[] public static IEnumerable<object[]> SingleRequestData = new[]
{ {
new object[] { true, new[] { Status.Archived, Status.Draft, Status.Published } }, new object[] { true, true, new[] { Status.Archived, Status.Draft, Status.Published } },
new object[] { false, new[] { Status.Published } } new object[] { true, false, new[] { Status.Archived, Status.Draft, Status.Published } },
new object[] { false, true, new[] { Status.Draft, Status.Published } },
new object[] { false, false, new[] { Status.Published } }
}; };
[Theory] [Theory]
[MemberData(nameof(SingleRequestData))] [MemberData(nameof(SingleRequestData))]
public async Task Should_return_content_from_repository_and_transform(bool isFrontend, params Status[] status) public async Task Should_return_content_from_repository_and_transform(bool isFrontend, bool unpublished, params Status[] status)
{ {
var contentId = Guid.NewGuid(); var contentId = Guid.NewGuid();
var content = CreateContent(contentId); var content = CreateContent(contentId);
@ -124,7 +126,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentRepository.FindContentAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), contentId)) A.CallTo(() => contentRepository.FindContentAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), contentId))
.Returns(content); .Returns(content);
var result = await sut.FindContentAsync(context.WithSchemaId(schemaId), contentId); var ctx = context.WithSchemaId(schemaId).WithUnpublished(unpublished);
var result = await sut.FindContentAsync(ctx, contentId);
Assert.Equal(contentTransformed, result.Data); Assert.Equal(contentTransformed, result.Data);
Assert.Equal(content.Id, result.Id); Assert.Equal(content.Id, result.Id);
@ -165,16 +169,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
public static IEnumerable<object[]> ManyRequestData = new[] public static IEnumerable<object[]> ManyRequestData = new[]
{ {
new object[] { 5, 200, false, true, new[] { Status.Published } }, new object[] { true, true, true, new[] { Status.Archived } },
new object[] { 5, 200, false, false, new[] { Status.Published } }, new object[] { true, true, false, new[] { Status.Archived } },
new object[] { 5, 200, true, false, new[] { Status.Draft, Status.Published } }, new object[] { true, false, true, new[] { Status.Draft, Status.Published } },
new object[] { 5, 200, true, true, new[] { Status.Archived } } new object[] { true, false, false, new[] { Status.Draft, Status.Published } },
new object[] { false, true, true, new[] { Status.Draft, Status.Published } },
new object[] { false, false, true, new[] { Status.Draft, Status.Published } },
new object[] { false, false, false, new[] { Status.Published } },
new object[] { false, true, false, new[] { Status.Published } }
}; };
[Theory] [Theory]
[MemberData(nameof(ManyRequestData))] [MemberData(nameof(ManyRequestData))]
public async Task Should_query_contents_by_query_from_repository_and_transform(int count, int total, bool isFrontend, bool archive, params Status[] status) public async Task Should_query_contents_by_query_from_repository_and_transform(bool isFrontend, bool archive, bool unpublished, params Status[] status)
{ {
const int count = 5, total = 200;
var contentId = Guid.NewGuid(); var contentId = Guid.NewGuid();
var content = CreateContent(contentId); var content = CreateContent(contentId);
@ -187,7 +197,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored)) A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<ODataUriParser>.Ignored))
.Returns(ResultList.Create(total, Enumerable.Repeat(content, count))); .Returns(ResultList.Create(total, Enumerable.Repeat(content, count)));
var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty); var ctx = context.WithSchemaId(schemaId).WithArchived(archive).WithUnpublished(unpublished);
var result = await sut.QueryAsync(ctx, Query.Empty);
Assert.Equal(contentData, result[0].Data); Assert.Equal(contentData, result[0].Data);
Assert.Equal(content.Id, result[0].Id); Assert.Equal(content.Id, result[0].Id);
@ -220,16 +232,22 @@ namespace Squidex.Domain.Apps.Entities.Contents
public static IEnumerable<object[]> ManyIdRequestData = new[] public static IEnumerable<object[]> ManyIdRequestData = new[]
{ {
new object[] { 5, 200, false, true, new[] { Status.Published } }, new object[] { true, true, true, new[] { Status.Archived } },
new object[] { 5, 200, false, false, new[] { Status.Published } }, new object[] { true, true, false, new[] { Status.Archived } },
new object[] { 5, 200, true, false, new[] { Status.Draft, Status.Published } }, new object[] { true, false, true, new[] { Status.Draft, Status.Published } },
new object[] { 5, 200, true, true, new[] { Status.Archived } } new object[] { true, false, false, new[] { Status.Draft, Status.Published } },
new object[] { false, true, true, new[] { Status.Draft, Status.Published } },
new object[] { false, false, true, new[] { Status.Draft, Status.Published } },
new object[] { false, false, false, new[] { Status.Published } },
new object[] { false, true, false, new[] { Status.Published } }
}; };
[Theory] [Theory]
[MemberData(nameof(ManyIdRequestData))] [MemberData(nameof(ManyIdRequestData))]
public async Task Should_query_contents_by_id_from_repository_and_transform(int count, int total, bool isFrontend, bool archive, params Status[] status) public async Task Should_query_contents_by_id_from_repository_and_transform(bool isFrontend, bool archive, bool unpublished, params Status[] status)
{ {
const int count = 5, total = 200;
var ids = Enumerable.Range(0, count).Select(x => Guid.NewGuid()).ToList(); var ids = Enumerable.Range(0, count).Select(x => Guid.NewGuid()).ToList();
SetupClaims(isFrontend); SetupClaims(isFrontend);
@ -241,7 +259,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<HashSet<Guid>>.Ignored)) A.CallTo(() => contentRepository.QueryAsync(app, schema, A<Status[]>.That.IsSameSequenceAs(status), A<HashSet<Guid>>.Ignored))
.Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle())); .Returns(ResultList.Create(total, ids.Select(x => CreateContent(x)).Shuffle()));
var result = await sut.QueryAsync(context.WithSchemaId(schemaId).WithArchived(archive), Query.Empty.WithIds(ids)); var ctx = context.WithSchemaId(schemaId).WithArchived(archive).WithUnpublished(unpublished);
var result = await sut.QueryAsync(ctx, Query.Empty.WithIds(ids));
Assert.Equal(ids, result.Select(x => x.Id).ToList()); Assert.Equal(ids, result.Select(x => x.Id).ToList());
Assert.Equal(total, result.Total); Assert.Equal(total, result.Total);

65
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Xunit; using Xunit;
@ -173,7 +174,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public async Task Should_return_single_asset_when_finding_asset() public async Task Should_return_single_asset_when_finding_asset()
{ {
var assetId = Guid.NewGuid(); var assetId = Guid.NewGuid();
var asset = CreateAsset(Guid.NewGuid()); var asset = CreateAsset(assetId);
var query = $@" var query = $@"
query {{ query {{
@ -713,6 +714,61 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
AssertResult(expected, result); AssertResult(expected, result);
} }
[Fact]
public async Task Should_make_multiple_queries()
{
var assetId1 = Guid.NewGuid();
var assetId2 = Guid.NewGuid();
var asset1 = CreateAsset(assetId1);
var asset2 = CreateAsset(assetId2);
var query1 = $@"
query {{
findAsset(id: ""{assetId1}"") {{
id
}}
}}";
var query2 = $@"
query {{
findAsset(id: ""{assetId2}"") {{
id
}}
}}";
A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId1))
.Returns(asset1);
A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId2))
.Returns(asset2);
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query1 }, new GraphQLQuery { Query = query2 });
var expected = new object[]
{
new
{
data = new
{
findAsset = new
{
id = asset1.Id
}
}
},
new
{
data = new
{
findAsset = new
{
id = asset2.Id
}
}
}
};
AssertResult(expected, result);
}
[Fact] [Fact]
public async Task Should_not_return_data_when_field_not_part_of_content() public async Task Should_not_return_data_when_field_not_part_of_content()
{ {
@ -742,12 +798,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query });
var expected = new var json = JToken.FromObject(result);
{
data = (object)null
};
AssertResult(expected, result, false); Assert.Null(json["data"]);
} }
private QueryContext MatchsAssetContext() private QueryContext MatchsAssetContext()

13
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -174,17 +174,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return asset; return asset;
} }
protected static void AssertResult(object expected, (object Data, object[] Errors) result, bool checkErrors = true) protected static void AssertResult(object expected, (bool HasErrors, object Response) result, bool checkErrors = true)
{ {
if (checkErrors && (result.Errors != null && result.Errors.Length > 0)) if (checkErrors && result.HasErrors)
{ {
throw new InvalidOperationException(result.Errors[0]?.ToString()); throw new InvalidOperationException(NewMethod(result));
} }
var resultJson = JsonConvert.SerializeObject(new { data = result.Data }, Formatting.Indented); var resultJson = JsonConvert.SerializeObject(result.Response, Formatting.Indented);
var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented); var expectJson = JsonConvert.SerializeObject(expected, Formatting.Indented);
Assert.Equal(expectJson, resultJson); Assert.Equal(expectJson, resultJson);
} }
private static string NewMethod((bool HasErrors, object Response) result)
{
return JsonConvert.SerializeObject(result).ToString();
}
} }
} }

2
tests/Squidex.Domain.Apps.Entities.Tests/Contents/SingletonCommandMiddlewareTests.cs

@ -28,7 +28,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
await sut.HandleAsync(context); await sut.HandleAsync(context);
A.CallTo(() => commandBus.PublishAsync(A<ICommand>.That.Matches(x => x is CreateContent))) A.CallTo(() => commandBus.PublishAsync(A<CreateContent>.That.Matches(x => x.Publish == true)))
.MustHaveHappened(); .MustHaveHappened();
} }

Loading…
Cancel
Save