Browse Source

Gallery (#856)

* Remove app templates.

* Refactoring and new service.

* Many fixes.

* Added missing files.

* CLI templates.

* Fix marker.

* Small layout fix.
pull/857/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
cea2990e81
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  2. 9
      backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs
  3. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs
  5. 59
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/CLILogger.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs
  7. 100
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs
  8. 16
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateRepository.cs
  9. 76
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs
  10. 16
      backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesOptions.cs
  11. 1
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  12. 2
      backend/src/Squidex.Domain.Apps.Events/Apps/AppClientAttached.cs
  13. 5
      backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs
  14. 10
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  15. 3
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  16. 4
      backend/src/Squidex/Squidex.csproj
  17. 13
      backend/src/Squidex/appsettings.json
  18. 114
      backend/src/Squidex/wwwroot/images/add-template.svg
  19. 26
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs
  20. 16
      frontend/src/app/features/apps/pages/apps-page.component.html
  21. 16
      frontend/src/app/features/apps/pages/apps-page.component.ts
  22. 2
      frontend/src/app/features/settings/pages/templates/template.component.html
  23. 8
      frontend/src/app/shared/components/app-form.component.html
  24. 11
      frontend/src/app/shared/components/app-form.component.ts
  25. 3
      frontend/src/app/shared/services/templates.service.spec.ts
  26. 4
      frontend/src/app/shared/services/templates.service.ts

2
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -26,7 +26,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="NJsonSchema" Version="10.6.7" /> <PackageReference Include="NJsonSchema" Version="10.6.10" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="6.0.0" />

9
backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -21,14 +22,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
var appId = NamedId.Of(createApp.AppId, createApp.Name); var appId = NamedId.Of(createApp.AppId, createApp.Name);
var publish = new Func<IAppCommand, Task>(command => var publish = new Func<IAppCommand, Task>(async command =>
{ {
command.AppId = appId; command.AppId = appId;
return context.CommandBus.PublishAsync(command); var newContext = await context.CommandBus.PublishAsync(command);
context.Complete(newContext.PlainResult);
}); });
await publish(new AttachClient { Id = "default" }); await publish(new AttachClient { Id = "default", Role = Role.Owner });
} }
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs

@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
public string Secret { get; set; } public string Secret { get; set; }
public string? Role { get; set; }
public AttachClient() public AttachClient()
{ {
Secret = RandomHash.New(); Secret = RandomHash.New();

2
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs

@ -100,7 +100,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject
return UpdateContributors(e, (e, c) => c.Remove(e.ContributorId)); return UpdateContributors(e, (e, c) => c.Remove(e.ContributorId));
case AppClientAttached e: case AppClientAttached e:
return UpdateClients(e, (e, c) => c.Add(e.Id, e.Secret)); return UpdateClients(e, (e, c) => c.Add(e.Id, e.Secret, e.Role));
case AppClientUpdated e: case AppClientUpdated e:
return UpdateClients(e, (e, c) => c.Update(e.Id, e.Name, e.Role, e.ApiCallsLimit, e.ApiTrafficLimit, e.AllowAnonymous)); return UpdateClients(e, (e, c) => c.Update(e.Id, e.Name, e.Role, e.ApiCallsLimit, e.ApiTrafficLimit, e.AllowAnonymous));

59
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/CLILogger.cs

@ -0,0 +1,59 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.CLI.Commands.Implementation;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Templates
{
internal sealed class CLILogger : ILogger, ILogLine
{
public static readonly CLILogger Instance = new CLILogger();
private CLILogger()
{
}
public void StepFailed(string reason)
{
throw new DomainException($"Template failed with {reason}");
}
public void StepSkipped(string reason)
{
}
public void StepStart(string process)
{
}
public void StepSuccess(string? details = null)
{
}
public void WriteLine()
{
}
public void WriteLine(string message)
{
}
public void WriteLine(string message, params object?[] args)
{
}
public void Dispose()
{
}
public ILogLine WriteSameLine()
{
return this;
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs

@ -9,7 +9,7 @@
namespace Squidex.Domain.Apps.Entities.Apps.Templates namespace Squidex.Domain.Apps.Entities.Apps.Templates
{ {
public sealed record Template(string Name, string Title, string Description) public sealed record Template(string Name, string Title, string Description, bool IsStarter)
{ {
} }
} }

100
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs

@ -0,0 +1,100 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.CLI.Commands.Implementation;
using Squidex.CLI.Commands.Implementation.FileSystem;
using Squidex.CLI.Commands.Implementation.Sync;
using Squidex.CLI.Commands.Implementation.Sync.App;
using Squidex.CLI.Commands.Implementation.Sync.AssertFolders;
using Squidex.CLI.Commands.Implementation.Sync.Assets;
using Squidex.CLI.Commands.Implementation.Sync.Rules;
using Squidex.CLI.Commands.Implementation.Sync.Schemas;
using Squidex.CLI.Commands.Implementation.Sync.Workflows;
using Squidex.CLI.Configuration;
using Squidex.ClientLibrary;
using Squidex.ClientLibrary.Configuration;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps.Templates
{
public sealed class TemplateCommandMiddleware : ICommandMiddleware
{
private readonly TemplatesClient templatesClient;
private readonly IUrlGenerator urlGenerator;
private readonly ISynchronizer[] targets =
{
new AppSynchronizer(CLILogger.Instance),
new AssetFoldersSynchronizer(CLILogger.Instance),
new AssetsSynchronizer(CLILogger.Instance),
new RulesSynchronizer(CLILogger.Instance),
new SchemasSynchronizer(CLILogger.Instance),
new WorkflowsSynchronizer(CLILogger.Instance),
};
public TemplateCommandMiddleware(TemplatesClient templatesClient, IUrlGenerator urlGenerator)
{
this.templatesClient = templatesClient;
this.urlGenerator = urlGenerator;
}
public async Task HandleAsync(CommandContext context, NextDelegate next)
{
await next(context);
if (context.IsCompleted && context.Command is CreateApp createApp && !string.IsNullOrWhiteSpace(createApp.Template))
{
await ApplyTemplateAsync(context.Result<IAppEntity>(), createApp.Template);
}
}
private async Task ApplyTemplateAsync(IAppEntity app, string template)
{
var repository = await templatesClient.GetRepositoryUrl(template);
if (string.IsNullOrEmpty(repository))
{
return;
}
var session = CreateSession(app);
var syncService = await CreateSyncServiceAsync(repository, session);
var syncOptions = new SyncOptions();
foreach (var target in targets.OrderBy(x => x.Name))
{
await target.ImportAsync(syncService, syncOptions, session);
}
}
private static async Task<ISyncService> CreateSyncServiceAsync(string repository, ISession session)
{
var fs = await FileSystems.CreateAsync(repository, session.WorkingDirectory);
return new SyncService(fs, session);
}
private ISession CreateSession(IAppEntity app)
{
var client = app.Clients.First();
return new Session(
app.Name,
new DirectoryInfo(Path.GetTempPath()),
new SquidexClientManager(new SquidexOptions
{
Configurator = AcceptAllCertificatesConfigurator.Instance,
AppName = app.Name,
ClientId = $"{app.Name}:{client.Key}",
ClientSecret = client.Value.Secret,
Url = urlGenerator.Root()
}));
}
}
}

16
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateRepository.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Templates
{
public sealed class TemplateRepository
{
public string ContentUrl { get; set; }
public string GitUrl { get; set; }
}
}

76
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs

@ -5,41 +5,76 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Net;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Templates namespace Squidex.Domain.Apps.Entities.Apps.Templates
{ {
public sealed class TemplatesClient public sealed class TemplatesClient
{ {
private const string DetailUrl = "https://raw.githubusercontent.com/Squidex/templates/main";
private const string OverviewUrl = "https://raw.githubusercontent.com/Squidex/templates/main/README.md";
private static readonly Regex Regex = new Regex("\\* \\[(?<Title>.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.Compiled | RegexOptions.ExplicitCapture); private static readonly Regex Regex = new Regex("\\* \\[(?<Title>.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.Compiled | RegexOptions.ExplicitCapture);
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly TemplatesOptions options;
public TemplatesClient(IHttpClientFactory httpClientFactory) public TemplatesClient(IHttpClientFactory httpClientFactory, IOptions<TemplatesOptions> options)
{ {
this.httpClientFactory = httpClientFactory; this.httpClientFactory = httpClientFactory;
this.options = options.Value;
} }
public async Task<List<Template>> GetTemplatesAsync( public async Task<string?> GetRepositoryUrl(string name,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (var httpClient = httpClientFactory.CreateClient()) using (var httpClient = httpClientFactory.CreateClient())
{ {
var url = OverviewUrl; var result = new List<Template>();
foreach (var repository in options.Repositories.OrEmpty())
{
var url = $"{repository.ContentUrl}/README.md";
var text = await httpClient.GetStringAsync(url, ct);
var text = await httpClient.GetStringAsync(url, ct); foreach (Match match in Regex.Matches(text))
{
var currentName = match.Groups["Name"].Value;
if (currentName == name)
{
return $"{repository.GitUrl ?? repository.ContentUrl}?folder={name}";
}
}
}
return null;
}
}
public async Task<List<Template>> GetTemplatesAsync(
CancellationToken ct = default)
{
using (var httpClient = httpClientFactory.CreateClient())
{
var result = new List<Template>(); var result = new List<Template>();
foreach (Match match in Regex.Matches(text)) foreach (var repository in options.Repositories.OrEmpty())
{ {
result.Add(new Template( var url = $"{repository.ContentUrl}/README.md";
match.Groups["Name"].Value,
match.Groups["Title"].Value, var text = await httpClient.GetStringAsync(url, ct);
match.Groups["Description"].Value));
foreach (Match match in Regex.Matches(text))
{
var title = match.Groups["Title"].Value;
result.Add(new Template(
match.Groups["Name"].Value,
title,
match.Groups["Description"].Value,
title.StartsWith("Starter ", StringComparison.OrdinalIgnoreCase)));
}
} }
return result; return result;
@ -53,17 +88,20 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
using (var httpClient = httpClientFactory.CreateClient()) using (var httpClient = httpClientFactory.CreateClient())
{ {
var url = $"{DetailUrl}/{name}/README.md"; foreach (var repository in options.Repositories.OrEmpty())
{
var url = $"{repository.ContentUrl}/{name}/README.md";
var response = await httpClient.GetAsync(url, ct); var response = await httpClient.GetAsync(url, ct);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.IsSuccessStatusCode)
{ {
return null; return await response.Content.ReadAsStringAsync(ct);
}
} }
return await response.Content.ReadAsStringAsync(ct);
} }
return null;
} }
} }
} }

16
backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesOptions.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Domain.Apps.Entities.Apps.Templates
{
public sealed class TemplatesOptions
{
public TemplateRepository[] Repositories { get; set; }
}
}

1
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -34,6 +34,7 @@
<PackageReference Include="Microsoft.Orleans.Core" Version="3.6.0" /> <PackageReference Include="Microsoft.Orleans.Core" Version="3.6.0" />
<PackageReference Include="Notifo.SDK" Version="1.0.1" /> <PackageReference Include="Notifo.SDK" Version="1.0.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.CLI.Core" Version="8.5.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />

2
backend/src/Squidex.Domain.Apps.Events/Apps/AppClientAttached.cs

@ -15,5 +15,7 @@ namespace Squidex.Domain.Apps.Events.Apps
public string Id { get; set; } public string Id { get; set; }
public string Secret { get; set; } public string Secret { get; set; }
public string? Role { get; set; }
} }
} }

5
backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs

@ -32,6 +32,11 @@ namespace Squidex.Areas.Api.Controllers.Templates.Models
[LocalizedRequired] [LocalizedRequired]
public string Description { get; set; } public string Description { get; set; }
/// <summary>
/// True, if the template is a starter.
/// </summary>
public bool IsStarter { get; set; }
public static TemplateDto FromDomain(Template template, Resources resources) public static TemplateDto FromDomain(Template template, Resources resources)
{ {
var result = SimpleMapper.Map(template, new TemplateDto()); var result = SimpleMapper.Map(template, new TemplateDto());

10
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Entities.Apps.DomainObject;
using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.Indexes;
using Squidex.Domain.Apps.Entities.Apps.Invitation; using Squidex.Domain.Apps.Entities.Apps.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Domain.Apps.Entities.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Assets.DomainObject; using Squidex.Domain.Apps.Entities.Assets.DomainObject;
using Squidex.Domain.Apps.Entities.Comments.DomainObject; using Squidex.Domain.Apps.Entities.Comments.DomainObject;
@ -63,6 +64,12 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<CustomCommandMiddlewareRunner>() services.AddSingletonAs<CustomCommandMiddlewareRunner>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<TemplateCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<AlwaysCreateClientCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<RestrictAppsCommandMiddleware>() services.AddSingletonAs<RestrictAppsCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
@ -108,9 +115,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SingletonCommandMiddleware>() services.AddSingletonAs<SingletonCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
services.AddSingletonAs<AlwaysCreateClientCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<UsageTrackerCommandMiddleware>() services.AddSingletonAs<UsageTrackerCommandMiddleware>()
.As<ICommandMiddleware>(); .As<ICommandMiddleware>();
} }

3
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -29,6 +29,9 @@ namespace Squidex.Config.Domain
services.Configure<ContentOptions>(config, services.Configure<ContentOptions>(config,
"contents"); "contents");
services.Configure<TemplatesOptions>(config,
"templates");
services.AddSingletonAs(c => new Lazy<IContentQueryService>(c.GetRequiredService<IContentQueryService>)) services.AddSingletonAs(c => new Lazy<IContentQueryService>(c.GetRequiredService<IContentQueryService>))
.AsSelf(); .AsSelf();

4
backend/src/Squidex/Squidex.csproj

@ -63,7 +63,7 @@
<PackageReference Include="MongoDB.Driver" Version="2.14.1" /> <PackageReference Include="MongoDB.Driver" Version="2.14.1" />
<PackageReference Include="Namotion.Reflection" Version="2.0.10" /> <PackageReference Include="Namotion.Reflection" Version="2.0.10" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NJsonSchema" Version="10.6.7" /> <PackageReference Include="NJsonSchema" Version="10.6.10" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="NSwag.AspNetCore" Version="13.15.7" /> <PackageReference Include="NSwag.AspNetCore" Version="13.15.7" />
<PackageReference Include="OpenCover" Version="4.7.1221" PrivateAssets="all" /> <PackageReference Include="OpenCover" Version="4.7.1221" PrivateAssets="all" />
@ -83,7 +83,7 @@
<PackageReference Include="Squidex.Assets.S3" Version="2.18.0" /> <PackageReference Include="Squidex.Assets.S3" Version="2.18.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="2.18.0" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="2.18.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" /> <PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.8.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="8.11.0" />
<PackageReference Include="Squidex.Hosting" Version="2.13.0" /> <PackageReference Include="Squidex.Hosting" Version="2.13.0" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" /> <PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

13
backend/src/Squidex/appsettings.json

@ -592,5 +592,18 @@
"twitter": { "twitter": {
"clientId": "QZhb3HQcGCvE6G8yNNP9ksNet", "clientId": "QZhb3HQcGCvE6G8yNNP9ksNet",
"clientSecret": "Pdu9wdN72T33KJRFdFy1w4urBKDRzIyuKpc0OItQC2E616DuZD" "clientSecret": "Pdu9wdN72T33KJRFdFy1w4urBKDRzIyuKpc0OItQC2E616DuZD"
},
// Tthe template repositories
"templates": {
"repositories": [
{
// The url to download readme files.
"contentUrl": "https://raw.githubusercontent.com/Squidex/templates/main",
// The url to the git repository.
"gitUrl": "https://github.com/Squidex/templates.git"
}
]
} }
} }

114
backend/src/Squidex/wwwroot/images/add-template.svg

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
id="Layer_1"
x="0"
y="0"
version="1.1"
viewBox="0 0 64 64"
xml:space="preserve"
sodipodi:docname="add-template.svg"
inkscape:version="1.1.1 (3bf5ae0d25, 2021-09-20)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs27" /><sodipodi:namedview
id="namedview25"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
showgrid="false"
inkscape:zoom="12.875"
inkscape:cx="31.961165"
inkscape:cy="31.961165"
inkscape:window-width="2560"
inkscape:window-height="1009"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="g4726" />
<style
id="style8137"
type="text/css">
.st15{fill:#fff}
</style>
<style
id="style8137-2"
type="text/css">
.st15{fill:#fff}
</style>
<style
id="style8137-0"
type="text/css">
.st15{fill:#fff}
</style>
<g
id="g4726"
transform="translate(-67.875 31.5)">
<g
id="g8143-8"
transform="translate(67.875 -31.5)">
<path
id="path8145-0"
fill="#b4bcc1"
d="M11.8 16c.2.4.2.9.2 1.3V19h1v-1.6c0-.7-.1-1.4-.4-2l-.1-3.1c-.4-1.2-.3-2.5.4-3.5.6-1 1.6-1.6 2.7-1.7H23.2c2.4 0 4.6.9 6.3 2.6l4.7 4.7c2.1 2.1 4.8 3.2 7.7 3.2H50v-2h-8.1c-2.4 0-4.6-.9-6.3-2.6l-4.7-4.7C28.8 6.1 26.1 5 23.2 5h-7.8c-1.7.2-3.2 1.1-4.2 2.6-1 1.6-1.2 3.5-.6 5.3l1.2 3.1z" />
</g>
<g
id="g8147-3"
transform="translate(67.875 -31.5)">
<path
id="path8149-6"
fill="#d7d7f9"
d="M3 24v-4.5C3 15.9 5.9 13 9.5 13h12.8c1.7 0 3.4.7 4.6 1.9l7.2 9.2c1.2 1.2 2.9 1.9 4.6 1.9h7.8C55.1 26 61 30.9 61 39.5v15c0 3.6-3.9 6.5-7.5 6.5h-44C5.9 61 3 58.1 3 54.5V24z" />
<path
id="path8151-3"
fill="#2e3842"
d="M53.5 62h-44C5.4 62 2 58.6 2 54.5v-35C2 15.4 5.4 12 9.5 12h12.8c2 0 3.9.8 5.3 2.2l.1.1 7.2 9.2c1 1 2.4 1.6 3.8 1.6h7.8C55.9 25 62 30.7 62 39.5v15c0 4.3-4.5 7.5-8.5 7.5zm-44-48c-3 0-5.5 2.5-5.5 5.5v35.1c0 3 2.5 5.5 5.5 5.5h44.1c2.9 0 6.5-2.4 6.5-5.5v-15C60 31.8 54.8 27 46.5 27h-7.8c-2 0-3.9-.8-5.3-2.2l-.1-.1-7.2-9.2c-1-1-2.4-1.6-3.8-1.6L9.633 14z" />
</g>
<g
id="g8153"
transform="translate(67.875 -31.5)">
<path
id="path8155"
fill="#ededf9"
d="M53 60H10c-3.8 0-6-2.2-6-6V20c0-3.8 2.2-6 6-6h12c2 0 3.6 1.2 4.7 2.3l.1.1 7 8.9c.6.5 2.1 1.7 4.5 1.5h7.3C54.7 26.9 60 31.3 60 39v15c0 3.6-2.8 6-7 6zM10 16c-2.7 0-4 1.3-4 4v34c0 2.7 1.3 4 4 4h43c2.3 0 5-1 5-4V39c0-8.8-7.9-10.1-12.5-10.1h-7.2c-2.2.1-4.4-.6-6-2.1l-.1-.1-7-8.9C24 16.5 23.1 16 22 16H10z" />
</g>
<g
id="g8197-0"
transform="translate(67.875 -31.5)">
<circle
id="circle8199-9"
cx="51.9"
cy="12.1"
r="11.1"
fill="#3389ff" />
<path
id="path8201-7"
fill="#fff"
d="M51.9 24.2c-6.7 0-12.1-5.4-12.1-12.1C39.8 5.4 45.2 0 51.9 0 58.6 0 64 5.4 64 12.1c0 6.7-5.4 12.1-12.1 12.1zm0-22.2c-5.6 0-10.1 4.5-10.1 10.1s4.5 10.1 10.1 10.1S62 17.7 62 12.1 57.5 2 51.9 2z"
class="st15" />
</g>
<g
id="g8203-4"
transform="translate(67.875 -31.5)">
<path
id="path8205-6"
fill="#fff"
d="M57 13H47c-.6 0-1-.4-1-1s.4-1 1-1h10c.6 0 1 .4 1 1s-.4 1-1 1z"
class="st15" />
</g>
<g
id="g8207-9"
transform="translate(67.875 -31.5)">
<path
id="path8209-3"
fill="#fff"
d="M52 18c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1s1 .4 1 1v10c0 .6-.4 1-1 1z"
class="st15" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

26
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Options;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Templates namespace Squidex.Domain.Apps.Entities.Apps.Templates
@ -21,7 +22,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
A.CallTo(() => httpClientFactory.CreateClient(null)) A.CallTo(() => httpClientFactory.CreateClient(null))
.Returns(new HttpClient()); .Returns(new HttpClient());
sut = new TemplatesClient(httpClientFactory); sut = new TemplatesClient(httpClientFactory, Options.Create(new TemplatesOptions
{
Repositories = new[]
{
new TemplateRepository
{
ContentUrl = "https://raw.githubusercontent.com/Squidex/templates/main"
}
}
}));
} }
[Fact] [Fact]
@ -30,6 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
var templates = await sut.GetTemplatesAsync(); var templates = await sut.GetTemplatesAsync();
Assert.NotEmpty(templates); Assert.NotEmpty(templates);
Assert.Contains(templates, x => x.IsStarter == true);
} }
[Fact] [Fact]
@ -45,6 +56,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates
} }
} }
[Fact]
public async Task Should_get_repository_from_templates()
{
var templates = await sut.GetTemplatesAsync();
foreach (var template in templates)
{
var repository = await sut.GetRepositoryUrl(template.Name);
Assert.NotNull(repository);
}
}
[Fact] [Fact]
public async Task Should_return_null_details_if_not_found() public async Task Should_return_null_details_if_not_found()
{ {

16
frontend/src/app/features/apps/pages/apps-page.component.html

@ -35,6 +35,20 @@
</sqx-form-hint> </sqx-form-hint>
</div> </div>
</div> </div>
<div class="card card-template card-href" *ngFor="let template of templates | async" (click)="createNewApp(template)">
<div class="card-body">
<div class="card-image">
<img src="./images/add-template.svg">
</div>
<h3 class="card-title">{{template.title}}</h3>
<sqx-form-hint>
{{template.description}}
</sqx-form-hint>
</div>
</div>
</div> </div>
<div *ngIf="info" class="apps-section"> <div *ngIf="info" class="apps-section">
@ -43,7 +57,7 @@
</div> </div>
<ng-container *sqxModal="addAppDialog"> <ng-container *sqxModal="addAppDialog">
<sqx-app-form (complete)="addAppDialog.hide()"></sqx-app-form> <sqx-app-form [template]="addAppTemplate" (complete)="addAppDialog.hide()"></sqx-app-form>
</ng-container> </ng-container>
<ng-container *sqxModal="onboardingDialog"> <ng-container *sqxModal="onboardingDialog">

16
frontend/src/app/features/apps/pages/apps-page.component.ts

@ -6,8 +6,9 @@
*/ */
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { take } from 'rxjs/operators'; import { Observable } from 'rxjs';
import { AppDto, AppsState, AuthService, DialogModel, FeatureDto, LocalStoreService, NewsService, OnboardingService, UIOptions, UIState } from '@app/shared'; import { map, take } from 'rxjs/operators';
import { AppDto, AppsState, AuthService, DialogModel, FeatureDto, LocalStoreService, NewsService, OnboardingService, TemplateDto, TemplatesState, UIOptions, UIState } from '@app/shared';
import { Settings } from '@app/shared/state/settings'; import { Settings } from '@app/shared/state/settings';
@Component({ @Component({
@ -17,6 +18,7 @@ import { Settings } from '@app/shared/state/settings';
}) })
export class AppsPageComponent implements OnInit { export class AppsPageComponent implements OnInit {
public addAppDialog = new DialogModel(); public addAppDialog = new DialogModel();
public addAppTemplate?: TemplateDto;
public onboardingDialog = new DialogModel(); public onboardingDialog = new DialogModel();
@ -25,6 +27,10 @@ export class AppsPageComponent implements OnInit {
public info = ''; public info = '';
public templates: Observable<TemplateDto[]> =
this.templatesState.templates.pipe(
map(x => x.filter(t => t.isStarter)));
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly authState: AuthService, public readonly authState: AuthService,
@ -32,6 +38,7 @@ export class AppsPageComponent implements OnInit {
private readonly localStore: LocalStoreService, private readonly localStore: LocalStoreService,
private readonly newsService: NewsService, private readonly newsService: NewsService,
private readonly onboardingService: OnboardingService, private readonly onboardingService: OnboardingService,
private readonly templatesState: TemplatesState,
private readonly uiOptions: UIOptions, private readonly uiOptions: UIOptions,
) { ) {
if (uiOptions.get('showInfo')) { if (uiOptions.get('showInfo')) {
@ -63,9 +70,12 @@ export class AppsPageComponent implements OnInit {
}); });
} }
}); });
this.templatesState.load();
} }
public createNewApp() { public createNewApp(template?: TemplateDto) {
this.addAppTemplate = template;
this.addAppDialog.show(); this.addAppDialog.show();
} }

2
frontend/src/app/features/settings/pages/templates/template.component.html

@ -3,7 +3,7 @@
<div class="col"> <div class="col">
{{template.title}} {{template.title}}
<sqx-form-hint>{{template.description}}</sqx-form-hint> <sqx-form-hint class="truncate">{{template.description}}</sqx-form-hint>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div class="float-end"> <div class="float-end">

8
frontend/src/app/shared/components/app-form.component.html

@ -1,7 +1,13 @@
<form [formGroup]="createForm.form" (ngSubmit)="createApp()"> <form [formGroup]="createForm.form" (ngSubmit)="createApp()">
<sqx-modal-dialog (close)="emitComplete()"> <sqx-modal-dialog (close)="emitComplete()">
<ng-container title> <ng-container title>
{{ 'apps.create' | sqxTranslate }} <ng-container *ngIf="template; else noTemplate">
{{ 'apps.createWithTemplate' | sqxTranslate: { template: template.title } }}
</ng-container>
<ng-template #noTemplate>
{{ 'apps.create' | sqxTranslate }}
</ng-template>
</ng-container> </ng-container>
<ng-container content> <ng-container content>

11
frontend/src/app/shared/components/app-form.component.ts

@ -5,8 +5,8 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ApiUrlConfig, AppsState, CreateAppForm } from '@app/shared/internal'; import { ApiUrlConfig, AppsState, CreateAppForm, TemplateDto } from '@app/shared/internal';
@Component({ @Component({
selector: 'sqx-app-form', selector: 'sqx-app-form',
@ -18,6 +18,9 @@ export class AppFormComponent {
@Output() @Output()
public complete = new EventEmitter(); public complete = new EventEmitter();
@Input()
public template?: TemplateDto;
public createForm = new CreateAppForm(); public createForm = new CreateAppForm();
constructor( constructor(
@ -34,7 +37,9 @@ export class AppFormComponent {
const value = this.createForm.submit(); const value = this.createForm.submit();
if (value) { if (value) {
this.appsStore.create(value) const request = { ...value, template: this.template?.name };
this.appsStore.create(request)
.subscribe({ .subscribe({
next: () => { next: () => {
this.emitComplete(); this.emitComplete();

3
frontend/src/app/shared/services/templates.service.spec.ts

@ -84,6 +84,7 @@ describe('TemplatesService', () => {
name: `name${key}`, name: `name${key}`,
title: `Title ${key}`, title: `Title ${key}`,
description: `Description ${key}`, description: `Description ${key}`,
isStarter: id % 2 === 0,
_links: { _links: {
self: { self: {
method: 'GET', href: `/templates/name${key}`, method: 'GET', href: `/templates/name${key}`,
@ -113,7 +114,7 @@ export function createTemplate(id: number, suffix = '') {
const key = `${id}${suffix}`; const key = `${id}${suffix}`;
return new TemplateDto(links, `name${key}`, `Title ${key}`, `Description ${key}`); return new TemplateDto(links, `name${key}`, `Title ${key}`, `Description ${key}`, id % 2 === 0);
} }

4
frontend/src/app/shared/services/templates.service.ts

@ -21,6 +21,7 @@ export class TemplateDto {
public readonly name: string, public readonly name: string,
public readonly title: string, public readonly title: string,
public readonly description: string, public readonly description: string,
public readonly isStarter: boolean,
) { ) {
this._links = links; this._links = links;
} }
@ -72,7 +73,8 @@ function parseTemplates(response: { items: any[] } & Resource) {
new TemplateDto(item._links, new TemplateDto(item._links,
item.name, item.name,
item.title, item.title,
item.description)); item.description,
item.isStarter));
return new TemplatesDto(items.length, items, response._links); return new TemplatesDto(items.length, items, response._links);
} }

Loading…
Cancel
Save