Browse Source

Cache improvements. (#954)

* Cache improvements.

* Also trim windows path separator.

* Improve scheduler.
pull/955/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
16fc031c31
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 6
      backend/i18n/frontend_pt.json
  5. 2
      backend/i18n/frontend_zh.json
  6. 2
      backend/i18n/source/frontend_en.json
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  9. 47
      backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs
  10. 6
      backend/src/Squidex.Shared/Texts.pt.resx
  11. 94
      backend/src/Squidex.Web/IgnoreHashFileProvider.cs
  12. 7
      backend/src/Squidex/Areas/Frontend/Startup.cs
  13. 8
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml
  14. 86
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml
  15. 14
      backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml
  16. 56
      backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  17. 202
      backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml
  18. 8
      backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml
  19. 39
      backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs
  20. 242
      backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs
  21. 33
      frontend/src/app/framework/angular/global-error-handler.ts
  22. 7
      frontend/src/app/framework/declarations.ts
  23. 9
      frontend/src/app/framework/module.ts
  24. 29
      frontend/src/config/webpack.config.js

2
backend/i18n/frontend_en.json

@ -282,6 +282,8 @@
"common.errorBack": "Back to previous page.", "common.errorBack": "Back to previous page.",
"common.errorNoPermission": "You do not have the permissions to do this.", "common.errorNoPermission": "You do not have the permissions to do this.",
"common.errorNotFound": "Not Found", "common.errorNotFound": "Not Found",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "Event", "common.event": "Event",
"common.events": "Events", "common.events": "Events",
"common.executed": "Executed", "common.executed": "Executed",

2
backend/i18n/frontend_it.json

@ -282,6 +282,8 @@
"common.errorBack": "Torna alla pagina precedente.", "common.errorBack": "Torna alla pagina precedente.",
"common.errorNoPermission": "Non hai i permessi per questo.", "common.errorNoPermission": "Non hai i permessi per questo.",
"common.errorNotFound": "Non trovato", "common.errorNotFound": "Non trovato",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "Evento", "common.event": "Evento",
"common.events": "Eventi", "common.events": "Eventi",
"common.executed": "Eseguito", "common.executed": "Eseguito",

2
backend/i18n/frontend_nl.json

@ -282,6 +282,8 @@
"common.errorBack": "Terug naar de vorige pagina.", "common.errorBack": "Terug naar de vorige pagina.",
"common.errorNoPermission": "Je hebt niet de permissies om dit te doen.", "common.errorNoPermission": "Je hebt niet de permissies om dit te doen.",
"common.errorNotFound": "Niet gevonden", "common.errorNotFound": "Niet gevonden",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "Evenement", "common.event": "Evenement",
"common.events": "Evenementen", "common.events": "Evenementen",
"common.executed": "Uitgevoerd", "common.executed": "Uitgevoerd",

6
backend/i18n/frontend_pt.json

@ -188,12 +188,16 @@
"clients.connectWizard.manuallyTokenHint": "Tokens normalmente expiram após 30 dias, mas você pode solicitar várias tokens.", "clients.connectWizard.manuallyTokenHint": "Tokens normalmente expiram após 30 dias, mas você pode solicitar várias tokens.",
"clients.connectWizard.postManDocs": "Comece com o tutorial do Carteiro na [Documentação](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).", "clients.connectWizard.postManDocs": "Comece com o tutorial do Carteiro na [Documentação](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).",
"clients.connectWizard.sdk": "Conecte-se à sua App com a SDK", "clients.connectWizard.sdk": "Conecte-se à sua App com a SDK",
"clients.connectWizard.sdkDocumentation": "Documentations for the .NET SDK is available: ",
"clients.connectWizard.sdkHelp": "Precisa de outro SDK?", "clients.connectWizard.sdkHelp": "Precisa de outro SDK?",
"clients.connectWizard.sdkHelpLink": "Contacte-nos no Fórum de Apoio", "clients.connectWizard.sdkHelpLink": "Contacte-nos no Fórum de Apoio",
"clients.connectWizard.sdkHint": "Descarregue um SDK e estabeleça uma ligação a esta aplicação.", "clients.connectWizard.sdkHint": "Descarregue um SDK e estabeleça uma ligação a esta aplicação.",
"clients.connectWizard.sdkStep1": "Instale o .NET SDK", "clients.connectWizard.sdkStep1": "Instale o .NET SDK",
"clients.connectWizard.sdkStep1Download": "O SDK está disponível em [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)", "clients.connectWizard.sdkStep1Download": "O SDK está disponível em [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)",
"clients.connectWizard.sdkStep2": "Criar um gestor de clientes", "clients.connectWizard.sdkStep2": "Criar um gestor de clientes",
"clients.connectWizard.sdkStep3": "Optionally: Install the Service Extensions for the SDK",
"clients.connectWizard.sdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)",
"clients.connectWizard.sdkStep4": "Optionally: Register the client manager and all clients",
"clients.connectWizard.step0Title": "Cliente de configuração", "clients.connectWizard.step0Title": "Cliente de configuração",
"clients.connectWizard.step1Title": "Escolha o método de ligação", "clients.connectWizard.step1Title": "Escolha o método de ligação",
"clients.connectWizard.step2Title": "Ligar", "clients.connectWizard.step2Title": "Ligar",
@ -278,6 +282,8 @@
"common.errorBack": "De volta à página anterior.", "common.errorBack": "De volta à página anterior.",
"common.errorNoPermission": "Não tem as permissões para fazer isto.", "common.errorNoPermission": "Não tem as permissões para fazer isto.",
"common.errorNotFound": "Não encontrado", "common.errorNotFound": "Não encontrado",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "Evento", "common.event": "Evento",
"common.events": "Eventos", "common.events": "Eventos",
"common.executed": "Executado", "common.executed": "Executado",

2
backend/i18n/frontend_zh.json

@ -282,6 +282,8 @@
"common.errorBack": "返回上一页。", "common.errorBack": "返回上一页。",
"common.errorNoPermission": "您无权执行此操作。", "common.errorNoPermission": "您无权执行此操作。",
"common.errorNotFound": "未找到", "common.errorNotFound": "未找到",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "事件", "common.event": "事件",
"common.events": "事件", "common.events": "事件",
"common.executed": "已执行", "common.executed": "已执行",

2
backend/i18n/source/frontend_en.json

@ -282,6 +282,8 @@
"common.errorBack": "Back to previous page.", "common.errorBack": "Back to previous page.",
"common.errorNoPermission": "You do not have the permissions to do this.", "common.errorNoPermission": "You do not have the permissions to do this.",
"common.errorNotFound": "Not Found", "common.errorNotFound": "Not Found",
"common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?",
"common.errors.chunkLoadingTitle": "Failed to load chunk",
"common.event": "Event", "common.event": "Event",
"common.events": "Events", "common.events": "Events",
"common.executed": "Executed", "common.executed": "Executed",

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -36,7 +36,7 @@ public sealed class AssetsFluidExtension : IFluidExtension
AddAssetFilter(options); AddAssetFilter(options);
AddAssetTextFilter(options); AddAssetTextFilter(options);
parser.RegisterParserTag("asset", parser.RegisterParserTag("asset",
parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser), parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser),
ResolveAsset); ResolveAsset);
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs

@ -34,7 +34,7 @@ public sealed class ReferencesFluidExtension : IFluidExtension
AddReferenceFilter(options); AddReferenceFilter(options);
parser.RegisterParserTag("reference", parser.RegisterParserTag("reference",
parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser), parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser),
ResolveReference); ResolveReference);
} }

47
backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs

@ -13,6 +13,8 @@ public delegate Task SchedulerTask(CancellationToken ct);
public sealed class Scheduler public sealed class Scheduler
{ {
private const int SpecialStateStartedOrDone = 1;
private const int SpecialStateCompleted = -1;
private readonly TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>(); private readonly TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
private readonly SemaphoreSlim semaphore; private readonly SemaphoreSlim semaphore;
private List<SchedulerTask>? tasks; private List<SchedulerTask>? tasks;
@ -30,7 +32,7 @@ public sealed class Scheduler
public void Schedule(SchedulerTask task) public void Schedule(SchedulerTask task)
{ {
if (pendingTasks < 0) if (pendingTasks <= SpecialStateCompleted)
{ {
// Already completed. // Already completed.
return; return;
@ -39,7 +41,7 @@ public sealed class Scheduler
if (pendingTasks >= 1) if (pendingTasks >= 1)
{ {
// If we already in a tasks we just queue it with the semaphore. // If we already in a tasks we just queue it with the semaphore.
ScheduleTask(task, default).Forget(); ScheduleTasks(new[] { task }, default);
return; return;
} }
@ -50,50 +52,35 @@ public sealed class Scheduler
public async ValueTask CompleteAsync( public async ValueTask CompleteAsync(
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (tasks == null || tasks.Count == 0) // Do not allow another completion call.
if (tasks == null || pendingTasks <= SpecialStateCompleted)
{ {
return; return;
} }
// Use the value to indicate that the task have been started. // Use the value to indicate that the task have been started.
pendingTasks = 1; pendingTasks = SpecialStateStartedOrDone;
try try
{ {
RunTasks(ct).AsTask().Forget(); ScheduleTasks(tasks, ct);
await tcs.Task; await tcs.Task;
} }
finally finally
{ {
pendingTasks = -1; pendingTasks = SpecialStateCompleted;
} }
} }
private async ValueTask RunTasks( private void ScheduleTasks(IReadOnlyCollection<SchedulerTask> taskToSchedule,
CancellationToken ct) CancellationToken ct)
{ {
// If nothing needs to be done, we can just stop here. // Increment the pending tasks once, so we avoid issues when the tasks are executed sequentially.
if (tasks == null || tasks.Count == 0) Interlocked.Add(ref pendingTasks, taskToSchedule.Count);
{
tcs.TrySetResult(true);
return;
}
// Quick check to avoid the allocation of the list. foreach (var task in taskToSchedule)
if (tasks.Count == 1)
{ {
await ScheduleTask(tasks[0], ct); ScheduleTask(task, ct).Forget();
return;
} }
var runningTasks = new List<Task>();
foreach (var validationTask in tasks)
{
runningTasks.Add(ScheduleTask(validationTask, ct));
}
await Task.WhenAll(runningTasks);
} }
private async Task ScheduleTask(SchedulerTask task, private async Task ScheduleTask(SchedulerTask task,
@ -101,9 +88,7 @@ public sealed class Scheduler
{ {
try try
{ {
// Use the interlock to reduce degree of parallelization. // Use the semaphore to reduce degree of parallelization.
Interlocked.Increment(ref pendingTasks);
await semaphore.WaitAsync(ct); await semaphore.WaitAsync(ct);
await task(ct); await task(ct);
} }
@ -115,7 +100,7 @@ public sealed class Scheduler
{ {
semaphore.Release(); semaphore.Release();
if (Interlocked.Decrement(ref pendingTasks) <= 1) if (Interlocked.Decrement(ref pendingTasks) <= SpecialStateStartedOrDone)
{ {
tcs.TrySetResult(true); tcs.TrySetResult(true);
} }

6
backend/src/Squidex.Shared/Texts.pt.resx

@ -730,6 +730,9 @@
<data name="history.apps.clientUpdated" xml:space="preserve"> <data name="history.apps.clientUpdated" xml:space="preserve">
<value>cliente {[Id]} actualizado</value> <value>cliente {[Id]} actualizado</value>
</data> </data>
<data name="history.apps.common.updated" xml:space="preserve">
<value>updated general settings</value>
</data>
<data name="history.apps.contributoreAssigned" xml:space="preserve"> <data name="history.apps.contributoreAssigned" xml:space="preserve">
<value>associado {user:[Contributor]} a {[Role]}</value> <value>associado {user:[Contributor]} a {[Role]}</value>
</data> </data>
@ -778,9 +781,6 @@
<data name="history.apps.transfered" xml:space="preserve"> <data name="history.apps.transfered" xml:space="preserve">
<value>actualizada app ao cliente</value> <value>actualizada app ao cliente</value>
</data> </data>
<data name="history.apps.updated" xml:space="preserve">
<value>actualizadas configurações gerais</value>
</data>
<data name="history.apps.workflowAdded" xml:space="preserve"> <data name="history.apps.workflowAdded" xml:space="preserve">
<value>adicionado fluxo de trabalho {[Name]}.</value> <value>adicionado fluxo de trabalho {[Name]}.</value>
</data> </data>

94
backend/src/Squidex.Web/IgnoreHashFileProvider.cs

@ -0,0 +1,94 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text.RegularExpressions;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
namespace Squidex.Web;
public sealed class IgnoreHashFileProvider : IFileProvider
{
private readonly char[] pathSeparators = { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, '\\' };
private readonly Dictionary<string, string> map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private readonly IFileProvider inner;
public IgnoreHashFileProvider(IFileProvider inner)
{
this.inner = inner;
var regex = new Regex("^(?<Name>[^.]+)\\.[0-9a-f]{4,}\\.(?<Extension>.+)$");
void MapDirectory(string path)
{
foreach (var file in inner.GetDirectoryContents(path))
{
if (file.IsDirectory)
{
MapDirectory(Combine(path, file.Name));
continue;
}
var match = regex.Match(file.Name);
if (match.Success)
{
var nameWithouthHash = $"{match.Groups["Name"].Value}.{match.Groups["Extension"].Value}";
var pathHashed = Combine(path, file.Name);
var pathNormal = Combine(path, nameWithouthHash);
map[pathNormal] = pathHashed;
}
}
}
MapDirectory(string.Empty);
}
public IFileInfo GetFileInfo(string subpath)
{
var file = inner.GetFileInfo(subpath);
if (!file.Exists)
{
subpath = subpath.TrimStart(pathSeparators).Replace('\\', '/');
if (map.TryGetValue(subpath, out var withHash))
{
file = inner.GetFileInfo(withHash);
}
}
return file;
}
public IDirectoryContents GetDirectoryContents(string subpath)
{
return inner.GetDirectoryContents(subpath);
}
public IChangeToken Watch(string filter)
{
return inner.Watch(filter);
}
private static string Combine(string path1, string path2)
{
if (string.IsNullOrWhiteSpace(path1))
{
return path2;
}
if (string.IsNullOrWhiteSpace(path2))
{
return path1;
}
return $"{path1}/{path2}";
}
}

7
backend/src/Squidex/Areas/Frontend/Startup.cs

@ -10,6 +10,7 @@ using Microsoft.Net.Http.Headers;
using Squidex.Areas.Frontend.Middlewares; using Squidex.Areas.Frontend.Middlewares;
using Squidex.Hosting.Web; using Squidex.Hosting.Web;
using Squidex.Pipeline.Squid; using Squidex.Pipeline.Squid;
using Squidex.Web;
using Squidex.Web.Pipeline; using Squidex.Web.Pipeline;
namespace Squidex.Areas.Frontend; namespace Squidex.Areas.Frontend;
@ -26,8 +27,10 @@ public static class Startup
if (!environment.IsDevelopment()) if (!environment.IsDevelopment())
{ {
fileProvider = new CompositeFileProvider(fileProvider, var buildFolder = new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "build"));
new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "build"))); var buildProvider = new IgnoreHashFileProvider(buildFolder);
fileProvider = new CompositeFileProvider(fileProvider, buildFolder, buildProvider);
} }
app.Map("/squid.svg", builder => app.Map("/squid.svg", builder =>

8
backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml

@ -5,10 +5,10 @@
} }
@functions { @functions {
public string ErrorClass(string error) public string ErrorClass(string error)
{ {
return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : ""; return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : "";
} }
} }
<form asp-controller="Account" asp-action="Consent" asp-route-returnurl="@Model!.ReturnUrl" method="post"> <form asp-controller="Account" asp-action="Consent" asp-route-returnurl="@Model!.ReturnUrl" method="post">

86
backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml

@ -3,7 +3,7 @@
@{ @{
var action = Model!.IsLogin ? T.Get("common.login") : T.Get("common.signup"); var action = Model!.IsLogin ? T.Get("common.login") : T.Get("common.signup");
ViewBag.Title = action; ViewBag.Title = action;
} }
<div class="login-container"> <div class="login-container">
@ -11,55 +11,55 @@ ViewBag.Title = action;
<div class="row text-center"> <div class="row text-center">
<div class="btn-group profile-headline"> <div class="btn-group profile-headline">
@if (Model!.IsLogin) @if (Model!.IsLogin)
{ {
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a> <a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
} }
else else
{ {
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a> <a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
} }
@if (!Model!.IsLogin) @if (!Model!.IsLogin)
{ {
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a> <a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
} }
else else
{ {
<a class="btn btn-toggle" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a> <a class="btn btn-toggle" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
} }
</div> </div>
</div> </div>
</div> </div>
<form asp-controller="Account" asp-action="External" asp-route-returnurl="@Model!.ReturnUrl" method="post"> <form asp-controller="Account" asp-action="External" asp-route-returnurl="@Model!.ReturnUrl" method="post">
@foreach (var provider in Model!.ExternalProviders) @foreach (var provider in Model!.ExternalProviders)
{ {
var schema = provider.AuthenticationScheme.ToLowerInvariant(); var schema = provider.AuthenticationScheme.ToLowerInvariant();
<div class="form-group"> <div class="form-group">
<button class="btn external-button btn-block btn btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme"> <button class="btn external-button btn-block btn btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<i class="icon-@schema external-icon"></i> @Html.Raw(T.Get("users.login.loginWith", new { action, provider = provider.DisplayName })) <i class="icon-@schema external-icon"></i> @Html.Raw(T.Get("users.login.loginWith", new { action, provider = provider.DisplayName }))
</button> </button>
</div> </div>
} }
</form> </form>
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth) @if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
{ {
<div class="profile-separator"> <div class="profile-separator">
<div class="profile-separator-text">@T.Get("users.login.separator")</div> <div class="profile-separator-text">@T.Get("users.login.separator")</div>
</div> </div>
} }
@if (Model!.HasPasswordAuth) @if (Model!.HasPasswordAuth)
{
if (Model!.IsLogin)
{
if (Model!.IsFailed)
{ {
<div class="form-alert form-alert-error">@T.Get("users.login.error")</div> if (Model!.IsLogin)
} {
if (Model!.IsFailed)
{
<div class="form-alert form-alert-error">@T.Get("users.login.error")</div>
}
<form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl" method="post"> <form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<div class="form-group"> <div class="form-group">
<input type="email" class="form-control" name="email" id="email" placeholder="@T.Get("users.login.emailPlaceholder")" /> <input type="email" class="form-control" name="email" id="email" placeholder="@T.Get("users.login.emailPlaceholder")" />
</div> </div>
@ -70,31 +70,31 @@ if (Model!.IsLogin)
<button type="submit" class="btn btn-block btn-primary">@action</button> <button type="submit" class="btn btn-block btn-primary">@action</button>
</form> </form>
} }
else else
{ {
<div class="profile-password-signup text-center">@T.Get("users.login.askAdmin")</div> <div class="profile-password-signup text-center">@T.Get("users.login.askAdmin")</div>
} }
} }
@if (Model!.IsLogin) @if (Model!.IsLogin)
{ {
<p class="profile-footer"> <p class="profile-footer">
@T.Get("users.login.noAccountSignupQuestion") @T.Get("users.login.noAccountSignupQuestion")
<a asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl"> <a asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">
@T.Get("users.login.noAccountSignupAction") @T.Get("users.login.noAccountSignupAction")
</a> </a>
</p> </p>
} }
else else
{ {
<p class="profile-footer"> <p class="profile-footer">
@T.Get("users.login.noAccountLoginQuestion") @T.Get("users.login.noAccountLoginQuestion")
<a asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl"> <a asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">
@T.Get("users.login.noAccountLoginAction") @T.Get("users.login.noAccountLoginAction")
</a> </a>
</p> </p>
} }
</div> </div>

14
backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml

@ -10,11 +10,11 @@
<p class="splash-text"> <p class="splash-text">
@if (Model!.ErrorMessage != null) @if (Model!.ErrorMessage != null)
{ {
@Model!.ErrorMessage @Model!.ErrorMessage
} }
else else
{ {
<span>@T.Get("users.error.text")</span> <span>@T.Get("users.error.text")</span>
} }
</p> </p>

56
backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml

@ -3,15 +3,15 @@
@{ @{
ViewBag.Title = T.Get("users.profile.title"); ViewBag.Title = T.Get("users.profile.title");
void RenderValidation(string field) void RenderValidation(string field)
{ {
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{ {
<div class="errors-container"> <div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span> <span class="errors">@Html.ValidationMessage(field)</span>
</div> </div>
} }
} }
} }
<h1>@T.Get("users.profile.headline")</h1> <h1>@T.Get("users.profile.headline")</h1>
@ -89,8 +89,8 @@ void RenderValidation(string field)
<col style="width: 100px;" /> <col style="width: 100px;" />
</colgroup> </colgroup>
@foreach (var login in Model!.ExternalLogins) @foreach (var login in Model!.ExternalLogins)
{ {
<tr> <tr>
<td> <td>
<span>@login.LoginProvider</span> <span>@login.LoginProvider</span>
</td> </td>
@ -99,8 +99,8 @@ void RenderValidation(string field)
</td> </td>
<td class="text-right"> <td class="text-right">
@if (Model!.ExternalLogins.Count > 1 || Model!.HasPassword) @if (Model!.ExternalLogins.Count > 1 || Model!.HasPassword)
{ {
<form asp-controller="Profile" asp-action="RemoveLogin" method="post"> <form asp-controller="Profile" asp-action="RemoveLogin" method="post">
<input type="hidden" value="@login.LoginProvider" name="LoginProvider" /> <input type="hidden" value="@login.LoginProvider" name="LoginProvider" />
<input type="hidden" value="@login.ProviderKey" name="ProviderKey" /> <input type="hidden" value="@login.ProviderKey" name="ProviderKey" />
@ -108,21 +108,21 @@ void RenderValidation(string field)
@T.Get("common.remove") @T.Get("common.remove")
</button> </button>
</form> </form>
} }
</td> </td>
</tr> </tr>
} }
</table> </table>
<form asp-controller="Profile" asp-action="AddLogin" method="post"> <form asp-controller="Profile" asp-action="AddLogin" method="post">
@foreach (var provider in Model!.ExternalProviders.Where(x => Model!.ExternalLogins.All(y => x.AuthenticationScheme != y.LoginProvider))) @foreach (var provider in Model!.ExternalProviders.Where(x => Model!.ExternalLogins.All(y => x.AuthenticationScheme != y.LoginProvider)))
{ {
var schema = provider.AuthenticationScheme.ToLowerInvariant(); var schema = provider.AuthenticationScheme.ToLowerInvariant();
<button class="btn external-button-small btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme"> <button class="btn external-button-small btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<i class="icon-@schema external-icon"></i> <i class="icon-@schema external-icon"></i>
</button> </button>
} }
</form> </form>
</div> </div>
} }
@ -134,9 +134,9 @@ void RenderValidation(string field)
<div class="profile-section"> <div class="profile-section">
<h2>@T.Get("users.profile.passwordTitle")</h2> <h2>@T.Get("users.profile.passwordTitle")</h2>
@if (Model!.HasPassword) @if (Model!.HasPassword)
{ {
<form class="profile-form" asp-controller="Profile" asp-action="ChangePassword" method="post"> <form class="profile-form" asp-controller="Profile" asp-action="ChangePassword" method="post">
<div class="form-group"> <div class="form-group">
<label for="oldPassword">@T.Get("common.oldPassword")</label> <label for="oldPassword">@T.Get("common.oldPassword")</label>
@ -165,10 +165,10 @@ void RenderValidation(string field)
<button type="submit" class="btn btn-primary">@T.Get("users.profile.changePassword")</button> <button type="submit" class="btn btn-primary">@T.Get("users.profile.changePassword")</button>
</div> </div>
</form> </form>
} }
else else
{ {
<form class="profile-form" asp-controller="Profile" asp-action="SetPassword" method="post"> <form class="profile-form" asp-controller="Profile" asp-action="SetPassword" method="post">
<div class="form-group"> <div class="form-group">
<label for="password">@T.Get("common.password")</label> <label for="password">@T.Get("common.password")</label>
@ -189,7 +189,7 @@ else
<button type="submit" class="btn btn-primary">@T.Get("users.profile.setPassword")</button> <button type="submit" class="btn btn-primary">@T.Get("users.profile.setPassword")</button>
</div> </div>
</form> </form>
} }
</div> </div>
} }
@ -233,8 +233,8 @@ else
<form class="profile-form" asp-controller="Profile" asp-action="UpdateProperties" method="post"> <form class="profile-form" asp-controller="Profile" asp-action="UpdateProperties" method="post">
<div class="mb-2" id="properties"> <div class="mb-2" id="properties">
@for (var i = 0; i < Model!.Properties.Count; i++) @for (var i = 0; i < Model!.Properties.Count; i++)
{ {
<div class="row g-2 form-group"> <div class="row g-2 form-group">
<div class="col-5 pr-2"> <div class="col-5 pr-2">
@{ RenderValidation($"Properties[{i}].Name"); } @{ RenderValidation($"Properties[{i}].Name"); }
@ -253,7 +253,7 @@ else
</button> </button>
</div> </div>
</div> </div>
} }
</div> </div>
<div class="form-group"> <div class="form-group">

202
backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml

@ -3,66 +3,66 @@
@{ @{
ViewBag.Title = T.Get("setup.title"); ViewBag.Title = T.Get("setup.title");
void RenderValidation(string field) void RenderValidation(string field)
{ {
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{ {
<div class="errors-container"> <div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span> <span class="errors">@Html.ValidationMessage(field)</span>
</div> </div>
} }
} }
void RenderRuleAsSuccess(string message) void RenderRuleAsSuccess(string message)
{ {
<div class="row mt-4"> <div class="row mt-4">
<div class="col-auto"> <div class="col-auto">
<div class="status-icon status-icon-success mt-2"> <div class="status-icon status-icon-success mt-2">
<i class="icon-checkmark"></i> <i class="icon-checkmark"></i>
</div>
</div> </div>
</div>
<div class="col"> <div class="col">
<div> <div>
@Html.Raw(message) @Html.Raw(message)
</div>
</div> </div>
</div> </div>
</div> }
}
void RenderRuleAsCritical(string message)
void RenderRuleAsCritical(string message) {
{ <div class="row mt-4">
<div class="row mt-4"> <div class="col-auto">
<div class="col-auto"> <div class="status-icon status-icon-failed mt-2">
<div class="status-icon status-icon-failed mt-2"> <i class="icon-exclamation"></i>
<i class="icon-exclamation"></i> </div>
</div> </div>
</div>
<div class="col"> <div class="col">
<div> <div>
<strong>@T.Get("common.critical")</strong>: @Html.Raw(message) <strong>@T.Get("common.critical")</strong>: @Html.Raw(message)
</div>
</div> </div>
</div> </div>
</div> }
}
void RenderRuleAsWarning(string message)
void RenderRuleAsWarning(string message) {
{ <div class="row mt-4">
<div class="row mt-4"> <div class="col-auto">
<div class="col-auto"> <div class="status-icon status-icon-warning mt-2">
<div class="status-icon status-icon-warning mt-2"> <i class="icon-exclamation"></i>
<i class="icon-exclamation"></i> </div>
</div> </div>
</div>
<div class="col"> <div class="col">
<div> <div>
<strong>@T.Get("common.warning")</strong>: @Html.Raw(message) <strong>@T.Get("common.warning")</strong>: @Html.Raw(message)
</div>
</div> </div>
</div> </div>
</div> }
}
} }
<h1>@T.Get("setup.headline")</h1> <h1>@T.Get("setup.headline")</h1>
@ -77,50 +77,50 @@ void RenderRuleAsWarning(string message)
<h2>@T.Get("setup.rules.headline")</h2> <h2>@T.Get("setup.rules.headline")</h2>
@if (Model!.IsValidHttps) @if (Model!.IsValidHttps)
{ {
RenderRuleAsSuccess(T.Get("setup.ruleHttps.success")); RenderRuleAsSuccess(T.Get("setup.ruleHttps.success"));
} }
else else
{ {
RenderRuleAsCritical(T.Get("setup.ruleHttps.failure")); RenderRuleAsCritical(T.Get("setup.ruleHttps.failure"));
} }
@if (Model!.BaseUrlConfigured == Model!.BaseUrlCurrent) @if (Model!.BaseUrlConfigured == Model!.BaseUrlCurrent)
{ {
RenderRuleAsSuccess(T.Get("setup.ruleUrl.success")); RenderRuleAsSuccess(T.Get("setup.ruleUrl.success"));
} }
else else
{ {
RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model!.BaseUrlCurrent, configured = Model!.BaseUrlConfigured })); RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model!.BaseUrlCurrent, configured = Model!.BaseUrlConfigured }));
} }
@if (Model!.EverybodyCanCreateApps) @if (Model!.EverybodyCanCreateApps)
{ {
RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAdmins")); RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAdmins"));
} }
else else
{ {
RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAll")); RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAll"));
} }
@if (Model!.EverybodyCanCreateTeams) @if (Model!.EverybodyCanCreateTeams)
{ {
RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAdmins")); RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAdmins"));
} }
else else
{ {
RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAll")); RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAll"));
} }
@if (Model!.IsAssetStoreFtp) @if (Model!.IsAssetStoreFtp)
{ {
RenderRuleAsWarning(T.Get("setup.ruleFtp.warning")); RenderRuleAsWarning(T.Get("setup.ruleFtp.warning"));
} }
@if (Model!.IsAssetStoreFile) @if (Model!.IsAssetStoreFile)
{ {
RenderRuleAsWarning(T.Get("setup.ruleFolder.warning")); RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
} }
</div> </div>
<hr /> <hr />
@ -142,24 +142,24 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
} }
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth) @if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
{ {
<div class="profile-separator"> <div class="profile-separator">
<div class="profile-separator-text">@T.Get("setup.createUser.separator")</div> <div class="profile-separator-text">@T.Get("setup.createUser.separator")</div>
</div> </div>
} }
@if (Model!.HasPasswordAuth) @if (Model!.HasPasswordAuth)
{ {
<h3>@T.Get("setup.createUser.headlineCreate")</h3> <h3>@T.Get("setup.createUser.headlineCreate")</h3>
@if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage)) @if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage))
{ {
<div class="form-alert form-alert-error"> <div class="form-alert form-alert-error">
@Model!.ErrorMessage @Model!.ErrorMessage
</div> </div>
} }
<form class="profile-form" asp-controller="Setup" asp-action="Setup" method="post"> <form class="profile-form" asp-controller="Setup" asp-action="Setup" method="post">
<div class="form-group"> <div class="form-group">
<label for="email">@T.Get("common.email")</label> <label for="email">@T.Get("common.email")</label>
@ -188,12 +188,12 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
<button type="submit" class="btn btn-success">@T.Get("setup.createUser.button")</button> <button type="submit" class="btn btn-success">@T.Get("setup.createUser.button")</button>
</div> </div>
</form> </form>
} }
@if (!Model!.HasExternalLogin && !Model!.HasPasswordAuth) @if (!Model!.HasExternalLogin && !Model!.HasPasswordAuth)
{ {
<div> <div>
@T.Get("setup.createUser.failure") @T.Get("setup.createUser.failure")
</div> </div>
} }
</div> </div>

8
backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml

@ -8,12 +8,12 @@
<title>@ViewBag.Title - @T.Get("common.product")</title> <title>@ViewBag.Title - @T.Get("common.product")</title>
<link rel="stylesheet" asp-append-version="true" href="styles.css" /> <link rel="stylesheet" href="styles.css" asp-append-version="true" />
@if (IsSectionDefined("header")) @if (IsSectionDefined("header"))
{ {
@await RenderSectionAsync("header") @await RenderSectionAsync("header")
} }
<environment include="Development"> <environment include="Development">
<script type="text/javascript" src="runtime.js"></script> <script type="text/javascript" src="runtime.js"></script>

39
backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs

@ -12,12 +12,12 @@ namespace Squidex.Infrastructure.Tasks;
public class SchedulerTests public class SchedulerTests
{ {
private readonly ConcurrentBag<int> actuals = new ConcurrentBag<int>(); private readonly ConcurrentBag<int> actuals = new ConcurrentBag<int>();
private readonly Scheduler sut = new Scheduler(); private Scheduler sut = new Scheduler();
[Fact] [Fact]
public async Task Should_schedule_single_task() public async Task Should_schedule_single_task()
{ {
Schedule(1); ScheduleAsync(1, sut);
await sut.CompleteAsync(); await sut.CompleteAsync();
@ -31,7 +31,7 @@ public class SchedulerTests
for (var i = 1; i <= 10; i++) for (var i = 1; i <= 10; i++)
{ {
Schedule(i, limited); ScheduleAsync(i, limited);
} }
await limited.CompleteAsync(); await limited.CompleteAsync();
@ -42,8 +42,19 @@ public class SchedulerTests
[Fact] [Fact]
public async Task Should_schedule_multiple_tasks() public async Task Should_schedule_multiple_tasks()
{ {
Schedule(1); ScheduleAsync(1, sut);
Schedule(2); ScheduleAsync(2, sut);
await sut.CompleteAsync();
Assert.Equal(new[] { 1, 2 }, actuals.OrderBy(x => x).ToArray());
}
[Fact]
public async Task Should_schedule_multiple_synchronous_tasks()
{
Schedule(1, sut);
Schedule(2, sut);
await sut.CompleteAsync(); await sut.CompleteAsync();
@ -65,7 +76,7 @@ public class SchedulerTests
actuals.Add(2); actuals.Add(2);
Schedule(3); ScheduleAsync(3, sut);
}); });
}); });
@ -77,20 +88,18 @@ public class SchedulerTests
[Fact] [Fact]
public async Task Should_ignore_schedule_after_completion() public async Task Should_ignore_schedule_after_completion()
{ {
Schedule(1); ScheduleAsync(1, sut);
await sut.CompleteAsync(); await sut.CompleteAsync();
Schedule(3); ScheduleAsync(3, sut);
await Task.Delay(50);
Assert.Equal(new[] { 1 }, actuals.OrderBy(x => x).ToArray()); Assert.Equal(new[] { 1 }, actuals.OrderBy(x => x).ToArray());
} }
private void Schedule(int value) private void ScheduleAsync(int value, Scheduler target)
{ {
sut.Schedule(async _ => target.Schedule(async _ =>
{ {
await Task.Delay(1); await Task.Delay(1);
@ -100,11 +109,11 @@ public class SchedulerTests
private void Schedule(int value, Scheduler target) private void Schedule(int value, Scheduler target)
{ {
target.Schedule(async _ => target.Schedule(_ =>
{ {
await Task.Delay(1);
actuals.Add(value); actuals.Add(value);
return Task.CompletedTask;
}); });
} }
} }

242
backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs

@ -0,0 +1,242 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.Extensions.FileProviders;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Web;
public class IgnoreHashFileProviderTests
{
private readonly IFileProvider inner = A.Fake<IFileProvider>();
[Fact]
public void Should_get_file_from_inner()
{
var fileNormal = CreateFile("styles.css");
var sut = CreateSut();
A.CallTo(() => inner.GetFileInfo(fileNormal.Name))
.Returns(fileNormal);
var actual = sut.GetFileInfo(fileNormal.Name);
Assert.Equal(fileNormal, actual);
}
[Theory]
[InlineData(@"\styles.css")]
[InlineData(@"/styles.css")]
public void Should_get_file_from_hashed_version_if_normal_file_does_not_exist(string path)
{
var fileNormal = CreateFile("styles.css", exists: false);
var fileHashed = CreateFile("styles.42efefef.css");
var directories = new[]
{
(string.Empty,
new[]
{
fileHashed
}
)
};
var sut = CreateSut(directories);
A.CallTo(() => inner.GetFileInfo(path))
.Returns(fileNormal);
A.CallTo(() => inner.GetFileInfo(fileHashed.Name))
.Returns(fileHashed);
var actual = sut.GetFileInfo(path);
Assert.Equal(fileHashed, actual);
}
[Theory]
[InlineData(@"build/styles.css")]
[InlineData(@"build\styles.css")]
[InlineData(@"\build\styles.css")]
[InlineData(@"/build/styles.css")]
public void Should_get_nested_file_from_hashed_version_if_normal_file_does_not_exist(string path)
{
var directory = CreateFile("build", directory: true);
var fileNormal = CreateFile("styles.css", exists: false);
var fileHashed = CreateFile("styles.42efefef.css");
var directories = new[]
{
(string.Empty,
new[]
{
directory
}
),
(directory.Name,
new[]
{
fileHashed
}
)
};
var sut = CreateSut(directories);
A.CallTo(() => inner.GetFileInfo(path))
.Returns(fileNormal);
A.CallTo(() => inner.GetFileInfo($"build/{fileHashed.Name}"))
.Returns(fileHashed);
var actual = sut.GetFileInfo(path);
Assert.Equal(fileHashed, actual);
}
[Fact]
public void Should_not_get_file_from_hashed_version_if_normal_file_exists()
{
var fileNormal = CreateFile("styles.css");
var fileHashed = CreateFile("styles.42efefef.css");
var directories = new[]
{
(string.Empty,
new[]
{
fileNormal
}
)
};
var sut = CreateSut(directories);
A.CallTo(() => inner.GetFileInfo(fileNormal.Name))
.Returns(fileNormal);
A.CallTo(() => inner.GetFileInfo(fileHashed.Name))
.Returns(fileHashed);
var actual = sut.GetFileInfo(fileNormal.Name);
Assert.Equal(fileNormal, actual);
}
[Fact]
public void Should_not_get_file_from_hashed_version_if_normal_file_is_directory()
{
var fileNormal = CreateFile("styles.css", directory: true);
var fileHashed = CreateFile("styles.42efefef.css");
var directories = new[]
{
(string.Empty,
new[]
{
fileNormal
}
)
};
var sut = CreateSut(directories);
A.CallTo(() => inner.GetFileInfo(fileNormal.Name))
.Returns(fileNormal);
A.CallTo(() => inner.GetFileInfo(fileHashed.Name))
.Returns(fileHashed);
var actual = sut.GetFileInfo(fileNormal.Name);
Assert.Equal(fileNormal, actual);
}
[Fact]
public void Should_not_get_file_from_hashed_version_if_not_mapped()
{
var fileNormal = CreateFile("styles.css");
var fileHashed = CreateFile("styles.42efefef.css");
var sut = CreateSut();
A.CallTo(() => inner.GetFileInfo(fileNormal.Name))
.Returns(fileNormal);
A.CallTo(() => inner.GetFileInfo(fileHashed.Name))
.Returns(fileHashed);
var actual = sut.GetFileInfo(fileNormal.Name);
Assert.Equal(fileNormal, actual);
}
[Fact]
public void Should_forward_watch_call_to_inner()
{
var sut = CreateSut();
sut.Watch("/");
A.CallTo(() => inner.Watch("/"))
.MustHaveHappened();
}
[Fact]
public void Should_forward_directory_call_to_inner()
{
var sut = CreateSut();
sut.GetDirectoryContents("/");
A.CallTo(() => inner.GetDirectoryContents("/"))
.MustHaveHappened();
}
private IgnoreHashFileProvider CreateSut(params (string Path, IFileInfo[] Files)[] directories)
{
foreach (var directory in directories)
{
A.CallTo(() => inner.GetDirectoryContents(directory.Path))
.Returns(new DirectoryContents(directory.Files));
}
return new IgnoreHashFileProvider(inner);
}
private static IFileInfo CreateFile(string name, bool exists = true, bool directory = false)
{
return new File(name, exists, directory);
}
public record File(string Name, bool Exists = true, bool IsDirectory = false, long Length = 100) : IFileInfo
#pragma warning restore SA1313 // Parameter names should begin with lower-case letter
{
public DateTimeOffset LastModified => default;
public string? PhysicalPath => default;
public Stream CreateReadStream()
{
throw new NotImplementedException();
}
}
private sealed class DirectoryContents : List<IFileInfo>, IDirectoryContents
{
bool IDirectoryContents.Exists => true;
public DirectoryContents(IEnumerable<IFileInfo> files)
: base(files)
{
}
}
}

33
frontend/src/app/framework/angular/global-error-handler.ts

@ -0,0 +1,33 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ErrorHandler, Injectable, NgZone } from '@angular/core';
import { DialogService } from './../internal';
@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
constructor(
private readonly dialogs: DialogService,
private readonly zone: NgZone,
) {
}
public handleError(error: any): void {
const chunkFailedMessage = /Loading chunk [\d]+ failed/;
if (chunkFailedMessage.test(error.message)) {
this.zone.run(() => {
this.dialogs.confirm('i18n:common.errors.chunkLoadingTitle', 'i18n:common.errors.chunkLoadingText')
.subscribe(() => {
location.reload();
});
});
}
console.error(error);
}
}

7
frontend/src/app/framework/declarations.ts

@ -11,8 +11,8 @@ export * from './angular/compensate-scrollbar.directive';
export * from './angular/dropdown-menu.component'; export * from './angular/dropdown-menu.component';
export * from './angular/external-link.directive'; export * from './angular/external-link.directive';
export * from './angular/forms/confirm-click.directive'; export * from './angular/forms/confirm-click.directive';
export * from './angular/forms/control-errors.component';
export * from './angular/forms/control-errors-messages.component'; export * from './angular/forms/control-errors-messages.component';
export * from './angular/forms/control-errors.component';
export * from './angular/forms/copy.directive'; export * from './angular/forms/copy.directive';
export * from './angular/forms/editable-title.component'; export * from './angular/forms/editable-title.component';
export * from './angular/forms/editors/autocomplete.component'; export * from './angular/forms/editors/autocomplete.component';
@ -40,18 +40,19 @@ export * from './angular/forms/progress-bar.component';
export * from './angular/forms/templated-form-array'; export * from './angular/forms/templated-form-array';
export * from './angular/forms/transform-input.directive'; export * from './angular/forms/transform-input.directive';
export * from './angular/forms/validators'; export * from './angular/forms/validators';
export * from './angular/global-error-handler';
export * from './angular/hover-background.directive'; export * from './angular/hover-background.directive';
export * from './angular/http/caching.interceptor'; export * from './angular/http/caching.interceptor';
export * from './angular/http/http-extensions'; export * from './angular/http/http-extensions';
export * from './angular/http/loading.interceptor'; export * from './angular/http/loading.interceptor';
export * from './angular/if-once.directive';
export * from './angular/image-source.directive'; export * from './angular/image-source.directive';
export * from './angular/image-url.directive'; export * from './angular/image-url.directive';
export * from './angular/if-once.directive';
export * from './angular/language-selector.component'; export * from './angular/language-selector.component';
export * from './angular/loader.component';
export * from './angular/layout-container.directive'; export * from './angular/layout-container.directive';
export * from './angular/layout.component'; export * from './angular/layout.component';
export * from './angular/list-view.component'; export * from './angular/list-view.component';
export * from './angular/loader.component';
export * from './angular/markdown.directive'; export * from './angular/markdown.directive';
export * from './angular/modals/dialog-renderer.component'; export * from './angular/modals/dialog-renderer.component';
export * from './angular/modals/modal-dialog.component'; export * from './angular/modals/modal-dialog.component';

9
frontend/src/app/framework/module.ts

@ -7,11 +7,11 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ModuleWithProviders, NgModule } from '@angular/core'; import { ErrorHandler, ModuleWithProviders, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { ColorPickerModule } from 'ngx-color-picker'; import { ColorPickerModule } from 'ngx-color-picker';
import { AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations'; import { AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, GlobalErrorHandler, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations';
@NgModule({ @NgModule({
imports: [ imports: [
@ -217,6 +217,11 @@ export class SqxFrameworkModule {
ShortcutService, ShortcutService,
TempService, TempService,
TitleService, TitleService,
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
multi: false,
},
{ {
provide: HTTP_INTERCEPTORS, provide: HTTP_INTERCEPTORS,
useClass: LoadingInterceptor, useClass: LoadingInterceptor,

29
frontend/src/config/webpack.config.js

@ -69,34 +69,5 @@ module.exports = (config, _, options) => {
); );
} }
const index = config.plugins.findIndex(x => x instanceof plugins.MiniCssExtractPlugin);
if (index >= 0) {
config.plugins.splice(index, 1);
}
config.plugins.push(new plugins.MiniCssExtractPlugin({
filename: '[name].css',
}));
/*
* Specifies the name of each output file on disk.
*
* See: https://webpack.js.org/configuration/output/#output-filename
*/
config.output.filename = '[name].js';
/*
* The filename of non-entry chunks as relative path inside the output.path directory.
*
* See: https://webpack.js.org/configuration/output/#output-chunkfilename
*/
config.output.chunkFilename = '[id].[fullhash].chunk.js';
/*
* The filename for assets.
*/
config.output.assetModuleFilename = 'assets/[hash][ext][query]';
return config; return config;
}; };
Loading…
Cancel
Save