diff --git a/src/Squidex.Infrastructure/Log/ILogStore.cs b/src/Squidex.Infrastructure/Log/ILogStore.cs new file mode 100644 index 000000000..0ea99c0f8 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/ILogStore.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; + +namespace Squidex.Infrastructure.Log +{ + public interface ILogStore + { + Task ReadLockAsync(string key, DateTime from, DateTime to, Stream stream); + } +} diff --git a/src/Squidex.Infrastructure/Log/LockingLogStore.cs b/src/Squidex.Infrastructure/Log/LockingLogStore.cs new file mode 100644 index 000000000..25849e108 --- /dev/null +++ b/src/Squidex.Infrastructure/Log/LockingLogStore.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Orleans; +using Squidex.Infrastructure.Orleans; + +namespace Squidex.Infrastructure.Log +{ + public sealed class LockingLogStore : ILogStore + { + private static readonly TimeSpan LockWaitingTime = TimeSpan.FromMinutes(10); + private readonly ILogStore inner; + private readonly ILockGrain lockGrain; + + public LockingLogStore(ILogStore inner, IGrainFactory grainFactory) + { + Guard.NotNull(inner, nameof(inner)); + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.inner = inner; + + lockGrain = grainFactory.GetGrain(SingleGrain.Id); + } + + public async Task ReadLockAsync(string key, DateTime from, DateTime to, Stream stream) + { + string releaseToken = null; + + using (var cts = new CancellationTokenSource(LockWaitingTime)) + { + while (!cts.IsCancellationRequested) + { + releaseToken = await lockGrain.AcquireLockAsync(key); + + if (releaseToken != null) + { + break; + } + + await Task.Delay(2000); + } + } + + try + { + await inner.ReadLockAsync(key, from, to, stream); + } + finally + { + await lockGrain.ReleaseLockAsync(releaseToken); + } + } + } +} diff --git a/src/Squidex.Infrastructure/Orleans/ILockGrain.cs b/src/Squidex.Infrastructure/Orleans/ILockGrain.cs new file mode 100644 index 000000000..05b4f18c0 --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/ILockGrain.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Orleans; + +namespace Squidex.Infrastructure.Orleans +{ + public interface ILockGrain : IGrainWithStringKey + { + Task AcquireLockAsync(string key); + + Task ReleaseLockAsync(string releaseToken); + } +} diff --git a/src/Squidex.Infrastructure/Orleans/LockGrain.cs b/src/Squidex.Infrastructure/Orleans/LockGrain.cs new file mode 100644 index 000000000..5e57fabaa --- /dev/null +++ b/src/Squidex.Infrastructure/Orleans/LockGrain.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Infrastructure.Orleans +{ + public sealed class LockGrain : GrainOfString, ILockGrain + { + private readonly Dictionary locks = new Dictionary(); + + public Task AcquireLockAsync(string key) + { + string releaseToken = null; + + if (!locks.ContainsKey(key)) + { + releaseToken = Guid.NewGuid().ToString(); + + locks.Add(key, releaseToken); + } + + return Task.FromResult(releaseToken); + } + + public Task ReleaseLockAsync(string releaseToken) + { + var key = locks.FirstOrDefault(x => x.Value == releaseToken).Key; + + if (!string.IsNullOrWhiteSpace(key)) + { + locks.Remove(key); + } + + return TaskHelper.Done; + } + } +} diff --git a/src/Squidex/app/framework/angular/forms/error-formatting.spec.ts b/src/Squidex/app/framework/angular/forms/error-formatting.spec.ts index 746436056..f6ac790c3 100644 --- a/src/Squidex/app/framework/angular/forms/error-formatting.spec.ts +++ b/src/Squidex/app/framework/angular/forms/error-formatting.spec.ts @@ -129,7 +129,7 @@ describe('formatErrors', () => { it('should format validArrayValues', () => { const error = validate([2, 4], ValidatorsEx.validArrayValues([1, 2, 3])); - expect(error).toEqual('MY_FIELD contains an invalid value.'); + expect(error).toEqual('MY_FIELD contains an invalid value: 4.'); }); it('should format match', () => { diff --git a/src/Squidex/app/framework/angular/forms/error-formatting.ts b/src/Squidex/app/framework/angular/forms/error-formatting.ts index 8efc7dc2a..a54d23941 100644 --- a/src/Squidex/app/framework/angular/forms/error-formatting.ts +++ b/src/Squidex/app/framework/angular/forms/error-formatting.ts @@ -28,7 +28,7 @@ const DEFAULT_ERRORS: { [key: string]: string } = { requiredTrue: '{field} is required.', validdatetime: '{field} is not a valid date time.', validvalues: '{field} is not a valid value.', - validarrayvalues: '{field} contains an invalid value.' + validarrayvalues: '{field} contains an invalid value: {invalidvalue}.' }; export function formatError(field: string, type: string, properties: any, value: any, errors?: any) { diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.scss b/src/Squidex/app/framework/angular/forms/tag-editor.component.scss index 9a40216e2..dbba70cd3 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.scss +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.scss @@ -72,7 +72,7 @@ div { @include border-radius(10px); color: $color-dark-foreground; cursor: default; - padding: 0 .6rem; + padding: 1px .6rem; background: $color-theme-blue; border: 0; font-size: .8rem; diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts b/src/Squidex/app/framework/angular/forms/tag-editor.component.ts index e0565f5bc..9a9c19608 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.ts @@ -211,6 +211,8 @@ export class TagEditorComponent implements AfterViewInit, ControlValueAccessor, } public markTouched() { + this.selectValue(this.addInput.value); + this.callTouched(); this.resetAutocompletion(); diff --git a/src/Squidex/app/framework/angular/forms/validators.ts b/src/Squidex/app/framework/angular/forms/validators.ts index c972541fa..e5e85654c 100644 --- a/src/Squidex/app/framework/angular/forms/validators.ts +++ b/src/Squidex/app/framework/angular/forms/validators.ts @@ -167,7 +167,7 @@ export module ValidatorsEx { if (ns) { for (let n of ns) { if (values.indexOf(n) < 0) { - return { validarrayvalues: false }; + return { validarrayvalues: { invalidvalue: n } }; } } } diff --git a/tests/Squidex.Infrastructure.Tests/Log/LockingLogStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Log/LockingLogStoreTests.cs new file mode 100644 index 000000000..eb3aca810 --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Log/LockingLogStoreTests.cs @@ -0,0 +1,60 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading.Tasks; +using FakeItEasy; +using Orleans; +using Squidex.Infrastructure.Orleans; +using Xunit; + +namespace Squidex.Infrastructure.Log +{ + public class LockingLogStoreTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly ILockGrain lockGrain = A.Fake(); + private readonly ILogStore inner = A.Fake(); + private readonly LockingLogStore sut; + + public LockingLogStoreTests() + { + A.CallTo(() => grainFactory.GetGrain(SingleGrain.Id, null)) + .Returns(lockGrain); + + sut = new LockingLogStore(inner, grainFactory); + } + + [Fact] + public async Task Should_lock_and_call_inner() + { + var stream = new MemoryStream(); + + var dateFrom = DateTime.Today; + var dateTo = dateFrom.AddDays(2); + + var key = "MyKey"; + + var releaseToken = Guid.NewGuid().ToString(); + + A.CallTo(() => lockGrain.AcquireLockAsync(key)) + .Returns(releaseToken); + + await sut.ReadLockAsync(key, dateFrom, dateTo, stream); + + A.CallTo(() => lockGrain.AcquireLockAsync(key)) + .MustHaveHappened(); + + A.CallTo(() => lockGrain.ReleaseLockAsync(releaseToken)) + .MustHaveHappened(); + + A.CallTo(() => inner.ReadLockAsync(key, dateFrom, dateTo, stream)) + .MustHaveHappened(); + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Orleans/LockGrainTests.cs b/tests/Squidex.Infrastructure.Tests/Orleans/LockGrainTests.cs new file mode 100644 index 000000000..ead17c4d1 --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Orleans/LockGrainTests.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using Xunit; + +namespace Squidex.Infrastructure.Orleans +{ + public class LockGrainTests + { + private readonly LockGrain sut = new LockGrain(); + + [Fact] + public async Task Should_not_acquire_lock_when_locked() + { + var releaseLock1 = await sut.AcquireLockAsync("Key1"); + var releaseLock2 = await sut.AcquireLockAsync("Key1"); + + Assert.NotNull(releaseLock1); + Assert.Null(releaseLock2); + } + + [Fact] + public async Task Should_acquire_lock_with_other_key() + { + var releaseLock1 = await sut.AcquireLockAsync("Key1"); + var releaseLock2 = await sut.AcquireLockAsync("Key2"); + + Assert.NotNull(releaseLock1); + Assert.NotNull(releaseLock2); + } + + [Fact] + public async Task Should_acquire_lock_after_released() + { + var releaseLock1 = await sut.AcquireLockAsync("Key1"); + + await sut.ReleaseLockAsync(releaseLock1); + + var releaseLock2 = await sut.AcquireLockAsync("Key1"); + + Assert.NotNull(releaseLock1); + Assert.NotNull(releaseLock2); + } + } +}