diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 20e9a7e11..335538761 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -26,7 +26,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs index 2439e0554..7cbc33387 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/AlwaysCreateClientCommandMiddleware.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; @@ -21,14 +22,16 @@ namespace Squidex.Domain.Apps.Entities.Apps { var appId = NamedId.Of(createApp.AppId, createApp.Name); - var publish = new Func(command => + var publish = new Func(async command => { 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 }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs index 6894dd79a..debde745d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/AttachClient.cs +++ b/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? Role { get; set; } + public AttachClient() { Secret = RandomHash.New(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs index db3ae23ca..81ea3c612 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppDomainObject.State.cs +++ b/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)); 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: return UpdateClients(e, (e, c) => c.Update(e.Id, e.Name, e.Role, e.ApiCallsLimit, e.ApiTrafficLimit, e.AllowAnonymous)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/CLILogger.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/CLILogger.cs new file mode 100644 index 000000000..dcf60fc0b --- /dev/null +++ b/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; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs index 1016fe07a..13c4257f8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/Template.cs @@ -9,7 +9,7 @@ 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) { } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs new file mode 100644 index 000000000..a8ece85a1 --- /dev/null +++ b/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(), 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 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() + })); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateRepository.cs new file mode 100644 index 000000000..e3baecc25 --- /dev/null +++ b/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; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs index a37c30657..bc7867746 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs @@ -5,41 +5,76 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Net; using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Templates { 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("\\* \\[(?.*)\\]\\((?<Name>.*)\\/README\\.md\\): (?<Description>.*)", RegexOptions.Compiled | RegexOptions.ExplicitCapture); private readonly IHttpClientFactory httpClientFactory; + private readonly TemplatesOptions options; - public TemplatesClient(IHttpClientFactory httpClientFactory) + public TemplatesClient(IHttpClientFactory httpClientFactory, IOptions<TemplatesOptions> options) { this.httpClientFactory = httpClientFactory; + + this.options = options.Value; } - public async Task<List<Template>> GetTemplatesAsync( + public async Task<string?> GetRepositoryUrl(string name, CancellationToken ct = default) { 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>(); - foreach (Match match in Regex.Matches(text)) + foreach (var repository in options.Repositories.OrEmpty()) { - result.Add(new Template( - match.Groups["Name"].Value, - match.Groups["Title"].Value, - match.Groups["Description"].Value)); + var url = $"{repository.ContentUrl}/README.md"; + + var text = await httpClient.GetStringAsync(url, ct); + + 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; @@ -53,17 +88,20 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates 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) - { - return null; + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(ct); + } } - - return await response.Content.ReadAsStringAsync(ct); } + + return null; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesOptions.cs new file mode 100644 index 000000000..c8f428f53 --- /dev/null +++ b/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; } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 878f2b733..647a4adb1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/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="Notifo.SDK" Version="1.0.1" /> <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="System.Collections.Immutable" Version="6.0.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" /> diff --git a/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientAttached.cs b/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientAttached.cs index b9a260e7d..e265a33d1 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Apps/AppClientAttached.cs +++ b/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 Secret { get; set; } + + public string? Role { get; set; } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs index 264b5e4b9..28f226a73 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Templates/Models/TemplateDto.cs @@ -32,6 +32,11 @@ namespace Squidex.Areas.Api.Controllers.Templates.Models [LocalizedRequired] 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) { var result = SimpleMapper.Map(template, new TemplateDto()); diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index 93320e989..18cd7c887 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/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.Invitation; 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.DomainObject; using Squidex.Domain.Apps.Entities.Comments.DomainObject; @@ -63,6 +64,12 @@ namespace Squidex.Config.Domain services.AddSingletonAs<CustomCommandMiddlewareRunner>() .As<ICommandMiddleware>(); + services.AddSingletonAs<TemplateCommandMiddleware>() + .As<ICommandMiddleware>(); + + services.AddSingletonAs<AlwaysCreateClientCommandMiddleware>() + .As<ICommandMiddleware>(); + services.AddSingletonAs<RestrictAppsCommandMiddleware>() .As<ICommandMiddleware>(); @@ -108,9 +115,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs<SingletonCommandMiddleware>() .As<ICommandMiddleware>(); - services.AddSingletonAs<AlwaysCreateClientCommandMiddleware>() - .As<ICommandMiddleware>(); - services.AddSingletonAs<UsageTrackerCommandMiddleware>() .As<ICommandMiddleware>(); } diff --git a/backend/src/Squidex/Config/Domain/ContentsServices.cs b/backend/src/Squidex/Config/Domain/ContentsServices.cs index e6598c47b..04fdef488 100644 --- a/backend/src/Squidex/Config/Domain/ContentsServices.cs +++ b/backend/src/Squidex/Config/Domain/ContentsServices.cs @@ -29,6 +29,9 @@ namespace Squidex.Config.Domain services.Configure<ContentOptions>(config, "contents"); + services.Configure<TemplatesOptions>(config, + "templates"); + services.AddSingletonAs(c => new Lazy<IContentQueryService>(c.GetRequiredService<IContentQueryService>)) .AsSelf(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 71c43f135..215deca2a 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -63,7 +63,7 @@ <PackageReference Include="MongoDB.Driver" Version="2.14.1" /> <PackageReference Include="Namotion.Reflection" Version="2.0.10" /> <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="NSwag.AspNetCore" Version="13.15.7" /> <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.TusAdapter" Version="2.18.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.OpenIddict.MongoDb" Version="4.0.1-dev" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 4b173e74f..60240f1dd 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -592,5 +592,18 @@ "twitter": { "clientId": "QZhb3HQcGCvE6G8yNNP9ksNet", "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" + } + ] } } diff --git a/backend/src/Squidex/wwwroot/images/add-template.svg b/backend/src/Squidex/wwwroot/images/add-template.svg new file mode 100644 index 000000000..a334d050f --- /dev/null +++ b/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> diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs index 7e64c9814..31a1c8a12 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Templates/TemplatesClientTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using FakeItEasy; +using Microsoft.Extensions.Options; using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Templates @@ -21,7 +22,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates A.CallTo(() => httpClientFactory.CreateClient(null)) .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] @@ -30,6 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Templates var templates = await sut.GetTemplatesAsync(); Assert.NotEmpty(templates); + Assert.Contains(templates, x => x.IsStarter == true); } [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] public async Task Should_return_null_details_if_not_found() { diff --git a/frontend/src/app/features/apps/pages/apps-page.component.html b/frontend/src/app/features/apps/pages/apps-page.component.html index 0a2f4a3bb..a87040f80 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.html +++ b/frontend/src/app/features/apps/pages/apps-page.component.html @@ -35,6 +35,20 @@ </sqx-form-hint> </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 *ngIf="info" class="apps-section"> @@ -43,7 +57,7 @@ </div> <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 *sqxModal="onboardingDialog"> diff --git a/frontend/src/app/features/apps/pages/apps-page.component.ts b/frontend/src/app/features/apps/pages/apps-page.component.ts index 2259bc1d8..d623248b3 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.ts +++ b/frontend/src/app/features/apps/pages/apps-page.component.ts @@ -6,8 +6,9 @@ */ import { Component, OnInit } from '@angular/core'; -import { take } from 'rxjs/operators'; -import { AppDto, AppsState, AuthService, DialogModel, FeatureDto, LocalStoreService, NewsService, OnboardingService, UIOptions, UIState } from '@app/shared'; +import { Observable } from 'rxjs'; +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'; @Component({ @@ -17,6 +18,7 @@ import { Settings } from '@app/shared/state/settings'; }) export class AppsPageComponent implements OnInit { public addAppDialog = new DialogModel(); + public addAppTemplate?: TemplateDto; public onboardingDialog = new DialogModel(); @@ -25,6 +27,10 @@ export class AppsPageComponent implements OnInit { public info = ''; + public templates: Observable<TemplateDto[]> = + this.templatesState.templates.pipe( + map(x => x.filter(t => t.isStarter))); + constructor( public readonly appsState: AppsState, public readonly authState: AuthService, @@ -32,6 +38,7 @@ export class AppsPageComponent implements OnInit { private readonly localStore: LocalStoreService, private readonly newsService: NewsService, private readonly onboardingService: OnboardingService, + private readonly templatesState: TemplatesState, private readonly uiOptions: UIOptions, ) { 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(); } diff --git a/frontend/src/app/features/settings/pages/templates/template.component.html b/frontend/src/app/features/settings/pages/templates/template.component.html index 90680d9db..8e2403e67 100644 --- a/frontend/src/app/features/settings/pages/templates/template.component.html +++ b/frontend/src/app/features/settings/pages/templates/template.component.html @@ -3,7 +3,7 @@ <div class="col"> {{template.title}} - <sqx-form-hint>{{template.description}}</sqx-form-hint> + <sqx-form-hint class="truncate">{{template.description}}</sqx-form-hint> </div> <div class="col-auto"> <div class="float-end"> diff --git a/frontend/src/app/shared/components/app-form.component.html b/frontend/src/app/shared/components/app-form.component.html index 570860f9c..9988e6394 100644 --- a/frontend/src/app/shared/components/app-form.component.html +++ b/frontend/src/app/shared/components/app-form.component.html @@ -1,7 +1,13 @@ <form [formGroup]="createForm.form" (ngSubmit)="createApp()"> <sqx-modal-dialog (close)="emitComplete()"> <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 content> diff --git a/frontend/src/app/shared/components/app-form.component.ts b/frontend/src/app/shared/components/app-form.component.ts index 3ae0bc779..0802a8972 100644 --- a/frontend/src/app/shared/components/app-form.component.ts +++ b/frontend/src/app/shared/components/app-form.component.ts @@ -5,8 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core'; -import { ApiUrlConfig, AppsState, CreateAppForm } from '@app/shared/internal'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { ApiUrlConfig, AppsState, CreateAppForm, TemplateDto } from '@app/shared/internal'; @Component({ selector: 'sqx-app-form', @@ -18,6 +18,9 @@ export class AppFormComponent { @Output() public complete = new EventEmitter(); + @Input() + public template?: TemplateDto; + public createForm = new CreateAppForm(); constructor( @@ -34,7 +37,9 @@ export class AppFormComponent { const value = this.createForm.submit(); if (value) { - this.appsStore.create(value) + const request = { ...value, template: this.template?.name }; + + this.appsStore.create(request) .subscribe({ next: () => { this.emitComplete(); diff --git a/frontend/src/app/shared/services/templates.service.spec.ts b/frontend/src/app/shared/services/templates.service.spec.ts index 94ada8f54..e5bc50e64 100644 --- a/frontend/src/app/shared/services/templates.service.spec.ts +++ b/frontend/src/app/shared/services/templates.service.spec.ts @@ -84,6 +84,7 @@ describe('TemplatesService', () => { name: `name${key}`, title: `Title ${key}`, description: `Description ${key}`, + isStarter: id % 2 === 0, _links: { self: { method: 'GET', href: `/templates/name${key}`, @@ -113,7 +114,7 @@ export function createTemplate(id: number, 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); } diff --git a/frontend/src/app/shared/services/templates.service.ts b/frontend/src/app/shared/services/templates.service.ts index f964a42c0..7faf23713 100644 --- a/frontend/src/app/shared/services/templates.service.ts +++ b/frontend/src/app/shared/services/templates.service.ts @@ -21,6 +21,7 @@ export class TemplateDto { public readonly name: string, public readonly title: string, public readonly description: string, + public readonly isStarter: boolean, ) { this._links = links; } @@ -72,7 +73,8 @@ function parseTemplates(response: { items: any[] } & Resource) { new TemplateDto(item._links, item.name, item.title, - item.description)); + item.description, + item.isStarter)); return new TemplatesDto(items.length, items, response._links); }